summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/extensions
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions')
-rw-r--r--toolkit/components/extensions/.eslintrc.js241
-rw-r--r--toolkit/components/extensions/ConduitsChild.jsm196
-rw-r--r--toolkit/components/extensions/ConduitsParent.jsm380
-rw-r--r--toolkit/components/extensions/DocumentObserver.h59
-rw-r--r--toolkit/components/extensions/Extension.jsm2907
-rw-r--r--toolkit/components/extensions/ExtensionActions.jsm510
-rw-r--r--toolkit/components/extensions/ExtensionActivityLog.jsm127
-rw-r--r--toolkit/components/extensions/ExtensionChild.jsm978
-rw-r--r--toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm116
-rw-r--r--toolkit/components/extensions/ExtensionCommon.jsm2619
-rw-r--r--toolkit/components/extensions/ExtensionContent.jsm1237
-rw-r--r--toolkit/components/extensions/ExtensionPageChild.jsm495
-rw-r--r--toolkit/components/extensions/ExtensionParent.jsm1948
-rw-r--r--toolkit/components/extensions/ExtensionPermissions.jsm420
-rw-r--r--toolkit/components/extensions/ExtensionPolicyService.cpp652
-rw-r--r--toolkit/components/extensions/ExtensionPolicyService.h131
-rw-r--r--toolkit/components/extensions/ExtensionPreferencesManager.jsm654
-rw-r--r--toolkit/components/extensions/ExtensionProcessScript.jsm422
-rw-r--r--toolkit/components/extensions/ExtensionSettingsStore.jsm690
-rw-r--r--toolkit/components/extensions/ExtensionShortcuts.jsm405
-rw-r--r--toolkit/components/extensions/ExtensionStorage.jsm457
-rw-r--r--toolkit/components/extensions/ExtensionStorageIDB.jsm871
-rw-r--r--toolkit/components/extensions/ExtensionStorageSync.jsm188
-rw-r--r--toolkit/components/extensions/ExtensionStorageSyncKinto.jsm1372
-rw-r--r--toolkit/components/extensions/ExtensionTelemetry.jsm188
-rw-r--r--toolkit/components/extensions/ExtensionTestCommon.jsm487
-rw-r--r--toolkit/components/extensions/ExtensionUtils.jsm343
-rw-r--r--toolkit/components/extensions/ExtensionXPCShellUtils.jsm1096
-rw-r--r--toolkit/components/extensions/FindContent.jsm258
-rw-r--r--toolkit/components/extensions/MatchGlob.h93
-rw-r--r--toolkit/components/extensions/MatchPattern.cpp744
-rw-r--r--toolkit/components/extensions/MatchPattern.h309
-rw-r--r--toolkit/components/extensions/MatchURLFilters.jsm182
-rw-r--r--toolkit/components/extensions/MessageChannel.jsm1174
-rw-r--r--toolkit/components/extensions/MessageManagerProxy.jsm215
-rw-r--r--toolkit/components/extensions/NativeManifests.jsm182
-rw-r--r--toolkit/components/extensions/NativeMessaging.jsm381
-rw-r--r--toolkit/components/extensions/PerformanceCounters.jsm171
-rw-r--r--toolkit/components/extensions/ProfilerGetSymbols-worker.js127
-rw-r--r--toolkit/components/extensions/ProfilerGetSymbols.jsm157
-rw-r--r--toolkit/components/extensions/ProxyChannelFilter.jsm414
-rw-r--r--toolkit/components/extensions/Schemas.jsm3647
-rw-r--r--toolkit/components/extensions/WebExtensionContentScript.h234
-rw-r--r--toolkit/components/extensions/WebExtensionPolicy.cpp931
-rw-r--r--toolkit/components/extensions/WebExtensionPolicy.h221
-rw-r--r--toolkit/components/extensions/WebNavigation.jsm511
-rw-r--r--toolkit/components/extensions/WebNavigationContent.js397
-rw-r--r--toolkit/components/extensions/WebNavigationFrames.jsm122
-rw-r--r--toolkit/components/extensions/child/.eslintrc.js11
-rw-r--r--toolkit/components/extensions/child/ext-backgroundPage.js32
-rw-r--r--toolkit/components/extensions/child/ext-contentScripts.js76
-rw-r--r--toolkit/components/extensions/child/ext-extension.js67
-rw-r--r--toolkit/components/extensions/child/ext-identity.js86
-rw-r--r--toolkit/components/extensions/child/ext-runtime.js94
-rw-r--r--toolkit/components/extensions/child/ext-storage.js344
-rw-r--r--toolkit/components/extensions/child/ext-test.js255
-rw-r--r--toolkit/components/extensions/child/ext-toolkit.js90
-rw-r--r--toolkit/components/extensions/child/ext-userScripts-content.js410
-rw-r--r--toolkit/components/extensions/child/ext-userScripts.js192
-rw-r--r--toolkit/components/extensions/child/ext-webRequest.js43
-rw-r--r--toolkit/components/extensions/docs/background.rst134
-rw-r--r--toolkit/components/extensions/docs/basics.rst208
-rw-r--r--toolkit/components/extensions/docs/events.rst314
-rw-r--r--toolkit/components/extensions/docs/functions.rst201
-rw-r--r--toolkit/components/extensions/docs/incognito.rst78
-rw-r--r--toolkit/components/extensions/docs/index.rst32
-rw-r--r--toolkit/components/extensions/docs/lifecycle.rst60
-rw-r--r--toolkit/components/extensions/docs/manifest.rst68
-rw-r--r--toolkit/components/extensions/docs/other.rst140
-rw-r--r--toolkit/components/extensions/docs/reference.rst35
-rw-r--r--toolkit/components/extensions/docs/schema.rst145
-rw-r--r--toolkit/components/extensions/dummy.xhtml6
-rw-r--r--toolkit/components/extensions/ext-browser-content.js350
-rw-r--r--toolkit/components/extensions/ext-toolkit.json235
-rw-r--r--toolkit/components/extensions/extensionProcessScriptLoader.js6
-rw-r--r--toolkit/components/extensions/extensions-toolkit.manifest13
-rw-r--r--toolkit/components/extensions/jar.mn61
-rwxr-xr-xtoolkit/components/extensions/moz.build124
-rw-r--r--toolkit/components/extensions/mozIExtensionProcessScript.idl21
-rw-r--r--toolkit/components/extensions/onExtensionBrowser.js10
-rw-r--r--toolkit/components/extensions/parent/.eslintrc.js31
-rw-r--r--toolkit/components/extensions/parent/ext-activityLog.js44
-rw-r--r--toolkit/components/extensions/parent/ext-alarms.js150
-rw-r--r--toolkit/components/extensions/parent/ext-backgroundPage.js231
-rw-r--r--toolkit/components/extensions/parent/ext-browserSettings.js463
-rw-r--r--toolkit/components/extensions/parent/ext-browsingData.js416
-rw-r--r--toolkit/components/extensions/parent/ext-captivePortal.js136
-rw-r--r--toolkit/components/extensions/parent/ext-clipboard.js89
-rw-r--r--toolkit/components/extensions/parent/ext-contentScripts.js202
-rw-r--r--toolkit/components/extensions/parent/ext-contextualIdentities.js338
-rw-r--r--toolkit/components/extensions/parent/ext-cookies.js613
-rw-r--r--toolkit/components/extensions/parent/ext-dns.js90
-rw-r--r--toolkit/components/extensions/parent/ext-downloads.js1264
-rw-r--r--toolkit/components/extensions/parent/ext-extension.js25
-rw-r--r--toolkit/components/extensions/parent/ext-geckoProfiler.js227
-rw-r--r--toolkit/components/extensions/parent/ext-i18n.js47
-rw-r--r--toolkit/components/extensions/parent/ext-identity.js158
-rw-r--r--toolkit/components/extensions/parent/ext-idle.js97
-rw-r--r--toolkit/components/extensions/parent/ext-management.js381
-rw-r--r--toolkit/components/extensions/parent/ext-networkStatus.js89
-rw-r--r--toolkit/components/extensions/parent/ext-notifications.js190
-rw-r--r--toolkit/components/extensions/parent/ext-permissions.js173
-rw-r--r--toolkit/components/extensions/parent/ext-privacy.js496
-rw-r--r--toolkit/components/extensions/parent/ext-protocolHandlers.js100
-rw-r--r--toolkit/components/extensions/parent/ext-proxy.js335
-rw-r--r--toolkit/components/extensions/parent/ext-runtime.js178
-rw-r--r--toolkit/components/extensions/parent/ext-storage.js228
-rw-r--r--toolkit/components/extensions/parent/ext-tabs-base.js2332
-rw-r--r--toolkit/components/extensions/parent/ext-telemetry.js211
-rw-r--r--toolkit/components/extensions/parent/ext-theme.js507
-rw-r--r--toolkit/components/extensions/parent/ext-toolkit.js100
-rw-r--r--toolkit/components/extensions/parent/ext-userScripts.js148
-rw-r--r--toolkit/components/extensions/parent/ext-webNavigation.js294
-rw-r--r--toolkit/components/extensions/parent/ext-webRequest.js162
-rw-r--r--toolkit/components/extensions/profiler_get_symbols.js426
-rw-r--r--toolkit/components/extensions/schemas/LICENSE27
-rw-r--r--toolkit/components/extensions/schemas/activity_log.json87
-rw-r--r--toolkit/components/extensions/schemas/alarms.json149
-rw-r--r--toolkit/components/extensions/schemas/browser_action.json481
-rw-r--r--toolkit/components/extensions/schemas/browser_settings.json111
-rw-r--r--toolkit/components/extensions/schemas/browsing_data.json423
-rw-r--r--toolkit/components/extensions/schemas/captive_portal.json75
-rw-r--r--toolkit/components/extensions/schemas/clipboard.json30
-rw-r--r--toolkit/components/extensions/schemas/content_scripts.json87
-rw-r--r--toolkit/components/extensions/schemas/contextual_identities.json169
-rw-r--r--toolkit/components/extensions/schemas/cookies.json239
-rw-r--r--toolkit/components/extensions/schemas/dns.json82
-rw-r--r--toolkit/components/extensions/schemas/downloads.json807
-rw-r--r--toolkit/components/extensions/schemas/events.json322
-rw-r--r--toolkit/components/extensions/schemas/experiments.json128
-rw-r--r--toolkit/components/extensions/schemas/extension.json181
-rw-r--r--toolkit/components/extensions/schemas/extension_protocol_handlers.json51
-rw-r--r--toolkit/components/extensions/schemas/extension_types.json144
-rw-r--r--toolkit/components/extensions/schemas/geckoProfiler.json190
-rw-r--r--toolkit/components/extensions/schemas/i18n.json139
-rw-r--r--toolkit/components/extensions/schemas/identity.json219
-rw-r--r--toolkit/components/extensions/schemas/idle.json70
-rw-r--r--toolkit/components/extensions/schemas/jar.mn52
-rw-r--r--toolkit/components/extensions/schemas/management.json368
-rw-r--r--toolkit/components/extensions/schemas/manifest.json652
-rw-r--r--toolkit/components/extensions/schemas/moz.build7
-rw-r--r--toolkit/components/extensions/schemas/native_manifest.json64
-rw-r--r--toolkit/components/extensions/schemas/network_status.json66
-rw-r--r--toolkit/components/extensions/schemas/notifications.json429
-rw-r--r--toolkit/components/extensions/schemas/page_action.json317
-rw-r--r--toolkit/components/extensions/schemas/permissions.json152
-rw-r--r--toolkit/components/extensions/schemas/privacy.json182
-rw-r--r--toolkit/components/extensions/schemas/proxy.json165
-rw-r--r--toolkit/components/extensions/schemas/runtime.json598
-rw-r--r--toolkit/components/extensions/schemas/storage.json363
-rw-r--r--toolkit/components/extensions/schemas/telemetry.json460
-rw-r--r--toolkit/components/extensions/schemas/test.json223
-rw-r--r--toolkit/components/extensions/schemas/theme.json436
-rw-r--r--toolkit/components/extensions/schemas/types.json162
-rw-r--r--toolkit/components/extensions/schemas/user_scripts.json120
-rw-r--r--toolkit/components/extensions/schemas/user_scripts_content.json61
-rw-r--r--toolkit/components/extensions/schemas/web_navigation.json398
-rw-r--r--toolkit/components/extensions/schemas/web_request.json901
-rw-r--r--toolkit/components/extensions/storage/ExtensionStorageComponents.h40
-rw-r--r--toolkit/components/extensions/storage/ExtensionStorageComponents.jsm118
-rw-r--r--toolkit/components/extensions/storage/components.conf22
-rw-r--r--toolkit/components/extensions/storage/moz.build33
-rw-r--r--toolkit/components/extensions/storage/mozIExtensionStorageArea.idl127
-rw-r--r--toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml22
-rw-r--r--toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs479
-rw-r--r--toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs125
-rw-r--r--toolkit/components/extensions/storage/webext_storage_bridge/src/lib.rs65
-rw-r--r--toolkit/components/extensions/storage/webext_storage_bridge/src/punt.rs321
-rw-r--r--toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs249
-rw-r--r--toolkit/components/extensions/test/browser/.eslintrc.js11
-rw-r--r--toolkit/components/extensions/test/browser/browser-serviceworker.ini9
-rw-r--r--toolkit/components/extensions/test/browser/browser.ini50
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js292
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js122
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js138
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js82
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_management_themes.js149
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_test_mock.js45
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js102
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js34
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js99
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js170
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js161
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js203
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js154
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js185
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js401
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js217
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js66
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js61
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js81
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js57
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js216
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js157
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js249
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js58
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_reset.js112
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js175
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_separators.js69
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js274
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js66
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js50
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js51
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js54
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js38
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js70
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js48
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js145
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js102
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js56
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js107
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js105
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js143
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js94
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js48
-rw-r--r--toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js61
-rw-r--r--toolkit/components/extensions/test/browser/data/test-download.txt1
-rw-r--r--toolkit/components/extensions/test/browser/data/test_downloads_referrer.html10
-rw-r--r--toolkit/components/extensions/test/browser/head.js103
-rw-r--r--toolkit/components/extensions/test/browser/head_serviceworker.js123
-rw-r--r--toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json11
-rw-r--r--toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js3
-rw-r--r--toolkit/components/extensions/test/marionette/manifest.ini1
-rw-r--r--toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py82
-rw-r--r--toolkit/components/extensions/test/mochitest/.eslintrc.js12
-rw-r--r--toolkit/components/extensions/test/mochitest/chrome.ini37
-rw-r--r--toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js66
-rw-r--r--toolkit/components/extensions/test/mochitest/chrome_head.js1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html7
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contains_iframe.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contains_img.html11
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html11
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_green.html3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_bad.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_good.pngbin0 -> 580 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_great.pngbin0 -> 580 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_image_redirect.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/mochitest/file_indexedDB.html28
-rw-r--r--toolkit/components/extensions/test/mochitest/file_mixed.html13
-rw-r--r--toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html30
-rw-r--r--toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_remote_frame.html20
-rw-r--r--toolkit/components/extensions/test/mochitest/file_sample.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_sample.txt1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_sample.txt^headers^1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_bad.js3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_good.js12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_redirect.js3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_script_xhr.js9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_serviceWorker.html16
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html23
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_xhr.html19
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html19
-rw-r--r--toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html23
-rw-r--r--toolkit/components/extensions/test/mochitest/file_streamfilter.txt1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_style_bad.css3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_style_good.css3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_style_redirect.css3
-rw-r--r--toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html11
-rw-r--r--toolkit/components/extensions/test/mochitest/file_third_party.html21
-rw-r--r--toolkit/components/extensions/test/mochitest/file_to_drawWindow.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html9
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html8
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^1
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html12
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html8
-rw-r--r--toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html7
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_about_blank.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_images.html10
-rw-r--r--toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html6
-rw-r--r--toolkit/components/extensions/test/mochitest/head.js123
-rw-r--r--toolkit/components/extensions/test/mochitest/head_cookies.js287
-rw-r--r--toolkit/components/extensions/test/mochitest/head_notifications.js169
-rw-r--r--toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js50
-rw-r--r--toolkit/components/extensions/test/mochitest/head_webrequest.js482
-rw-r--r--toolkit/components/extensions/test/mochitest/hsts.sjs8
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest-common.ini206
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest-remote.ini8
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest.ini12
-rw-r--r--toolkit/components/extensions/test/mochitest/mochitest_console.js53
-rw-r--r--toolkit/components/extensions/test/mochitest/oauth.html26
-rw-r--r--toolkit/components/extensions/test/mochitest/redirect_auto.sjs21
-rw-r--r--toolkit/components/extensions/test/mochitest/redirection.sjs4
-rw-r--r--toolkit/components/extensions/test/mochitest/return_headers.sjs20
-rw-r--r--toolkit/components/extensions/test/mochitest/serviceWorker.js0
-rw-r--r--toolkit/components/extensions/test/mochitest/slow_response.sjs55
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html104
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html64
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html80
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html114
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html257
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html116
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html176
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html98
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html81
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html94
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html89
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html193
-rw-r--r--toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html56
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_activityLog.html390
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_all_apis.js181
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html376
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html50
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_background_page.html84
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html161
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html322
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html71
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html141
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html67
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html64
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_clipboard.html210
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html262
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html116
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html371
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html113
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html138
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html77
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html100
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html105
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html61
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies.html366
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html97
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html72
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html316
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html112
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html115
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html89
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html90
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html94
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html91
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html110
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_generate.html48
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_geolocation.html86
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_identity.html390
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_idle.html68
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html49
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html62
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html152
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_notifications.html340
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html394
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html92
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html129
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html82
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html102
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html126
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html77
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html100
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html49
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html82
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html78
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html204
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html235
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html126
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html110
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html91
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html73
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html340
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html301
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html780
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html95
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html95
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_test.html196
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html139
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html81
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html174
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html265
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html611
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html299
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html109
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html134
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html182
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html120
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html446
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html61
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html227
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html214
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html223
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html70
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html83
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html89
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html212
-rw-r--r--toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html104
-rw-r--r--toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html31
-rw-r--r--toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html22
-rw-r--r--toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js9
-rw-r--r--toolkit/components/extensions/test/mochitest/webrequest_test.jsm22
-rw-r--r--toolkit/components/extensions/test/mochitest/webrequest_worker.js3
-rw-r--r--toolkit/components/extensions/test/xpcshell/.eslintrc.js9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/dummy_page.html7
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt0
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file download.txt1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html25
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html19
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js2
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html19
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js2
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_csp.html14
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_document_open.html21
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_document_write.html35
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_download.html12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_download.txt1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_iframe.html9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_image_bad.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_image_good.pngbin0 -> 580 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_image_redirect.pngbin0 -> 5401 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html34
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html61
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_sample.html12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script.html14
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_bad.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_good.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js9
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html13
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_style_bad.css3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_style_good.css3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css1
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html3
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html19
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_toplevel.html12
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html10
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/lorem.html.gzbin0 -> 392 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/pixel_green.gifbin0 -> 35 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/data/pixel_red.gifbin0 -> 35 bytes
-rw-r--r--toolkit/components/extensions/test/xpcshell/head.js277
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_e10s.js8
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_legacy_ep.js13
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_native_messaging.js153
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_remote.js7
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_storage.js1227
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_sync.js65
-rw-r--r--toolkit/components/extensions/test/xpcshell/head_telemetry.js110
-rw-r--r--toolkit/components/extensions/test/xpcshell/native_messaging.ini15
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js86
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_MatchPattern.js552
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js286
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js209
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js376
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js20
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js278
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_csp_validator.js298
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js80
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js21
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js160
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js129
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms.js219
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js34
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js50
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js56
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js76
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js195
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js23
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js24
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js24
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js88
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js99
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js104
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js41
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js54
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js454
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js36
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js456
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js192
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js109
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js53
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js591
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js251
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js266
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js78
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js65
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js79
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js128
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js348
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js160
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js177
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js355
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js98
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js61
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js71
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js70
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js61
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js102
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js1373
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js91
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js75
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js85
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js59
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contexts.js198
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js273
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js513
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js675
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js334
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js109
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js316
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dns.js176
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads.js38
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js216
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js680
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js1069
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js308
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js682
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js235
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_error_location.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js90
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_experiments.js358
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension.js80
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js887
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js1089
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js151
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js93
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_file_access.js193
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js208
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js56
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_geturl.js61
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_i18n.js574
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js197
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_idle.js270
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_incognito.js302
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js101
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js150
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js39
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_l10n.js150
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js50
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_management.js205
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js146
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest.js95
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js82
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js48
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js12
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js270
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js685
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js130
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js85
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js190
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js108
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js41
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js30
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js86
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js654
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js235
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions.js845
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js397
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js252
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js160
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js521
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy.js964
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js201
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js167
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js116
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js633
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js302
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js107
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js557
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js52
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js158
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_redirects.js567
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js26
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js26
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js36
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js46
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js84
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js401
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js69
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js168
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js452
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js101
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js66
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js67
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js93
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js131
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js233
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js42
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schema.js79
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas.js2097
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js157
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js352
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js174
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js174
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js103
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js507
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js242
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js59
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js40
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js42
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_simple.js111
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startupData.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js172
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js73
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js64
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js39
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js31
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js31
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js787
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js73
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js170
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js82
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js106
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js29
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js2290
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js122
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js245
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js369
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js98
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js870
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js64
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js20
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js63
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js213
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js230
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js671
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js1108
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js175
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js425
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js311
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js69
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js43
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js523
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js85
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js35
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js57
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js99
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js81
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js214
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js154
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js129
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js47
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js57
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js765
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js308
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js603
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js84
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js49
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js294
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js33
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js41
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js95
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js144
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js55
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js150
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js72
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js99
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js172
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_locale_converter.js146
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_locale_data.js221
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_native_manifests.js443
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js103
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js469
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_listener.js318
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js43
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js79
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js102
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js182
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini13
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-common.ini260
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-content.ini22
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini28
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini23
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini30
-rw-r--r--toolkit/components/extensions/test/xpcshell/xpcshell.ini89
-rw-r--r--toolkit/components/extensions/webrequest/ChannelWrapper.cpp1205
-rw-r--r--toolkit/components/extensions/webrequest/ChannelWrapper.h352
-rw-r--r--toolkit/components/extensions/webrequest/PStreamFilter.ipdl38
-rw-r--r--toolkit/components/extensions/webrequest/SecurityInfo.jsm328
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilter.cpp286
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilter.h97
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterBase.h38
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterChild.cpp520
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterChild.h137
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterEvents.cpp53
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterEvents.h68
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterParent.cpp777
-rw-r--r--toolkit/components/extensions/webrequest/StreamFilterParent.h195
-rw-r--r--toolkit/components/extensions/webrequest/WebRequest.jsm1187
-rw-r--r--toolkit/components/extensions/webrequest/WebRequestService.cpp54
-rw-r--r--toolkit/components/extensions/webrequest/WebRequestService.h77
-rw-r--r--toolkit/components/extensions/webrequest/WebRequestUpload.jsm552
-rw-r--r--toolkit/components/extensions/webrequest/moz.build54
701 files changed, 141731 insertions, 0 deletions
diff --git a/toolkit/components/extensions/.eslintrc.js b/toolkit/components/extensions/.eslintrc.js
new file mode 100644
index 0000000000..8b68a8c6ab
--- /dev/null
+++ b/toolkit/components/extensions/.eslintrc.js
@@ -0,0 +1,241 @@
+/* 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,
+ 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",
+
+ "valid-jsdoc": [
+ "error",
+ {
+ prefer: {
+ return: "returns",
+ },
+ preferType: {
+ Boolean: "boolean",
+ Number: "number",
+ String: "string",
+ bool: "boolean",
+ },
+ requireParamDescription: false,
+ requireReturn: false,
+ requireReturnDescription: false,
+ },
+ ],
+
+ // 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 variables before defined
+ "no-use-before-define": "error",
+
+ // 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",
+
+ // Disallow fallthrough of case statements, except if there is a comment.
+ "no-fallthrough": "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.jsm b/toolkit/components/extensions/ConduitsChild.jsm
new file mode 100644
index 0000000000..e531fc0ca7
--- /dev/null
+++ b/toolkit/components/extensions/ConduitsChild.jsm
@@ -0,0 +1,196 @@
+/* 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 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
+ * @prop {ConduitID} [target]
+ * @prop {ConduitID} [sender]
+ * @prop {boolean} query
+ * @prop {object} arg
+ */
+
+const EXPORTED_SYMBOLS = ["BaseConduit", "ConduitsChild"];
+
+/**
+ * Base class for both child (Point) and parent (Broadcast) side of conduits,
+ * handles setting up send/receive method stubs.
+ */
+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.
+ */
+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.
+ */
+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 {string} name
+ * @param {MessageData|MessageData[]} 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();
+ }
+}
diff --git a/toolkit/components/extensions/ConduitsParent.jsm b/toolkit/components/extensions/ConduitsParent.jsm
new file mode 100644
index 0000000000..23473dcf0d
--- /dev/null
+++ b/toolkit/components/extensions/ConduitsParent.jsm
@@ -0,0 +1,380 @@
+/* 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 EXPORTED_SYMBOLS = ["BroadcastConduit", "ConduitsParent"];
+
+/**
+ * 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
+ * @prop {ConduitID} id Globally unique across all processes.
+ * @prop {string[]} [recv]
+ * @prop {string[]} [send]
+ * @prop {string[]} [query]
+ * @prop {string[]} [cast]
+ * Lists of recvX, sendX, queryX and castX methods this subject will use.
+ *
+ * @typedef {"messenger"|"port"|"tab"} BroadcastKind
+ * Kinds of broadcast targeting filters.
+ *
+ * @example:
+ *
+ * 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 });
+ * }
+ *
+ */
+
+const {
+ ExtensionUtils: { DefaultWeakMap, ExtensionError },
+} = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+const { BaseConduit } = ChromeUtils.import(
+ "resource://gre/modules/ConduitsChild.jsm"
+);
+
+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<ConduitID, ConduitAddress> Info about all child conduits. */
+ remotes: new Map(),
+
+ /** @type Map<ConduitID, BroadcastConduit> All open parent conduits. */
+ conduits: new Map(),
+
+ /** @type Map<string, BroadcastConduit> Parent conduits by recvMethod. */
+ byMethod: new Map(),
+
+ /** @type WeakMap<ConduitsParent, Set<ConduitAddress>> Conduits by actor. */
+ byActor: new DefaultWeakMap(() => new Set()),
+
+ /** @type Map<string, BroadcastConduit> */
+ 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.remove(name);
+ }
+ },
+
+ /**
+ * Confirm that a remote conduit comes from an extension page.
+ * @see ExtensionPolicyService::CheckParentFrames
+ * @param {ConduitAddress} remote
+ * @returns {boolean}
+ */
+ verifyEnv({ actor, envType, extensionId }) {
+ if (!extensionId || !ADDON_ENV.has(envType)) {
+ return false;
+ }
+ 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}`);
+ },
+
+ /**
+ * Save info about a new remote conduit.
+ * @param {ConduitAddress} address
+ * @param {ConduitsParent} actor
+ */
+ recvConduitOpened(address, actor) {
+ address.actor = actor;
+ address.verified = this.verifyEnv(address);
+ 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.
+ */
+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<any>}
+ */
+ _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[]}
+ */
+ _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),
+ };
+
+ 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.
+ * @param {Promise<response>[]} promises
+ * @returns {Promise<response?>}
+ */
+ _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.
+ */
+class ConduitsParent extends JSWindowActorParent {
+ constructor() {
+ super();
+ this.batchData = [];
+ this.batchPromise = null;
+ }
+
+ /**
+ * Group webRequest events to send them as a batch, reducing IPC overhead.
+ * @param {string} name
+ * @param {MessageData} data
+ */
+ async batch(name, data) {
+ let num = this.batchData.length;
+ this.batchData.push(data);
+
+ if (!num) {
+ let resolve;
+ this.batchPromise = new Promise(r => (resolve = r));
+
+ let send = () => {
+ resolve(this.manager && this.sendQuery(name, this.batchData));
+ this.batchData = [];
+ };
+ ChromeUtils.idleDispatch(send, { timeout: BATCH_TIMEOUT_MS });
+ }
+
+ let results = await this.batchPromise;
+ return results && results[num];
+ }
+
+ /**
+ * JSWindowActor method, routes the message to the target subject.
+ * @param {string} name
+ * @param {MessageData} data
+ * @returns {Promise?}
+ */
+ async receiveMessage({ name, data: { arg, query, sender } }) {
+ if (name === "ConduitOpened") {
+ return Hub.recvConduitOpened(arg, this);
+ }
+
+ sender = Hub.remotes.get(sender);
+ if (!sender || sender.actor !== this) {
+ throw new Error(`Unknown sender or wrong actor for recv${name}`);
+ }
+
+ if (name === "ConduitClosed") {
+ return Hub.recvConduitClosed(sender);
+ }
+
+ 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 });
+ }
+
+ /**
+ * JSWindowActor method, ensure cleanup.
+ */
+ didDestroy() {
+ Hub.actorClosed(this);
+ }
+}
diff --git a/toolkit/components/extensions/DocumentObserver.h b/toolkit/components/extensions/DocumentObserver.h
new file mode 100644
index 0000000000..3ed2b5c203
--- /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_SCRIPT_HOLDER_CLASS(DocumentObserver)
+
+ static already_AddRefed<DocumentObserver> Constructor(
+ dom::GlobalObject& aGlobal, dom::MozDocumentCallback& aCallbacks);
+
+ void Observe(const dom::Sequence<OwningNonNull<MozDocumentMatcher>>& matchers,
+ ErrorResult& aRv);
+
+ void Disconnect();
+
+ const nsTArray<RefPtr<MozDocumentMatcher>>& 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<JSObject*> aGivenProto) override;
+
+ protected:
+ virtual ~DocumentObserver() = default;
+
+ private:
+ explicit DocumentObserver(nsISupports* aParent,
+ dom::MozDocumentCallback& aCallbacks)
+ : mParent(aParent), mCallbacks(&aCallbacks) {}
+
+ nsCOMPtr<nsISupports> mParent;
+ RefPtr<dom::MozDocumentCallback> mCallbacks;
+ nsTArray<RefPtr<MozDocumentMatcher>> mMatchers;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_DocumentObserver_h
diff --git a/toolkit/components/extensions/Extension.jsm b/toolkit/components/extensions/Extension.jsm
new file mode 100644
index 0000000000..1b940b6d99
--- /dev/null
+++ b/toolkit/components/extensions/Extension.jsm
@@ -0,0 +1,2907 @@
+/* -*- 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 EXPORTED_SYMBOLS = [
+ "Dictionary",
+ "Extension",
+ "ExtensionData",
+ "Langpack",
+ "Management",
+ "ExtensionAddonObserver",
+];
+
+/* exported Extension, ExtensionData */
+
+/*
+ * 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).
+ */
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.jsm",
+ AMTelemetry: "resource://gre/modules/AddonManager.jsm",
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+ E10SUtils: "resource://gre/modules/E10SUtils.jsm",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
+ ExtensionPreferencesManager:
+ "resource://gre/modules/ExtensionPreferencesManager.jsm",
+ ExtensionProcessScript: "resource://gre/modules/ExtensionProcessScript.jsm",
+ ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.jsm",
+ FileSource: "resource://gre/modules/L10nRegistry.jsm",
+ L10nRegistry: "resource://gre/modules/L10nRegistry.jsm",
+ LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
+ Log: "resource://gre/modules/Log.jsm",
+ MessageChannel: "resource://gre/modules/MessageChannel.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ PluralForm: "resource://gre/modules/PluralForm.jsm",
+ Schemas: "resource://gre/modules/Schemas.jsm",
+ ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm",
+ XPIProvider: "resource://gre/modules/addons/XPIProvider.jsm",
+});
+
+// This is used for manipulating jar entry paths, which always use Unix
+// separators.
+XPCOMUtils.defineLazyGetter(this, "OSPath", () => {
+ let obj = {};
+ ChromeUtils.import("resource://gre/modules/osfile/ospath_unix.jsm", obj);
+ return obj;
+});
+
+XPCOMUtils.defineLazyGetter(this, "resourceProtocol", () =>
+ Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler)
+);
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+ spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
+ uuidGen: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "processCount",
+ "dom.ipc.processCount.extension"
+);
+
+// Temporary pref to be turned on when ready.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "allowPrivateBrowsingByDefault",
+ "extensions.allowPrivateBrowsingByDefault",
+ true
+);
+
+var {
+ GlobalManager,
+ ParentAPIManager,
+ StartupCache,
+ apiManager: Management,
+} = ExtensionParent;
+
+const { getUniqueId, promiseTimeout } = ExtensionUtils;
+
+const { EventEmitter } = ExtensionCommon;
+
+XPCOMUtils.defineLazyGetter(this, "console", ExtensionCommon.getConsole);
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "LocaleData",
+ () => ExtensionCommon.LocaleData
+);
+
+XPCOMUtils.defineLazyGetter(this, "LAZY_NO_PROMPT_PERMISSIONS", async () => {
+ // Wait until all extension API schemas have been loaded and parsed.
+ await Management.lazyInit();
+ return new Set(
+ Schemas.getPermissionNames([
+ "PermissionNoPrompt",
+ "OptionalPermissionNoPrompt",
+ ])
+ );
+});
+
+const { sharedData } = Services.ppmm;
+
+const PRIVATE_ALLOWED_PERMISSION = "internal:privateBrowsingAllowed";
+
+// 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.jsm).
+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",
+ "geckoViewAddons",
+ "telemetry",
+ "urlbar",
+ "nativeMessagingFromContent",
+ "normandyAddonStudy",
+ "networkStatus",
+]);
+
+/**
+ * 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 };
+ }
+ 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 = uuidGen.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);
+ },
+};
+
+/**
+ * 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) {
+ AddonManager.addAddonListener(this);
+ this.initialized = true;
+ }
+ },
+
+ // AddonTestUtils will call this as necessary.
+ uninit() {
+ if (this.initialized) {
+ 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) {
+ 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 the registered service workers for the extension
+ // principal.
+ // 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).
+ if (WebExtensionPolicy.backgroundServiceWorkerEnabled) {
+ // 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.
+ AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Clear ServiceWorkers for ${addon.id}`,
+ ServiceWorkerCleanUp.removeFromPrincipal(principal)
+ );
+ }
+
+ if (!Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false)) {
+ // Clear browser.storage.local backends.
+ AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Clear Extension Storage ${addon.id} (File Backend)`,
+ ExtensionStorage.clear(addon.id, { shouldNotifyListeners: false })
+ );
+
+ // Clear any IndexedDB 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);
+
+ 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, "indexedDB");
+ Services.perms.removeFromPrincipal(principal, "persistent-storage");
+ }
+
+ ExtensionPermissions.removeAll(addon.id);
+
+ if (!Services.prefs.getBoolPref(LEAVE_UUID_PREF, false)) {
+ // Clear the entry in the UUID map
+ UUIDMap.remove(addon.id);
+ }
+ },
+};
+
+ExtensionAddonObserver.init();
+
+const manifestTypes = new Map([
+ ["theme", "manifest.ThemeManifest"],
+ ["langpack", "manifest.WebExtensionLangpackManifest"],
+ ["dictionary", "manifest.WebExtensionDictionaryManifest"],
+ ["extension", "manifest.WebExtensionManifest"],
+]);
+
+/**
+ * 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.
+ */
+class ExtensionData {
+ constructor(rootURI) {
+ this.rootURI = rootURI;
+ this.resourceURL = rootURI.spec;
+
+ 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 = [];
+ }
+
+ get builtinMessages() {
+ return null;
+ }
+
+ get logger() {
+ let id = this.id || "<unknown>";
+ return 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}`;
+ }
+
+ async readDirectory(path) {
+ if (this.rootURI instanceof Ci.nsIFileURL) {
+ let uri = Services.io.newURI("./" + path, null, this.rootURI);
+ let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path;
+
+ let iter = new OS.File.DirectoryIterator(fullPath);
+ let results = [];
+
+ try {
+ await iter.forEach(entry => {
+ results.push(entry);
+ });
+ } catch (e) {
+ // Always return a list, even if the directory does not exist (or is
+ // not a directory) for symmetry with the ZipReader behavior.
+ if (!e.becauseNoSuchFile) {
+ Cu.reportError(e);
+ }
+ }
+ iter.close();
+
+ 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 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)) {
+ results.push({
+ name: name.replace("/", ""),
+ isDir: name.endsWith("/"),
+ });
+ }
+ }
+
+ return results;
+ }
+
+ readJSON(path) {
+ return new Promise((resolve, reject) => {
+ let uri = this.rootURI.resolve(`./${path}`);
+
+ 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 = NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available(),
+ { charset: "utf-8" }
+ );
+
+ text = text.replace(COMMENT_REGEXP, "$1");
+
+ resolve(JSON.parse(text));
+ } catch (e) {
+ reject(e);
+ }
+ }
+ );
+ });
+ }
+
+ canCheckSignature() {
+ // ExtensionData instances can't check the signature because it is not yet
+ // available when XPIProvider does use it to load the extension manifest.
+ //
+ // This method will return true for the ExtensionData subclasses (like
+ // the Extension class) to enable the additional validation that would require
+ // the signature to be available (e.g. to check if the extension is allowed to
+ // use a privileged permission).
+ return this.constructor != ExtensionData;
+ }
+
+ get restrictSchemes() {
+ // mozillaAddons permission is only allowed for privileged addons and
+ // filtered out if the extension isn't privileged.
+ // When the manifest is loaded by an explicit ExtensionData class
+ // instance, the signature data isn't available yet and this helper
+ // would always return false, but it will return true when appropriate
+ // (based on the isPrivileged boolean property) for the Extension class.
+ return !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
+ * @returns {Object} permissions object
+ */
+ permissionsObject(permissionsArray) {
+ let permissions = new Set();
+ let origins = new Set();
+ let { restrictSchemes, isPrivileged } = this;
+ for (let perm of permissionsArray || []) {
+ 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, origins } = this.permissionsObject(
+ this.manifest.permissions
+ );
+
+ if (
+ this.manifest.devtools_page &&
+ !this.manifest.optional_permissions.includes("devtools")
+ ) {
+ permissions.add("devtools");
+ }
+
+ for (let entry of this.manifest.content_scripts || []) {
+ for (let origin of entry.matches) {
+ origins.add(origin);
+ }
+ }
+
+ return {
+ permissions: Array.from(permissions),
+ origins: Array.from(origins),
+ };
+ }
+
+ get manifestOptionalPermissions() {
+ if (this.type !== "extension") {
+ return null;
+ }
+
+ let { permissions, origins } = this.permissionsObject(
+ this.manifest.optional_permissions
+ );
+ 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 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 ExtensionPreferencesManager.removeSettingsForPermissions(id, removed);
+
+ // Remove any optional permissions that have been removed from the manifest.
+ await ExtensionPermissions.remove(id, {
+ permissions: removed,
+ origins: [],
+ });
+ }
+
+ canUseExperiment(manifest) {
+ return this.experimentsAllowed && manifest.experiment_apis;
+ }
+
+ /**
+ * 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 {object} 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.manifest.manifest_version,
+ };
+
+ if (this.fluentL10n || this.localeData) {
+ context.preprocessors.localize = (value, context) =>
+ this.localize(value, locale);
+ }
+
+ return Schemas.normalize(this.rawManifest, manifestType, context);
+ }
+
+ // eslint-disable-next-line complexity
+ async parseManifest() {
+ let [manifest] = await Promise.all([
+ this.readJSON("manifest.json"),
+ Management.lazyInit(),
+ ]);
+
+ this.manifest = manifest;
+ this.rawManifest = manifest;
+
+ if (
+ allowPrivateBrowsingByDefault &&
+ "incognito" in manifest &&
+ manifest.incognito == "not_allowed"
+ ) {
+ throw new Error(
+ `manifest.incognito set to "not_allowed" is currently unvailable for use.`
+ );
+ }
+
+ if (manifest && manifest.default_locale) {
+ await this.initLocale();
+ }
+
+ // When parsing the manifest from an ExtensionData instance, we don't
+ // have isPrivileged, so ignore fluent localization in that pass.
+ // This means that fluent cannot be used to localize manifest properties
+ // read from the add-on manager (e.g., author, homepage, etc.)
+ if (manifest && manifest.l10n_resources && "isPrivileged" in this) {
+ if (this.isPrivileged) {
+ this.fluentL10n = new Localization(manifest.l10n_resources, true);
+ } else {
+ // Warn but don't make this fatal.
+ Cu.reportError("Ignoring l10n_resources in unprivileged extension");
+ }
+ }
+
+ if (this.manifest.theme) {
+ this.type = "theme";
+ } else if (this.manifest.langpack_id) {
+ this.type = "langpack";
+ } else if (this.manifest.dictionaries) {
+ this.type = "dictionary";
+ } else {
+ this.type = "extension";
+ }
+
+ let normalized = await this._getNormalizedManifest();
+ if (normalized.error) {
+ this.manifestError(normalized.error);
+ return null;
+ }
+
+ manifest = normalized.value;
+
+ let id;
+ try {
+ if (manifest.applications.gecko.id) {
+ id = manifest.applications.gecko.id;
+ }
+ } catch (e) {
+ // Errors are handled by the type checks above.
+ }
+
+ if (!this.id) {
+ this.id = id;
+ }
+
+ let apiNames = new Set();
+ let dependencies = new Set();
+ let originPermissions = new Set();
+ let permissions = new Set();
+ let webAccessibleResources = [];
+
+ let schemaPromises = new Map();
+
+ let result = {
+ apiNames,
+ dependencies,
+ id,
+ manifest,
+ modules: null,
+ originPermissions,
+ permissions,
+ schemaURLs: null,
+ type: this.type,
+ webAccessibleResources,
+ };
+
+ if (this.type === "extension") {
+ let { isPrivileged } = this;
+ let restrictSchemes = !(
+ isPrivileged && manifest.permissions.includes("mozillaAddons")
+ );
+
+ for (let perm of manifest.permissions) {
+ if (perm === "geckoProfiler" && !isPrivileged) {
+ const acceptedExtensions = Services.prefs.getStringPref(
+ "extensions.geckoProfiler.acceptedExtensionIds",
+ ""
+ );
+ if (!acceptedExtensions.split(",").includes(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;
+ originPermissions.add(perm);
+ } else if (type.api) {
+ apiNames.add(type.api);
+ } else if (type.invalid) {
+ if (!this.canCheckSignature() && PRIVILEGED_PERMS.has(perm)) {
+ // Do not emit the warning if the invalid permission is a privileged one
+ // and the current instance can't yet check for a valid signature
+ // (see Bug 1675858 and the inline comment inside the canCheckSignature
+ // method for more details).
+ //
+ // This parseManifest method will be called again on the Extension class
+ // instance, which will have the signature available and the invalid
+ // extension permission warnings will be collected and logged if necessary.
+ continue;
+ }
+
+ this.manifestWarning(`Invalid extension permission: ${perm}`);
+ continue;
+ }
+
+ // Bug 1671244: Currently all manifest permissions are added to permissions,
+ // even when used otherwise above. Host permissions other than all_urls
+ // probably should not be in this list.
+ 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 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 (this.canUseExperiment(manifest)) {
+ 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 =>
+ 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),
+ };
+ }
+
+ // Normalize all patterns to contain a single leading /
+ if (manifest.web_accessible_resources) {
+ webAccessibleResources.push(
+ ...manifest.web_accessible_resources.map(path =>
+ path.replace(/^\/*/, "/")
+ )
+ );
+ }
+ } else if (this.type == "langpack") {
+ // 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 = OSPath.dirname(path);
+ if (dir === ".") {
+ dir = "";
+ }
+ let leafName = OSPath.basename(path);
+ let affixPath = leafName.slice(0, -3) + "aff";
+
+ let entries = Array.from(
+ await this.readDirectory(dir),
+ entry => entry.name
+ );
+ 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.map(
+ res => new MatchGlob(res)
+ );
+ 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() {
+ 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;
+ }
+
+ // 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");
+ for (let file of entries) {
+ if (file.isDir) {
+ let locale = this.normalizeLocaleCode(file.name);
+ locales.set(locale, file.name);
+ }
+ }
+
+ return locales;
+ }
+
+ _setupLocaleData(locales) {
+ if (this.localeData) {
+ return this.localeData.locales;
+ }
+
+ this.localeData = new 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];
+ }
+
+ /**
+ * Classify host permissions
+ * @param {array<string>} origins
+ * permission origins
+ * @returns {object}
+ * "object.allUrls" contains the permission used to obtain all urls access
+ * "object.wildcards" set contains permissions with wildcards
+ * "object.sites" set contains explicit host permissions
+ */
+ static classifyOriginPermissions(origins = []) {
+ let allUrls = null,
+ wildcards = new Set(),
+ sites = new Set();
+ for (let permission of origins) {
+ if (permission == "<all_urls>") {
+ allUrls = permission;
+ break;
+ }
+
+ // 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.
+ if (!match[1] || match[1] == "*") {
+ allUrls = permission;
+ } else if (match[1].startsWith("*.")) {
+ wildcards.add(match[1].slice(2));
+ } else {
+ sites.add(match[1]);
+ }
+ }
+ return { allUrls, wildcards, sites };
+ }
+
+ /**
+ * Formats all the strings for a permissions dialog/notification.
+ *
+ * @param {object} info Information about the permissions being requested.
+ *
+ * @param {array<string>} info.permissions.origins
+ * Origin permissions requested.
+ * @param {array<string>} info.permissions.permissions
+ * Regular (non-origin) permissions requested.
+ * @param {array<string>} info.optionalPermissions.origins
+ * Optional origin permissions listed in the manifest.
+ * @param {array<string>} info.optionalPermissions.permissions
+ * Optional (non-origin) permissions listed in the manifest.
+ * @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 {string} info.appName
+ * The localized name of the application, to be substituted
+ * in computed strings as needed.
+ * @param {nsIStringBundle} bundle
+ * The string bundle to use for l10n.
+ * @param {object} options
+ * @param {boolean} options.collapseOrigins
+ * Wether to limit the number of displayed host permissions.
+ * Default is 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(
+ info,
+ bundle,
+ { collapseOrigins = false } = {}
+ ) {
+ let result = {
+ msgs: [],
+ optionalPermissions: {},
+ optionalOrigins: {},
+ };
+
+ let perms = info.permissions || { origins: [], permissions: [] };
+ let optional_permissions = info.optionalPermissions || {
+ origins: [],
+ permissions: [],
+ };
+
+ // First classify our host permissions
+ let { allUrls, wildcards, sites } = ExtensionData.classifyOriginPermissions(
+ perms.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) {
+ result.msgs.push(
+ bundle.GetStringFromName("webextPerms.hostDescription.allUrls")
+ );
+ } 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"
+ let format = (list, itemKey, moreKey) => {
+ function formatItems(items) {
+ result.msgs.push(
+ ...items.map(item => bundle.formatStringFromName(itemKey, [item]))
+ );
+ }
+ if (list.length < 5 || !collapseOrigins) {
+ formatItems(list);
+ } else {
+ formatItems(list.slice(0, 3));
+
+ let remaining = list.length - 3;
+ result.msgs.push(
+ PluralForm.get(
+ remaining,
+ bundle.GetStringFromName(moreKey)
+ ).replace("#1", remaining)
+ );
+ }
+ };
+
+ format(
+ Array.from(wildcards),
+ "webextPerms.hostDescription.wildcard",
+ "webextPerms.hostDescription.tooManyWildcards"
+ );
+ format(
+ Array.from(sites),
+ "webextPerms.hostDescription.oneSite",
+ "webextPerms.hostDescription.tooManySites"
+ );
+ }
+
+ let permissionKey = perm => `webextPerms.description.${perm}`;
+
+ // Next, show the native messaging permission if it is present.
+ const NATIVE_MSG_PERM = "nativeMessaging";
+ if (perms.permissions.includes(NATIVE_MSG_PERM)) {
+ result.msgs.push(
+ bundle.formatStringFromName(permissionKey(NATIVE_MSG_PERM), [
+ info.appName,
+ ])
+ );
+ }
+
+ // Finally, show remaining permissions, in the same order as AMO.
+ // The permissions are sorted alphabetically by the permission
+ // string to match AMO.
+ let permissionsCopy = perms.permissions.slice(0);
+ for (let permission of permissionsCopy.sort()) {
+ // Handled above
+ if (permission == NATIVE_MSG_PERM) {
+ continue;
+ }
+ try {
+ result.msgs.push(bundle.GetStringFromName(permissionKey(permission)));
+ } catch (err) {
+ // We deliberately do not include all permissions in the prompt.
+ // So if we don't find one then just skip it.
+ }
+ }
+
+ // Generate a map of permission names to permission strings for optional
+ // permissions. The key is necessary to handle toggling those permissions.
+ for (let permission of optional_permissions.permissions) {
+ if (permission == NATIVE_MSG_PERM) {
+ result.optionalPermissions[
+ permission
+ ] = bundle.formatStringFromName(permissionKey(permission), [
+ info.appName,
+ ]);
+ continue;
+ }
+ try {
+ result.optionalPermissions[permission] = bundle.GetStringFromName(
+ permissionKey(permission)
+ );
+ } catch (err) {
+ // We deliberately do not have strings for all permissions.
+ // So if we don't find one then just skip it.
+ }
+ }
+ allUrls = ExtensionData.classifyOriginPermissions(
+ optional_permissions.origins
+ ).allUrls;
+ if (allUrls) {
+ result.optionalOrigins[allUrls] = bundle.GetStringFromName(
+ "webextPerms.hostDescription.allUrls"
+ );
+ }
+
+ const haveAccessKeys = AppConstants.platform !== "android";
+
+ result.header = bundle.formatStringFromName("webextPerms.header", ["<>"]);
+ result.text = info.unsigned
+ ? bundle.GetStringFromName("webextPerms.unsignedWarning")
+ : "";
+ result.listIntro = bundle.GetStringFromName("webextPerms.listIntro");
+
+ result.acceptText = bundle.GetStringFromName("webextPerms.add.label");
+ result.cancelText = bundle.GetStringFromName("webextPerms.cancel.label");
+ if (haveAccessKeys) {
+ result.acceptKey = bundle.GetStringFromName("webextPerms.add.accessKey");
+ result.cancelKey = bundle.GetStringFromName(
+ "webextPerms.cancel.accessKey"
+ );
+ }
+
+ if (info.type == "sideload") {
+ result.header = bundle.formatStringFromName(
+ "webextPerms.sideloadHeader",
+ ["<>"]
+ );
+ let key = !result.msgs.length
+ ? "webextPerms.sideloadTextNoPerms"
+ : "webextPerms.sideloadText2";
+ result.text = bundle.GetStringFromName(key);
+ result.acceptText = bundle.GetStringFromName(
+ "webextPerms.sideloadEnable.label"
+ );
+ result.cancelText = bundle.GetStringFromName(
+ "webextPerms.sideloadCancel.label"
+ );
+ if (haveAccessKeys) {
+ result.acceptKey = bundle.GetStringFromName(
+ "webextPerms.sideloadEnable.accessKey"
+ );
+ result.cancelKey = bundle.GetStringFromName(
+ "webextPerms.sideloadCancel.accessKey"
+ );
+ }
+ } else if (info.type == "update") {
+ result.header = bundle.formatStringFromName("webextPerms.updateText", [
+ "<>",
+ ]);
+ result.text = "";
+ result.acceptText = bundle.GetStringFromName(
+ "webextPerms.updateAccept.label"
+ );
+ if (haveAccessKeys) {
+ result.acceptKey = bundle.GetStringFromName(
+ "webextPerms.updateAccept.accessKey"
+ );
+ }
+ } else if (info.type == "optional") {
+ result.header = bundle.formatStringFromName(
+ "webextPerms.optionalPermsHeader",
+ ["<>"]
+ );
+ result.text = "";
+ result.listIntro = bundle.GetStringFromName(
+ "webextPerms.optionalPermsListIntro"
+ );
+ result.acceptText = bundle.GetStringFromName(
+ "webextPerms.optionalPermsAllow.label"
+ );
+ result.cancelText = bundle.GetStringFromName(
+ "webextPerms.optionalPermsDeny.label"
+ );
+ if (haveAccessKeys) {
+ result.acceptKey = bundle.GetStringFromName(
+ "webextPerms.optionalPermsAllow.accessKey"
+ );
+ result.cancelKey = bundle.GetStringFromName(
+ "webextPerms.optionalPermsDeny.accessKey"
+ );
+ }
+ }
+
+ return result;
+ }
+}
+
+const PROXIED_EVENTS = new Set([
+ "test-harness-message",
+ "add-permissions",
+ "remove-permissions",
+]);
+
+class BootstrapScope {
+ install(data, reason) {}
+ uninstall(data, reason) {
+ 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) {
+ // 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,
+ });
+ }
+
+ startup(data, reason) {
+ // eslint-disable-next-line no-use-before-define
+ this.extension = new Extension(
+ data,
+ this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]
+ );
+ return this.extension.startup();
+ }
+
+ async shutdown(data, reason) {
+ let result = await this.extension.shutdown(
+ this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]
+ );
+ this.extension = null;
+ return result;
+ }
+}
+
+XPCOMUtils.defineLazyGetter(
+ BootstrapScope.prototype,
+ "BOOTSTRAP_REASON_TO_STRING_MAP",
+ () => {
+ const { BOOTSTRAP_REASONS } = AddonManagerPrivate;
+
+ return Object.freeze({
+ [BOOTSTRAP_REASONS.APP_STARTUP]: "APP_STARTUP",
+ [BOOTSTRAP_REASONS.APP_SHUTDOWN]: "APP_SHUTDOWN",
+ [BOOTSTRAP_REASONS.ADDON_ENABLE]: "ADDON_ENABLE",
+ [BOOTSTRAP_REASONS.ADDON_DISABLE]: "ADDON_DISABLE",
+ [BOOTSTRAP_REASONS.ADDON_INSTALL]: "ADDON_INSTALL",
+ [BOOTSTRAP_REASONS.ADDON_UNINSTALL]: "ADDON_UNINSTALL",
+ [BOOTSTRAP_REASONS.ADDON_UPGRADE]: "ADDON_UPGRADE",
+ [BOOTSTRAP_REASONS.ADDON_DOWNGRADE]: "ADDON_DOWNGRADE",
+ });
+ }
+);
+
+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(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+ }
+
+ shutdown(data, reason) {
+ this.dictionary.shutdown(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+ this.dictionary = null;
+ }
+}
+
+class LangpackBootstrapScope extends BootstrapScope {
+ install(data, reason) {}
+ uninstall(data, reason) {}
+ update(data, reason) {}
+
+ startup(data, reason) {
+ // eslint-disable-next-line no-use-before-define
+ this.langpack = new Langpack(data);
+ return this.langpack.startup(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+ }
+
+ shutdown(data, reason) {
+ this.langpack.shutdown(this.BOOTSTRAP_REASON_TO_STRING_MAP[reason]);
+ this.langpack = null;
+ }
+}
+
+let activeExtensionIDs = new Set();
+
+let pendingExtensions = new Map();
+
+/**
+ * This class is the main representation of an active WebExtension
+ * in the main process.
+ * @extends ExtensionData
+ */
+class Extension extends ExtensionData {
+ constructor(addonData, startupReason) {
+ super(addonData.resourceURI);
+
+ this.startupStates = new Set();
+ this.state = "Not started";
+
+ 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;
+ }
+
+ this.addonData = addonData;
+ this.startupData = addonData.startupData || {};
+ this.startupReason = startupReason;
+
+ if (["ADDON_UPGRADE", "ADDON_DOWNGRADE"].includes(startupReason)) {
+ StartupCache.clearAddonData(addonData.id);
+ }
+
+ this.remote = !WebExtensionPolicy.isExtensionProcess;
+ this.remoteType = this.remote ? E10SUtils.EXTENSION_REMOTE_TYPE : null;
+
+ if (this.remote && 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();
+
+ 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") {
+ 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);
+ }
+
+ if (permissions.origins.length) {
+ let patterns = this.allowedOrigins.patterns.map(host => host.pattern);
+
+ this.allowedOrigins = new MatchPatternSet(
+ new Set([...patterns, ...permissions.origins]),
+ {
+ restrictSchemes: this.restrictSchemes,
+ ignorePath: true,
+ }
+ );
+ }
+
+ this.policy.permissions = Array.from(this.permissions);
+ this.policy.allowedOrigins = this.allowedOrigins;
+
+ this.cachePermissions();
+ this.updatePermissions();
+ });
+
+ this.on("remove-permissions", (ignoreEvent, permissions) => {
+ for (let perm of permissions.permissions) {
+ this.permissions.delete(perm);
+ }
+
+ let origins = permissions.origins.map(
+ origin => new MatchPattern(origin, { ignorePath: true }).pattern
+ );
+
+ this.allowedOrigins = new MatchPatternSet(
+ this.allowedOrigins.patterns.filter(
+ host => !origins.includes(host.pattern)
+ )
+ );
+
+ this.policy.permissions = Array.from(this.permissions);
+ this.policy.allowedOrigins = this.allowedOrigins;
+
+ 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);
+ }
+
+ 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;
+ }
+
+ 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.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];
+ }
+
+ get isPrivileged() {
+ return (
+ this.addonData.signedState === AddonManager.SIGNEDSTATE_PRIVILEGED ||
+ this.addonData.signedState === AddonManager.SIGNEDSTATE_SYSTEM ||
+ this.addonData.builtIn ||
+ (AddonSettings.EXPERIMENTS_ENABLED && this.addonData.temporarilyInstalled)
+ );
+ }
+
+ get experimentsAllowed() {
+ return AddonSettings.EXPERIMENTS_ENABLED || this.isPrivileged;
+ }
+
+ saveStartupData() {
+ if (this.dontSaveStartupData) {
+ return;
+ }
+ XPIProvider.setStartupData(this.id, this.startupData);
+ }
+
+ parseManifest() {
+ 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 manifestVersion() {
+ return this.manifest.manifest_version;
+ }
+
+ 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 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);
+ }
+
+ // 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,
+ manifestVersion: this.manifestVersion,
+ extensionPageCSP: this.extensionPageCSP,
+ instanceId: this.instanceId,
+ resourceURL: this.resourceURL,
+ contentScripts: this.contentScripts,
+ webAccessibleResources: this.webAccessibleResources.map(res => res.glob),
+ allowedOrigins: this.allowedOrigins.patterns.map(pat => pat.pattern),
+ permissions: this.permissions,
+ optionalPermissions: this.optionalPermissions,
+ isPrivileged: this.isPrivileged,
+ };
+ }
+
+ // Extended serialized data which is only needed in the extensions process,
+ // and is never deserialized in web content processes.
+ serializeExtended() {
+ return {
+ backgroundScripts: this.backgroundScripts,
+ backgroundWorkerScript: this.backgroundWorkerScript,
+ childModules: this.modules && this.modules.child,
+ dependencies: this.dependencies,
+ 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);
+ }
+
+ /**
+ * Update site permissions as necessary.
+ *
+ * @param {string|undefined} 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,
+ "indexedDB",
+ 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, "indexedDB");
+ Services.perms.removeFromPrincipal(principal, "persistent-storage");
+ }
+ } else if (
+ reason === "APP_STARTUP" &&
+ this.hasPermission("unlimitedStorage") &&
+ (testPermission("indexedDB") !== Services.perms.ALLOW_ACTION ||
+ 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");
+ }
+ }
+ }
+
+ static async migratePrivateBrowsing(addonData) {
+ if (addonData.incognito !== "not_allowed") {
+ ExtensionPermissions.add(addonData.id, {
+ permissions: [PRIVATE_ALLOWED_PERMISSION],
+ origins: [],
+ });
+ await StartupCache.clearAddonData(addonData.id);
+
+ // Record a telemetry event for the extension automatically allowed on private browsing as
+ // part of the Firefox upgrade.
+ AMTelemetry.recordActionEvent({
+ extra: { addonId: addonData.id },
+ object: "appUpgrade",
+ action: "privateBrowsingAllowed",
+ value: "on",
+ });
+ }
+ }
+
+ async startup() {
+ this.state = "Startup";
+
+ // readyPromise is resolved with the policy upon success,
+ // and with null if startup was interrupted.
+ 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,
+ 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,
+ });
+ sharedData.set("extensions/pending", pendingExtensions);
+
+ 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;
+ }
+
+ // We automatically add permissions to system/built-in extensions.
+ // Extensions expliticy stating not_allowed will never get permission.
+ if (!allowPrivateBrowsingByDefault) {
+ 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) {
+ ExtensionPermissions.remove(this.id, {
+ permissions: [PRIVATE_ALLOWED_PERMISSION],
+ origins: [],
+ });
+ this.permissions.delete(PRIVATE_ALLOWED_PERMISSION);
+ }
+ } else if (
+ !isAllowed &&
+ this.isPrivileged &&
+ !this.addonData.temporarilyInstalled
+ ) {
+ // Add to EP so it is preserved after ADDON_INSTALL. We don't wait on the add here
+ // since we are pushing the value into this.permissions. EP will eventually save.
+ ExtensionPermissions.add(this.id, {
+ permissions: [PRIVATE_ALLOWED_PERMISSION],
+ origins: [],
+ });
+ this.permissions.add(PRIVATE_ALLOWED_PERMISSION);
+ }
+ }
+
+ // Ensure devtools permission is set
+ if (
+ this.manifest.devtools_page &&
+ !this.manifest.optional_permissions.includes("devtools")
+ ) {
+ ExtensionPermissions.add(this.id, {
+ permissions: ["devtools"],
+ origins: [],
+ });
+ this.permissions.add("devtools");
+ }
+
+ GlobalManager.init(this);
+
+ this.initSharedData();
+
+ this.policy.active = false;
+ this.policy = 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 (!ExtensionStorageIDB.isBackendEnabled) {
+ this.setSharedData("storageIDBBackend", false);
+ } else if (ExtensionStorageIDB.isMigratedExtension(this)) {
+ this.setSharedData("storageIDBBackend", true);
+ this.setSharedData(
+ "storageIDBPrincipal",
+ 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.
+ ExtensionStorageIDB.setMigratedExtensionPref(this, true);
+ this.setSharedData("storageIDBBackend", true);
+ this.setSharedData(
+ "storageIDBPrincipal",
+ ExtensionStorageIDB.getStoragePrincipal(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 {
+ 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);
+ }
+ }
+
+ 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") &&
+ ExtensionStorageIDB.selectedBackendPromises.has(this)
+ ) {
+ this.state = "Shutdown: Storage";
+
+ // Wait the data migration to complete.
+ try {
+ await 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);
+
+ 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}`
+ );
+ }
+
+ MessageChannel.abortResponses({ extensionId: this.id });
+
+ 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 { restrictSchemes, isPrivileged } = this;
+ let origins = this.manifest.optional_permissions.filter(
+ perm => classifyPermission(perm, restrictSchemes, isPrivileged).origin
+ );
+ this._optionalOrigins = new MatchPatternSet(origins, {
+ restrictSchemes,
+ ignorePath: true,
+ });
+ }
+ return this._optionalOrigins;
+ }
+}
+
+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;
+
+ spellCheck.addDictionary(lang, uri);
+ }
+
+ Management.emit("ready", this);
+ }
+
+ async shutdown(reason) {
+ if (reason !== "APP_SHUTDOWN") {
+ XPIProvider.unregisterDictionaries(this.dictionaries);
+ }
+ }
+}
+
+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 = aomStartup.registerChrome(
+ manifestURI,
+ this.startupData.chromeEntries
+ );
+ }
+
+ const langpackId = this.startupData.langpackId;
+ const l10nRegistrySources = this.startupData.l10nRegistrySources;
+
+ resourceProtocol.setSubstitution(langpackId, this.rootURI);
+
+ const fileSources = Object.entries(l10nRegistrySources).map(entry => {
+ const [sourceName, basePath] = entry;
+ return new FileSource(
+ `${sourceName}-${langpackId}`,
+ this.startupData.languages,
+ `resource://${langpackId}/${basePath}localization/{locale}/`
+ );
+ });
+
+ L10nRegistry.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.removeSources(sourcesToRemove);
+
+ if (this.chromeRegistryHandle) {
+ this.chromeRegistryHandle.destruct();
+ this.chromeRegistryHandle = null;
+ }
+
+ resourceProtocol.setSubstitution(this.startupData.langpackId, null);
+ }
+}
diff --git a/toolkit/components/extensions/ExtensionActions.jsm b/toolkit/components/extensions/ExtensionActions.jsm
new file mode 100644
index 0000000000..d448240b6a
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionActions.jsm
@@ -0,0 +1,510 @@
+/* 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 EXPORTED_SYMBOLS = ["BrowserActionBase", "PageActionBase"];
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { ExtensionError } = ExtensionUtils;
+
+const { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+const { IconDetails, StartupCache } = ExtensionParent;
+
+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 || "",
+ };
+ 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);
+ });
+ }
+
+ 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
+ * String property to retrieve. Should should be one of "icon", "title",
+ * "badgeText", "popup", "badgeBackgroundColor" or "enabled".
+ * @returns {string} 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);
+ }
+ }
+
+ 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}` });
+ }
+ 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.
+ * @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;
+ }
+}
+
+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 <all_urls> 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);
+ },
+ };
+ }
+}
+
+class BrowserActionBase extends PanelActionBase {
+ constructor(tabContext, extension) {
+ const options = extension.manifest.browser_action;
+ super(options, tabContext, extension);
+
+ this.defaults = {
+ ...this.defaults,
+ badgeText: "",
+ badgeBackgroundColor: [0xd9, 0, 0, 255],
+ badgeDefaultColor: [255, 255, 255, 255],
+ badgeTextColor: null,
+ default_area: options.default_area || "navbar",
+ };
+ this.globals = Object.create(this.defaults);
+ }
+
+ async loadIconData() {
+ const { extension } = this;
+ const options = extension.manifest.browser_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;
+ }
+
+ 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.jsm b/toolkit/components/extensions/ExtensionActivityLog.jsm
new file mode 100644
index 0000000000..1041e6234c
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionActivityLog.jsm
@@ -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/. */
+"use strict";
+
+const EXPORTED_SYMBOLS = ["ExtensionActivityLog"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+XPCOMUtils.defineLazyGetter(this, "tabTracker", () => {
+ return ExtensionParent.apiManager.global.tabTracker;
+});
+
+var { DefaultMap } = ExtensionUtils;
+
+const MSG_SET_ENABLED = "Extension:ActivityLog:SetEnabled";
+const MSG_LOG = "Extension:ActivityLog:DoLog";
+
+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 {string} 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 = 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.jsm b/toolkit/components/extensions/ExtensionChild.jsm
new file mode 100644
index 0000000000..5da616d651
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -0,0 +1,978 @@
+/* -*- 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";
+
+/* exported ExtensionChild */
+
+var EXPORTED_SYMBOLS = ["ExtensionChild", "ExtensionActivityLogChild"];
+
+/**
+ * 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.
+ */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "finalizationService",
+ "@mozilla.org/toolkit/finalizationwitness;1",
+ "nsIFinalizationWitnessService"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ ExtensionContent: "resource://gre/modules/ExtensionContent.jsm",
+ ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.jsm",
+ ExtensionProcessScript: "resource://gre/modules/ExtensionProcessScript.jsm",
+ MessageChannel: "resource://gre/modules/MessageChannel.jsm",
+ NativeApp: "resource://gre/modules/NativeMessaging.jsm",
+ PerformanceCounters: "resource://gre/modules/PerformanceCounters.jsm",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
+});
+
+// We're using the pref to avoid loading PerformanceCounters.jsm for nothing.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gTimingEnabled",
+ "extensions.webextensions.enablePerformanceCounters",
+ false
+);
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+const { DefaultMap, ExtensionError, LimitedSet, getUniqueId } = ExtensionUtils;
+
+const {
+ EventEmitter,
+ EventManager,
+ LocalAPIImplementation,
+ LocaleData,
+ NoCloneSpreadArgs,
+ SchemaAPIInterface,
+ withHandlingUserInput,
+} = ExtensionCommon;
+
+const { sharedData } = Services.cpmm;
+
+const isContentProcess =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT;
+
+const MSG_SET_ENABLED = "Extension:ActivityLog:SetEnabled";
+const MSG_LOG = "Extension:ActivityLog:DoLog";
+
+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 = 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) {
+ super({ context, name });
+ this.fires = new Set();
+ this.register = fire => {
+ this.fires.add(fire);
+ fire.location = context.getCaller();
+ return () => this.fires.delete(fire);
+ };
+ }
+ 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(data, native = null) {
+ if (native && AppConstants.platform !== "android") {
+ data = NativeApp.encodeMessage(native.context, data);
+ }
+ return new StructuredCloneHolder(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.holdMessage = native ? data => holdMessage(data, this) : holdMessage;
+
+ this.conduit = context.openConduit(this, {
+ portId,
+ native,
+ source: !sender,
+ recv: ["PortMessage", "PortDisconnect"],
+ send: ["PortMessage"],
+ });
+
+ this.onMessage = new SimpleEventAPI(context, "Port.onMessage");
+ this.onDisconnect = new SimpleEventAPI(context, "Port.onDisconnect");
+
+ // Public Port object handed to extensions from `connect()` and `onConnect`.
+ let api = {
+ name,
+ sender,
+ error: null,
+ onMessage: this.onMessage.api(),
+ onDisconnect: this.onDisconnect.api(),
+ postMessage: this.sendPortMessage.bind(this),
+ disconnect: () => this.conduit.close(),
+ };
+ this.api = Cu.cloneInto(api, context.cloneScope, { cloneFunctions: true });
+ }
+
+ 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(json) });
+ }
+ throw new this.context.Error("Attempt to postMessage on disconnected port");
+ }
+}
+
+/**
+ * Each extension context gets its own Messenger object. It handles the
+ * basics of sendMessage, onMessage, connect and onConnect.
+ */
+class Messenger {
+ constructor(context, sender) {
+ this.context = context;
+ this.conduit = context.openConduit(this, {
+ url: sender.url,
+ frameId: sender.frameId,
+ childId: context.childManager.id,
+ query: ["NativeMessage", "RuntimeMessage", "PortConnect"],
+ recv: ["RuntimeMessage", "PortConnect"],
+ });
+
+ 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(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(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;
+ this.instanceId = policy.instanceId;
+ this.optionalPermissions = policy.optionalPermissions;
+
+ if (WebExtensionPolicy.isExtensionProcess) {
+ Object.assign(this, this.getSharedData("extendedData"));
+ }
+
+ this.MESSAGE_EMIT_EVENT = `Extension:EmitEvent:${this.instanceId}`;
+ Services.cpmm.addMessageListener(this.MESSAGE_EMIT_EVENT, this);
+
+ let restrictSchemes = !this.hasPermission("mozillaAddons");
+
+ 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.views = new Set();
+
+ // Only used for devtools views.
+ this.devtoolsViews = new Set();
+
+ /* eslint-disable mozilla/balanced-listeners */
+ this.on("add-permissions", (ignoreEvent, permissions) => {
+ if (permissions.permissions.length) {
+ let perms = new Set(this.policy.permissions);
+ for (let perm of permissions.permissions) {
+ perms.add(perm);
+ }
+ this.policy.permissions = perms;
+ }
+
+ if (permissions.origins.length) {
+ let patterns = this.allowedOrigins.patterns.map(host => host.pattern);
+
+ this.policy.allowedOrigins = new MatchPatternSet(
+ [...patterns, ...permissions.origins],
+ { restrictSchemes, ignorePath: true }
+ );
+ }
+ });
+
+ this.on("remove-permissions", (ignoreEvent, permissions) => {
+ if (permissions.permissions.length) {
+ let perms = new Set(this.policy.permissions);
+ for (let perm of permissions.permissions) {
+ perms.delete(perm);
+ }
+ this.policy.permissions = perms;
+ }
+
+ if (permissions.origins.length) {
+ let origins = permissions.origins.map(
+ origin => new MatchPattern(origin, { ignorePath: true }).pattern
+ );
+
+ this.policy.allowedOrigins = new MatchPatternSet(
+ this.allowedOrigins.patterns.filter(
+ host => !origins.includes(host.pattern)
+ )
+ );
+ }
+ });
+ /* eslint-enable mozilla/balanced-listeners */
+
+ 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;
+ }
+
+ get webAccessibleResources() {
+ return this.policy.webAccessibleResources;
+ }
+
+ 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 privateBrowsingAllowed() {
+ return this.policy.privateBrowsingAllowed;
+ }
+
+ canAccessWindow(window) {
+ return this.policy.canAccessWindow(window);
+ }
+
+ getAPIManager() {
+ let apiManagers = [ExtensionPageChild.apiManager];
+
+ if (this.dependencies) {
+ for (let id of this.dependencies) {
+ let extension = 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);
+ ExtensionContent.shutdownExtension(this);
+ Services.cpmm.removeMessageListener(this.MESSAGE_EMIT_EVENT, this);
+ if (isContentProcess) {
+ MessageChannel.abortResponses({ extensionId: this.id });
+ }
+ this.emit("shutdown");
+ }
+
+ getContext(window) {
+ return ExtensionContent.getContext(this, window);
+ }
+
+ emit(event, ...args) {
+ Services.cpmm.sendAsyncMessage(this.MESSAGE_EMIT_EVENT, { event, args });
+ 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);
+ }
+}
+
+/**
+ * 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) {
+ if (requireUserInput) {
+ let context = this.childApiManager.context;
+ if (!context.contentWindow.windowUtils.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 }
+ );
+ }
+
+ 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 PerformanceCounters and/or activity logging)
+ *
+ * @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() * 1000;
+ try {
+ return callable();
+ } finally {
+ if (gTimingEnabled) {
+ let end = Cu.now() * 1000;
+ PerformanceCounters.storeExecutionTime(
+ this.context.extension.id,
+ this.name,
+ end - start,
+ this.childApiManagerId
+ );
+ }
+ }
+ }
+
+ 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 message manager 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", "APICall", "AddListener", "RemoveListener"],
+ recv: ["CallResult", "RunListener"],
+ });
+
+ 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("add-permissions", this.updatePermissions);
+ this.context.extension.on("remove-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) {
+ 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(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`
+ );
+ }
+ }
+
+ /**
+ * 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 {function(*)} [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 = PromiseUtils.defer();
+ this.callPromises.set(callId, deferred);
+
+ // 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.
+ let { alreadyLogged = true } = options;
+
+ // TODO: conduit.queryAPICall()
+ this.conduit.sendAPICall({
+ childId: this.id,
+ callId,
+ path,
+ args,
+ options: { alreadyLogged },
+ });
+ 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) {
+ path = path.split(".");
+
+ let name = path.pop();
+ let namespace = path.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("add-permissions", this.updatePermissions);
+ this.context.extension.off("remove-permissions", this.updatePermissions);
+ }
+ }
+
+ get cloneScope() {
+ return this.context.cloneScope;
+ }
+
+ get principal() {
+ return this.context.principal;
+ }
+
+ 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);
+ }
+}
+
+var ExtensionChild = {
+ BrowserExtensionContent,
+ ChildAPIManager,
+ Messenger,
+};
diff --git a/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm b/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm
new file mode 100644
index 0000000000..b8754b8e04
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionChildDevToolsUtils.jsm
@@ -0,0 +1,116 @@
+/* -*- 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";
+
+/**
+ * @fileOverview
+ * This module contains utilities for interacting with DevTools
+ * from the child process.
+ */
+
+var EXPORTED_SYMBOLS = ["ExtensionChildDevToolsUtils"];
+
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// 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;
+ }
+}
+
+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 {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.jsm b/toolkit/components/extensions/ExtensionCommon.jsm
new file mode 100644
index 0000000000..50585afc3a
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionCommon.jsm
@@ -0,0 +1,2619 @@
+/* -*- 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";
+
+/**
+ * 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.
+ */
+
+/* exported ExtensionCommon */
+
+var EXPORTED_SYMBOLS = ["ExtensionCommon"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ ConsoleAPI: "resource://gre/modules/Console.jsm",
+ MessageChannel: "resource://gre/modules/MessageChannel.jsm",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
+ Schemas: "resource://gre/modules/Schemas.jsm",
+ SchemaRoot: "resource://gre/modules/Schemas.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "styleSheetService",
+ "@mozilla.org/content/style-sheet-service;1",
+ "nsIStyleSheetService"
+);
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+var {
+ DefaultMap,
+ DefaultWeakMap,
+ ExtensionError,
+ filterStack,
+ getInnerWindowID,
+ getUniqueId,
+} = ExtensionUtils;
+
+function getConsole() {
+ return new ConsoleAPI({
+ maxLogLevelPref: "extensions.webextensions.log.level",
+ prefix: "WebExtensions",
+ });
+}
+
+XPCOMUtils.defineLazyGetter(this, "console", getConsole);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "DELAYED_BG_STARTUP",
+ "extensions.webextensions.background-delayed-startup"
+);
+
+var ExtensionCommon;
+
+// Run a function and report exceptions.
+function runSafeSyncWithoutClone(f, ...args) {
+ try {
+ return f(...args);
+ } catch (e) {
+ dump(
+ `Extension error: ${e} ${e.fileName} ${
+ e.lineNumber
+ }\n[[Exception stack\n${filterStack(e)}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.
+ *
+ * @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 {function} getter
+ * The function to call in order to generate the final property
+ * value.
+ */
+function defineLazyGetter(object, prop, getter) {
+ let redefine = (obj, value) => {
+ Object.defineProperty(obj, prop, {
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ value,
+ });
+ return value;
+ };
+
+ Object.defineProperty(object, prop, {
+ enumerable: true,
+ configurable: true,
+
+ get() {
+ return redefine(this, getter.call(this));
+ },
+
+ set(value) {
+ redefine(this, value);
+ },
+ });
+}
+
+function checkLoadURL(url, 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, Services.io.newURI(url), flags);
+ } catch (e) {
+ return false;
+ }
+ return true;
+}
+
+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)} 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)} 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)} listener
+ * The listener to call when events are emitted.
+ */
+ once(event, listener) {
+ let wrapper = (...args) => {
+ this.off(event, wrapper);
+ this[ONCE_MAP].delete(listener);
+
+ return listener(...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() {}
+
+ onManifestEntry(entry) {}
+
+ getAPI(context) {
+ throw new Error("Not Implemented");
+ }
+}
+
+/**
+ * A wrapper around a window that returns the window iff the inner window
+ * matches the inner window at the construction of this wrapper.
+ *
+ * This wrapper should not be used after the inner window is destroyed.
+ **/
+class InnerWindowReference {
+ constructor(contentWindow, innerWindowID) {
+ this.contentWindow = contentWindow;
+ this.innerWindowID = innerWindowID;
+ this.needWindowIDCheck = false;
+
+ contentWindow.addEventListener(
+ "pagehide",
+ this,
+ { mozSystemGroup: true },
+ false
+ );
+ contentWindow.addEventListener(
+ "pageshow",
+ this,
+ { mozSystemGroup: true },
+ false
+ );
+ }
+
+ get() {
+ // If the pagehide event has fired, the inner window ID needs to be checked,
+ // in case the window ref is dereferenced in a pageshow listener (before our
+ // pageshow listener was dispatched) or during the unload event.
+ if (
+ !this.needWindowIDCheck ||
+ getInnerWindowID(this.contentWindow) === this.innerWindowID
+ ) {
+ return this.contentWindow;
+ }
+ return null;
+ }
+
+ invalidate() {
+ // If invalidate() is called while the inner window is in the bfcache, then
+ // we are unable to remove the event listener, and handleEvent will be
+ // called once more if the page is revived from the bfcache.
+ if (this.contentWindow && !Cu.isDeadWrapper(this.contentWindow)) {
+ this.contentWindow.removeEventListener("pagehide", this, {
+ mozSystemGroup: true,
+ });
+ this.contentWindow.removeEventListener("pageshow", this, {
+ mozSystemGroup: true,
+ });
+ }
+ this.contentWindow = null;
+ this.needWindowIDCheck = false;
+ }
+
+ handleEvent(event) {
+ if (this.contentWindow) {
+ this.needWindowIDCheck = event.type === "pagehide";
+ } else {
+ // Remove listener when restoring from the bfcache - see invalidate().
+ event.currentTarget.removeEventListener("pagehide", this, {
+ mozSystemGroup: true,
+ });
+ event.currentTarget.removeEventListener("pageshow", this, {
+ mozSystemGroup: true,
+ });
+ }
+ }
+}
+
+/**
+ * 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 {
+ 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.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 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;
+ }
+
+ canAccessWindow(window) {
+ return this.extension.canAccessWindow(window);
+ }
+
+ /**
+ * 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 {PointConduit}
+ */
+ 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 = PrivateBrowsingUtils.isContentWindowPrivate(
+ contentWindow
+ );
+ }
+
+ MessageChannel.setupMessageManagers([this.messageManager]);
+
+ let windowRef = new InnerWindowReference(contentWindow, this.innerWindowID);
+ Object.defineProperty(this, "active", {
+ configurable: true,
+ enumerable: true,
+ get: () => windowRef.get() !== null,
+ });
+ Object.defineProperty(this, "contentWindow", {
+ configurable: true,
+ enumerable: true,
+ get: () => windowRef.get(),
+ });
+ this.callOnClose({
+ close: () => {
+ // Allow other "close" handlers to use these properties, until the next tick.
+ Promise.resolve().then(() => {
+ windowRef.invalidate();
+ windowRef = null;
+ Object.defineProperty(this, "contentWindow", { value: null });
+ Object.defineProperty(this, "active", { value: false });
+ });
+ },
+ });
+ }
+
+ // 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}`);
+ }
+
+ get cloneScope() {
+ throw new Error("Not implemented");
+ }
+
+ 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) {
+ dump(
+ `Extension error: ${e} ${e.fileName} ${
+ e.lineNumber
+ }\n[[Exception stack\n${filterStack(e)}Current stack\n${filterStack(
+ Error()
+ )}]]\n`
+ );
+ 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 {array<any>} args Arguments 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);
+ }
+
+ /**
+ * A wrapper around MessageChannel.sendMessage which adds the extension ID
+ * to the recipient object, and ensures replies are not processed after the
+ * context has been unloaded.
+ *
+ * @param {nsIMessageManager} target
+ * @param {string} messageName
+ * @param {object} data
+ * @param {object} [options]
+ * @param {object} [options.sender]
+ * @param {object} [options.recipient]
+ *
+ * @returns {Promise}
+ */
+ sendMessage(target, messageName, data, options = {}) {
+ options.recipient = Object.assign(
+ { extensionId: this.extension.id },
+ options.recipient
+ );
+ options.sender = options.sender || {};
+
+ options.sender.extensionId = this.extension.id;
+ options.sender.contextId = this.contextId;
+
+ return MessageChannel.sendMessage(target, messageName, data, options);
+ }
+
+ 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;
+
+ MessageChannel.abortResponses({
+ extensionId: this.extension.id,
+ contextId: this.contextId,
+ });
+
+ 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 {function(*)} [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][Schemas.REVOKE]) {
+ this.pathObj[this.name][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 (!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<string>} scopes
+ * The list of scope names into which the API may be loaded.
+ *
+ * @property {Array<string>} 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<string>} 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<string>} permissions
+ * An optional list of permissions, any of which must be present
+ * in order for the module to load.
+ *
+ * @property {Array<Array<string>>} 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 {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({
+ 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<ExtensionAPI>?}
+ */
+ 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 {class}
+ */
+ 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<class>}
+ */
+ 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.permissions &&
+ !module.permissions.some(perm => extension.hasPermission(perm))
+ ) {
+ return false;
+ }
+
+ if (!scope) {
+ return true;
+ }
+
+ if (!module.scopes.includes(scope)) {
+ return false;
+ }
+
+ if (!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, {
+ Cc,
+ ChromeWorker,
+ Ci,
+ Cr,
+ Cu,
+ ExtensionAPI,
+ ExtensionCommon,
+ MatchGlob,
+ MatchPattern,
+ MatchPatternSet,
+ StructuredCloneHolder,
+ XPCOMUtils,
+ extensions: this,
+ global,
+ });
+
+ ChromeUtils.import("resource://gre/modules/AppConstants.jsm", global);
+
+ XPCOMUtils.defineLazyGetter(global, "console", getConsole);
+
+ XPCOMUtils.defineLazyModuleGetters(global, {
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm",
+ XPCOMUtils: "resource://gre/modules/XPCOMUtils.jsm",
+ });
+
+ 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);
+
+ this.initialized = false;
+
+ this.initModuleData(moduleData);
+
+ this.schemaURLs = schemaURLs;
+ }
+}
+
+defineLazyGetter(LazyAPIManager.prototype, "schema", function() {
+ let root = new SchemaRoot(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] === Schemas) {
+ bases.pop();
+ }
+
+ if (bases.length === 1) {
+ bases = bases[0];
+ }
+ return new SchemaRoot(bases, new Map());
+});
+
+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;
+ },
+};
+
+defineLazyGetter(LocaleData.prototype, "availableLocales", function() {
+ return new Set(
+ [this.BUILTIN, this.selectedLocale, this.defaultLocale].filter(locale =>
+ this.messages.has(locale)
+ )
+ );
+});
+
+/**
+ * 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 {
+ /*
+ * @param {object} params
+ * Parameters that control this EventManager.
+ * @param {BaseContext} params.context
+ * An object representing the extension instance using this event.
+ * @param {string} params.name
+ * A name used only for debugging.
+ * @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.
+ * @param {object} [params.persistent]
+ * Details for persistent event listeners
+ * @param {string} params.persistent.module
+ * The name of the module in which this event is defined.
+ * @param {string} params.persistent.event
+ * The name of this event.
+ */
+ constructor(params) {
+ let {
+ context,
+ name,
+ register,
+ inputHandling = false,
+ persistent = null,
+ } = params;
+ this.context = context;
+ this.name = name;
+ this.register = register;
+ this.inputHandling = inputHandling;
+ this.persistent = persistent;
+
+ // Don't bother with persistent event handling if delayed background
+ // startup is not enabled.
+ if (!DELAYED_BG_STARTUP) {
+ this.persistent = null;
+ }
+
+ this.unregister = new Map();
+ this.remove = new Map();
+
+ if (this.persistent) {
+ if (AppConstants.DEBUG) {
+ if (this.context.envType !== "addon_parent") {
+ throw new Error(
+ "Persistent event managers can only be created for addon_parent"
+ );
+ }
+ if (!this.persistent.module || !this.persistent.event) {
+ throw new Error(
+ "Persistent event manager must specify module and event"
+ );
+ }
+ }
+ if (this.context.viewType !== "background") {
+ this.persistent = null;
+ }
+ }
+ }
+
+ /*
+ * 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 is a plain object with a property called
+ * `params` that is the original (ie, not uneval()ed) extra arguments to
+ * addListener(). For a primed listener (i.e., the stub listener created
+ * during browser startup before the extension background page is started,
+ * the object also has a `primed` property that holds the things needed
+ * to handle events during startup and eventually connect the listener
+ * with a callback registered from the extension.
+ *
+ * @param {Extension} extension
+ * @returns {boolean} True if the extension had any persistent listeners.
+ */
+ static _initPersistentListeners(extension) {
+ if (extension.persistentListeners) {
+ return false;
+ }
+
+ let listeners = new DefaultMap(() => new DefaultMap(() => new Map()));
+ extension.persistentListeners = listeners;
+
+ let { persistentListeners } = extension.startupData;
+ if (!persistentListeners) {
+ return false;
+ }
+
+ let found = false;
+ for (let [module, entry] of Object.entries(persistentListeners)) {
+ for (let [event, paramlists] of Object.entries(entry)) {
+ for (let paramlist of paramlists) {
+ let key = uneval(paramlist);
+ listeners
+ .get(module)
+ .get(event)
+ .set(key, { params: paramlist });
+ 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) {
+ startupListeners[module][event] = Array.from(
+ eventEntry.values(),
+ listener => listener.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) {
+ if (!EventManager._initPersistentListeners(extension)) {
+ return;
+ }
+
+ for (let [module, moduleEntry] of extension.persistentListeners) {
+ 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 listener of eventEntry.values()) {
+ let primed = { pendingEvents: [] };
+ listener.primed = primed;
+
+ let fireEvent = (...args) =>
+ new Promise((resolve, reject) => {
+ if (!listener.primed) {
+ reject(new Error("primed listener not re-registered"));
+ return;
+ }
+ primed.pendingEvents.push({ args, resolve, reject });
+ extension.emit("background-page-event");
+ });
+
+ let fire = {
+ wakeup: () => extension.wakeupBackground(),
+ sync: fireEvent,
+ async: fireEvent,
+ };
+
+ let { unregister, convert } = api.primeListener(
+ extension,
+ event,
+ fire,
+ listener.params
+ );
+ Object.assign(primed, { unregister, convert });
+ }
+ }
+ }
+ }
+
+ // Remove any primed listeners that were not re-registered.
+ // This function is called after the background page has started.
+ // The removed listeners are removed from the set of saved listeners, unless
+ // `clearPersistent` is false. If false, the listeners are cleared from
+ // memory, but not removed from the extension's startup data.
+ static clearPrimedListeners(extension, clearPersistent = true) {
+ for (let [module, moduleEntry] of extension.persistentListeners) {
+ for (let [event, listeners] of moduleEntry) {
+ for (let [key, listener] of listeners) {
+ let { primed } = listener;
+ if (!primed) {
+ continue;
+ }
+ listener.primed = null;
+
+ for (let evt of primed.pendingEvents) {
+ evt.reject(new Error("listener not re-registered"));
+ }
+
+ if (clearPersistent) {
+ EventManager.clearPersistentListener(extension, module, event, key);
+ }
+ primed.unregister();
+ }
+ }
+ }
+ }
+
+ // 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);
+ extension.persistentListeners
+ .get(module)
+ .get(event)
+ .set(key, { params: args });
+ EventManager._writePersistentListeners(extension);
+ }
+
+ // 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([])) {
+ let listeners = extension.persistentListeners.get(module).get(event);
+ listeners.delete(key);
+
+ if (listeners.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 fire = {
+ sync: (...args) => {
+ if (shouldFire()) {
+ 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()) {
+ 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");
+ }
+ 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()) {
+ let result = this.context.applySafeWithoutClone(callback, args);
+ this.context.logActivity("api_event", this.name, { args, result });
+ return result;
+ }
+ });
+ },
+ };
+
+ let { extension } = this.context;
+
+ 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.persistent) {
+ recordStartupData = true;
+ let { module, event } = this.persistent;
+
+ let key = uneval(args);
+ EventManager._initPersistentListeners(extension);
+ let listener = extension.persistentListeners
+ .get(module)
+ .get(event)
+ .get(key);
+
+ if (listener) {
+ // If extensions.webextensions.background-delayed-startup is disabled,
+ // we can have stored info here but no primed listener. This check
+ // can be removed if/when we make delayed background startup the only
+ // supported setting.
+ 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));
+ }
+ }
+
+ recordStartupData = false;
+ this.remove.set(callback, () => {
+ EventManager.clearPersistentListener(
+ extension,
+ module,
+ event,
+ uneval(args)
+ );
+ });
+ }
+ }
+
+ 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) {
+ let { module, event } = this.persistent;
+ EventManager.savePersistentListener(extension, module, event, args);
+ this.remove.set(callback, () => {
+ EventManager.clearPersistentListener(
+ extension,
+ module,
+ event,
+ uneval(args)
+ );
+ });
+ }
+ }
+
+ 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,
+ [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 styleSheetService.preloadSheet(uri, styleSheetService.AGENT_SHEET);
+});
+
+ExtensionCommon = {
+ BaseContext,
+ CanOfAPIs,
+ EventManager,
+ ExtensionAPI,
+ EventEmitter,
+ LocalAPIImplementation,
+ LocaleData,
+ NoCloneSpreadArgs,
+ SchemaAPIInterface,
+ SchemaAPIManager,
+ SpreadArgs,
+ checkLoadURL,
+ defineLazyGetter,
+ getConsole,
+ ignoreEvent,
+ instanceOf,
+ makeWidgetId,
+ normalizeTime,
+ runSafeSyncWithoutClone,
+ stylesheetMap,
+ withHandlingUserInput,
+
+ MultiAPIManager,
+ LazyAPIManager,
+};
diff --git a/toolkit/components/extensions/ExtensionContent.jsm b/toolkit/components/extensions/ExtensionContent.jsm
new file mode 100644
index 0000000000..cc1bc30d0b
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionContent.jsm
@@ -0,0 +1,1237 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ExtensionContent", "ExtensionContentChild"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ ExtensionProcessScript: "resource://gre/modules/ExtensionProcessScript.jsm",
+ ExtensionTelemetry: "resource://gre/modules/ExtensionTelemetry.jsm",
+ LanguageDetector: "resource:///modules/translation/LanguageDetector.jsm",
+ MessageChannel: "resource://gre/modules/MessageChannel.jsm",
+ Schemas: "resource://gre/modules/Schemas.jsm",
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "styleSheetService",
+ "@mozilla.org/content/style-sheet-service;1",
+ "nsIStyleSheetService"
+);
+
+const Timer = Components.Constructor(
+ "@mozilla.org/timer;1",
+ "nsITimer",
+ "initWithCallback"
+);
+
+const { ExtensionChild, ExtensionActivityLogChild } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionChild.jsm"
+);
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["crypto", "TextEncoder"]);
+
+const {
+ DefaultMap,
+ DefaultWeakMap,
+ getInnerWindowID,
+ promiseDocumentIdle,
+ promiseDocumentLoaded,
+ promiseDocumentReady,
+} = ExtensionUtils;
+
+const {
+ BaseContext,
+ CanOfAPIs,
+ SchemaAPIManager,
+ defineLazyGetter,
+ runSafeSyncWithoutClone,
+} = ExtensionCommon;
+
+const { BrowserExtensionContent, ChildAPIManager, Messenger } = ExtensionChild;
+
+XPCOMUtils.defineLazyGetter(this, "console", ExtensionCommon.getConsole);
+
+XPCOMUtils.defineLazyGetter(this, "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", 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();
+ }
+
+ 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, null, extension);
+ this.options = options;
+ }
+
+ defaultConstructor(url) {
+ let promise = ChromeUtils.compileScript(url, this.options);
+ promise.then(script => {
+ promise.script = script;
+ });
+ return promise;
+ }
+}
+
+/**
+ * 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;
+ }
+ }
+
+ 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 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;
+ }
+ const uri = Services.io.newURI(
+ "data:text/css;charset=utf-8," + encodeURIComponent(cssCode)
+ );
+ const value = 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"];
+
+ 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) {
+ return this.matcher.matchesWindowGlobal(windowGlobal);
+ }
+
+ async injectInto(window) {
+ if (!isContentScriptProcess) {
+ return;
+ }
+
+ 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);
+ } 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 {BaseContext} context
+ * The content script context into which to inject the scripts.
+ * @returns {Promise<any>}
+ * Resolves to the last value in the evaluated script, when
+ * execution is complete.
+ */
+ async inject(context) {
+ 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.
+ ExtensionTelemetry.contentScriptInjection.stopwatchStart(
+ extension,
+ context
+ );
+ try {
+ for (let script of scripts) {
+ result = script.executeInGlobal(context.cloneScope);
+ }
+
+ if (this.matcher.jsCode) {
+ result = Cu.evalInSandbox(
+ this.matcher.jsCode,
+ context.cloneScope,
+ "latest",
+ "sandbox eval code",
+ 1
+ );
+ }
+ } finally {
+ 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 {BaseContext} context
+ * The document to block the parsing on, if the scripts are not yet precompiled and cached.
+ *
+ * @returns {Array<PreloadedScript> | Promise<Array<PreloadedScript>>}
+ * 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 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<ContentScriptContextChild, Sandbox>
+ this.sandboxes = new DefaultWeakMap(context => {
+ return this.createSandbox(context);
+ });
+ }
+
+ async inject(context) {
+ const { extension } = 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);
+ }
+
+ // The evaluations below may throw, in which case the promise will be
+ // automatically rejected.
+ ExtensionTelemetry.userScriptInjection.stopwatchStart(extension, context);
+ try {
+ 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);
+ }
+ } finally {
+ ExtensionTelemetry.userScriptInjection.stopwatchFinish(
+ extension,
+ context
+ );
+ }
+ }
+
+ 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"],
+ originAttributes: contentPrincipal.originAttributes,
+ metadata: {
+ "inner-window-id": context.innerWindowID,
+ addonId: this.extension.policy.id,
+ },
+ });
+
+ return sandbox;
+ }
+}
+
+var contentScripts = new DefaultWeakMap(matcher => {
+ const extension = 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 = 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,
+ };
+
+ this.sandbox = Cu.Sandbox(principal, {
+ metadata,
+ sandboxName: `Content Script ${extension.policy.debugName}`,
+ sandboxPrototype: contentWindow,
+ sameZoneAs: contentWindow,
+ wantXrays: true,
+ isWebExtensionContentScript: true,
+ wantExportHelpers: true,
+ wantGlobalProperties: ["XMLHttpRequest", "fetch"],
+ 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;
+
+ // 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),
+ };
+
+ window.JSON = JSON;
+ window.XMLHttpRequest = XMLHttpRequest;
+ window.fetch = fetch;
+ `,
+ this.sandbox
+ );
+ }
+
+ Object.defineProperty(this, "principal", {
+ value: Cu.getObjectPrincipal(this.sandbox),
+ enumerable: true,
+ configurable: true,
+ });
+
+ this.url = contentWindow.location.href;
+
+ defineLazyGetter(this, "chromeObj", () => {
+ let chromeObj = Cu.createObjectIn(this.sandbox);
+
+ this.childManager.inject(chromeObj);
+ return chromeObj;
+ });
+
+ Schemas.exportLazyGetter(this.sandbox, "browser", () => this.chromeObj);
+ 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)
+ Schemas.exportLazyGetter(
+ this.contentWindow,
+ "browser",
+ () => this.chromeObj
+ );
+ 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;
+ }
+}
+
+defineLazyGetter(ContentScriptContextChild.prototype, "messenger", function() {
+ return new Messenger(this, { frameId: this.frameId, url: this.url });
+});
+
+defineLazyGetter(
+ ContentScriptContextChild.prototype,
+ "childManager",
+ function() {
+ apiManager.lazyInit();
+
+ let localApis = {};
+ let can = new CanOfAPIs(this, apiManager, localApis);
+
+ let childManager = new ChildAPIManager(this, this.messageManager, can, {
+ envType: "content_parent",
+ url: this.url,
+ });
+
+ this.callOnClose(childManager);
+
+ return childManager;
+ }
+);
+
+// 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;
+
+ MessageChannel.abortResponses({ innerWindowID: windowId });
+
+ // 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);
+ }
+ },
+ },
+
+ 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();
+ },
+};
+
+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;
+ },
+
+ handleDetectLanguage(global, target) {
+ let doc = target.content.document;
+
+ return promiseDocumentReady(doc).then(() => {
+ let elem = doc.documentElement;
+
+ let language =
+ elem.getAttribute("xml:lang") ||
+ elem.getAttribute("lang") ||
+ doc.contentLanguage ||
+ null;
+
+ // We only want the last element of the TLD here.
+ // Only country codes have any effect on the results, but other
+ // values cause no harm.
+ let tld = doc.location.hostname.match(/[a-z]*$/)[0];
+
+ // The CLD2 library used by the language detector is capable of
+ // analyzing raw HTML. Unfortunately, that takes much more memory,
+ // and since it's hosted by emscripten, and therefore can't shrink
+ // its heap after it's grown, it has a performance cost.
+ // So we send plain text instead.
+ let encoder = Cu.createDocumentEncoder("text/plain");
+ encoder.init(
+ doc,
+ "text/plain",
+ Ci.nsIDocumentEncoder.SkipInvisibleContent
+ );
+ let text = encoder.encodeToStringWithMaxLength(60 * 1024);
+
+ let encoding = doc.characterSet;
+
+ return LanguageDetector.detectLanguage({
+ language,
+ tld,
+ text,
+ encoding,
+ }).then(result => (result.language === "un" ? "und" : result.language));
+ });
+ },
+
+ // Used to executeScript, insertCSS and removeCSS.
+ async handleActorExecute({ options, windows }) {
+ let policy = WebExtensionPolicy.getByID(options.extensionId);
+ 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)) {
+ return script.injectInto(wg.browsingContext.window);
+ }
+ };
+
+ let all = Promise.all(windows.map(executeInWin).filter(p => p));
+ let result = await all.catch(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] ?? "<anonymous code>";
+ let message = `Script '${path}' result is non-structured-clonable data`;
+ return Promise.reject({ message, fileName: path });
+ }
+ },
+
+ handleWebNavigationGetFrame(global, { frameId }) {
+ return WebNavigationFrames.getFrame(global.docShell, frameId);
+ },
+
+ handleWebNavigationGetAllFrames(global) {
+ return WebNavigationFrames.getAllFrames(global.docShell);
+ },
+
+ async receiveMessage(global, name, target, data, recipient) {
+ switch (name) {
+ case "Extension:DetectLanguage":
+ return this.handleDetectLanguage(global, target);
+ case "WebNavigation:GetFrame":
+ return this.handleWebNavigationGetFrame(global, data.options);
+ case "WebNavigation:GetAllFrames":
+ return this.handleWebNavigationGetAllFrames(global);
+ }
+ return null;
+ },
+
+ // Helpers
+
+ *enumerateWindows(docShell) {
+ let docShells = docShell.getAllDocShellsInSubtree(
+ docShell.typeContent,
+ docShell.ENUMERATE_FORWARDS
+ );
+
+ for (let docShell of docShells) {
+ try {
+ yield docShell.domWindow;
+ } catch (e) {
+ // This can fail if the docShell is being destroyed, so just
+ // ignore the error.
+ }
+ }
+ },
+};
+
+/**
+ * Child side of the ExtensionContent process actor, handles some tabs.* APIs.
+ */
+class ExtensionContentChild extends JSProcessActorChild {
+ receiveMessage({ name, data }) {
+ if (!isContentScriptProcess) {
+ return;
+ }
+ switch (name) {
+ case "Execute":
+ return ExtensionContent.handleActorExecute(data);
+ }
+ }
+}
diff --git a/toolkit/components/extensions/ExtensionPageChild.jsm b/toolkit/components/extensions/ExtensionPageChild.jsm
new file mode 100644
index 0000000000..e54f5ff169
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionPageChild.jsm
@@ -0,0 +1,495 @@
+/* -*- 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";
+
+/* exported ExtensionPageChild */
+
+var EXPORTED_SYMBOLS = ["ExtensionPageChild"];
+
+/**
+ * This file handles privileged extension page logic that runs in the
+ * child process.
+ */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionChildDevToolsUtils",
+ "resource://gre/modules/ExtensionChildDevToolsUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionProcessScript",
+ "resource://gre/modules/ExtensionProcessScript.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Schemas",
+ "resource://gre/modules/Schemas.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "WebNavigationFrames",
+ "resource://gre/modules/WebNavigationFrames.jsm"
+);
+
+const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
+const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionChild, ExtensionActivityLogChild } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionChild.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+const { getInnerWindowID, promiseEvent } = ExtensionUtils;
+
+const {
+ BaseContext,
+ CanOfAPIs,
+ SchemaAPIManager,
+ defineLazyGetter,
+} = ExtensionCommon;
+
+const { ChildAPIManager, Messenger } = ExtensionChild;
+
+var ExtensionPageChild;
+
+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);
+
+ 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);
+ }
+
+ 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",
+ });
+};
+
+function getFrameData(global) {
+ return ExtensionProcessScript.getFrameData(global, true);
+}
+
+var apiManager = new (class extends SchemaAPIManager {
+ constructor() {
+ super("addon", 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", 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);
+ }
+ }
+ }
+})();
+
+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".
+ */
+ 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;
+
+ // This is the MessageSender property passed to extension.
+ let sender = { id: extension.id };
+ if (viewType == "tab") {
+ sender.frameId = WebNavigationFrames.getFrameId(contentWindow);
+ sender.tabId = tabId;
+ Object.defineProperty(this, "tabId", {
+ value: tabId,
+ enumerable: true,
+ configurable: true,
+ });
+ }
+ if (uri) {
+ sender.url = uri.spec;
+ }
+ this.sender = sender;
+
+ Schemas.exportLazyGetter(contentWindow, "browser", () => {
+ let browserObj = Cu.createObjectIn(contentWindow);
+ this.childManager.inject(browserObj);
+ return browserObj;
+ });
+
+ Schemas.exportLazyGetter(contentWindow, "chrome", () => {
+ let chromeApiWrapper = Object.create(this.childManager);
+ chromeApiWrapper.isChromeCompat = true;
+
+ let chromeObj = Cu.createObjectIn(contentWindow);
+ chromeApiWrapper.inject(chromeObj);
+ return chromeObj;
+ });
+ }
+
+ logActivity(type, name, data) {
+ ExtensionActivityLogChild.log(this, type, name, data);
+ }
+
+ get cloneScope() {
+ return this.contentWindow;
+ }
+
+ get principal() {
+ return this.contentWindow.document.nodePrincipal;
+ }
+
+ get windowId() {
+ if (["tab", "popup", "sidebar"].includes(this.viewType)) {
+ let frameData = getFrameData(this.messageManager);
+ return frameData ? frameData.windowId : -1;
+ }
+ return -1;
+ }
+
+ 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();
+ }
+}
+
+defineLazyGetter(ExtensionBaseContextChild.prototype, "messenger", function() {
+ return new Messenger(this, this.sender);
+});
+
+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".
+ */
+ 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);
+ }
+}
+
+defineLazyGetter(
+ ExtensionPageContextChild.prototype,
+ "childManager",
+ function() {
+ this.extension.apiManager.lazyInit();
+
+ let localApis = {};
+ let can = new CanOfAPIs(this, this.extension.apiManager, localApis);
+
+ let childManager = new ChildAPIManager(this, this.messageManager, can, {
+ envType: "addon_parent",
+ viewType: this.viewType,
+ url: this.uri.spec,
+ incognito: this.incognito,
+ });
+
+ this.callOnClose(childManager);
+
+ return childManager;
+ }
+);
+
+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".
+ */
+ constructor(extension, params) {
+ super(extension, Object.assign(params, { envType: "devtools_child" }));
+
+ this.devtoolsToolboxInfo = params.devtoolsToolboxInfo;
+ ExtensionChildDevToolsUtils.initThemeChangeObserver(
+ params.devtoolsToolboxInfo.themeName,
+ this
+ );
+
+ this.extension.devtoolsViews.add(this);
+ }
+
+ unload() {
+ super.unload();
+ this.extension.devtoolsViews.delete(this);
+ }
+}
+
+defineLazyGetter(DevToolsContextChild.prototype, "childManager", function() {
+ devtoolsAPIManager.lazyInit();
+
+ let localApis = {};
+ let can = new CanOfAPIs(this, devtoolsAPIManager, localApis);
+
+ let childManager = new ChildAPIManager(this, this.messageManager, can, {
+ envType: "devtools_parent",
+ viewType: this.viewType,
+ url: this.uri.spec,
+ incognito: this.incognito,
+ });
+
+ this.callOnClose(childManager);
+
+ return childManager;
+});
+
+ExtensionPageChild = {
+ // Map<innerWindowId, ExtensionPageContextChild>
+ extensionContexts: new Map(),
+
+ initialized: false,
+
+ 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) {
+ if (["popup", "sidebar"].includes(viewType)) {
+ global.docShell.isAppTab = true;
+ }
+
+ promiseEvent(
+ global,
+ "DOMContentLoaded",
+ true,
+ event => event.target.location != "about:blank"
+ ).then(() => {
+ let windowId = getInnerWindowID(global.content);
+ let context = this.extensionContexts.get(windowId);
+
+ global.sendAsyncMessage("Extension:ExtensionViewLoaded", {
+ childId: context && context.childManager.id,
+ });
+ });
+ },
+
+ /**
+ * 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 mm = contentWindow.docShell.messageManager;
+
+ let { viewType, tabId, devtoolsToolboxInfo } = getFrameData(mm) || {};
+
+ let uri = contentWindow.document.documentURIObject;
+
+ 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.jsm b/toolkit/components/extensions/ExtensionParent.jsm
new file mode 100644
index 0000000000..e12ee54784
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionParent.jsm
@@ -0,0 +1,1948 @@
+/* -*- 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";
+
+/**
+ * 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.
+ */
+
+/* exported ExtensionParent */
+
+var EXPORTED_SYMBOLS = ["ExtensionParent"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+ BroadcastConduit: "resource://gre/modules/ConduitsParent.jsm",
+ DeferredTask: "resource://gre/modules/DeferredTask.jsm",
+ DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.jsm",
+ ExtensionData: "resource://gre/modules/Extension.jsm",
+ ExtensionActivityLog: "resource://gre/modules/ExtensionActivityLog.jsm",
+ GeckoViewConnection: "resource://gre/modules/GeckoViewWebExtension.jsm",
+ MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.jsm",
+ NativeApp: "resource://gre/modules/NativeMessaging.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ PerformanceCounters: "resource://gre/modules/PerformanceCounters.jsm",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
+ Schemas: "resource://gre/modules/Schemas.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+});
+
+// We're using the pref to avoid loading PerformanceCounters.jsm for nothing.
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gTimingEnabled",
+ "extensions.webextensions.enablePerformanceCounters",
+ false
+);
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+var {
+ BaseContext,
+ CanOfAPIs,
+ SchemaAPIManager,
+ SpreadArgs,
+ defineLazyGetter,
+} = 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;
+let StartupCache;
+
+const global = this;
+
+// This object loads the ext-*.js scripts that define the extension API.
+let apiManager = new (class extends SchemaAPIManager {
+ constructor() {
+ super("main", 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 }) => {
+ let modules = this.eventModules.get("update");
+ if (modules.size == 0) {
+ return;
+ }
+
+ let extension = new ExtensionData(resourceURI);
+ 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 = AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_DISABLED
+ );
+ if (disabledIds.length) {
+ this._callHandlers(disabledIds, "disable", "onDisable");
+ }
+
+ let uninstalledIds = AddonManager.getStartupChanges(
+ 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 Schemas.load(BASE_SCHEMA).then(() => {
+ let promises = [];
+ for (let { value } of Services.catMan.enumerateCategory(
+ CATEGORY_EXTENSION_SCHEMAS
+ )) {
+ promises.push(Schemas.load(value));
+ }
+ for (let [url, { content }] of this.schemaURLs) {
+ promises.push(Schemas.load(url, content));
+ }
+ for (let url of schemaURLs) {
+ promises.push(Schemas.load(url));
+ }
+ return Promise.all(promises).then(() => {
+ Schemas.updateSharedSchemas();
+ });
+ });
+ })();
+
+ /* eslint-disable mozilla/balanced-listeners */
+ Services.mm.addMessageListener("Extension:GetTabAndWindowId", this);
+ /* eslint-enable mozilla/balanced-listeners */
+
+ this.initialized = promise;
+ return this.initialized;
+ }
+
+ receiveMessage({ name, target, sync }) {
+ if (name === "Extension:GetTabAndWindowId") {
+ let result = this.global.tabTracker.getBrowserData(target);
+
+ if (result.tabId) {
+ if (sync) {
+ return result;
+ }
+ target.messageManager.sendAsyncMessage(
+ "Extension:SetFrameData",
+ result
+ );
+ }
+ }
+ }
+
+ // 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)));
+ }
+
+ AsyncShutdown.profileBeforeChange.addBlocker(
+ `Extension API ${event} handlers for ${ids.join(",")}`,
+ Promise.all(promises)
+ );
+ }
+})();
+
+// Receives messages related to the extension messaging API and forwards them
+// to relevant child messengers. Also handles Native messaging and GeckoView.
+const ProxyMessenger = {
+ /**
+ * @typedef {object} ParentPort
+ * @prop {function(StructuredCloneHolder)} onPortMessage
+ * @prop {function()} onPortDisconnect
+ */
+ /** @type Map<number, ParentPort> */
+ ports: new Map(),
+
+ init() {
+ this.conduit = new 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);
+ let { extension } = context;
+ if (extension.hasPermission("geckoViewAddons")) {
+ let allowMessagingFromContent = extension.hasPermission(
+ "nativeMessagingFromContent"
+ );
+ return new GeckoViewConnection(
+ sender,
+ nativeApp,
+ allowMessagingFromContent
+ );
+ } else if (sender.verified) {
+ return new NativeApp(context, nativeApp);
+ }
+ throw new Error(`Native messaging not allowed: ${JSON.stringify(sender)}`);
+ },
+
+ recvNativeMessage({ nativeApp, holder }, { sender }) {
+ return this.openNative(nativeApp, sender).sendMessage(holder);
+ },
+
+ getSender(extension, source) {
+ let { extensionId, envType, frameId, url, actor, id } = source;
+ let sender = { id: extensionId, envType, frameId, url, contextId: id };
+ let target = actor.browsingContext.top.embedderElement;
+ apiManager.global.tabGetSender(extension, target, sender);
+ 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);
+ 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);
+ this.ports.set(arg.portId, port);
+ return;
+ }
+
+ // PortMessages that follow will need to wait for the port to be opened.
+ 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) {
+ return this.ports.get(sender.portId).onPortMessage(holder);
+ }
+ 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) {
+ 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) {
+ this.conduit.castPortDisconnect("port", { portId, source, error });
+ this.ports.delete(portId);
+ },
+};
+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,
+
+ init(extension) {
+ if (this.extensionMap.size == 0) {
+ apiManager.on("extension-browser-inserted", this._onExtensionBrowser);
+ this.initialized = true;
+ Services.ppmm.addMessageListener(
+ "Extension:SendPerformanceCounter",
+ this
+ );
+ }
+ 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;
+ Services.ppmm.removeMessageListener(
+ "Extension:SendPerformanceCounter",
+ this
+ );
+ }
+ },
+
+ async receiveMessage({ name, data }) {
+ switch (name) {
+ case "Extension:SendPerformanceCounter":
+ PerformanceCounters.merge(data.counters);
+ break;
+ }
+ },
+
+ _onExtensionBrowser(type, browser, additionalData = {}) {
+ browser.messageManager.loadFrameScript(
+ "resource://gre/modules/onExtensionBrowser.js",
+ false,
+ true
+ );
+
+ let viewType = browser.getAttribute("webextension-view-type");
+ if (viewType) {
+ let data = { viewType };
+
+ let { tabTracker } = apiManager.global;
+ Object.assign(data, tabTracker.getBrowserData(browser), additionalData);
+
+ browser.messageManager.sendAsyncMessage("Extension:SetFrameData", 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, xulBrowser, principal) {
+ super(envType, extension);
+
+ this.uri = Services.io.newURI(params.url);
+
+ this.incognito = params.incognito;
+
+ this.listenerPromises = new Set();
+
+ // 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.
+ this.messageManagerProxy = new MessageManagerProxy(xulBrowser);
+
+ Object.defineProperty(this, "principal", {
+ value: principal,
+ enumerable: true,
+ configurable: true,
+ });
+
+ this.listenerProxies = new Map();
+
+ this.pendingEventBrowser = null;
+
+ apiManager.emit("proxy-context-load", this);
+ }
+
+ 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() {
+ return this.messageManagerProxy.messageManager;
+ }
+
+ shutdown() {
+ this.unload();
+ }
+
+ unload() {
+ if (this.unloaded) {
+ return;
+ }
+ this.messageManagerProxy.dispose();
+ super.unload();
+ apiManager.emit("proxy-context-unload", this);
+ }
+}
+
+defineLazyGetter(ProxyContextParent.prototype, "apiCan", function() {
+ let obj = {};
+ let can = new CanOfAPIs(this, this.extension.apiManager, obj);
+ return can;
+});
+
+defineLazyGetter(ProxyContextParent.prototype, "apiObj", function() {
+ return this.apiCan.root;
+});
+
+defineLazyGetter(ProxyContextParent.prototype, "sandbox", function() {
+ // NOTE: the required Blob and URL globals are used in the ext-registerContentScript.js
+ // API module to convert JS and CSS data into blob URLs.
+ return Cu.Sandbox(this.principal, {
+ sandboxName: this.uri.spec,
+ wantGlobalProperties: ["Blob", "URL"],
+ });
+});
+
+/**
+ * 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, xulBrowser) {
+ super(envType, extension, params, xulBrowser, extension.principal);
+
+ this.viewType = params.viewType;
+
+ 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 windowId() {
+ let { currentWindow } = this;
+ let { windowTracker } = apiManager.global;
+
+ if (currentWindow && windowTracker) {
+ return windowTracker.getId(currentWindow);
+ }
+ }
+
+ get tabId() {
+ let { tabTracker } = apiManager.global;
+ let data = tabTracker.getBrowserData(this.xulBrowser);
+ if (data.tabId >= 0) {
+ return data.tabId;
+ }
+ }
+
+ onBrowserChange(browser) {
+ super.onBrowserChange(browser);
+ this.xulBrowser = browser;
+ }
+
+ 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);
+
+ // We want to explicitly set `this._devToolsToolbox` as well to `null` here, but the
+ // toolbox is set during the processing of the parent's constructor, so it is currently
+ // not possible to set.
+ this._currentDevToolsTarget = null;
+ this._onNavigatedListeners = null;
+
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ 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;
+
+ return toolbox;
+ }
+
+ get devToolsToolbox() {
+ return this._devToolsToolbox;
+ }
+
+ async addOnNavigatedListener(listener) {
+ if (!this._onNavigatedListeners) {
+ this._onNavigatedListeners = new Set();
+
+ await this.devToolsToolbox.resourceWatcher.watchResources(
+ [this.devToolsToolbox.resourceWatcher.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: this._onResourceAvailable,
+ ignoreExistingResources: true,
+ }
+ );
+ }
+
+ this._onNavigatedListeners.add(listener);
+ }
+
+ removeOnNavigatedListener(listener) {
+ if (this._onNavigatedListeners) {
+ this._onNavigatedListeners.delete(listener);
+ }
+ }
+
+ /**
+ * The returned target may be destroyed when navigating to another process and so,
+ * should only be used accordingly. That is to say, we can do an immediate action on it,
+ * but not listen to RDP events.
+ * @returns {Promise<TabTarget>}
+ * The current devtools target associated to the context.
+ */
+ async getCurrentDevToolsTarget() {
+ if (!this._currentDevToolsTarget) {
+ if (!this._pendingWatchTargetsPromise) {
+ // When _onTargetAvailable is called, it will create a new target,
+ // via DevToolsShim.createDescriptorForTab. If this function is called multiple times
+ // before this._currentDevToolsTarget is populated, we don't want to create X
+ // new, duplicated targets, so we store the Promise returned by watchTargets, in
+ // order to properly wait on subsequent calls.
+ this._pendingWatchTargetsPromise = this.devToolsToolbox.targetList.watchTargets(
+ [this.devToolsToolbox.targetList.TYPES.FRAME],
+ this._onTargetAvailable
+ );
+ }
+ await this._pendingWatchTargetsPromise;
+ this._pendingWatchTargetsPromise = null;
+ }
+
+ return this._currentDevToolsTarget;
+ }
+
+ unload() {
+ // Bail if the toolbox reference was already cleared.
+ if (!this.devToolsToolbox) {
+ return;
+ }
+
+ this.devToolsToolbox.targetList.unwatchTargets(
+ [this.devToolsToolbox.targetList.TYPES.FRAME],
+ this._onTargetAvailable
+ );
+
+ if (this._onNavigatedListeners) {
+ this.devToolsToolbox.resourceWatcher.unwatchResources(
+ [this.devToolsToolbox.resourceWatcher.TYPES.DOCUMENT_EVENT],
+ { onAvailable: this._onResourceAvailable }
+ );
+ }
+
+ if (this._currentDevToolsTarget) {
+ this._currentDevToolsTarget.destroy();
+ this._currentDevToolsTarget = null;
+ }
+
+ if (this._onNavigatedListeners) {
+ this._onNavigatedListeners.clear();
+ this._onNavigatedListeners = null;
+ }
+
+ this._devToolsToolbox = null;
+
+ super.unload();
+ }
+
+ async _onTargetAvailable({ targetFront }) {
+ if (!targetFront.isTopLevel) {
+ return;
+ }
+
+ const descriptorFront = await DevToolsShim.createDescriptorForTab(
+ targetFront.localTab
+ );
+
+ // Update the TabDescriptor `isDevToolsExtensionContext` flag.
+ // This is a duplicated target, attached to no toolbox, DevTools needs to
+ // handle it differently compared to a regular top-level target.
+ descriptorFront.isDevToolsExtensionContext = true;
+
+ this._currentDevToolsTarget = await descriptorFront.getTarget();
+
+ await this._currentDevToolsTarget.attach();
+ }
+
+ async _onResourceAvailable(resources) {
+ for (const resource of resources) {
+ const { targetFront } = resource;
+ if (targetFront.isTopLevel && resource.name === "dom-complete") {
+ const url = targetFront.localTab.linkedBrowser.currentURI.spec;
+ for (const listener of this._onNavigatedListeners) {
+ listener(url);
+ }
+ }
+ }
+ }
+}
+
+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 BroadcastConduit(this, {
+ id: "ParentAPIManager",
+ reportOnClosed: "childId",
+ recv: ["CreateProxyContext", "APICall", "AddListener", "RemoveListener"],
+ send: ["CallResult"],
+ query: ["RunListener"],
+ });
+ },
+
+ 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);
+ }
+ }
+ },
+
+ recvCreateProxyContext(data, { actor, sender }) {
+ let { envType, extensionId, childId, principal } = data;
+ let target = actor.browsingContext.top.embedderElement;
+
+ 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 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"
+ );
+ }
+
+ if (envType == "addon_parent") {
+ context = new ExtensionPageContextParent(
+ envType,
+ extension,
+ data,
+ target
+ );
+ } else if (envType == "devtools_parent") {
+ context = new DevToolsExtensionPageContextParent(
+ envType,
+ extension,
+ data,
+ target
+ );
+ }
+ } else if (envType == "content_parent") {
+ context = new ContentScriptContextParent(
+ envType,
+ extension,
+ data,
+ target,
+ principal
+ );
+ } else {
+ throw new Error(`Invalid WebExtension context envType: ${envType}`);
+ }
+ this.proxyContexts.set(childId, context);
+ },
+
+ recvConduitClosed(sender) {
+ this.closeProxyContext(sender.id);
+ },
+
+ closeProxyContext(childId) {
+ let context = this.proxyContexts.get(childId);
+ if (context) {
+ context.unload();
+ this.proxyContexts.delete(childId);
+ }
+ },
+
+ async retrievePerformanceCounters() {
+ // getting the parent counters
+ return PerformanceCounters.getData();
+ },
+
+ /**
+ * Call the given function and also log the call as appropriate
+ * (i.e., with PerformanceCounters and/or activity logging)
+ *
+ * @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) {
+ ExtensionActivityLog.log(id, context.viewType, "api_call", data.path, {
+ args: data.args,
+ });
+ }
+
+ let start = Cu.now() * 1000;
+ try {
+ return callable();
+ } finally {
+ if (gTimingEnabled) {
+ let end = Cu.now() * 1000;
+ PerformanceCounters.storeExecutionTime(id, data.path, end - start);
+ }
+ }
+ },
+
+ async recvAPICall(data, { actor }) {
+ let context = this.getContextById(data.childId);
+ let target = actor.browsingContext.top.embedderElement;
+ if (context.parentMessageManager !== target.messageManager) {
+ throw new Error("Got message on unexpected message manager");
+ }
+
+ let reply = result => {
+ if (!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 {
+ let args = data.args;
+ let pendingBrowser = context.pendingEventBrowser;
+ let fun = await context.apiCan.asyncFindAPIPath(data.path);
+ let result = this.callAndLog(context, data, () => {
+ return context.withPendingBrowser(pendingBrowser, () => fun(...args));
+ });
+
+ if (data.callId) {
+ result = result || Promise.resolve();
+
+ result.then(
+ result => {
+ result = result instanceof SpreadArgs ? [...result] : [result];
+
+ let holder = new StructuredCloneHolder(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);
+ let target = actor.browsingContext.top.embedderElement;
+ if (context.parentMessageManager !== target.messageManager) {
+ throw new Error("Got message on unexpected message manager");
+ }
+
+ let { childId, alreadyLogged = false } = data;
+ let handlingUserInput = false;
+
+ let listener = async (...listenerArgs) => {
+ let result = await this.conduit.queryRunListener(childId, {
+ childId,
+ handlingUserInput,
+ listenerId: data.listenerId,
+ path: data.path,
+ get args() {
+ return new StructuredCloneHolder(listenerArgs);
+ },
+ });
+ let rv = result && result.deserialize(global);
+ 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.viewType === "background" && 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) {
+ 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) {
+ 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<void>}
+ * 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 (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+ let attrs = chromeShell.getOriginAttributes();
+ attrs.privateBrowsingId = 1;
+ chromeShell.setOriginAttributes(attrs);
+ }
+
+ windowlessBrowser.browsingContext.useGlobalHistory = false;
+ chromeShell.loadURI("chrome://extensions/content/dummy.xhtml", {
+ 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<XULElement>}
+ * 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");
+
+ for (const [name, value] of Object.entries(xulAttributes)) {
+ if (value != null) {
+ browser.setAttribute(name, value);
+ }
+ }
+
+ let awaitFrameLoader = Promise.resolve();
+
+ 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;
+ 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<XULElement>}
+ * 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<extensionId, Promise<XULElement>>
+ debugBrowserPromises: new Map(),
+ // DefaultWeakMap<Promise<browser XULElement>, Set<WebExtensionParentActor>>
+ 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 [];
+ },
+
+ /**
+ * 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<XULElement>}
+ * 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 {MessageListenerManager} 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 promiseExtensionViewLoaded(browser) {
+ let { childId } = await promiseMessageFromChild(
+ browser.messageManager,
+ "Extension:ExtensionViewLoaded"
+ );
+ 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.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);
+ };
+}
+
+// Manages icon details for toolbar buttons in the |pageAction| and
+// |browserAction| APIs.
+let IconDetails = {
+ DEFAULT_ICON: "chrome://browser/content/extension.svg",
+
+ // WeakMap<Extension -> Map<url-string -> Map<iconType-string -> 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://browser/content/extension.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);
+ },
+};
+
+// 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.
+StartupCache = {
+ STORE_NAMES: Object.freeze([
+ "general",
+ "locales",
+ "manifests",
+ "other",
+ "permissions",
+ "schemas",
+ ]),
+
+ // When the application version changes, this file is removed by
+ // RemoveComponentRegistries in nsAppRunner.cpp.
+ file: OS.Path.join(
+ OS.Constants.Path.localProfileDir,
+ "startupCache",
+ "webext.sc.lz4"
+ ),
+
+ async _saveNow() {
+ let data = new Uint8Array(aomStartup.encodeBlob(this._data));
+ await OS.File.writeAtomic(this.file, data, { tmpPath: `${this.file}.tmp` });
+ },
+
+ async save() {
+ if (!this._saveTask) {
+ OS.File.makeDir(OS.Path.dirname(this.file), {
+ ignoreExisting: true,
+ from: OS.Constants.Path.localProfileDir,
+ });
+
+ this._saveTask = new DeferredTask(() => this._saveNow(), 5000);
+
+ AsyncShutdown.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 {
+ let { buffer } = await OS.File.read(this.file);
+
+ result = aomStartup.decodeBlob(buffer);
+ } catch (e) {
+ if (!e.becauseNoSuchFile) {
+ Cu.reportError(e);
+ }
+ }
+
+ 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),
+ ]).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]);
+ },
+};
+
+Services.obs.addObserver(StartupCache, "startupcache-invalidate");
+
+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();
+ }
+ }
+}
+
+for (let name of StartupCache.STORE_NAMES) {
+ StartupCache[name] = new CacheStore(name);
+}
+
+var ExtensionParent = {
+ GlobalManager,
+ HiddenExtensionPage,
+ IconDetails,
+ ParentAPIManager,
+ StartupCache,
+ WebExtensionPolicy,
+ apiManager,
+ promiseExtensionViewLoaded,
+ watchExtensionProxyContextLoad,
+ 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();
+
+XPCOMUtils.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;
+ })(),
+ });
+});
+
+/**
+ * Retreives the browser_style stylesheets needed for extension popups and sidebars.
+ * @returns {Array<string>} an array of stylesheets needed for the current platform.
+ */
+XPCOMUtils.defineLazyGetter(ExtensionParent, "extensionStylesheets", () => {
+ let stylesheets = ["chrome://browser/content/extension.css"];
+
+ if (AppConstants.platform === "macosx") {
+ stylesheets.push("chrome://browser/content/extension-mac.css");
+ }
+
+ return stylesheets;
+});
diff --git a/toolkit/components/extensions/ExtensionPermissions.jsm b/toolkit/components/extensions/ExtensionPermissions.jsm
new file mode 100644
index 0000000000..bc56838ea7
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionPermissions.jsm
@@ -0,0 +1,420 @@
+/* -*- 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 { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
+ JSONFile: "resource://gre/modules/JSONFile.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "StartupCache",
+ () => ExtensionParent.StartupCache
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "KeyValueService",
+ "resource://gre/modules/kvstore.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "Management",
+ () => ExtensionParent.apiManager
+);
+
+var EXPORTED_SYMBOLS = ["ExtensionPermissions"];
+
+// This is the old preference file pre-migration to rkv
+const FILE_NAME = "extension-preferences.json";
+
+function emptyPermissions() {
+ return { permissions: [], origins: [] };
+}
+
+const DEFAULT_VALUE = JSON.stringify(emptyPermissions());
+
+const VERSION_KEY = "_version";
+const VERSION_VALUE = 1;
+
+const KEY_PREFIX = "id-";
+
+// 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 = OS.Path.join(OS.Constants.Path.profileDir, FILE_NAME);
+
+ prefs = new JSONFile({ path });
+ prefs.data = {};
+
+ try {
+ let { buffer } = await OS.File.read(path);
+ prefs.data = JSON.parse(new TextDecoder().decode(buffer));
+ } catch (e) {
+ if (!e.becauseNoSuchFile) {
+ 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 {
+ async _init() {
+ const storePath = FileUtils.getDir("ProfD", ["extension-store"]).path;
+ // Make sure the folder exists
+ await OS.File.makeDir(storePath, { ignoreExisting: true });
+ this._store = await KeyValueService.getOrCreate(storePath, "permissions");
+ if (!(await this._store.has(VERSION_KEY))) {
+ await this.maybeMigrateData();
+ }
+ }
+
+ 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 maybeMigrateData() {
+ let migrationWasSuccessful = false;
+ let oldStore = OS.Path.join(OS.Constants.Path.profileDir, FILE_NAME);
+ try {
+ await this.migrateFrom(oldStore);
+ migrationWasSuccessful = true;
+ } catch (e) {
+ if (!e.becauseNoSuchFile) {
+ Cu.reportError(e);
+ }
+ }
+
+ await this._store.put(VERSION_KEY, VERSION_VALUE);
+
+ if (migrationWasSuccessful) {
+ OS.File.remove(oldStore);
+ }
+ }
+
+ async migrateFrom(oldStore) {
+ // Some other migration job might have started and not completed, let's
+ // start from scratch
+ await this._store.clear();
+
+ let { buffer } = await OS.File.read(oldStore);
+ let json = JSON.parse(new TextDecoder().decode(buffer));
+ 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();
+
+var ExtensionPermissions = {
+ async _update(extensionId, perms) {
+ await store.put(extensionId, perms);
+ return StartupCache.permissions.set(extensionId, perms);
+ },
+
+ async _get(extensionId) {
+ return store.get(extensionId);
+ },
+
+ async _getCached(extensionId) {
+ return 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 <all_urls> as an API permission as well.
+ // If it is added to either, ensure it is added to both.
+ if (perms.origins.includes("<all_urls>")) {
+ perms.permissions.push("<all_urls>");
+ } else if (perms.permissions.includes("<all_urls>")) {
+ perms.origins.push("<all_urls>");
+ }
+ },
+
+ /**
+ * 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 });
+ 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 });
+ Management.emit("change-permissions", { extensionId, removed });
+ if (emitter) {
+ emitter.emit("remove-permissions", removed);
+ }
+ }
+ },
+
+ async removeAll(extensionId) {
+ StartupCache.permissions.delete(extensionId);
+
+ let removed = store.get(extensionId);
+ await store.delete(extensionId);
+ 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() {
+ await store.uninitForTest();
+ store = createStore(!this._useLegacyStorageBackend);
+ },
+
+ // Convenience listener members for all permission changes.
+ addListener(listener) {
+ Management.on("change-permissions", listener);
+ },
+
+ removeListener(listener) {
+ Management.off("change-permissions", listener);
+ },
+};
diff --git a/toolkit/components/extensions/ExtensionPolicyService.cpp b/toolkit/components/extensions/ExtensionPolicyService.cpp
new file mode 100644
index 0000000000..04d0d7df0d
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionPolicyService.cpp
@@ -0,0 +1,652 @@
+/* -*- 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/dom/ContentChild.h"
+#include "mozilla/dom/ContentFrameMessageManager.h"
+#include "mozilla/dom/ContentParent.h"
+#include "mozilla/dom/Promise.h"
+#include "mozilla/dom/Promise-inl.h"
+#include "mozIExtensionProcessScript.h"
+#include "nsDocShell.h"
+#include "nsEscape.h"
+#include "nsGkAtoms.h"
+#include "nsIChannel.h"
+#include "nsIContentPolicy.h"
+#include "nsIDocShell.h"
+#include "mozilla/dom/Document.h"
+#include "nsGlobalWindowOuter.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::ContentFrameMessageManager;
+using dom::Document;
+using dom::Promise;
+
+#define DEFAULT_CSP_PREF \
+ "extensions.webextensions.default-content-security-policy"
+#define DEFAULT_DEFAULT_CSP "script-src 'self'; object-src 'self';"
+
+#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";
+
+static mozIExtensionProcessScript& ProcessScript() {
+ static nsCOMPtr<mozIExtensionProcessScript> sProcessScript;
+
+ if (MOZ_UNLIKELY(!sProcessScript)) {
+ sProcessScript =
+ do_ImportModule("resource://gre/modules/ExtensionProcessScript.jsm",
+ "ExtensionProcessScript");
+ MOZ_RELEASE_ASSERT(sProcessScript);
+ ClearOnShutdown(&sProcessScript);
+ }
+ return *sProcessScript;
+}
+
+/*****************************************************************************
+ * ExtensionPolicyService
+ *****************************************************************************/
+
+/* static */ ExtensionPolicyService& ExtensionPolicyService::GetSingleton() {
+ static RefPtr<ExtensionPolicyService> sExtensionPolicyService;
+
+ if (MOZ_UNLIKELY(!sExtensionPolicyService)) {
+ sExtensionPolicyService = new ExtensionPolicyService();
+ RegisterWeakMemoryReporter(sExtensionPolicyService);
+ ClearOnShutdown(&sExtensionPolicyService);
+ }
+ return *sExtensionPolicyService.get();
+}
+
+ExtensionPolicyService::ExtensionPolicyService() {
+ mObs = services::GetObserverService();
+ MOZ_RELEASE_ASSERT(mObs);
+
+ mDefaultCSP.SetIsVoid(true);
+
+ RegisterObservers();
+}
+
+ExtensionPolicyService::~ExtensionPolicyService() {
+ UnregisterWeakMemoryReporter(this);
+}
+
+bool ExtensionPolicyService::UseRemoteExtensions() const {
+ static Maybe<bool> 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();
+}
+
+WebExtensionPolicy* ExtensionPolicyService::GetByURL(const URLInfo& aURL) {
+ if (aURL.Scheme() == nsGkAtoms::moz_extension) {
+ return GetByHost(aURL.Host());
+ }
+ return nullptr;
+}
+
+void ExtensionPolicyService::GetAll(
+ nsTArray<RefPtr<WebExtensionPolicy>>& aResult) {
+ for (auto iter = mExtensions.Iter(); !iter.Done(); iter.Next()) {
+ aResult.AppendElement(iter.Data());
+ }
+}
+
+bool ExtensionPolicyService::RegisterExtension(WebExtensionPolicy& aPolicy) {
+ bool ok =
+ (!GetByID(aPolicy.Id()) && !GetByHost(aPolicy.MozExtensionHostname()));
+ MOZ_ASSERT(ok);
+
+ if (!ok) {
+ return false;
+ }
+
+ mExtensions.Put(aPolicy.Id(), RefPtr{&aPolicy});
+ mExtensionHosts.Put(aPolicy.MozExtensionHostname(), RefPtr{&aPolicy});
+ 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());
+ mExtensionHosts.Remove(aPolicy.MozExtensionHostname());
+ return true;
+}
+
+bool ExtensionPolicyService::RegisterObserver(DocumentObserver& aObserver) {
+ if (mObservers.GetWeak(&aObserver)) {
+ return false;
+ }
+
+ mObservers.Put(&aObserver, RefPtr{&aObserver});
+ return true;
+}
+
+bool ExtensionPolicyService::UnregisterObserver(DocumentObserver& aObserver) {
+ if (!mObservers.GetWeak(&aObserver)) {
+ return false;
+ }
+
+ mObservers.Remove(&aObserver);
+ return true;
+}
+
+/*****************************************************************************
+ * nsIMemoryReporter
+ *****************************************************************************/
+
+NS_IMETHODIMP
+ExtensionPolicyService::CollectReports(nsIHandleReportCallback* aHandleReport,
+ nsISupports* aData, bool aAnonymize) {
+ for (auto iter = mExtensions.Iter(); !iter.Done(); iter.Next()) {
+ auto& ext = iter.Data();
+
+ 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);
+ mObs->AddObserver(this, "tab-content-frameloader-created", 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);
+}
+
+void ExtensionPolicyService::UnregisterObservers() {
+ mObs->RemoveObserver(this, kDocElementInserted);
+ mObs->RemoveObserver(this, "tab-content-frameloader-created");
+ if (XRE_IsContentProcess()) {
+ mObs->RemoveObserver(this, "http-on-opening-request");
+ mObs->RemoveObserver(this, "document-on-opening-request");
+ }
+
+ Preferences::RemoveObserver(this, DEFAULT_CSP_PREF);
+}
+
+nsresult ExtensionPolicyService::Observe(nsISupports* aSubject,
+ const char* aTopic,
+ const char16_t* aData) {
+ if (!strcmp(aTopic, kDocElementInserted)) {
+ nsCOMPtr<Document> doc = do_QueryInterface(aSubject);
+ if (doc) {
+ CheckDocument(doc);
+ }
+ } else if (!strcmp(aTopic, "http-on-opening-request") ||
+ !strcmp(aTopic, "document-on-opening-request")) {
+ nsCOMPtr<nsIChannel> chan = do_QueryInterface(aSubject);
+ if (chan) {
+ CheckRequest(chan);
+ }
+ } else if (!strcmp(aTopic, "tab-content-frameloader-created")) {
+ RefPtr<ContentFrameMessageManager> mm = do_QueryObject(aSubject);
+ NS_ENSURE_TRUE(mm, NS_ERROR_UNEXPECTED);
+
+ mMessageManagers.PutEntry(mm);
+
+ mm->AddSystemEventListener(u"unload"_ns, this, false, false);
+ } 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);
+ }
+ }
+ return NS_OK;
+}
+
+nsresult ExtensionPolicyService::HandleEvent(dom::Event* aEvent) {
+ RefPtr<ContentFrameMessageManager> mm = do_QueryObject(aEvent->GetTarget());
+ MOZ_ASSERT(mm);
+ if (mm) {
+ mMessageManagers.RemoveEntry(mm);
+ }
+ return NS_OK;
+}
+
+nsresult ForEachDocShell(
+ nsIDocShell* aDocShell,
+ const std::function<nsresult(nsIDocShell*)>& aCallback) {
+ nsTArray<RefPtr<nsIDocShell>> docShells;
+ MOZ_TRY(aDocShell->GetAllDocShellsInSubtree(
+ nsIDocShell::typeContent, nsIDocShell::ENUMERATE_FORWARDS, docShells));
+
+ for (auto& docShell : docShells) {
+ MOZ_TRY(aCallback(docShell));
+ }
+ return NS_OK;
+}
+
+already_AddRefed<Promise> ExtensionPolicyService::ExecuteContentScript(
+ nsPIDOMWindowInner* aWindow, WebExtensionContentScript& aScript) {
+ if (!aWindow->IsCurrentInnerWindow()) {
+ return nullptr;
+ }
+
+ RefPtr<Promise> promise;
+ ProcessScript().LoadContentScript(&aScript, aWindow, getter_AddRefs(promise));
+ return promise.forget();
+}
+
+RefPtr<Promise> ExtensionPolicyService::ExecuteContentScripts(
+ JSContext* aCx, nsPIDOMWindowInner* aWindow,
+ const nsTArray<RefPtr<WebExtensionContentScript>>& aScripts) {
+ AutoTArray<RefPtr<Promise>, 8> promises;
+
+ for (auto& script : aScripts) {
+ if (RefPtr<Promise> promise = ExecuteContentScript(aWindow, *script)) {
+ promises.AppendElement(std::move(promise));
+ }
+ }
+
+ RefPtr<Promise> promise = Promise::All(aCx, promises, IgnoreErrors());
+ MOZ_RELEASE_ASSERT(promise);
+ return promise;
+}
+
+nsresult ExtensionPolicyService::InjectContentScripts(
+ WebExtensionPolicy* aExtension) {
+ AutoJSAPI jsapi;
+ MOZ_ALWAYS_TRUE(jsapi.Init(xpc::PrivilegedJunkScope()));
+
+ for (auto iter = mMessageManagers.ConstIter(); !iter.Done(); iter.Next()) {
+ ContentFrameMessageManager* mm = iter.Get()->GetKey();
+
+ nsCOMPtr<nsIDocShell> docShell = mm->GetDocShell(IgnoreErrors());
+ NS_ENSURE_TRUE(docShell, NS_ERROR_UNEXPECTED);
+
+ auto result =
+ ForEachDocShell(docShell, [&](nsIDocShell* aDocShell) -> nsresult {
+ nsCOMPtr<nsPIDOMWindowOuter> win = aDocShell->GetWindow();
+ if (!win->GetDocumentURI()) {
+ return NS_OK;
+ }
+ DocInfo docInfo(win);
+
+ using RunAt = dom::ContentScriptRunAt;
+ namespace RunAtValues = dom::ContentScriptRunAtValues;
+ using Scripts = AutoTArray<RefPtr<WebExtensionContentScript>, 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<nsPIDOMWindowInner> inner = win->GetCurrentInnerWindow();
+
+ MOZ_TRY(ExecuteContentScripts(jsapi.cx(), inner,
+ GetScripts(RunAt::Document_start))
+ ->ThenWithCycleCollectedArgs(
+ [](JSContext* aCx, JS::HandleValue aValue,
+ 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::HandleValue aValue,
+ ExtensionPolicyService* aSelf,
+ nsPIDOMWindowInner* aInner, Scripts&& aScripts) {
+ return aSelf
+ ->ExecuteContentScripts(aCx, aInner, aScripts)
+ .forget();
+ },
+ this, inner, GetScripts(RunAt::Document_idle));
+ }));
+
+ return NS_OK;
+ });
+ MOZ_TRY(result);
+ }
+ return NS_OK;
+}
+
+// Checks a request for matching content scripts, and begins pre-loading them
+// if necessary.
+void ExtensionPolicyService::CheckRequest(nsIChannel* aChannel) {
+ nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo();
+ auto loadType = loadInfo->GetExternalContentPolicyType();
+ if (loadType != ExtContentPolicy::TYPE_DOCUMENT &&
+ loadType != ExtContentPolicy::TYPE_SUBDOCUMENT) {
+ return;
+ }
+
+ nsCOMPtr<nsIURI> uri;
+ if (NS_FAILED(aChannel->GetURI(getter_AddRefs(uri)))) {
+ return;
+ }
+
+ CheckContentScripts({uri.get(), loadInfo}, true);
+}
+
+static bool CheckParentFrames(nsPIDOMWindowOuter* aWindow,
+ WebExtensionPolicy& aPolicy) {
+ nsCOMPtr<nsIURI> aboutAddons;
+ if (NS_FAILED(NS_NewURI(getter_AddRefs(aboutAddons), "about:addons"))) {
+ return false;
+ }
+ nsCOMPtr<nsIURI> 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 <browser> 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<nsPIDOMWindowOuter> win = aDocument->GetWindow();
+ if (win) {
+ nsIDocShell* docShell = win->GetDocShell();
+ RefPtr<ContentFrameMessageManager> mm = docShell->GetMessageManager();
+ nsString group = win->GetBrowsingContext()->Top()->GetMessageManagerGroup();
+
+ // Currently, we use frame scripts to select specific kinds of browsers
+ // where we want to run content scripts.
+ if ((!mm || !mMessageManagers.Contains(mm)) &&
+ // With Fission, OOP iframes don't have a frame message manager, so we
+ // use the browser's MessageManagerGroup attribute to decide if content
+ // scripts should run. The "browsers" group includes iframes from tabs,
+ // and the "webext-browsers" group includes custom browsers for
+ // extension popups/sidebars and xpcshell tests.
+ !group.EqualsLiteral("browsers") &&
+ !group.EqualsLiteral("webext-browsers")) {
+ return;
+ }
+
+ if (win->GetDocumentURI()) {
+ CheckContentScripts(win.get(), false);
+ }
+
+ nsIPrincipal* principal = aDocument->NodePrincipal();
+
+ RefPtr<WebExtensionPolicy> 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<nsPIDOMWindowInner> win;
+ if (!aIsPreload) {
+ win = aDocInfo.GetWindow()->GetCurrentInnerWindow();
+ }
+
+ nsTArray<RefPtr<WebExtensionContentScript>> scriptsToLoad;
+
+ for (auto iter = mExtensions.Iter(); !iter.Done(); iter.Next()) {
+ RefPtr<WebExtensionPolicy> policy = iter.Data();
+
+ 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> promise;
+ ProcessScript().LoadContentScript(script, win, getter_AddRefs(promise));
+ }
+
+ scriptsToLoad.ClearAndRetainStorage();
+ }
+
+ for (auto iter = mObservers.Iter(); !iter.Done(); iter.Next()) {
+ RefPtr<DocumentObserver> observer = iter.Data();
+
+ for (auto& matcher : observer->Matchers()) {
+ if (matcher->Matches(aDocInfo)) {
+ if (aIsPreload) {
+ observer->NotifyMatch(*matcher, aDocInfo.GetLoadInfo());
+ } else {
+ observer->NotifyMatch(*matcher, aDocInfo.GetWindow());
+ }
+ }
+ }
+ }
+}
+
+/*****************************************************************************
+ * 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::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::ExtensionURILoadableByAnyone(nsIURI* aURI,
+ bool* aResult) {
+ URLInfo url(aURI);
+ if (WebExtensionPolicy* policy = GetByURL(url)) {
+ *aResult = policy->IsPathWebAccessible(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, mExtensionHosts,
+ mObservers)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ExtensionPolicyService)
+ NS_INTERFACE_MAP_ENTRY(nsIAddonPolicyService)
+ NS_INTERFACE_MAP_ENTRY(nsIObserver)
+ NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener)
+ 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..585fb79d5b
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionPolicyService.h
@@ -0,0 +1,131 @@
+/* -*- 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 "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsHashKeys.h"
+#include "nsIAddonPolicyService.h"
+#include "nsAtom.h"
+#include "nsIDOMEventListener.h"
+#include "nsIMemoryReporter.h"
+#include "nsIObserver.h"
+#include "nsIObserverService.h"
+#include "nsISupports.h"
+#include "nsPointerHashKeys.h"
+#include "nsRefPtrHashtable.h"
+#include "nsTHashtable.h"
+
+class nsIChannel;
+class nsIObserverService;
+
+class nsIPIDOMWindowInner;
+class nsIPIDOMWindowOuter;
+
+namespace mozilla {
+namespace dom {
+class ContentFrameMessageManager;
+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 nsIDOMEventListener,
+ public nsIMemoryReporter {
+ public:
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(ExtensionPolicyService,
+ nsIAddonPolicyService)
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_NSIADDONPOLICYSERVICE
+ NS_DECL_NSIOBSERVER
+ NS_DECL_NSIDOMEVENTLISTENER
+ NS_DECL_NSIMEMORYREPORTER
+
+ static ExtensionPolicyService& GetSingleton();
+
+ static already_AddRefed<ExtensionPolicyService> GetInstance() {
+ return do_AddRef(&GetSingleton());
+ }
+
+ WebExtensionPolicy* GetByID(const nsAtom* aAddonId) {
+ return mExtensions.GetWeak(aAddonId);
+ }
+
+ WebExtensionPolicy* GetByID(const nsAString& aAddonId) {
+ RefPtr<nsAtom> atom = NS_AtomizeMainThread(aAddonId);
+ return GetByID(atom);
+ }
+
+ WebExtensionPolicy* GetByURL(const extensions::URLInfo& aURL);
+
+ WebExtensionPolicy* GetByHost(const nsACString& aHost) const {
+ return mExtensionHosts.GetWeak(aHost);
+ }
+
+ void GetAll(nsTArray<RefPtr<WebExtensionPolicy>>& 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;
+
+ 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<dom::Promise> ExecuteContentScript(
+ nsPIDOMWindowInner* aWindow,
+ extensions::WebExtensionContentScript& aScript);
+
+ RefPtr<dom::Promise> ExecuteContentScripts(
+ JSContext* aCx, nsPIDOMWindowInner* aWindow,
+ const nsTArray<RefPtr<extensions::WebExtensionContentScript>>& aScripts);
+
+ nsRefPtrHashtable<nsPtrHashKey<const nsAtom>, WebExtensionPolicy> mExtensions;
+ nsRefPtrHashtable<nsCStringHashKey, WebExtensionPolicy> mExtensionHosts;
+
+ nsTHashtable<nsRefPtrHashKey<dom::ContentFrameMessageManager>>
+ mMessageManagers;
+
+ nsRefPtrHashtable<nsPtrHashKey<const extensions::DocumentObserver>,
+ extensions::DocumentObserver>
+ mObservers;
+
+ nsCOMPtr<nsIObserverService> mObs;
+
+ nsString mDefaultCSP;
+};
+
+} // namespace mozilla
+
+#endif // mozilla_ExtensionPolicyService_h
diff --git a/toolkit/components/extensions/ExtensionPreferencesManager.jsm b/toolkit/components/extensions/ExtensionPreferencesManager.jsm
new file mode 100644
index 0000000000..2199c9627b
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionPreferencesManager.jsm
@@ -0,0 +1,654 @@
+/* -*- 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";
+
+/**
+ * @fileOverview
+ * 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.
+ */
+
+var EXPORTED_SYMBOLS = ["ExtensionPreferencesManager"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+);
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionCommon",
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+const { ExtensionError } = ExtensionUtils;
+
+XPCOMUtils.defineLazyGetter(this, "defaultPreferences", function() {
+ return new 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 (Preferences.isSet(pref)) {
+ initialValue[pref] = 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] === 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 (Preferences.isSet(pref)) {
+ changed = true;
+ Preferences.reset(pref);
+ }
+ } else if (Preferences.get(pref) != prefs[pref]) {
+ 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 ExtensionSettingsStore.initialize();
+ let expectedItem = ExtensionSettingsStore.getSetting(STORE_TYPE, name);
+ let item = 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] && Preferences.get(pref) != expectedPrefs[pref]
+ )
+ ) {
+ return false;
+ }
+ setPrefs(name, setting, item);
+ return true;
+ }
+ return false;
+}
+
+this.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 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 ExtensionSettingsStore.initialize();
+ let item = await 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 ExtensionSettingsStore.initialize();
+ let settings = 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 ExtensionSettingsStore.initialize();
+ let settings = 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 ExtensionSettingsStore.initialize();
+ let settings = 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<string>}
+ * 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 {Object} The current setting object.
+ */
+ async getSetting(name) {
+ await ExtensionSettingsStore.initialize();
+ return 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 (Preferences.locked(prefName)) {
+ return "not_controllable";
+ }
+ }
+ }
+ await ExtensionSettingsStore.initialize();
+ return 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._getSettingsAPI(
+ arguments.length === 1
+ ? extensionId
+ : {
+ extensionId,
+ name,
+ callback,
+ storeType,
+ readOnly,
+ validate,
+ }
+ );
+ },
+
+ /**
+ * Returns an API object with get/set/clear used for a setting.
+ *
+ * @param {object} params The params object contains the following:
+ * {BaseContext} context
+ * {string} extensionId, optional to support old API
+ * {string} name
+ * The unique id of the setting.
+ * {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} API object with get/set/clear methods
+ */
+ _getSettingsAPI(params) {
+ let {
+ extensionId,
+ context,
+ name,
+ callback,
+ storeType,
+ readOnly = false,
+ onChange,
+ validate = () => {},
+ } = params;
+ if (!extensionId) {
+ extensionId = context.extension.id;
+ }
+
+ const checkScope = details => {
+ let { scope } = details;
+ if (scope && scope !== "regular") {
+ throw new ExtensionError(
+ `Firefox does not support the ${scope} settings scope.`
+ );
+ }
+ };
+
+ let settingsAPI = {
+ async get(details) {
+ validate();
+ 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 callback(),
+ };
+ },
+ set(details) {
+ validate();
+ checkScope(details);
+ if (!readOnly) {
+ return ExtensionPreferencesManager.setSetting(
+ extensionId,
+ name,
+ details.value
+ );
+ }
+ return false;
+ },
+ clear(details) {
+ validate();
+ checkScope(details);
+ if (!readOnly) {
+ return ExtensionPreferencesManager.removeSetting(extensionId, name);
+ }
+ return false;
+ },
+ onChange,
+ };
+ // 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.
+ let setting = settingsMap.get(name);
+ if (!setting) {
+ Services.console.logStringMessage(
+ `ExtensionPreferencesManager API ${name} created but addSetting was not called.`
+ );
+ return settingsAPI;
+ }
+
+ settingsAPI.onChange = new ExtensionCommon.EventManager({
+ context,
+ name: `${name}.onChange`,
+ register: fire => {
+ let listener = async () => {
+ fire.async(await settingsAPI.get({}));
+ };
+ Management.on(`extension-setting-changed:${name}`, listener);
+ return () => {
+ Management.off(`extension-setting-changed:${name}`, listener);
+ };
+ },
+ }).api();
+ }
+ return settingsAPI;
+ },
+};
diff --git a/toolkit/components/extensions/ExtensionProcessScript.jsm b/toolkit/components/extensions/ExtensionProcessScript.jsm
new file mode 100644
index 0000000000..e939fa4238
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionProcessScript.jsm
@@ -0,0 +1,422 @@
+/* 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 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.
+ */
+
+var EXPORTED_SYMBOLS = ["ExtensionProcessScript"];
+
+const { MessageChannel } = ChromeUtils.import(
+ "resource://gre/modules/MessageChannel.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ ExtensionChild: "resource://gre/modules/ExtensionChild.jsm",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
+ ExtensionContent: "resource://gre/modules/ExtensionContent.jsm",
+ ExtensionPageChild: "resource://gre/modules/ExtensionPageChild.jsm",
+});
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "console", () =>
+ ExtensionCommon.getConsole()
+);
+
+const { DefaultWeakMap, getInnerWindowID } = 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
+XPCOMUtils.defineLazyGetter(this, "isContentProcess", () => {
+ return Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT;
+});
+
+XPCOMUtils.defineLazyGetter(this, "isContentScriptProcess", () => {
+ return (
+ isContentProcess ||
+ !WebExtensionPolicy.useRemoteWebExtensions ||
+ // Thunderbird still loads some content in the parent process.
+ AppConstants.MOZ_APP_NAME == "thunderbird"
+ );
+});
+
+var extensions = new DefaultWeakMap(policy => {
+ return new ExtensionChild.BrowserExtensionContent(policy);
+});
+
+var pendingExtensions = new Map();
+
+var ExtensionManager;
+
+class ExtensionGlobal {
+ constructor(global) {
+ this.global = global;
+ this.global.addMessageListener("Extension:SetFrameData", this);
+
+ this.frameData = null;
+
+ MessageChannel.addListener(global, "Extension:DetectLanguage", this);
+ MessageChannel.addListener(global, "WebNavigation:GetFrame", this);
+ MessageChannel.addListener(global, "WebNavigation:GetAllFrames", this);
+ }
+
+ get messageFilterStrict() {
+ return {
+ innerWindowID: getInnerWindowID(this.global.content),
+ };
+ }
+
+ getFrameData(force = false) {
+ if (!this.frameData && force) {
+ this.frameData = this.global.sendSyncMessage(
+ "Extension:GetTabAndWindowId"
+ )[0];
+ }
+ return this.frameData;
+ }
+
+ receiveMessage({ target, messageName, recipient, data, name }) {
+ switch (name) {
+ case "Extension:SetFrameData":
+ if (this.frameData) {
+ Object.assign(this.frameData, data);
+ } else {
+ this.frameData = data;
+ }
+ if (data.viewType && WebExtensionPolicy.isExtensionProcess) {
+ ExtensionPageChild.expectViewLoad(this.global, data.viewType);
+ }
+ return;
+ }
+
+ // SetFrameData does not have a recipient extension, or it would be
+ // an extension process. Anything following this point must have
+ // a recipient extension, so check access to the window.
+ let policy = WebExtensionPolicy.getByID(recipient.extensionId);
+ if (!policy.canAccessWindow(this.global.content)) {
+ throw new Error("Extension cannot access window");
+ }
+
+ return ExtensionContent.receiveMessage(
+ this.global,
+ messageName,
+ target,
+ data,
+ recipient
+ );
+ }
+}
+
+ExtensionManager = {
+ // WeakMap<WebExtensionPolicy, Map<string, WebExtensionContentScript>>
+ registeredContentScripts: new DefaultWeakMap(policy => new Map()),
+
+ globals: new WeakMap(),
+
+ init() {
+ MessageChannel.setupMessageManagers([Services.cpmm]);
+
+ Services.cpmm.addMessageListener("Extension:Startup", this);
+ Services.cpmm.addMessageListener("Extension:Shutdown", this);
+ Services.cpmm.addMessageListener("Extension:FlushJarCache", this);
+ Services.cpmm.addMessageListener("Extension:RegisterContentScript", this);
+ Services.cpmm.addMessageListener(
+ "Extension:UnregisterContentScripts",
+ this
+ );
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ Services.obs.addObserver(
+ global => this.globals.set(global, new ExtensionGlobal(global)),
+ "tab-content-frameloader-created"
+ );
+
+ 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") || {});
+ }
+
+ policy = new WebExtensionPolicy({
+ id: extension.id,
+ mozExtensionHostname: extension.uuid,
+ name: extension.name,
+ baseURL: extension.resourceURL,
+
+ isPrivileged: extension.isPrivileged,
+ permissions: extension.permissions,
+ allowedOrigins: extension.allowedOrigins,
+ webAccessibleResources: extension.webAccessibleResources,
+
+ manifestVersion: extension.manifestVersion,
+ extensionPageCSP: extension.extensionPageCSP,
+
+ localizeCallback,
+
+ backgroundScripts,
+ backgroundWorkerScript,
+
+ 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 (isContentProcess) {
+ policy.active = false;
+ }
+ }
+ break;
+ }
+
+ case "Extension:FlushJarCache":
+ ExtensionUtils.flushJarCache(data.path);
+ break;
+
+ case "Extension:RegisterContentScript": {
+ let policy = WebExtensionPolicy.getByID(data.id);
+
+ if (policy) {
+ const registeredContentScripts = this.registeredContentScripts.get(
+ policy
+ );
+ const type =
+ "userScriptOptions" in data.options
+ ? "userScript"
+ : "contentScript";
+
+ if (registeredContentScripts.has(data.scriptId)) {
+ Cu.reportError(
+ new Error(
+ `Registering ${type} ${data.scriptId} on ${data.id} more than once`
+ )
+ );
+ } else {
+ const script = new WebExtensionContentScript(
+ policy,
+ data.options
+ );
+
+ // If the script is a userScript, add the additional userScriptOptions
+ // property to the WebExtensionContentScript instance.
+ if (type === "userScript") {
+ script.userScriptOptions = data.options.userScriptOptions;
+ }
+
+ policy.registerContentScript(script);
+ registeredContentScripts.set(data.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;
+ }
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ Services.cpmm.sendAsyncMessage(`${name}Complete`);
+ },
+};
+
+var ExtensionProcessScript = {
+ extensions,
+
+ getFrameData(global, force) {
+ let extGlobal = ExtensionManager.globals.get(global);
+ return extGlobal && extGlobal.getFrameData(force);
+ },
+
+ initExtension(extension) {
+ return ExtensionManager.initExtensionPolicy(extension);
+ },
+
+ initExtensionDocument(policy, doc, privileged) {
+ let extension = extensions.get(policy);
+ if (privileged) {
+ ExtensionPageChild.initExtensionContext(extension, doc.defaultView);
+ } else {
+ ExtensionContent.initExtensionContext(extension, doc.defaultView);
+ }
+ },
+
+ getExtensionChild(id) {
+ let policy = WebExtensionPolicy.getByID(id);
+ if (policy) {
+ return extensions.get(policy);
+ }
+ },
+
+ preloadContentScript(contentScript) {
+ if (isContentScriptProcess) {
+ ExtensionContent.contentScripts.get(contentScript).preload();
+ }
+ },
+
+ loadContentScript(contentScript, window) {
+ return ExtensionContent.contentScripts
+ .get(contentScript)
+ .injectInto(window);
+ },
+};
+
+ExtensionManager.init();
diff --git a/toolkit/components/extensions/ExtensionSettingsStore.jsm b/toolkit/components/extensions/ExtensionSettingsStore.jsm
new file mode 100644
index 0000000000..0f06aff223
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionSettingsStore.jsm
@@ -0,0 +1,690 @@
+/* -*- 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";
+
+/**
+ * @fileOverview
+ * 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: {
+ * // ...
+ * }
+ * }
+ * }
+ *
+ */
+
+var EXPORTED_SYMBOLS = ["ExtensionSettingsStore"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "JSONFile",
+ "resource://gre/modules/JSONFile.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+// 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 = 2;
+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 (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 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;
+}
+
+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 {object | null} 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 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 {string}
+ * 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 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 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.jsm b/toolkit/components/extensions/ExtensionShortcuts.jsm
new file mode 100644
index 0000000000..f7498bc719
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionShortcuts.jsm
@@ -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";
+
+/* exported ExtensionShortcuts */
+const EXPORTED_SYMBOLS = ["ExtensionShortcuts"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { ShortcutUtils } = ChromeUtils.import(
+ "resource://gre/modules/ShortcutUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "windowTracker", () => {
+ return ExtensionParent.apiManager.global.windowTracker;
+});
+XPCOMUtils.defineLazyGetter(this, "browserActionFor", () => {
+ return ExtensionParent.apiManager.global.browserActionFor;
+});
+XPCOMUtils.defineLazyGetter(this, "pageActionFor", () => {
+ return ExtensionParent.apiManager.global.pageActionFor;
+});
+XPCOMUtils.defineLazyGetter(this, "sidebarActionFor", () => {
+ return ExtensionParent.apiManager.global.sidebarActionFor;
+});
+
+const { ExtensionError } = ExtensionUtils;
+const { makeWidgetId } = ExtensionCommon;
+
+const EXECUTE_PAGE_ACTION = "_execute_page_action";
+const EXECUTE_BROWSER_ACTION = "_execute_browser_action";
+const EXECUTE_SIDEBAR_ACTION = "_execute_sidebar_action";
+
+function normalizeShortcut(shortcut) {
+ return shortcut ? shortcut.replace(/\s+/g, "") : "";
+}
+
+/**
+ * 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.
+ */
+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 ExtensionSettingsStore.initialize();
+ ExtensionSettingsStore.getAllForExtension(extensionId, "commands").forEach(
+ key => {
+ ExtensionSettingsStore.removeSetting(extensionId, "commands", key);
+ }
+ );
+ }
+
+ constructor({ extension, onCommand }) {
+ this.keysetsMap = new WeakMap();
+ this.windowOpenListener = null;
+ this.extension = extension;
+ this.onCommand = onCommand;
+ 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 ExtensionSettingsStore.getSetting(
+ "commands",
+ name,
+ extension.id
+ );
+ let commandUpdates = (previousUpdates && previousUpdates.value) || {};
+
+ if (description && description != command.description) {
+ commandUpdates.description = description;
+ command.description = description;
+ }
+
+ if (shortcut != null && shortcut != command.shortcut) {
+ shortcut = normalizeShortcut(shortcut);
+ commandUpdates.shortcut = shortcut;
+ command.shortcut = shortcut;
+ }
+
+ await ExtensionSettingsStore.addSetting(
+ extension.id,
+ "commands",
+ name,
+ commandUpdates
+ );
+
+ this.registerKeys(commands);
+ }
+
+ 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 = ExtensionSettingsStore.getSetting(
+ "commands",
+ name,
+ extension.id
+ );
+
+ if (storedCommand && storedCommand.value) {
+ commands.set(name, { ...manifestCommands.get(name) });
+ 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 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);
+ }
+ };
+
+ 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 windowTracker.browserWindows()) {
+ if (this.keysetsMap.has(window)) {
+ this.keysetsMap.get(window).remove();
+ }
+ }
+
+ 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<string, object>}
+ */
+ 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 } = 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 ExtensionSettingsStore.initialize();
+ let names = ExtensionSettingsStore.getAllForExtension(
+ extensionId,
+ "commands"
+ );
+ return names.reduce((map, name) => {
+ let command = 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 &&
+ 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;
+ commands.forEach((command, name) => {
+ 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({ key: sidebarKey });
+ }
+ 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 {Document} 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;
+ if (name == EXECUTE_PAGE_ACTION) {
+ action = pageActionFor(this.extension);
+ } else if (name == EXECUTE_BROWSER_ACTION) {
+ action = browserActionFor(this.extension);
+ } else if (name == EXECUTE_SIDEBAR_ACTION) {
+ action = sidebarActionFor(this.extension);
+ } else {
+ this.extension.tabManager.addActiveTabPermission();
+ this.onCommand(name);
+ return;
+ }
+ if (action) {
+ let win = event.target.ownerGlobal;
+ action.triggerAction(win);
+ }
+ });
+ /* 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 {Document} 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",
+ 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] = ShortcutUtils.getKeyAttribute(chromeKey);
+ keyElement.setAttribute(attribute, value);
+ if (attribute == "keycode") {
+ keyElement.setAttribute("event", "keydown");
+ }
+
+ return keyElement;
+ }
+}
diff --git a/toolkit/components/extensions/ExtensionStorage.jsm b/toolkit/components/extensions/ExtensionStorage.jsm
new file mode 100644
index 0000000000..d3d534181c
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorage.jsm
@@ -0,0 +1,457 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ExtensionStorage"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionUtils",
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "JSONFile",
+ "resource://gre/modules/JSONFile.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+const global = this;
+
+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(global);
+ 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 {StructuredCloneHolder|*} value
+ * A value to serialize.
+ * @returns {*}
+ */
+function serialize(value) {
+ if (value && typeof value === "object" && !isStructuredCloneHolder(value)) {
+ return new StructuredCloneHolder(value);
+ }
+ return value;
+}
+
+var ExtensionStorage = {
+ // Map<extension-id, Promise<JSONFile>>
+ 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<JSONFile>}
+ */
+ async _readFile(extensionId) {
+ OS.File.makeDir(this.getExtensionDir(extensionId), {
+ ignoreExisting: true,
+ from: OS.Constants.Path.profileDir,
+ });
+
+ let jsonFile = new 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<JSONFile>}
+ */
+ 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 {value} 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 ExtensionUtils.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 OS.Path.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 OS.Path.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<void>}
+ */
+ 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(jsonFile.data.get(prop)),
+ newValue: serialize(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<string>} items
+ * A list of storage items to remove.
+ * @returns {Promise<void>}
+ */
+ 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(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<void>}
+ */
+ 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(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<string>|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<object>}
+ * 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(jsonFile.data, keys);
+ },
+
+ async _filterProperties(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(data.get(prop));
+ } else {
+ result[prop] = keys[prop];
+ }
+ }
+ } else {
+ for (let prop of [].concat(keys)) {
+ if (data.has(prop)) {
+ result[prop] = serialize(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<string>|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<string>|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(value, context.cloneScope);
+ } catch (e) {
+ throw new ExtensionUtils.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;
+ },
+};
+
+XPCOMUtils.defineLazyGetter(ExtensionStorage, "extensionDir", () =>
+ OS.Path.join(OS.Constants.Path.profileDir, "browser-extension-data")
+);
+
+ExtensionStorage.init();
diff --git a/toolkit/components/extensions/ExtensionStorageIDB.jsm b/toolkit/components/extensions/ExtensionStorageIDB.jsm
new file mode 100644
index 0000000000..8ab338915a
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorageIDB.jsm
@@ -0,0 +1,871 @@
+/* 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.EXPORTED_SYMBOLS = ["ExtensionStorageIDB"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { IndexedDB } = ChromeUtils.import(
+ "resource://gre/modules/IndexedDB.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm",
+ getTrimmedString: "resource://gre/modules/ExtensionTelemetry.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "quotaManagerService",
+ "@mozilla.org/dom/quota-manager-service;1",
+ "nsIQuotaManagerService"
+);
+
+// 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.jsm).
+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 (
+ error instanceof DOMException ||
+ error instanceof DataMigrationAbortedError
+ ) {
+ if (error.name.length > 80) {
+ return 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);
+ }
+
+ Services.telemetry.recordEvent(
+ "extensions.data",
+ "migrateResult",
+ "storageLocal",
+ getTrimmedString(extensionId),
+ extra
+ );
+ } 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 {string} extensionId
+ * The id of the extension migrated.
+ * @param {string} storageMethod
+ * The storage.local API method being run.
+ * @param {Error} error
+ * The unexpected error raised during the API call.
+ */
+ recordStorageLocalError({ extensionId, storageMethod, error }) {
+ this.lazyInit();
+
+ Services.telemetry.recordEvent(
+ "extensions.data",
+ "storageLocalError",
+ storageMethod,
+ getTrimmedString(extensionId),
+ { error_name: this.getErrorName(error) }
+ );
+ },
+};
+
+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 {function} 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<null|object>}
+ * 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,
+ "readwrite"
+ );
+ const transactionCompleted = transaction.promiseComplete();
+
+ 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 ? serialize(oldValue) : oldValue,
+ newValue: serialize ? serialize(items[key]) : 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<string>|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<object>}
+ * 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<string>} keys
+ * A string key of a list of storage items keys to remove.
+ * @returns {Promise<Object>}
+ * 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<Object>}
+ * 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 = ExtensionStorage.getStorageFile(extension.id);
+ oldStorageExists = await OS.File.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 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 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 OS.File.exists(oldStoragePath)) {
+ let openInfo = await OS.File.openUnique(`${oldStoragePath}.migrated`, {
+ humanReadable: true,
+ });
+ await openInfo.file.close();
+ await OS.File.move(oldStoragePath, openInfo.path);
+ }
+ } 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.
+ */
+this.ExtensionStorageIDB = {
+ BACKEND_ENABLED_PREF,
+ IDB_MIGRATED_PREF_BRANCH,
+ IDB_MIGRATE_RESULT_HISTOGRAM,
+
+ // Map<extension-id, Set<Function>>
+ 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<extension -> Promise<boolean>
+ 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 {BaseContext} context
+ * The extension context that is selecting the storage backend.
+ *
+ * @returns {Promise<Object>}
+ * 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(
+ 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 = quotaManagerService.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<ExtensionStorageLocalIDB>}
+ * 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 } = ExtensionUtils;
+
+ if (error instanceof ExtensionError) {
+ return error;
+ }
+
+ let errorMessage;
+
+ if (error instanceof DOMException) {
+ 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.jsm b/toolkit/components/extensions/ExtensionStorageSync.jsm
new file mode 100644
index 0000000000..3f08c05c22
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorageSync.jsm
@@ -0,0 +1,188 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ExtensionStorageSync", "extensionStorageSync"];
+
+const STORAGE_SYNC_ENABLED_PREF = "webextensions.storage.sync.enabled";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016;
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "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.
+XPCOMUtils.defineLazyGetter(this, "storageSvc", () =>
+ Cc["@mozilla.org/extensions/storage/sync;1"]
+ .getService(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.mozIExtensionStorageArea)
+);
+
+// We might end up falling back to kinto...
+XPCOMUtils.defineLazyGetter(
+ this,
+ "extensionStorageSyncKinto",
+ () =>
+ ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageSyncKinto.jsm",
+ {}
+ ).extensionStorageSync
+);
+
+// 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.
+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 (prefPermitsStorageSync !== true) {
+ throw new 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(JSON.stringify);
+ 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 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 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);
+ }
+
+ 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) {
+ ExtensionCommon.runSafeSyncWithoutClone(listener, changes);
+ }
+ }
+ }
+}
+
+var extensionStorageSync = new ExtensionStorageSync();
diff --git a/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm b/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm
new file mode 100644
index 0000000000..71bdb34c86
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorageSyncKinto.jsm
@@ -0,0 +1,1372 @@
+/* -*- 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";
+
+// TODO:
+// * find out how the Chrome implementation deals with conflicts
+
+/* exported extensionIdToCollectionId */
+
+var EXPORTED_SYMBOLS = ["ExtensionStorageSync", "extensionStorageSync"];
+
+const global = this;
+
+Cu.importGlobalProperties(["atob", "btoa"]);
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+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;
+
+const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+ BulkKeyBundle: "resource://services-sync/keys.js",
+ CollectionKeyManager: "resource://services-sync/record.js",
+ CommonUtils: "resource://services-common/utils.js",
+ CryptoUtils: "resource://services-crypto/utils.js",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
+ fxAccounts: "resource://gre/modules/FxAccounts.jsm",
+ KintoHttpClient: "resource://services-common/kinto-http-client.js",
+ Kinto: "resource://services-common/kinto-offline-client.js",
+ FirefoxAdapter: "resource://services-common/kinto-storage-adapter.js",
+ Observers: "resource://services-common/observers.js",
+ Utils: "resource://services-sync/util.js",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "prefPermitsStorageSync",
+ STORAGE_SYNC_ENABLED_PREF,
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "prefStorageSyncServerURL",
+ STORAGE_SYNC_SERVER_URL_PREF,
+ KINTO_DEFAULT_SERVER_URL
+);
+XPCOMUtils.defineLazyGetter(this, "WeaveCrypto", function() {
+ let { WeaveCrypto } = ChromeUtils.import(
+ "resource://services-crypto/WeaveCrypto.js"
+ );
+ return new WeaveCrypto();
+});
+
+const { DefaultMap } = ExtensionUtils;
+
+// Map of Extensions to Set<Contexts> 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 = 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 ExtensionStorageSync instance that extensions and Fx Sync use.
+// On Android, because there's no FXAccounts instance, any syncing
+// operations will fail.
+var extensionStorageSync = 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 {string} The computed HMAC
+ */
+function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
+ const hasher = keyBundle.sha256HMACHasher;
+ return CommonUtils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, hasher));
+}
+
+/**
+ * Get the current user's hashed kB.
+ *
+ * @param {FXAccounts} fxaService The service to use to get the
+ * current user.
+ * @returns {string} 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 = WeaveCrypto.generateRandomIV();
+ let ciphertext = await WeaveCrypto.encrypt(
+ JSON.stringify(record),
+ keyBundle.encryptionKeyB64,
+ IV
+ );
+ let hmac = 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 = ciphertextHMAC(
+ keyBundle,
+ record.id,
+ record.IV,
+ record.ciphertext
+ );
+
+ if (computedHMAC != record.hmac) {
+ Utils.throwHMACMismatch(record.hmac, computedHMAC);
+ }
+
+ // Handle invalid data here. Elsewhere we assume that cleartext is an object.
+ let cleartext = await 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 a Promise<KeyBundle>.
+ */
+ 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<string>} The ID to use.
+ */
+ getEncodedRecordId(record) {
+ return Promise.resolve(record.id);
+ }
+}
+global.EncryptionRemoteTransformer = EncryptionRemoteTransformer;
+
+/**
+ * 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 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 (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
+ );
+ }
+}
+global.KeyRingEncryptionRemoteTransformer = KeyRingEncryptionRemoteTransformer;
+
+/**
+ * A Promise that centralizes initialization of ExtensionStorageSync.
+ *
+ * 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<Object>}
+ */
+async function storageSyncInit() {
+ // Memoize the result to share the connection.
+ if (storageSyncInit.promise === undefined) {
+ const path = "storage-sync.sqlite";
+ storageSyncInit.promise = FirefoxAdapter.openConnection({ path })
+ .then(connection => {
+ return {
+ connection,
+ kinto: new Kinto({
+ adapter: 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;
+}
+
+// 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(
+ 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<Object>}
+ */
+ 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 uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(
+ Ci.nsIUUIDGenerator
+ );
+ const uuid = uuidgen.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<bytestring>} A collection ID suitable for use to sync to.
+ */
+ extensionIdToCollectionId(extensionId) {
+ return this.hashWithExtensionSalt(
+ 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<bytestring>} 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 = CryptoUtils.digestBytes(message, hasher);
+ return CommonUtils.encodeBase64URL(hash, false);
+ }
+
+ /**
+ * Retrieve the actual keyring from the crypto collection.
+ *
+ * @returns {Promise<CollectionKeyManager>}
+ */
+ async getKeyRing() {
+ const cryptoKeyRecord = await this.getKeyRingRecord();
+ const collectionKeys = new 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(extensionStorageSync) {
+ const collection = await this.getCollection();
+ return extensionStorageSync._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();
+ }
+}
+this.CryptoCollection = CryptoCollection;
+
+/**
+ * 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 = 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}`);
+ }
+};
+
+global.CollectionKeyEncryptionRemoteTransformer = CollectionKeyEncryptionRemoteTransformer;
+
+/**
+ * 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<Collection>}
+ */
+const openCollection = async function(extension, options = {}) {
+ let collectionId = extension.id;
+ const { kinto } = await storageSyncInit();
+ const coll = kinto.collection(collectionId, {
+ ...options,
+ idSchema: storageSyncIdSchema,
+ });
+ return coll;
+};
+
+class ExtensionStorageSync {
+ /**
+ * @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<Set<Extension>>}
+ * 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 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<SyncResultObject>}
+ */
+ _syncCollection(collection, options) {
+ // FIXME: this should support syncing to self-hosted
+ return this._requestWithToken(`Syncing ${collection.name}`, function(
+ token
+ ) {
+ const allOptions = Object.assign(
+ {},
+ {
+ remote: 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<void>}
+ */
+ _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 KintoHttpClient(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<string>} 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<string>} extIds
+ * The IDs of the extensions which need keys.
+ * @returns {Promise<CollectionKeyManager>}
+ */
+ 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<Collection>}
+ */
+ getCollection(extension, context) {
+ if (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, global);
+ }
+
+ 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) {
+ Observers.notify("ext.storage.sync-changed");
+ let listeners = this.listeners.get(extension) || new Set();
+ if (listeners) {
+ for (let listener of listeners) {
+ ExtensionCommon.runSafeSyncWithoutClone(listener, changes);
+ }
+ }
+ }
+}
+this.ExtensionStorageSync = ExtensionStorageSync;
+extensionStorageSync = new ExtensionStorageSync(_fxaService);
diff --git a/toolkit/components/extensions/ExtensionTelemetry.jsm b/toolkit/components/extensions/ExtensionTelemetry.jsm
new file mode 100644
index 0000000000..c06d4f179c
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionTelemetry.jsm
@@ -0,0 +1,188 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ExtensionTelemetry", "getTrimmedString"];
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+// Map of the base histogram ids for the metrics recorded for the extensions.
+const histograms = {
+ extensionStartup: "WEBEXT_EXTENSION_STARTUP_MS",
+ 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",
+ 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",
+ userScriptInjection: "WEBEXT_USER_SCRIPT_INJECTION_MS",
+};
+
+/**
+ * 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.
+ */
+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)}`;
+}
+
+/**
+ * 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;
+ }
+
+ // Stopwatch methods.
+ stopwatchStart(extension, obj = extension) {
+ this._wrappedStopwatchMethod("start", this.metric, extension, obj);
+ }
+
+ stopwatchFinish(extension, obj = extension) {
+ this._wrappedStopwatchMethod("finish", this.metric, extension, obj);
+ }
+
+ stopwatchCancel(extension, obj = extension) {
+ this._wrappedStopwatchMethod("cancel", this.metric, extension, obj);
+ }
+
+ // Histogram counters methods.
+ histogramAdd(opts) {
+ this._histogramAdd(this.metric, opts);
+ }
+
+ /**
+ * 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 _histogram 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) {
+ throw new Error(`Mandatory extension parameter is undefined`);
+ }
+
+ const baseId = histograms[metric];
+ if (!baseId) {
+ throw new Error(`Unknown metric ${metric}`);
+ }
+
+ // 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) {
+ throw new Error(`Mandatory extension parameter is undefined`);
+ }
+
+ const baseId = histograms[metric];
+ if (!baseId) {
+ throw new Error(`Unknown metric ${metric}`);
+ }
+
+ 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);
+ }
+ }
+}
+
+// Cache of the ExtensionTelemetryMetric instances that has been lazily created by the
+// Extension Telemetry Proxy.
+const metricsCache = new Map();
+
+/**
+ * This proxy object provides the telemetry helpers for the currently supported metrics (the ones listed in
+ * ExtensionTelemetryHelpers._histograms), 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});
+ */
+var ExtensionTelemetry = new Proxy(metricsCache, {
+ get(target, prop, receiver) {
+ if (!(prop in histograms)) {
+ throw new Error(`Unknown metric ${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.jsm b/toolkit/components/extensions/ExtensionTestCommon.jsm
new file mode 100644
index 0000000000..51198154a9
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionTestCommon.jsm
@@ -0,0 +1,487 @@
+/* -*- 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";
+
+/**
+ * This module contains extension testing helper logic which is common
+ * between all test suites.
+ */
+
+/* exported ExtensionTestCommon, MockExtension */
+
+var EXPORTED_SYMBOLS = ["ExtensionTestCommon", "MockExtension"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["TextEncoder"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Extension",
+ "resource://gre/modules/Extension.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionPermissions",
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "apiManager",
+ () => ExtensionParent.apiManager
+);
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "uuidGen",
+ "@mozilla.org/uuid-generator;1",
+ "nsIUUIDGenerator"
+);
+
+const { flushJarCache } = ExtensionUtils;
+
+const { instanceOf } = ExtensionCommon;
+
+XPCOMUtils.defineLazyGetter(this, "console", () =>
+ ExtensionCommon.getConsole()
+);
+
+var ExtensionTestCommon;
+
+/**
+ * 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
+ */
+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) {
+ apiManager.off(eventName, onstartup);
+ this._extension = extension;
+ resolve(extension);
+ }
+ };
+ 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.applications.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 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 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 OS.File.remove(this.file.path);
+ });
+ }
+}
+
+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);
+ }
+}
+
+ExtensionTestCommon = class ExtensionTestCommon {
+ /**
+ * 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": "<JS code>"
+ * 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");
+
+ if (data.background) {
+ let bgScript = uuidGen.generateUUID().number + ".js";
+
+ provide(manifest, ["background", "scripts"], [bgScript], true);
+ 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 = FileUtils.getFile("TmpD", [baseName]);
+ file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 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("utf-8").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 ExtensionPermissions.remove(id, {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ });
+ }
+ return ExtensionPermissions.add(id, {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ });
+ }
+
+ static setExtensionID(data) {
+ try {
+ if (data.manifest.applications.gecko.id) {
+ return;
+ }
+ } catch (e) {
+ // No ID is set.
+ }
+ provide(
+ data,
+ ["manifest", "applications", "gecko", "id"],
+ uuidGen.generateUUID().number
+ );
+ }
+
+ /**
+ * Generates a new extension using |Extension.generateXPI|, and initializes a
+ * new |Extension| instance which will execute it.
+ *
+ * @param {object} data
+ * @returns {Extension}
+ */
+ static generate(data) {
+ 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 = uuidGen.generateUUID().number;
+ }
+
+ let signedState = AddonManager.SIGNEDSTATE_SIGNED;
+ if (data.isPrivileged) {
+ signedState = AddonManager.SIGNEDSTATE_PRIVILEGED;
+ }
+ if (data.isSystem) {
+ signedState = AddonManager.SIGNEDSTATE_SYSTEM;
+ }
+
+ return new Extension({
+ id,
+ resourceURI: jarURI,
+ cleanupFile: file,
+ signedState,
+ incognitoOverride: data.incognitoOverride,
+ temporarilyInstalled: !!data.temporarilyInstalled,
+ TEST_NO_ADDON_MANAGER: true,
+ });
+ }
+};
diff --git a/toolkit/components/extensions/ExtensionUtils.jsm b/toolkit/components/extensions/ExtensionUtils.jsm
new file mode 100644
index 0000000000..5b706c3749
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -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/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ExtensionUtils"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "btoa"]);
+
+// xpcshell doesn't handle idle callbacks well.
+XPCOMUtils.defineLazyGetter(this, "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 => 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 e instanceof DOMException && e.name === "ExtensionError";
+ }
+}
+
+function filterStack(error) {
+ return String(error.stack).replace(
+ /(^.*(Task\.jsm|Promise-backend\.js).*\n)+/gm,
+ "<Promise Chain>\n"
+ );
+}
+
+/**
+ * Similar to a WeakMap, but creates a new key with the given
+ * constructor if one is not present.
+ */
+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);
+ }
+ 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<Document>}
+ */
+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 <head> 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<IdleDeadline>}
+ */
+function promiseDocumentIdle(window) {
+ return window.document.documentReadyForIdle.then(() => {
+ return new Promise(resolve =>
+ window.requestIdleCallback(resolve, { timeout: 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<Document>}
+ */
+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 {Event} [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<Event>}
+ */
+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(nsISupports, string)} [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<object>}
+ */
+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<string>}
+ */
+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)}`;
+}
+
+var ExtensionUtils = {
+ flushJarCache,
+ getInnerWindowID,
+ getMessageManager,
+ getUniqueId,
+ filterStack,
+ makeDataURI,
+ parseMatchPatterns,
+ promiseDocumentIdle,
+ promiseDocumentLoaded,
+ promiseDocumentReady,
+ promiseEvent,
+ promiseObserved,
+ promiseTimeout,
+ DefaultMap,
+ DefaultWeakMap,
+ ExtensionError,
+ LimitedSet,
+};
diff --git a/toolkit/components/extensions/ExtensionXPCShellUtils.jsm b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
new file mode 100644
index 0000000000..7fee082a0b
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionXPCShellUtils.jsm
@@ -0,0 +1,1096 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ExtensionTestUtils"];
+
+// Need to import ActorManagerParent.jsm so that the actors are initialized before
+// running extension XPCShell tests.
+ChromeUtils.import("resource://gre/modules/ActorManagerParent.jsm");
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+// Windowless browsers can create documents that rely on XUL Custom Elements:
+ChromeUtils.import("resource://gre/modules/CustomElementsListener.jsm", null);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonTestUtils",
+ "resource://testing-common/AddonTestUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ContentTask",
+ "resource://testing-common/ContentTask.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionTestCommon",
+ "resource://testing-common/ExtensionTestCommon.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "MessageChannel",
+ "resource://gre/modules/MessageChannel.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Schemas",
+ "resource://gre/modules/Schemas.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "TestUtils",
+ "resource://testing-common/TestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+ const { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+ );
+ return Management;
+});
+
+/* exported ExtensionTestUtils */
+
+const { promiseDocumentLoaded, promiseEvent, promiseObserved } = ExtensionUtils;
+
+var REMOTE_CONTENT_SCRIPTS = Services.appinfo.browserTabsRemoteAutostart;
+const REMOTE_CONTENT_SUBFRAMES = Services.appinfo.fissionAutostart;
+
+let BASE_MANIFEST = Object.freeze({
+ applications: Object.freeze({
+ gecko: Object.freeze({
+ id: "test@web.ext",
+ }),
+ }),
+
+ manifest_version: 2,
+
+ name: "name",
+ version: "0",
+});
+
+function frameScript() {
+ const { MessageChannel } = ChromeUtils.import(
+ "resource://gre/modules/MessageChannel.jsm"
+ );
+ const { Services } = ChromeUtils.import(
+ "resource://gre/modules/Services.jsm"
+ );
+
+ // We need to make sure that the ExtensionPolicy service has been initialized
+ // as it sets up the observers that inject extension content scripts.
+ Cc["@mozilla.org/addons/policy-service;1"].getService();
+
+ Services.obs.notifyObservers(this, "tab-content-frameloader-created");
+
+ const messageListener = {
+ async receiveMessage({ target, messageName, recipient, data, name }) {
+ /* globals content */
+ let resp = await content.fetch(data.url, data.options);
+ return resp.text();
+ },
+ };
+ MessageChannel.addListener(this, "Test:Fetch", messageListener);
+
+ // eslint-disable-next-line mozilla/balanced-listeners, no-undef
+ addEventListener(
+ "MozHeapMinimize",
+ () => {
+ Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
+ },
+ true,
+ true
+ );
+}
+
+let kungFuDeathGrip = new Set();
+function promiseBrowserLoaded(browser, url, redirectUrl) {
+ url = url && Services.io.newURI(url);
+ redirectUrl = redirectUrl && Services.io.newURI(redirectUrl);
+
+ return new Promise(resolve => {
+ const listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsISupportsWeakReference",
+ "nsIWebProgressListener",
+ ]),
+
+ onStateChange(webProgress, request, stateFlags, statusCode) {
+ request.QueryInterface(Ci.nsIChannel);
+
+ let requestURI =
+ request.originalURI ||
+ webProgress.DOMWindow.document.documentURIObject;
+ if (
+ webProgress.isTopLevel &&
+ (url?.equals(requestURI) || redirectUrl?.equals(requestURI)) &&
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP
+ ) {
+ resolve();
+ kungFuDeathGrip.delete(listener);
+ browser.removeProgressListener(listener);
+ }
+ },
+ };
+
+ // addProgressListener only supports weak references, so we need to
+ // use one. But we also need to make sure it stays alive until we're
+ // done with it, so thunk away a strong reference to keep it alive.
+ kungFuDeathGrip.add(listener);
+ browser.addProgressListener(
+ listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW
+ );
+ });
+}
+
+class ContentPage {
+ constructor(
+ remote = REMOTE_CONTENT_SCRIPTS,
+ remoteSubframes = REMOTE_CONTENT_SUBFRAMES,
+ extension = null,
+ privateBrowsing = false,
+ userContextId = undefined
+ ) {
+ this.remote = remote;
+
+ // If an extension has been passed, overwrite remote
+ // with extension.remote to be sure that the ContentPage
+ // will have the same remoteness of the extension.
+ if (extension) {
+ this.remote = extension.remote;
+ }
+
+ this.remoteSubframes = this.remote && remoteSubframes;
+ this.extension = extension;
+ this.privateBrowsing = privateBrowsing;
+ this.userContextId = userContextId;
+
+ this.browserReady = this._initBrowser();
+ }
+
+ async _initBrowser() {
+ let chromeFlags = 0;
+ if (this.remote) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW;
+ }
+ if (this.remoteSubframes) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW;
+ }
+ if (this.privateBrowsing) {
+ chromeFlags |= Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW;
+ }
+ this.windowlessBrowser = Services.appShell.createWindowlessBrowser(
+ true,
+ chromeFlags
+ );
+
+ let system = Services.scriptSecurityManager.getSystemPrincipal();
+
+ let chromeShell = this.windowlessBrowser.docShell.QueryInterface(
+ Ci.nsIWebNavigation
+ );
+
+ chromeShell.createAboutBlankContentViewer(system, system);
+ this.windowlessBrowser.browsingContext.useGlobalHistory = false;
+ let loadURIOptions = {
+ triggeringPrincipal: system,
+ };
+ chromeShell.loadURI(
+ "chrome://extensions/content/dummy.xhtml",
+ loadURIOptions
+ );
+
+ await promiseObserved(
+ "chrome-document-global-created",
+ win => win.document == chromeShell.document
+ );
+
+ let chromeDoc = await promiseDocumentLoaded(chromeShell.document);
+
+ let browser = chromeDoc.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ if (this.userContextId) {
+ browser.setAttribute("usercontextid", this.userContextId);
+ }
+
+ if (this.extension?.remote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", "extension");
+ }
+
+ // Ensure that the extension is loaded into the correct
+ // BrowsingContextGroupID by default.
+ if (this.extension) {
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ this.extension.browsingContextGroupId
+ );
+ }
+
+ let awaitFrameLoader = Promise.resolve();
+ if (this.remote) {
+ awaitFrameLoader = promiseEvent(browser, "XULFrameLoaderCreated");
+ browser.setAttribute("remote", "true");
+
+ browser.setAttribute("maychangeremoteness", "true");
+ browser.addEventListener(
+ "DidChangeBrowserRemoteness",
+ this.didChangeBrowserRemoteness.bind(this)
+ );
+ }
+
+ chromeDoc.documentElement.appendChild(browser);
+
+ // Forcibly flush layout so that we get a pres shell soon enough, see
+ // bug 1274775.
+ browser.getBoundingClientRect();
+
+ await awaitFrameLoader;
+
+ this.browser = browser;
+
+ this.loadFrameScript(frameScript);
+
+ return browser;
+ }
+
+ sendMessage(msg, data) {
+ return MessageChannel.sendMessage(this.browser.messageManager, msg, data);
+ }
+
+ loadFrameScript(func) {
+ let frameScript = `data:text/javascript,(${encodeURI(func)}).call(this)`;
+ this.browser.messageManager.loadFrameScript(frameScript, true, true);
+ }
+
+ addFrameScriptHelper(func) {
+ let frameScript = `data:text/javascript,${encodeURI(func)}`;
+ this.browser.messageManager.loadFrameScript(frameScript, false, true);
+ }
+
+ didChangeBrowserRemoteness(event) {
+ // XXX: Tests can load their own additional frame scripts, so we may need to
+ // track all scripts that have been loaded, and reload them here?
+ this.loadFrameScript(frameScript);
+ }
+
+ async loadURL(url, redirectUrl = undefined) {
+ await this.browserReady;
+
+ this.browser.loadURI(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ return promiseBrowserLoaded(this.browser, url, redirectUrl);
+ }
+
+ async fetch(url, options) {
+ return this.sendMessage("Test:Fetch", { url, options });
+ }
+
+ spawn(params, task) {
+ return ContentTask.spawn(this.browser, params, task);
+ }
+
+ async close() {
+ await this.browserReady;
+
+ let { messageManager } = this.browser;
+
+ this.browser.removeEventListener(
+ "DidChangeBrowserRemoteness",
+ this.didChangeBrowserRemoteness.bind(this)
+ );
+ this.browser = null;
+
+ this.windowlessBrowser.close();
+ this.windowlessBrowser = null;
+
+ await TestUtils.topicObserved(
+ "message-manager-disconnect",
+ subject => subject === messageManager
+ );
+ }
+}
+
+class ExtensionWrapper {
+ 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;
+ }
+
+ async startup() {
+ if (this.state != "uninitialized") {
+ throw new Error("Extension already started");
+ }
+ this.state = "pending";
+
+ await 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 marks the extension unloading without actually calling
+ * shutdown, since shutting down a MockExtension causes it to be uninstalled.
+ *
+ * Normally you shouldn't need to use this unless you need to test something
+ * that requires a restart, such as updates.
+ */
+ markUnloaded() {
+ if (this.state != "running") {
+ throw new Error("Extension not running");
+ }
+ this.state = "unloaded";
+
+ return Promise.resolve();
+ }
+
+ 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);
+
+ Management.on("ready", this.onEvent);
+ Management.on("shutdown", this.onEvent);
+ Management.on("startup", this.onEvent);
+
+ AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
+ AddonTestUtils.on("addon-manager-started", this.onEvent);
+
+ AddonManager.addAddonListener(this);
+ }
+
+ destroy() {
+ this.id = null;
+ this.addon = null;
+
+ Management.off("ready", this.onEvent);
+ Management.off("shutdown", this.onEvent);
+ Management.off("startup", this.onEvent);
+
+ AddonTestUtils.off("addon-manager-shutdown", this.onEvent);
+ AddonTestUtils.off("addon-manager-started", this.onEvent);
+
+ 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 = 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();
+ }
+
+ async upgrade(data) {
+ this.startupPromise = new Promise(resolve => {
+ this.resolveStartup = resolve;
+ });
+ this.state = "restarting";
+
+ await this._flushCache();
+
+ let xpiFile = ExtensionTestCommon.generateXPI(data);
+
+ this.cleanupFiles.push(xpiFile);
+
+ return this._install(xpiFile);
+ }
+}
+
+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.applications.gecko;
+ if (id) {
+ return 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 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 AddonManager.getInstallForFile(
+ xpiFile,
+ null,
+ this.installTelemetryInfo
+ ).then(install => {
+ let listener = {
+ 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);
+ }
+}
+
+class ExternallyInstalledWrapper extends AOMExtensionWrapper {
+ constructor(testScope, id) {
+ super(testScope);
+
+ this.id = id;
+ this.startupPromise = new Promise(resolve => {
+ this.resolveStartup = resolve;
+ });
+
+ this.state = "restarting";
+ }
+
+ maybeSetID(uri, id) {}
+}
+
+var ExtensionTestUtils = {
+ BASE_MANIFEST,
+
+ async normalizeManifest(
+ manifest,
+ manifestType = "manifest.WebExtensionManifest",
+ baseManifest = BASE_MANIFEST
+ ) {
+ await Management.lazyInit();
+
+ let errors = [];
+ let context = {
+ url: null,
+
+ logError: error => {
+ errors.push(error);
+ },
+
+ preprocessors: {},
+ };
+
+ manifest = Object.assign({}, baseManifest, manifest);
+
+ let normalized = Schemas.normalize(manifest, manifestType, context);
+ normalized.errors = errors;
+
+ return normalized;
+ },
+
+ currentScope: null,
+
+ profileDir: null,
+
+ init(scope) {
+ this.currentScope = scope;
+
+ this.profileDir = scope.do_get_profile();
+
+ this.fetchScopes = new Map();
+
+ // We need to load at least one frame script into every message
+ // manager to ensure that the scriptable wrapper for its global gets
+ // created before we try to access it externally. If we don't, we
+ // fail sanity checks on debug builds the first time we try to
+ // create a wrapper, because we should never have a global without a
+ // cached wrapper.
+ Services.mm.loadFrameScript("data:text/javascript,//", true, true);
+
+ let tmpD = this.profileDir.clone();
+ tmpD.append("tmp");
+ tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, 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;
+
+ return Promise.all(
+ Array.from(this.fetchScopes.values(), promise =>
+ promise.then(scope => scope.close())
+ )
+ );
+ });
+ },
+
+ addonManagerStarted: false,
+
+ mockAppInfo() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "48",
+ "48"
+ );
+ },
+
+ startAddonManager() {
+ if (this.addonManagerStarted) {
+ return;
+ }
+ this.addonManagerStarted = true;
+ this.mockAppInfo();
+
+ return 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) {
+ ExtensionTestCommon.setExtensionID(data);
+ }
+ let xpiFile = ExtensionTestCommon.generateXPI(data);
+
+ return this.loadExtensionXPI(xpiFile, data);
+ }
+
+ let extension = 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);
+ });
+ }
+ },
+
+ get remoteContentScripts() {
+ return REMOTE_CONTENT_SCRIPTS;
+ },
+
+ set remoteContentScripts(val) {
+ REMOTE_CONTENT_SCRIPTS = !!val;
+ },
+
+ async fetch(origin, url, options) {
+ let fetchScopePromise = this.fetchScopes.get(origin);
+ if (!fetchScopePromise) {
+ fetchScopePromise = this.loadContentPage(origin);
+ this.fetchScopes.set(origin, fetchScopePromise);
+ }
+
+ let fetchScope = await fetchScopePromise;
+ return fetchScope.sendMessage("Test:Fetch", { url, options });
+ },
+
+ /**
+ * 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 {ContentPage}
+ */
+ loadContentPage(
+ url,
+ {
+ extension = undefined,
+ remote = undefined,
+ remoteSubframes = undefined,
+ redirectUrl = undefined,
+ privateBrowsing = false,
+ userContextId = undefined,
+ } = {}
+ ) {
+ ContentTask.setTestScope(this.currentScope);
+
+ let contentPage = new ContentPage(
+ remote,
+ remoteSubframes,
+ extension && extension.extension,
+ privateBrowsing,
+ userContextId
+ );
+
+ return contentPage.loadURL(url, redirectUrl).then(() => {
+ return contentPage;
+ });
+ },
+};
diff --git a/toolkit/components/extensions/FindContent.jsm b/toolkit/components/extensions/FindContent.jsm
new file mode 100644
index 0000000000..930a3aea84
--- /dev/null
+++ b/toolkit/components/extensions/FindContent.jsm
@@ -0,0 +1,258 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["FindContent"];
+
+/* exported FindContent */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FinderIterator",
+ "resource://gre/modules/FinderIterator.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FinderHighlighter",
+ "resource://gre/modules/FinderHighlighter.jsm"
+);
+
+class FindContent {
+ constructor(docShell) {
+ const { Finder } = ChromeUtils.import("resource://gre/modules/Finder.jsm");
+ this.finder = new Finder(docShell);
+ }
+
+ get iterator() {
+ if (!this._iterator) {
+ this._iterator = new FinderIterator();
+ }
+ return this._iterator;
+ }
+
+ get highlighter() {
+ if (!this._highlighter) {
+ this._highlighter = new 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} queryphrase - the text to search for.
+ * @param {boolean} caseSensitive - whether to use case sensitive matches.
+ * @param {boolean} includeRangeData - whether to collect and return range data.
+ * @param {boolean} matchDiacritics - whether diacritics must match.
+ * @param {boolean} searchString - whether to 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..3b2069e185
--- /dev/null
+++ b/toolkit/components/extensions/MatchGlob.h
@@ -0,0 +1,93 @@
+/* -*- 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 "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsISupports.h"
+#include "nsWrapperCache.h"
+
+namespace mozilla {
+namespace extensions {
+
+class MatchPattern;
+
+class MatchGlob final : public nsISupports, public nsWrapperCache {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MatchGlob)
+
+ static already_AddRefed<MatchGlob> Constructor(dom::GlobalObject& aGlobal,
+ const nsAString& aGlob,
+ bool aAllowQuestion,
+ ErrorResult& aRv);
+
+ bool Matches(const nsAString& aString) const;
+
+ bool IsWildcard() const { return mIsPrefix && mPathLiteral.IsEmpty(); }
+
+ void GetGlob(nsAString& aGlob) const { aGlob = mGlob; }
+
+ nsISupports* GetParentObject() const { return mParent; }
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) override;
+
+ protected:
+ virtual ~MatchGlob();
+
+ private:
+ friend class MatchPattern;
+
+ explicit MatchGlob(nsISupports* aParent) : mParent(aParent) {}
+
+ void Init(JSContext* aCx, const nsAString& aGlob, bool aAllowQuestion,
+ ErrorResult& aRv);
+
+ nsCOMPtr<nsISupports> mParent;
+
+ // The original glob string that this glob object represents.
+ nsString 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.
+ nsString 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.
+ JS::Heap<JSObject*> mRegExp;
+};
+
+class MatchGlobSet final : public CopyableTArray<RefPtr<MatchGlob>> {
+ 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<RefPtr<MatchGlob>> aIL)
+ : CopyableTArray(aIL) {}
+
+ bool Matches(const nsAString& 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..8e46752272
--- /dev/null
+++ b/toolkit/components/extensions/MatchPattern.cpp
@@ -0,0 +1,744 @@
+/* -*- 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/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
+ *****************************************************************************/
+
+AtomSet::AtomSet(const nsTArray<nsString>& aElems) {
+ mElems.SetCapacity(aElems.Length());
+
+ for (const auto& elem : aElems) {
+ mElems.AppendElement(NS_AtomizeMainThread(elem));
+ }
+
+ SortAndUniquify();
+}
+
+AtomSet::AtomSet(const char** aElems) {
+ for (const char** elemp = aElems; *elemp; elemp++) {
+ mElems.AppendElement(NS_Atomize(*elemp));
+ }
+
+ SortAndUniquify();
+}
+
+AtomSet::AtomSet(std::initializer_list<nsAtom*> aIL) {
+ mElems.SetCapacity(aIL.size());
+
+ for (const auto& elem : aIL) {
+ mElems.AppendElement(elem);
+ }
+
+ SortAndUniquify();
+}
+
+void AtomSet::SortAndUniquify() {
+ mElems.Sort();
+
+ nsAtom* prev = nullptr;
+ mElems.RemoveElementsBy([&prev](const RefPtr<nsAtom>& aAtom) {
+ bool remove = aAtom == prev;
+ prev = aAtom;
+ return remove;
+ });
+
+ mElems.Compact();
+}
+
+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;
+}
+
+void AtomSet::Add(nsAtom* aAtom) {
+ auto index = mElems.IndexOfFirstElementGt(aAtom);
+ if (index == 0 || mElems[index - 1] != aAtom) {
+ mElems.InsertElementAt(index, aAtom);
+ }
+}
+
+void AtomSet::Remove(nsAtom* aAtom) {
+ auto index = mElems.BinaryIndexOf(aAtom);
+ if (index != ArrayType::NoIndex) {
+ mElems.RemoveElementAt(index);
+ }
+}
+
+/*****************************************************************************
+ * 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 nsString& URLInfo::FilePath() const {
+ if (mFilePath.IsEmpty()) {
+ nsCString path;
+ nsCOMPtr<nsIURL> url = do_QueryInterface(mURI);
+ if (url && NS_SUCCEEDED(url->GetFilePath(path))) {
+ AppendUTF8toUTF16(path, mFilePath);
+ } else {
+ mFilePath = Path();
+ }
+ }
+ return mFilePath;
+}
+
+const nsString& URLInfo::Path() const {
+ if (mPath.IsEmpty()) {
+ nsCString path;
+ if (NS_SUCCEEDED(URINoRef()->GetPathQueryRef(path))) {
+ AppendUTF8toUTF16(path, mPath);
+ }
+ }
+ 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;
+}
+
+/*****************************************************************************
+ * MatchPattern
+ *****************************************************************************/
+
+const char* PERMITTED_SCHEMES[] = {"http", "https", "ws", "wss",
+ "file", "ftp", "data", nullptr};
+
+// Known schemes that are followed by "://" instead of ":".
+const char* HOST_LOCATOR_SCHEMES[] = {
+ "http", "https", "ws", "wss", "file", "ftp", "moz-extension",
+ "chrome", "resource", "moz", "moz-icon", "moz-gio", nullptr};
+
+const char* WILDCARD_SCHEMES[] = {"http", "https", "ws", "wss", nullptr};
+
+/* static */
+already_AddRefed<MatchPattern> MatchPattern::Constructor(
+ dom::GlobalObject& aGlobal, const nsAString& aPattern,
+ const MatchPatternOptions& aOptions, ErrorResult& aRv) {
+ RefPtr<MatchPattern> pattern = new MatchPattern(aGlobal.GetAsSupports());
+ pattern->Init(aGlobal.Context(), aPattern, aOptions.mIgnorePath,
+ aOptions.mRestrictSchemes, aRv);
+ if (aRv.Failed()) {
+ return nullptr;
+ }
+ return pattern.forget();
+}
+
+void MatchPattern::Init(JSContext* aCx, const nsAString& aPattern,
+ bool aIgnorePath, bool aRestrictSchemes,
+ ErrorResult& aRv) {
+ RefPtr<AtomSet> permittedSchemes = AtomSet::Get<PERMITTED_SCHEMES>();
+
+ mPattern = aPattern;
+
+ if (aPattern.EqualsLiteral("<all_urls>")) {
+ 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<nsAtom> scheme = NS_AtomizeMainThread(StringHead(aPattern, index));
+ bool requireHostLocatorScheme = true;
+ if (scheme == nsGkAtoms::_asterisk) {
+ mSchemes = AtomSet::Get<WILDCARD_SCHEMES>();
+ } else if (!aRestrictSchemes || permittedSchemes->Contains(scheme) ||
+ scheme == nsGkAtoms::moz_extension) {
+ mSchemes = new AtomSet({scheme});
+ RefPtr<AtomSet> hostLocatorSchemes = AtomSet::Get<HOST_LOCATOR_SCHEMES>();
+ 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;
+ }
+
+ auto path = tail;
+ if (path.IsEmpty()) {
+ aRv.Throw(NS_ERROR_INVALID_ARG);
+ return;
+ }
+
+ mPath = new MatchGlob(this);
+ mPath->Init(aCx, path, false, aRv);
+}
+
+bool MatchPattern::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 MatchPattern::Matches(const nsAString& aURL, bool aExplicit,
+ ErrorResult& aRv) const {
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL);
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ return false;
+ }
+
+ return Matches(uri.get(), aExplicit);
+}
+
+bool MatchPattern::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 MatchPattern::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 MatchPattern::SubsumesDomain(const MatchPattern& aPattern) const {
+ if (!mMatchSubdomain && aPattern.mMatchSubdomain &&
+ aPattern.mDomain == mDomain) {
+ return false;
+ }
+
+ return MatchesDomain(aPattern.mDomain);
+}
+
+bool MatchPattern::Subsumes(const MatchPattern& aPattern) const {
+ for (auto& scheme : *aPattern.mSchemes) {
+ if (!mSchemes->Contains(scheme)) {
+ return false;
+ }
+ }
+
+ return SubsumesDomain(aPattern);
+}
+
+bool MatchPattern::Overlaps(const MatchPattern& aPattern) const {
+ if (!mSchemes->Intersects(*aPattern.mSchemes)) {
+ return false;
+ }
+
+ return SubsumesDomain(aPattern) || aPattern.SubsumesDomain(*this);
+}
+
+JSObject* MatchPattern::WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) {
+ return MatchPattern_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+/* static */
+bool MatchPattern::MatchesAllURLs(const URLInfo& aURL) {
+ RefPtr<AtomSet> permittedSchemes = AtomSet::Get<PERMITTED_SCHEMES>();
+ return permittedSchemes->Contains(aURL.Scheme());
+}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPattern, mPath, 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)
+
+/*****************************************************************************
+ * MatchPatternSet
+ *****************************************************************************/
+
+/* static */
+already_AddRefed<MatchPatternSet> MatchPatternSet::Constructor(
+ dom::GlobalObject& aGlobal,
+ const nsTArray<dom::OwningStringOrMatchPattern>& aPatterns,
+ const MatchPatternOptions& aOptions, ErrorResult& aRv) {
+ ArrayType patterns;
+
+ for (auto& elem : aPatterns) {
+ if (elem.IsMatchPattern()) {
+ patterns.AppendElement(elem.GetAsMatchPattern());
+ } else {
+ RefPtr<MatchPattern> pattern =
+ MatchPattern::Constructor(aGlobal, elem.GetAsString(), aOptions, aRv);
+
+ if (!pattern) {
+ return nullptr;
+ }
+ patterns.AppendElement(std::move(pattern));
+ }
+ }
+
+ RefPtr<MatchPatternSet> patternSet =
+ new MatchPatternSet(aGlobal.GetAsSupports(), std::move(patterns));
+ return patternSet.forget();
+}
+
+bool MatchPatternSet::Matches(const nsAString& aURL, bool aExplicit,
+ ErrorResult& aRv) const {
+ nsCOMPtr<nsIURI> uri;
+ nsresult rv = NS_NewURI(getter_AddRefs(uri), aURL);
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ return false;
+ }
+
+ return Matches(uri.get(), aExplicit);
+}
+
+bool MatchPatternSet::Matches(const URLInfo& aURL, bool aExplicit) const {
+ for (const auto& pattern : mPatterns) {
+ if (pattern->Matches(aURL, aExplicit)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool MatchPatternSet::MatchesCookie(const CookieInfo& aCookie) const {
+ for (const auto& pattern : mPatterns) {
+ if (pattern->MatchesCookie(aCookie)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool MatchPatternSet::Subsumes(const MatchPattern& aPattern) const {
+ for (const auto& pattern : mPatterns) {
+ if (pattern->Subsumes(aPattern)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool MatchPatternSet::SubsumesDomain(const MatchPattern& aPattern) const {
+ for (const auto& pattern : mPatterns) {
+ if (pattern->SubsumesDomain(aPattern)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool MatchPatternSet::Overlaps(const MatchPatternSet& aPatternSet) const {
+ for (const auto& pattern : aPatternSet.mPatterns) {
+ if (Overlaps(*pattern)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool MatchPatternSet::Overlaps(const MatchPattern& aPattern) const {
+ for (const auto& pattern : mPatterns) {
+ if (pattern->Overlaps(aPattern)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+bool MatchPatternSet::OverlapsAll(const MatchPatternSet& aPatternSet) const {
+ for (const auto& pattern : aPatternSet.mPatterns) {
+ if (!Overlaps(*pattern)) {
+ return false;
+ }
+ }
+ return aPatternSet.mPatterns.Length() > 0;
+}
+
+JSObject* MatchPatternSet::WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) {
+ return MatchPatternSet_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MatchPatternSet, mPatterns, 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)
+
+/*****************************************************************************
+ * MatchGlob
+ *****************************************************************************/
+
+MatchGlob::~MatchGlob() { mozilla::DropJSObjects(this); }
+
+/* static */
+already_AddRefed<MatchGlob> MatchGlob::Constructor(dom::GlobalObject& aGlobal,
+ const nsAString& aGlob,
+ bool aAllowQuestion,
+ ErrorResult& aRv) {
+ RefPtr<MatchGlob> glob = new MatchGlob(aGlobal.GetAsSupports());
+ glob->Init(aGlobal.Context(), aGlob, aAllowQuestion, aRv);
+ if (aRv.Failed()) {
+ return nullptr;
+ }
+ return glob.forget();
+}
+
+void MatchGlob::Init(JSContext* aCx, const nsAString& aGlob,
+ bool aAllowQuestion, 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.
+ if (index == (int32_t)mGlob.Length() - 1 && mGlob[index] == '*') {
+ mPathLiteral = StringHead(mGlob, index);
+ mIsPrefix = true;
+ return;
+ }
+
+ // Fall back to the regexp slow path.
+ constexpr auto metaChars = ".+*?^${}()|[]\\"_ns;
+
+ nsAutoString escaped;
+ escaped.Append('^');
+
+ for (uint32_t i = 0; i < mGlob.Length(); i++) {
+ auto c = mGlob[i];
+ if (c == '*') {
+ escaped.AppendLiteral(".*");
+ } else if (c == '?' && aAllowQuestion) {
+ escaped.Append('.');
+ } else {
+ if (metaChars.Contains(c)) {
+ escaped.Append('\\');
+ }
+ escaped.Append(c);
+ }
+ }
+
+ escaped.Append('$');
+
+ // TODO: Switch to the Rust regexp crate, when Rust integration is easier.
+ // It uses a much more efficient, linear time matching algorithm, and
+ // doesn't require special casing for the literal and prefix cases.
+ mRegExp = JS::NewUCRegExpObject(aCx, escaped.get(), escaped.Length(), 0);
+ if (mRegExp) {
+ mozilla::HoldJSObjects(this);
+ } else {
+ aRv.NoteJSContextException(aCx);
+ }
+}
+
+bool MatchGlob::Matches(const nsAString& aString) const {
+ if (mRegExp) {
+ AutoJSAPI jsapi;
+ jsapi.Init();
+ JSContext* cx = jsapi.cx();
+
+ JSAutoRealm ar(cx, mRegExp);
+
+ JS::RootedObject regexp(cx, mRegExp);
+ JS::RootedValue result(cx);
+
+ nsString input(aString);
+
+ size_t index = 0;
+ if (!JS::ExecuteRegExpNoStatics(cx, regexp, input.BeginWriting(),
+ aString.Length(), &index, true, &result)) {
+ return false;
+ }
+
+ return result.isBoolean() && result.toBoolean();
+ }
+
+ if (mIsPrefix) {
+ return mPathLiteral == StringHead(aString, mPathLiteral.Length());
+ }
+
+ return mPathLiteral == aString;
+}
+
+JSObject* MatchGlob::WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) {
+ return MatchGlob_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(MatchGlob)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(MatchGlob)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent)
+ tmp->mRegExp = nullptr;
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(MatchGlob)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(MatchGlob)
+ NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER
+ NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mRegExp)
+NS_IMPL_CYCLE_COLLECTION_TRACE_END
+
+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 nsAString& 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..caaf564dbe
--- /dev/null
+++ b/toolkit/components/extensions/MatchPattern.h
@@ -0,0 +1,309 @@
+/* -*- 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 <utility>
+
+#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, binary-search-backed set of atoms, optimized for frequent lookups
+// and infrequent updates.
+class AtomSet final : public RefCounted<AtomSet> {
+ using ArrayType = AutoTArray<RefPtr<nsAtom>, 1>;
+
+ public:
+ MOZ_DECLARE_REFCOUNTED_TYPENAME(AtomSet)
+
+ explicit AtomSet(const nsTArray<nsString>& aElems);
+
+ explicit AtomSet(const char** aElems);
+
+ MOZ_IMPLICIT AtomSet(std::initializer_list<nsAtom*> aIL);
+
+ bool Contains(const nsAString& elem) const {
+ RefPtr<nsAtom> atom = NS_AtomizeMainThread(elem);
+ return Contains(atom);
+ }
+
+ bool Contains(const nsACString& aElem) const {
+ RefPtr<nsAtom> atom = NS_Atomize(aElem);
+ return Contains(atom);
+ }
+
+ bool Contains(const nsAtom* aAtom) const {
+ return mElems.ContainsSorted(aAtom);
+ }
+
+ bool Intersects(const AtomSet& aOther) const;
+
+ void Add(nsAtom* aElem);
+ void Remove(nsAtom* aElem);
+
+ void Add(const nsAString& aElem) {
+ RefPtr<nsAtom> atom = NS_AtomizeMainThread(aElem);
+ return Add(atom);
+ }
+
+ void Remove(const nsAString& aElem) {
+ RefPtr<nsAtom> atom = NS_AtomizeMainThread(aElem);
+ return Remove(atom);
+ }
+
+ // Returns a cached, statically-allocated matcher for the given set of
+ // literal strings.
+ template <const char** schemes>
+ static already_AddRefed<AtomSet> Get() {
+ static RefPtr<AtomSet> sMatcher;
+
+ if (MOZ_UNLIKELY(!sMatcher)) {
+ sMatcher = new AtomSet(schemes);
+ ClearOnShutdown(&sMatcher);
+ }
+
+ return do_AddRef(sMatcher);
+ }
+
+ void Get(nsTArray<nsString>& aResult) const {
+ aResult.SetCapacity(mElems.Length());
+
+ for (const auto& atom : mElems) {
+ aResult.AppendElement(nsDependentAtomString(atom));
+ }
+ }
+
+ auto begin() const -> decltype(std::declval<const ArrayType>().begin()) {
+ return mElems.begin();
+ }
+
+ auto end() const -> decltype(std::declval<const ArrayType>().end()) {
+ return mElems.end();
+ }
+
+ private:
+ ArrayType mElems;
+
+ void SortAndUniquify();
+};
+
+// 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 nsString& Path() const;
+ const nsString& FilePath() const;
+ const nsString& Spec() const;
+ const nsCString& CSpec() const;
+
+ bool InheritsPrincipal() const;
+
+ private:
+ nsIURI* URINoRef() const;
+
+ nsCOMPtr<nsIURI> mURI;
+ mutable nsCOMPtr<nsIURI> mURINoRef;
+
+ mutable RefPtr<nsAtom> mScheme;
+ mutable nsCString mHost;
+ mutable RefPtr<nsAtom> mHostAtom;
+
+ mutable nsString mPath;
+ mutable nsString mFilePath;
+ mutable nsString mSpec;
+ mutable nsCString mCSpec;
+
+ mutable Maybe<bool> 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<nsICookie> mCookie;
+
+ mutable Maybe<bool> mIsSecure;
+ mutable Maybe<bool> mIsDomain;
+
+ mutable nsCString mHost;
+ mutable nsCString mRawHost;
+};
+
+class MatchPattern final : public nsISupports, public nsWrapperCache {
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MatchPattern)
+
+ static already_AddRefed<MatchPattern> Constructor(
+ dom::GlobalObject& aGlobal, const nsAString& aPattern,
+ 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 Matches(const URLInfo& aURL, bool aExplicit, ErrorResult& aRv) const {
+ return Matches(aURL, aExplicit);
+ }
+
+ bool MatchesCookie(const CookieInfo& aCookie) const;
+
+ bool MatchesDomain(const nsACString& aDomain) const;
+
+ bool Subsumes(const MatchPattern& aPattern) const;
+
+ bool SubsumesDomain(const MatchPattern& aPattern) const;
+
+ bool Overlaps(const MatchPattern& aPattern) const;
+
+ bool DomainIsWildcard() const { return mMatchSubdomain && mDomain.IsEmpty(); }
+
+ void GetPattern(nsAString& aPattern) const { aPattern = mPattern; }
+
+ nsISupports* GetParentObject() const { return mParent; }
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) override;
+
+ protected:
+ virtual ~MatchPattern() = default;
+
+ private:
+ explicit MatchPattern(nsISupports* aParent) : mParent(aParent) {}
+
+ void Init(JSContext* aCx, const nsAString& aPattern, bool aIgnorePath,
+ bool aRestrictSchemes, ErrorResult& aRv);
+
+ nsCOMPtr<nsISupports> mParent;
+
+ // The normalized match pattern string that this object represents.
+ nsString mPattern;
+
+ // The set of atomized URI schemes that this pattern matches.
+ RefPtr<AtomSet> 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<MatchGlob> mPath;
+
+ public:
+ // A quick way to check if a particular URL matches <all_urls> without
+ // actually instantiating a MatchPattern
+ static bool MatchesAllURLs(const URLInfo& aURL);
+};
+
+class MatchPatternSet final : public nsISupports, public nsWrapperCache {
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MatchPatternSet)
+
+ using ArrayType = nsTArray<RefPtr<MatchPattern>>;
+
+ static already_AddRefed<MatchPatternSet> Constructor(
+ dom::GlobalObject& aGlobal,
+ const nsTArray<dom::OwningStringOrMatchPattern>& 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 Matches(const URLInfo& aURL, bool aExplicit, ErrorResult& aRv) const {
+ return Matches(aURL, aExplicit);
+ }
+
+ bool MatchesCookie(const CookieInfo& aCookie) const;
+
+ bool Subsumes(const MatchPattern& aPattern) const;
+
+ bool SubsumesDomain(const MatchPattern& aPattern) const;
+
+ bool Overlaps(const MatchPattern& aPattern) const;
+
+ bool Overlaps(const MatchPatternSet& aPatternSet) const;
+
+ bool OverlapsAll(const MatchPatternSet& aPatternSet) const;
+
+ void GetPatterns(ArrayType& aPatterns) {
+ aPatterns.AppendElements(mPatterns);
+ }
+
+ nsISupports* GetParentObject() const { return mParent; }
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) override;
+
+ protected:
+ virtual ~MatchPatternSet() = default;
+
+ private:
+ explicit MatchPatternSet(nsISupports* aParent, ArrayType&& aPatterns)
+ : mParent(aParent), mPatterns(std::forward<ArrayType>(aPatterns)) {}
+
+ nsCOMPtr<nsISupports> mParent;
+
+ ArrayType mPatterns;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_MatchPattern_h
diff --git a/toolkit/components/extensions/MatchURLFilters.jsm b/toolkit/components/extensions/MatchURLFilters.jsm
new file mode 100644
index 0000000000..53dc66ca0b
--- /dev/null
+++ b/toolkit/components/extensions/MatchURLFilters.jsm
@@ -0,0 +1,182 @@
+/* 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.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+/* exported MatchURLFilters */
+
+var EXPORTED_SYMBOLS = ["MatchURLFilters"];
+
+// Match WebNavigation URL Filters.
+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.getProtocolHandler(uri.scheme).defaultPort;
+ }
+ }
+
+ // Return false if none of the ports (or port ranges) is verified
+ return filter.ports.some(filterPort => {
+ if (Array.isArray(filterPort)) {
+ let [lower, upper] = filterPort;
+ return port >= lower && port <= upper;
+ }
+
+ return port === filterPort;
+ });
+ }
+
+ // 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.jsm b/toolkit/components/extensions/MessageChannel.jsm
new file mode 100644
index 0000000000..7b8ed5c712
--- /dev/null
+++ b/toolkit/components/extensions/MessageChannel.jsm
@@ -0,0 +1,1174 @@
+/* -*- 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";
+
+/**
+ * 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,
+ * });
+ * },
+ *
+ */
+
+var EXPORTED_SYMBOLS = ["MessageChannel"];
+
+/* globals MessageChannel */
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MessageManagerProxy",
+ "resource://gre/modules/MessageManagerProxy.jsm"
+);
+
+function getMessageManager(target) {
+ if (typeof target.sendAsyncMessage === "function") {
+ return target;
+ }
+ return new 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";
+
+// ESLint can't tell that these are referenced, so tell it that they're
+// exported to make it happy.
+/* exported _deferredResult, _makeDeferred */
+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 = {};
+ this._deferredResult = res;
+ res.promise = new Promise(_makeDeferred);
+ this._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;
+ }
+}
+
+this.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<Deferred>} 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<string>} 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<nsIMessageListenerManager>} 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<nsIMessageListenerManager>} 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<nsIMessageListenerManager>} 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<MessageHandler>} 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.jsm b/toolkit/components/extensions/MessageManagerProxy.jsm
new file mode 100644
index 0000000000..91b06a6e63
--- /dev/null
+++ b/toolkit/components/extensions/MessageManagerProxy.jsm
@@ -0,0 +1,215 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["MessageManagerProxy"];
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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 <browser>.
+ *
+ * @param {nsIMessageSender|Element} target
+ * The target message manager on which to send messages, or the
+ * <browser> element which owns it.
+ */
+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 <browser> 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 <browser>
+ * 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 <browser> 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);
+ }
+ }
+
+ /**
+ * @private
+ * Iterates over all of the currently registered message listeners.
+ */
+ *iterListeners() {
+ for (let [message, listeners] of this.listeners) {
+ for (let [listener, listenWhenClosed] of listeners) {
+ yield { message, listener, listenWhenClosed };
+ }
+ }
+ }
+
+ /**
+ * @private
+ * Adds docShell swap listeners to the message manager owner.
+ *
+ * @param {Element} target
+ * The target element.
+ */
+ 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
+ );
+ }
+ }
+
+ /**
+ * @private
+ * Removes docShell swap listeners to the message manager owner.
+ *
+ * @param {Element} target
+ * The target element.
+ */
+ 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.jsm b/toolkit/components/extensions/NativeManifests.jsm
new file mode 100644
index 0000000000..5e8e0dc510
--- /dev/null
+++ b/toolkit/components/extensions/NativeManifests.jsm
@@ -0,0 +1,182 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["NativeManifests"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ Schemas: "resource://gre/modules/Schemas.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+ WindowsRegistry: "resource://gre/modules/WindowsRegistry.jsm",
+});
+
+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";
+
+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 = 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 = WindowsRegistry.readRegKey(
+ REGISTRY.ROOT_KEY_CURRENT_USER,
+ regPath,
+ "",
+ REGISTRY.WOW64_64
+ );
+ if (!path) {
+ path = WindowsRegistry.readRegKey(
+ REGISTRY.ROOT_KEY_LOCAL_MACHINE,
+ regPath,
+ "",
+ REGISTRY.WOW64_32
+ );
+ }
+ if (!path) {
+ path = WindowsRegistry.readRegKey(
+ REGISTRY.ROOT_KEY_LOCAL_MACHINE,
+ regPath,
+ "",
+ REGISTRY.WOW64_64
+ );
+ }
+ if (!path) {
+ return null;
+ }
+
+ let manifest = await this._tryPath(type, path, name, context, true);
+ return manifest ? { path, manifest } : null;
+ },
+
+ _tryPath(type, path, name, context, logIfNotFound) {
+ return Promise.resolve()
+ .then(() => OS.File.read(path, { encoding: "utf-8" }))
+ .then(data => {
+ let manifest;
+ try {
+ manifest = JSON.parse(data);
+ } catch (ex) {
+ Cu.reportError(
+ `Error parsing native manifest ${path}: ${ex.message}`
+ );
+ return null;
+ }
+
+ let normalized = 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;
+ })
+ .catch(ex => {
+ if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+ if (logIfNotFound) {
+ Cu.reportError(
+ `Error reading native manifest file ${path}: file is referenced in the registry but does not exist`
+ );
+ }
+ return null;
+ }
+ throw ex;
+ });
+ },
+
+ async _tryPaths(type, name, dirs, context) {
+ for (let dir of dirs) {
+ let path = OS.Path.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.jsm b/toolkit/components/extensions/NativeMessaging.jsm
new file mode 100644
index 0000000000..775c4703a4
--- /dev/null
+++ b/toolkit/components/extensions/NativeMessaging.jsm
@@ -0,0 +1,381 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["NativeApp"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const { EventEmitter } = ChromeUtils.import(
+ "resource://gre/modules/EventEmitter.jsm"
+);
+
+const {
+ ExtensionUtils: { ExtensionError, promiseTimeout },
+} = ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+ NativeManifests: "resource://gre/modules/NativeManifests.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+ Subprocess: "resource://gre/modules/Subprocess.jsm",
+});
+
+// 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";
+
+const global = this;
+
+var NativeApp = class 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 = 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") {
+ // OS.Path.join() ignores anything before the last absolute path
+ // it sees, so if command is already absolute, it remains unchanged
+ // here. If it is relative, we get the proper absolute path here.
+ command = OS.Path.join(OS.Path.dirname(hostInfo.path), command);
+ }
+
+ let subprocessOpts = {
+ command: command,
+ arguments: [hostInfo.path, context.extension.id],
+ workdir: OS.Path.dirname(command),
+ stderr: "pipe",
+ disclaim: true,
+ };
+
+ return 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 {NativeMessenger} port Parent NativeMessenger used to send messages.
+ * @returns {ParentPort}
+ */
+ onConnect(portId, port) {
+ // eslint-disable-next-line
+ this.on("message", (_, message) => {
+ port.sendPortMessage(portId, new StructuredCloneHolder(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 > NativeApp.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 > NativeApp.maxRead) {
+ throw new ExtensionError(
+ `Native application tried to send a message of ${len} bytes, which exceeds the limit of ${NativeApp.maxRead} bytes.`
+ );
+ }
+ return this.proc.stdout.readJSON(len);
+ })
+ .then(msg => {
+ this.emit("message", msg);
+ this.readPromise = null;
+ this._startRead();
+ })
+ .catch(err => {
+ if (err.errorCode != 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(global);
+ 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 > NativeApp.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 == 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 != 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);
+ }
+ }),
+ ]);
+
+ 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(() => {
+ 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;
+ }
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ NativeApp,
+ "maxRead",
+ PREF_MAX_READ,
+ MAX_READ
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ NativeApp,
+ "maxWrite",
+ PREF_MAX_WRITE,
+ MAX_WRITE
+);
diff --git a/toolkit/components/extensions/PerformanceCounters.jsm b/toolkit/components/extensions/PerformanceCounters.jsm
new file mode 100644
index 0000000000..8db7a30de6
--- /dev/null
+++ b/toolkit/components/extensions/PerformanceCounters.jsm
@@ -0,0 +1,171 @@
+/* -*- 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";
+
+/**
+ * This module contains a global counter to store API call in the current process.
+ */
+
+/* exported Counters */
+var EXPORTED_SYMBOLS = ["PerformanceCounters"];
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { DeferredTask } = ChromeUtils.import(
+ "resource://gre/modules/DeferredTask.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const { DefaultMap } = ExtensionUtils;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gTimingEnabled",
+ "extensions.webextensions.enablePerformanceCounters",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gTimingMaxAge",
+ "extensions.webextensions.performanceCountersMaxAge",
+ 1000
+);
+
+class CounterMap extends DefaultMap {
+ defaultConstructor() {
+ return new DefaultMap(() => ({ duration: 0, calls: 0 }));
+ }
+
+ flush() {
+ let result = new CounterMap(undefined, this);
+ this.clear();
+ return result;
+ }
+
+ merge(other) {
+ for (let [webextId, counters] of other) {
+ for (let [api, counter] of counters) {
+ let current = this.get(webextId).get(api);
+ current.calls += counter.calls;
+ current.duration += counter.duration;
+ }
+ }
+ }
+}
+
+/**
+ * Global Deferred used to send to the parent performance counters
+ * when the counter is in a child.
+ */
+var _performanceCountersSender = null;
+
+// Pre-definition of the global Counters instance.
+var PerformanceCounters = null;
+
+function _sendPerformanceCounters(childApiManagerId) {
+ let counters = PerformanceCounters.flush();
+ // No need to send empty counters.
+ if (counters.size == 0) {
+ _performanceCountersSender.arm();
+ return;
+ }
+ let options = { childId: childApiManagerId, counters: counters };
+ Services.cpmm.sendAsyncMessage("Extension:SendPerformanceCounter", options);
+ _performanceCountersSender.arm();
+}
+
+class Counters {
+ constructor() {
+ this.data = new CounterMap();
+ }
+
+ /**
+ * Returns true if performance counters are enabled.
+ *
+ * Indirection used so gTimingEnabled is not exposed direcly
+ * in PerformanceCounters -- which would prevent tests to dynamically
+ * change the preference value once PerformanceCounters.jsm is loaded.
+ *
+ * @returns {boolean}
+ */
+ get enabled() {
+ return gTimingEnabled;
+ }
+
+ /**
+ * Returns the counters max age
+ *
+ * Indirection used so gTimingMaxAge is not exposed direcly
+ * in PerformanceCounters -- which would prevent tests to dynamically
+ * change the preference value once PerformanceCounters.jsm is loaded.
+ *
+ * @returns {number}
+ */
+ get maxAge() {
+ return gTimingMaxAge;
+ }
+
+ /**
+ * Stores an execution time.
+ *
+ * @param {string} webExtensionId The web extension id.
+ * @param {string} apiPath The API path.
+ * @param {integer} duration How long the call took.
+ * @param {childApiManagerId} childApiManagerId If executed from a child, its API manager id.
+ */
+ storeExecutionTime(webExtensionId, apiPath, duration, childApiManagerId) {
+ let apiCounter = this.data.get(webExtensionId).get(apiPath);
+ apiCounter.duration += duration;
+ apiCounter.calls += 1;
+
+ // Create the global deferred task if we're in a child and
+ // it's the first time.
+ if (childApiManagerId) {
+ if (!_performanceCountersSender) {
+ _performanceCountersSender = new DeferredTask(() => {
+ _sendPerformanceCounters(childApiManagerId);
+ }, this.maxAge);
+ _performanceCountersSender.arm();
+ }
+ }
+ }
+
+ /**
+ * Merges another CounterMap into this.data
+ *
+ * Can be used by the main process to merge data received
+ * from the children.
+ *
+ * @param {CounterMap} data The map to merge.
+ */
+ merge(data) {
+ this.data.merge(data);
+ }
+
+ /**
+ * Returns the performance counters and purges them.
+ *
+ * @returns {CounterMap}
+ */
+ flush() {
+ return this.data.flush();
+ }
+
+ /**
+ * Returns the performance counters.
+ *
+ * @returns {CounterMap}
+ */
+ getData() {
+ return this.data;
+ }
+}
+
+PerformanceCounters = new Counters();
diff --git a/toolkit/components/extensions/ProfilerGetSymbols-worker.js b/toolkit/components/extensions/ProfilerGetSymbols-worker.js
new file mode 100644
index 0000000000..f4a10da629
--- /dev/null
+++ b/toolkit/components/extensions/ProfilerGetSymbols-worker.js
@@ -0,0 +1,127 @@
+/* -*- 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/. */
+
+/* eslint-env mozilla/chrome-worker */
+
+"use strict";
+
+importScripts(
+ "resource://gre/modules/osfile.jsm",
+ "resource://gre/modules/profiler_get_symbols.js"
+);
+
+// This worker uses the wasm module that was generated from https://github.com/mstange/profiler-get-symbols.
+// See ProfilerGetSymbols.jsm for more information.
+//
+// The worker instantiates the module, reads the binary into wasm memory, runs
+// the wasm code, and returns the symbol table or an error. Then it shuts down
+// itself.
+
+const { WasmMemBuffer, get_compact_symbol_table } = wasm_bindgen;
+
+// Read an open OS.File instance into the Uint8Array dataBuf.
+function readFileInto(file, dataBuf) {
+ // Ideally we'd be able to call file.readTo(dataBuf) here, but readTo no
+ // longer exists.
+ // So instead, we copy the file over into wasm memory in 4MB chunks. This
+ // will take 425 invocations for a a 1.7GB file (such as libxul.so for a
+ // Firefox for Android build) and not take up too much memory per call.
+ const dataBufLen = dataBuf.byteLength;
+ const chunkSize = 4 * 1024 * 1024;
+ let pos = 0;
+ while (pos < dataBufLen) {
+ const chunkData = file.read({ bytes: chunkSize });
+ const chunkBytes = chunkData.byteLength;
+ if (chunkBytes === 0) {
+ break;
+ }
+
+ dataBuf.set(chunkData, pos);
+ pos += chunkBytes;
+ }
+}
+
+// Returns a plain object that is Structured Cloneable and has name and
+// description properties.
+function createPlainErrorObject(e) {
+ // OS.File.Error has an empty message property; it constructs the error
+ // message on-demand in its toString() method. So we handle those errors
+ // specially.
+ if (!(e instanceof OS.File.Error)) {
+ // Regular errors: just rewrap the object.
+ if (e instanceof Error) {
+ const { name, message, fileName, lineNumber } = e;
+ return { name, message, fileName, lineNumber };
+ }
+ // The WebAssembly code throws errors with fields error_type and error_msg.
+ if (e.error_type) {
+ return {
+ name: e.error_type,
+ message: e.error_msg,
+ };
+ }
+ }
+
+ return {
+ name: e instanceof OS.File.Error ? "OSFileError" : "Error",
+ message: e.toString(),
+ fileName: e.fileName,
+ lineNumber: e.lineNumber,
+ };
+}
+
+onmessage = async e => {
+ try {
+ const { binaryPath, debugPath, breakpadId, module } = e.data;
+
+ if (!(module instanceof WebAssembly.Module)) {
+ throw new Error("invalid WebAssembly module");
+ }
+
+ // Instantiate the WASM module.
+ await wasm_bindgen(module);
+
+ // Read the binary file into WASM memory.
+ const binaryFile = OS.File.open(binaryPath, { read: true });
+ const binaryData = new WasmMemBuffer(binaryFile.stat().size, array => {
+ readFileInto(binaryFile, array);
+ });
+ binaryFile.close();
+
+ // Do the same for the debug file, if it is supplied and different from the
+ // binary file. This is only the case on Windows.
+ let debugData = binaryData;
+ if (debugPath && debugPath !== binaryPath) {
+ const debugFile = OS.File.open(debugPath, { read: true });
+ debugData = new WasmMemBuffer(debugFile.stat().size, array => {
+ readFileInto(debugFile, array);
+ });
+ debugFile.close();
+ }
+
+ try {
+ let output = get_compact_symbol_table(binaryData, debugData, breakpadId);
+ const result = [
+ output.take_addr(),
+ output.take_index(),
+ output.take_buffer(),
+ ];
+ output.free();
+ postMessage(
+ { result },
+ result.map(r => r.buffer)
+ );
+ } finally {
+ binaryData.free();
+ if (debugData != binaryData) {
+ debugData.free();
+ }
+ }
+ } catch (error) {
+ postMessage({ error: createPlainErrorObject(error) });
+ }
+ close();
+};
diff --git a/toolkit/components/extensions/ProfilerGetSymbols.jsm b/toolkit/components/extensions/ProfilerGetSymbols.jsm
new file mode 100644
index 0000000000..47973976ee
--- /dev/null
+++ b/toolkit/components/extensions/ProfilerGetSymbols.jsm
@@ -0,0 +1,157 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ProfilerGetSymbols"];
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "clearTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+
+Cu.importGlobalProperties(["fetch"]);
+
+const global = this;
+
+// This module obtains symbol tables for binaries.
+// It does so with the help of a WASM module which gets pulled in from the
+// internet on demand. We're doing this purely for the purposes of saving on
+// code size. The contents of the WASM module are expected to be static, they
+// are checked against the hash specified below.
+// The WASM code is run on a ChromeWorker thread. It takes the raw byte
+// contents of the to-be-dumped binary (and of an additional optional pdb file
+// on Windows) as its input, and returns a set of typed arrays which make up
+// the symbol table.
+
+// Don't let the strange looking URLs and strings below scare you.
+// The hash check ensures that the contents of the wasm module are what we
+// expect them to be.
+// The source code is at https://github.com/mstange/profiler-get-symbols/ .
+
+// Generated from https://github.com/mstange/profiler-get-symbols/commit/90ee39f1d18d2727f07dc57bd93cff6bc73ce8a0
+const WASM_MODULE_URL =
+ "https://zealous-rosalind-a98ce8.netlify.com/wasm/8f7ca2f70e1cd21b5a2dbe96545672752887bfbd4e7b3b9437e9fc7c3da0a3bedae4584ff734f0c9f08c642e6b66ffab.wasm";
+const WASM_MODULE_INTEGRITY =
+ "sha384-j3yi9w4c0htaLb6WVFZydSiHv71OezuUN+n8fD2go77a5FhP9zTwyfCMZC5rZv+r";
+
+const EXPIRY_TIME_IN_MS = 5 * 60 * 1000; // 5 minutes
+
+let gCachedWASMModulePromise = null;
+let gCachedWASMModuleExpiryTimer = 0;
+
+// Keep active workers alive (see bug 1592227).
+let gActiveWorkers = new Set();
+
+function clearCachedWASMModule() {
+ gCachedWASMModulePromise = null;
+ gCachedWASMModuleExpiryTimer = 0;
+}
+
+function getWASMProfilerGetSymbolsModule() {
+ if (!gCachedWASMModulePromise) {
+ gCachedWASMModulePromise = (async function() {
+ const request = new Request(WASM_MODULE_URL, {
+ integrity: WASM_MODULE_INTEGRITY,
+ credentials: "omit",
+ });
+ return WebAssembly.compileStreaming(fetch(request));
+ })();
+ }
+
+ // Reset expiry timer.
+ clearTimeout(gCachedWASMModuleExpiryTimer);
+ gCachedWASMModuleExpiryTimer = setTimeout(
+ clearCachedWASMModule,
+ EXPIRY_TIME_IN_MS
+ );
+
+ return gCachedWASMModulePromise;
+}
+
+this.ProfilerGetSymbols = {
+ /**
+ * Obtain symbols for the binary at the specified location.
+ *
+ * @param {string} binaryPath The absolute path to the binary on the local
+ * file system.
+ * @param {string} debugPath The absolute path to the binary's pdb file on the
+ * local file system if on Windows, otherwise the same as binaryPath.
+ * @param {string} breakpadId The breakpadId for the binary whose symbols
+ * should be obtained. This is used for two purposes: 1) to locate the
+ * correct single-arch binary in "FatArch" files, and 2) to make sure the
+ * binary at the given path is actually the one that we want. If no ID match
+ * is found, this function throws (rejects the promise).
+ * @returns {Promise} The symbol table in SymbolTableAsTuple format, see the
+ * documentation for nsIProfiler.getSymbolTable.
+ */
+ async getSymbolTable(binaryPath, debugPath, breakpadId) {
+ const module = await getWASMProfilerGetSymbolsModule();
+
+ return new Promise((resolve, reject) => {
+ const worker = new ChromeWorker(
+ "resource://gre/modules/ProfilerGetSymbols-worker.js"
+ );
+ gActiveWorkers.add(worker);
+
+ worker.onmessage = msg => {
+ gActiveWorkers.delete(worker);
+ if (msg.data.error) {
+ const error = msg.data.error;
+ if (error.name) {
+ // Turn the JSON error object into a real Error object.
+ const { name, message, fileName, lineNumber } = error;
+ const ErrorObjConstructor =
+ name in global && Error.isPrototypeOf(global[name])
+ ? global[name]
+ : Error;
+ const e = new ErrorObjConstructor(message, fileName, lineNumber);
+ e.name = name;
+ reject(e);
+ } else {
+ reject(error);
+ }
+ return;
+ }
+ resolve(msg.data.result);
+ };
+
+ // Handle uncaught errors from the worker script. onerror is called if
+ // there's a syntax error in the worker script, for example, or when an
+ // unhandled exception is thrown, but not for unhandled promise
+ // rejections. Without this handler, mistakes during development such as
+ // syntax errors can be hard to track down.
+ worker.onerror = errorEvent => {
+ gActiveWorkers.delete(worker);
+ worker.terminate();
+ const { message, filename, lineno } = errorEvent;
+ const error = new Error(message, filename, lineno);
+ error.name = "WorkerError";
+ reject(error);
+ };
+
+ // Handle errors from messages that cannot be deserialized. I'm not sure
+ // how to get into such a state, but having this handler seems like a good
+ // idea.
+ worker.onmessageerror = errorEvent => {
+ gActiveWorkers.delete(worker);
+ worker.terminate();
+ const { message, filename, lineno } = errorEvent;
+ const error = new Error(message, filename, lineno);
+ error.name = "WorkerMessageError";
+ reject(error);
+ };
+
+ worker.postMessage({ binaryPath, debugPath, breakpadId, module });
+ });
+ },
+};
diff --git a/toolkit/components/extensions/ProxyChannelFilter.jsm b/toolkit/components/extensions/ProxyChannelFilter.jsm
new file mode 100644
index 0000000000..a2cfd975d2
--- /dev/null
+++ b/toolkit/components/extensions/ProxyChannelFilter.jsm
@@ -0,0 +1,414 @@
+/* -*- 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 EXPORTED_SYMBOLS = ["ProxyChannelFilter"];
+
+/* exported ProxyChannelFilter */
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "ProxyService",
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService"
+);
+
+XPCOMUtils.defineLazyGetter(this, "tabTracker", () => {
+ return ExtensionParent.apiManager.global.tabTracker;
+});
+XPCOMUtils.defineLazyGetter(this, "getCookieStoreIdForOriginAttributes", () => {
+ return 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(
+ 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(
+ proxyDataList,
+ defaultProxyInfo,
+ proxyDataListIndex + 1
+ );
+
+ if (type === PROXY_TYPES.SOCKS || type === PROXY_TYPES.SOCKS4) {
+ return ProxyService.newProxyInfoWithAuth(
+ type,
+ host,
+ port,
+ username,
+ password,
+ proxyAuthorizationHeader,
+ connectionIsolationKey,
+ proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0,
+ failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC,
+ failoverProxy
+ );
+ }
+ return ProxyService.newProxyInfo(
+ type,
+ host,
+ port,
+ proxyAuthorizationHeader,
+ connectionIsolationKey,
+ proxyDNS ? TRANSPARENT_PROXY_RESOLVES_HOST : 0,
+ failoverTimeout ? failoverTimeout : PROXY_TIMEOUT_SEC,
+ failoverProxy
+ );
+ },
+};
+
+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,
+ };
+}
+
+class ProxyChannelFilter {
+ constructor(context, extension, listener, filter, extraInfoSpec) {
+ this.context = context;
+ this.extension = extension;
+ this.filter = normalizeFilter(filter);
+ this.listener = listener;
+ this.extraInfoSpec = extraInfoSpec || [];
+
+ 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 = 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 = tabTracker.getBrowserData(wrapper.browserElement);
+ }
+ let { filter } = this;
+ if (filter.tabId != null && browserData.tabId !== filter.tabId) {
+ return;
+ }
+ if (filter.windowId != null && browserData.windowId !== filter.windowId) {
+ return;
+ }
+
+ if (wrapper.matches(filter, this.extension.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(
+ 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() {
+ ProxyService.unregisterFilter(this);
+ }
+}
diff --git a/toolkit/components/extensions/Schemas.jsm b/toolkit/components/extensions/Schemas.jsm
new file mode 100644
index 0000000000..d2a37001a8
--- /dev/null
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -0,0 +1,3647 @@
+/* -*- 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 global = this;
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+var { DefaultMap, DefaultWeakMap } = ExtensionUtils;
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ShortcutUtils",
+ "resource://gre/modules/ShortcutUtils.jsm"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "contentPolicyService",
+ "@mozilla.org/addons/content-policy;1",
+ "nsIAddonContentPolicy"
+);
+
+XPCOMUtils.defineLazyGetter(
+ this,
+ "StartupCache",
+ () => ExtensionParent.StartupCache
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "treatWarningsAsErrors",
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+var EXPORTED_SYMBOLS = ["SchemaRoot", "Schemas"];
+
+const KEY_CONTENT_SCHEMAS = "extensions-framework/schemas/content";
+const KEY_PRIVILEGED_SCHEMAS = "extensions-framework/schemas/privileged";
+
+const { DEBUG } = AppConstants;
+
+const isParentProcess =
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+function readJSON(url) {
+ return new Promise((resolve, reject) => {
+ 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 = 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(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);
+ },
+};
+
+// 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<string>} overridableMethods
+ */
+ constructor(params, overridableMethods = CONTEXT_FOR_VALIDATION) {
+ this.params = params;
+
+ this.path = [];
+ this.preprocessors = {
+ localize(value, context) {
+ return value;
+ },
+ };
+ this.postprocessors = POSTPROCESSORS;
+ this.isChromeCompat = false;
+
+ this.currentChoices = new Set();
+ this.choicePathIndex = 0;
+
+ for (let method of overridableMethods) {
+ if (method in params) {
+ this[method] = params[method].bind(params);
+ }
+ }
+
+ let props = ["preprocessors", "isChromeCompat", "manifestVersion"];
+ for (let prop of props) {
+ if (prop in params) {
+ if (prop in this && typeof this[prop] == "object") {
+ Object.assign(this[prop], params[prop]);
+ } else {
+ this[prop] = params[prop];
+ }
+ }
+ }
+ }
+
+ 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);
+ }
+ }
+
+ /**
+ * 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();
+ }
+ }
+}
+
+/**
+ * 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<string>} 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<string>} 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.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<string>} 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<string>} path
+ * The full path from the root injection object to this entry.
+ * @param {Entry} 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<string>} 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) {
+ 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;
+ },
+
+ url(string, context) {
+ let url = new URL(string).href;
+
+ if (!context.checkLoadURL(url)) {
+ throw new Error(`Access denied for URL ${url}`);
+ }
+ return url;
+ },
+
+ 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 localhost. 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_LOCALHOST;
+ let error = 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(new Date(string))) {
+ throw new Error(`Invalid date string ${string}`);
+ }
+ return string;
+ },
+
+ manifestShortcutKey(string, context) {
+ if (ShortcutUtils.validate(string) == 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);
+ },
+};
+
+// 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<string>} 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 || [];
+ }
+
+ /**
+ * 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 {value} [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);
+ }
+ }
+
+ this.logWarning(context, message);
+ }
+
+ /**
+ * @param {Context} context
+ * @param {string} warningMessage
+ */
+ logWarning(context, warningMessage) {
+ let error = context.makeError(warningMessage, { warning: true });
+ context.logError(error);
+
+ if (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;
+ }
+ }
+
+ /**
+ * Checks whether the entry is deprecated and, if so, logs a
+ * deprecation message.
+ *
+ * @param {Context} context
+ * @param {value} [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<string>} 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<string>} 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",
+ "allowedContexts",
+ ];
+ }
+
+ /**
+ * 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<string>} 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<string>} [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<string>} 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<string>} [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: <normalized-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];
+ }
+
+ 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) {
+ 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));
+ }
+}
+
+// This is a reference to another type--essentially a typedef.
+class RefType extends Type {
+ static get EXTRA_PROPERTIES() {
+ return ["$ref", ...super.EXTRA_PROPERTIES];
+ }
+
+ 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 (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") {
+ this.logWarning(context, 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(functions, events);
+ }
+
+ constructor(functions, events) {
+ super();
+ 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") {
+ this.logWarning(context, 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) {
+ return {
+ descriptor: { value: this.value },
+ };
+ }
+}
+
+// Represents a "property" defined in a schema namespace that is not a
+// constant.
+class TypeProperty extends Entry {
+ 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) {
+ 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;
+ }
+
+ getDescriptor(path, context) {
+ let obj = Cu.createObjectIn(context.cloneScope);
+
+ 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);
+ }
+
+ 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 {
+ 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, global);
+ } 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.
+ 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",
+ ]),
+ schema.unsupported || false,
+ schema.allowAmbiguousOptionalArguments || false,
+ returns,
+ schema.permissions || null
+ );
+ }
+
+ constructor(
+ schema,
+ path,
+ name,
+ type,
+ unsupported,
+ allowAmbiguousOptionalArguments,
+ returns,
+ permissions
+ ) {
+ super(schema, path, name, type.parameters, allowAmbiguousOptionalArguments);
+ this.unsupported = unsupported;
+ this.returns = returns;
+ this.permissions = permissions;
+
+ 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) },
+ 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.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"]) {
+ 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);
+ }
+
+ for (let type of Object.keys(LOADERS)) {
+ this[type] = 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()) {
+ 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();
+ }
+
+ *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<SchemaRoot>|null} base
+ * A base schema root (or roots) from which to derive, or null.
+ * @param {Map<string, Array|StructuredCloneHolder>} schemaJSON
+ * A map of schema URLs and corresponding JSON blobs from which to
+ * populate this root namespace.
+ */
+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(global, 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;
+ }
+}
+
+this.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,
+
+ 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 = 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;
+ }
+
+ let schemaCache = await this.loadCachedSchemas();
+
+ let blob =
+ schemaCache.get(url) ||
+ (await StartupCache.schemas.get(url, readJSONAndBlobbify));
+
+ if (!this.schemaJSON.has(url)) {
+ this.addSchema(url, blob, content);
+ }
+ },
+
+ /**
+ * 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",
+ ]
+ ) {
+ 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);
+ },
+};
diff --git a/toolkit/components/extensions/WebExtensionContentScript.h b/toolkit/components/extensions/WebExtensionContentScript.h
new file mode 100644
index 0000000000..1a1b9e3482
--- /dev/null
+++ b/toolkit/components/extensions/WebExtensionContentScript.h
@@ -0,0 +1,234 @@
+/* -*- 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 ShouldMatchActiveTabPermission() const;
+
+ uint64_t FrameID() const;
+
+ nsPIDOMWindowOuter* GetWindow() const {
+ if (mObj.is<Window>()) {
+ return mObj.as<Window>();
+ }
+ return nullptr;
+ }
+
+ nsILoadInfo* GetLoadInfo() const {
+ if (mObj.is<LoadInfo>()) {
+ return mObj.as<LoadInfo>();
+ }
+ return nullptr;
+ }
+
+ already_AddRefed<nsILoadContext> GetLoadContext() const {
+ nsCOMPtr<nsILoadContext> loadContext;
+ if (nsPIDOMWindowOuter* window = GetWindow()) {
+ nsIDocShell* docShell = window->GetDocShell();
+ loadContext = do_QueryInterface(docShell);
+ } else if (nsILoadInfo* loadInfo = GetLoadInfo()) {
+ nsCOMPtr<nsISupports> requestingContext = loadInfo->GetLoadingContext();
+ loadContext = do_QueryInterface(requestingContext);
+ }
+ return loadContext.forget();
+ }
+
+ private:
+ void SetURL(const URLInfo& aURL);
+
+ const URLInfo mURL;
+ mutable Maybe<const URLInfo> mPrincipalURL;
+
+ mutable Maybe<bool> mIsTopLevel;
+
+ mutable Maybe<nsCOMPtr<nsIPrincipal>> mPrincipal;
+ mutable Maybe<uint64_t> mFrameID;
+
+ using Window = nsPIDOMWindowOuter*;
+ using LoadInfo = nsILoadInfo*;
+
+ const Variant<LoadInfo, Window> mObj;
+};
+
+class MozDocumentMatcher : public nsISupports, public nsWrapperCache {
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(MozDocumentMatcher)
+
+ using MatchGlobArray = nsTArray<RefPtr<MatchGlob>>;
+
+ static already_AddRefed<MozDocumentMatcher> Constructor(
+ dom::GlobalObject& aGlobal, const dom::MozDocumentMatcherInit& aInit,
+ ErrorResult& aRv);
+
+ bool Matches(const DocInfo& aDoc) const;
+ bool MatchesURI(const URLInfo& aURL) const;
+
+ bool MatchesLoadInfo(const URLInfo& aURL, nsILoadInfo* aLoadInfo) const {
+ return Matches({aURL, aLoadInfo});
+ }
+
+ bool MatchesWindowGlobal(dom::WindowGlobalChild& aWindow) const;
+
+ WebExtensionPolicy* GetExtension() { return mExtension; }
+
+ WebExtensionPolicy* Extension() { return mExtension; }
+ const WebExtensionPolicy* Extension() const { return mExtension; }
+
+ bool AllFrames() const { return mAllFrames; }
+ 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; }
+
+ void GetIncludeGlobs(Nullable<MatchGlobArray>& aGlobs) {
+ ToNullable(mExcludeGlobs, aGlobs);
+ }
+ void GetExcludeGlobs(Nullable<MatchGlobArray>& aGlobs) {
+ ToNullable(mExcludeGlobs, aGlobs);
+ }
+
+ Nullable<uint64_t> GetFrameID() const { return mFrameID; }
+
+ WebExtensionPolicy* GetParentObject() const { return mExtension; }
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) override;
+
+ protected:
+ friend class WebExtensionPolicy;
+
+ virtual ~MozDocumentMatcher() = default;
+
+ MozDocumentMatcher(dom::GlobalObject& aGlobal,
+ const dom::MozDocumentMatcherInit& aInit, bool aRestricted,
+ ErrorResult& aRv);
+
+ RefPtr<WebExtensionPolicy> mExtension;
+
+ bool mHasActiveTabPermission;
+ bool mRestricted;
+
+ RefPtr<MatchPatternSet> mMatches;
+ RefPtr<MatchPatternSet> mExcludeMatches;
+
+ Nullable<MatchGlobSet> mIncludeGlobs;
+ Nullable<MatchGlobSet> mExcludeGlobs;
+
+ bool mAllFrames;
+ Nullable<uint64_t> mFrameID;
+ bool mMatchAboutBlank;
+
+ private:
+ template <typename T, typename U>
+ void ToNullable(const Nullable<T>& aInput, Nullable<U>& aOutput) {
+ if (aInput.IsNull()) {
+ aOutput.SetNull();
+ } else {
+ aOutput.SetValue(aInput.Value());
+ }
+ }
+
+ template <typename T, typename U>
+ void ToNullable(const Nullable<T>& aInput, Nullable<nsTArray<U>>& aOutput) {
+ if (aInput.IsNull()) {
+ aOutput.SetNull();
+ } else {
+ aOutput.SetValue(aInput.Value().Clone());
+ }
+ }
+};
+
+class WebExtensionContentScript final : public MozDocumentMatcher {
+ public:
+ using RunAtEnum = dom::ContentScriptRunAt;
+
+ static already_AddRefed<WebExtensionContentScript> Constructor(
+ dom::GlobalObject& aGlobal, WebExtensionPolicy& aExtension,
+ const ContentScriptInit& aInit, ErrorResult& aRv);
+
+ RunAtEnum RunAt() const { return mRunAt; }
+
+ void GetCssPaths(nsTArray<nsString>& aPaths) const {
+ aPaths.AppendElements(mCssPaths);
+ }
+ void GetJsPaths(nsTArray<nsString>& aPaths) const {
+ aPaths.AppendElements(mJsPaths);
+ }
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) override;
+
+ protected:
+ friend class WebExtensionPolicy;
+
+ virtual ~WebExtensionContentScript() = default;
+
+ WebExtensionContentScript(dom::GlobalObject& aGlobal,
+ WebExtensionPolicy& aExtension,
+ const ContentScriptInit& aInit, ErrorResult& aRv);
+
+ private:
+ nsTArray<nsString> mCssPaths;
+ nsTArray<nsString> 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..513a92a994
--- /dev/null
+++ b/toolkit/components/extensions/WebExtensionPolicy.cpp
@@ -0,0 +1,931 @@
+/* -*- 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/AddonManagerWebAPI.h"
+#include "mozilla/dom/WindowGlobalChild.h"
+#include "mozilla/ResultExtensions.h"
+#include "mozilla/StaticPrefs_extensions.h"
+#include "nsContentUtils.h"
+#include "nsEscape.h"
+#include "nsIObserver.h"
+#include "nsISubstitutingProtocolHandler.h"
+#include "nsNetUtil.h"
+#include "nsPrintfCString.h"
+
+namespace mozilla {
+namespace extensions {
+
+using namespace dom;
+
+static const char kProto[] = "moz-extension";
+
+static const char kBackgroundPageHTMLStart[] =
+ "<!DOCTYPE html>\n\
+<html>\n\
+ <head><meta charset=\"utf-8\"></head>\n\
+ <body>";
+
+static const char kBackgroundPageHTMLScript[] =
+ "\n\
+ <script type=\"text/javascript\" src=\"%s\"></script>";
+
+static const char kBackgroundPageHTMLEnd[] =
+ "\n\
+ </body>\n\
+</html>";
+
+#define BASE_CSP_PREF_V2 "extensions.webextensions.base-content-security-policy"
+#define DEFAULT_BASE_CSP_V2 \
+ "script-src 'self' https://* moz-extension: blob: filesystem: " \
+ "'unsafe-eval' 'unsafe-inline'; " \
+ "object-src 'self' https://* moz-extension: blob: filesystem:;"
+
+#define BASE_CSP_PREF_V3 \
+ "extensions.webextensions.base-content-security-policy.v3"
+#define DEFAULT_BASE_CSP_V3 \
+ "script-src 'self'; object-src 'self'; " \
+ "style-src 'self'; worker-src 'self';"
+
+static const char kRestrictedDomainPref[] =
+ "extensions.webextensions.restrictedDomains";
+
+static inline ExtensionPolicyService& EPS() {
+ return ExtensionPolicyService::GetSingleton();
+}
+
+static nsISubstitutingProtocolHandler* Proto() {
+ static nsCOMPtr<nsISubstitutingProtocolHandler> sHandler;
+
+ if (MOZ_UNLIKELY(!sHandler)) {
+ nsCOMPtr<nsIIOService> ios = do_GetIOService();
+ MOZ_RELEASE_ASSERT(ios);
+
+ nsCOMPtr<nsIProtocolHandler> handler;
+ ios->GetProtocolHandler(kProto, getter_AddRefs(handler));
+
+ sHandler = do_QueryInterface(handler);
+ MOZ_RELEASE_ASSERT(sHandler);
+
+ ClearOnShutdown(&sHandler);
+ }
+
+ return sHandler;
+}
+
+bool ParseGlobs(GlobalObject& aGlobal, Sequence<OwningMatchGlobOrString> aGlobs,
+ nsTArray<RefPtr<MatchGlob>>& aResult, ErrorResult& aRv) {
+ for (auto& elem : aGlobs) {
+ if (elem.IsMatchGlob()) {
+ aResult.AppendElement(elem.GetAsMatchGlob());
+ } else {
+ RefPtr<MatchGlob> glob =
+ MatchGlob::Constructor(aGlobal, elem.GetAsString(), true, aRv);
+ if (aRv.Failed()) {
+ return false;
+ }
+ aResult.AppendElement(glob);
+ }
+ }
+ return true;
+}
+
+enum class ErrorBehavior {
+ CreateEmptyPattern,
+ Fail,
+};
+
+already_AddRefed<MatchPatternSet> 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<OwningStringOrMatchPattern> 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<MatchPatternSet> result =
+ MatchPatternSet::Constructor(aGlobal, patterns, aOptions, aRv);
+
+ if (aRv.Failed() && aErrorBehavior == ErrorBehavior::CreateEmptyPattern) {
+ aRv.SuppressException();
+ result = MatchPatternSet::Constructor(aGlobal, {}, aOptions, aRv);
+ }
+
+ return result.forget();
+}
+
+/*****************************************************************************
+ * WebExtensionPolicy
+ *****************************************************************************/
+
+WebExtensionPolicy::WebExtensionPolicy(GlobalObject& aGlobal,
+ const WebExtensionInit& aInit,
+ ErrorResult& aRv)
+ : mId(NS_AtomizeMainThread(aInit.mId)),
+ mHostname(aInit.mMozExtensionHostname),
+ mName(aInit.mName),
+ mManifestVersion(aInit.mManifestVersion),
+ mExtensionPageCSP(aInit.mExtensionPageCSP),
+ mLocalizeCallback(aInit.mLocalizeCallback),
+ mIsPrivileged(aInit.mIsPrivileged),
+ mPermissions(new AtomSet(aInit.mPermissions)) {
+ if (!ParseGlobs(aGlobal, aInit.mWebAccessibleResources, mWebAccessiblePaths,
+ aRv)) {
+ return;
+ }
+
+ // We set this here to prevent this policy changing after creation.
+ mAllowPrivateBrowsingByDefault =
+ StaticPrefs::extensions_allowPrivateBrowsingByDefault();
+
+ MatchPatternOptions options;
+ options.mRestrictSchemes = !HasPermission(nsGkAtoms::mozillaAddons);
+
+ mHostPermissions = ParseMatches(aGlobal, aInit.mAllowedOrigins, options,
+ ErrorBehavior::CreateEmptyPattern, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+
+ if (!aInit.mBackgroundScripts.IsNull()) {
+ mBackgroundScripts.SetValue().AppendElements(
+ aInit.mBackgroundScripts.Value());
+ }
+
+ if (!aInit.mBackgroundWorkerScript.IsEmpty()) {
+ mBackgroundWorkerScript.Assign(aInit.mBackgroundWorkerScript);
+ }
+
+ InitializeBaseCSP();
+
+ if (mExtensionPageCSP.IsVoid()) {
+ EPS().GetDefaultCSP(mExtensionPageCSP);
+ }
+
+ 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<WebExtensionContentScript> contentScript =
+ new WebExtensionContentScript(aGlobal, *this, scriptInit, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ mContentScripts.AppendElement(std::move(contentScript));
+ }
+
+ if (aInit.mReadyPromise.WasPassed()) {
+ mReadyPromise = &aInit.mReadyPromise.Value();
+ }
+
+ nsresult rv = NS_NewURI(getter_AddRefs(mBaseURI), aInit.mBaseURL);
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::Constructor(
+ GlobalObject& aGlobal, const WebExtensionInit& aInit, ErrorResult& aRv) {
+ RefPtr<WebExtensionPolicy> policy =
+ new WebExtensionPolicy(aGlobal, aInit, aRv);
+ if (aRv.Failed()) {
+ return nullptr;
+ }
+ return policy.forget();
+}
+
+void WebExtensionPolicy::InitializeBaseCSP() {
+ if (mManifestVersion < 3) {
+ nsresult rv = Preferences::GetString(BASE_CSP_PREF_V2, mBaseCSP);
+ if (NS_FAILED(rv)) {
+ mBaseCSP.AssignLiteral(DEFAULT_BASE_CSP_V2);
+ }
+ return;
+ }
+ // Version 3 or higher.
+ nsresult rv = Preferences::GetString(BASE_CSP_PREF_V3, mBaseCSP);
+ if (NS_FAILED(rv)) {
+ mBaseCSP.AssignLiteral(DEFAULT_BASE_CSP_V3);
+ }
+}
+
+/* static */
+void WebExtensionPolicy::GetActiveExtensions(
+ dom::GlobalObject& aGlobal,
+ nsTArray<RefPtr<WebExtensionPolicy>>& aResults) {
+ EPS().GetAll(aResults);
+}
+
+/* static */
+already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::GetByID(
+ dom::GlobalObject& aGlobal, const nsAString& aID) {
+ return do_AddRef(EPS().GetByID(aID));
+}
+
+/* static */
+already_AddRefed<WebExtensionPolicy> WebExtensionPolicy::GetByHostname(
+ dom::GlobalObject& aGlobal, const nsACString& aHostname) {
+ return do_AddRef(EPS().GetByHost(aHostname));
+}
+
+/* static */
+already_AddRefed<WebExtensionPolicy> 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 ID for use by this WebExtensionPolicy.
+ mBrowsingContextGroupId = nsContentUtils::GenerateBrowsingContextId();
+ }
+
+ Unused << Proto()->SetSubstitution(MozExtensionHostname(), mBaseURI);
+
+ mActive = true;
+ return true;
+}
+
+bool WebExtensionPolicy::Disable() {
+ MOZ_ASSERT(mActive);
+ MOZ_ASSERT(EPS().GetByID(Id()) == this);
+
+ if (!EPS().UnregisterExtension(*this)) {
+ return false;
+ }
+
+ 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<nsString, nsresult> WebExtensionPolicy::GetURL(
+ const nsAString& aPath) const {
+ nsPrintfCString spec("%s://%s/", kProto, mHostname.get());
+
+ nsCOMPtr<nsIURI> uri;
+ MOZ_TRY(NS_NewURI(getter_AddRefs(uri), spec));
+
+ MOZ_TRY(uri->Resolve(NS_ConvertUTF16toUTF8(aPath), spec));
+
+ return NS_ConvertUTF8toUTF16(spec);
+}
+
+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<WebExtensionContentScript> 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);
+}
+
+bool WebExtensionPolicy::CanAccessURI(const URLInfo& aURI, bool aExplicit,
+ bool aCheckRestricted,
+ bool aAllowFilePermission) const {
+ return (!aCheckRestricted || !IsRestrictedURI(aURI)) && mHostPermissions &&
+ mHostPermissions->Matches(aURI, aExplicit) &&
+ (aURI.Scheme() != nsGkAtoms::file || aAllowFilePermission);
+}
+
+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) {
+ return StaticPrefs::extensions_backgroundServiceWorker_enabled_AtStartup();
+}
+
+namespace {
+/**
+ * Maintains a dynamically updated AtomSet based on the comma-separated
+ * values in the given string pref.
+ */
+class AtomSetPref : public nsIObserver, public nsSupportsWeakReference {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIOBSERVER
+
+ static already_AddRefed<AtomSetPref> Create(const nsCString& aPref) {
+ RefPtr<AtomSetPref> self = new AtomSetPref(aPref.get());
+ Preferences::AddWeakObserver(self, aPref);
+ return self.forget();
+ }
+
+ const AtomSet& Get() const;
+
+ bool Contains(const nsAtom* aAtom) const { return Get().Contains(aAtom); }
+
+ protected:
+ virtual ~AtomSetPref() = default;
+
+ explicit AtomSetPref(const char* aPref) : mPref(aPref) {}
+
+ private:
+ mutable RefPtr<AtomSet> mAtomSet;
+ const char* mPref;
+};
+
+const AtomSet& AtomSetPref::Get() const {
+ if (!mAtomSet) {
+ nsAutoCString eltsString;
+ Unused << Preferences::GetCString(mPref, eltsString);
+
+ AutoTArray<nsString, 32> elts;
+ for (const nsACString& elt : eltsString.Split(',')) {
+ elts.AppendElement(NS_ConvertUTF8toUTF16(elt));
+ elts.LastElement().StripWhitespace();
+ }
+ mAtomSet = new AtomSet(elts);
+ }
+
+ return *mAtomSet;
+}
+
+NS_IMETHODIMP
+AtomSetPref::Observe(nsISupports* aSubject, const char* aTopic,
+ const char16_t* aData) {
+ mAtomSet = nullptr;
+ return NS_OK;
+}
+
+NS_IMPL_ISUPPORTS(AtomSetPref, nsIObserver, nsISupportsWeakReference)
+}; // namespace
+
+/* 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) {
+ static RefPtr<AtomSetPref> domains;
+ if (!domains) {
+ domains = AtomSetPref::Create(nsLiteralCString(kRestrictedDomainPref));
+ ClearOnShutdown(&domains);
+ }
+
+ if (domains->Contains(aURI.HostAtom())) {
+ return true;
+ }
+
+ if (AddonManagerWebAPI::IsValidSite(aURI.URI())) {
+ return true;
+ }
+
+ return false;
+}
+
+nsCString WebExtensionPolicy::BackgroundPageHTML() const {
+ nsCString result;
+
+ if (mBackgroundScripts.IsNull()) {
+ result.SetIsVoid(true);
+ return result;
+ }
+
+ result.AppendLiteral(kBackgroundPageHTMLStart);
+
+ for (auto& script : mBackgroundScripts.Value()) {
+ nsCString escaped;
+ nsAppendEscapedHTML(NS_ConvertUTF16toUTF8(script), escaped);
+
+ result.AppendPrintf(kBackgroundPageHTMLScript, escaped.get());
+ }
+
+ result.AppendLiteral(kBackgroundPageHTMLEnd);
+ return result;
+}
+
+void WebExtensionPolicy::Localize(const nsAString& aInput,
+ nsString& aOutput) const {
+ RefPtr<WebExtensionLocalizeCallback> callback(mLocalizeCallback);
+ callback->Call(aInput, aOutput);
+}
+
+JSObject* WebExtensionPolicy::WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) {
+ return WebExtensionPolicy_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+void WebExtensionPolicy::GetContentScripts(
+ nsTArray<RefPtr<WebExtensionContentScript>>& aScripts) const {
+ aScripts.AppendElements(mContentScripts);
+}
+
+bool WebExtensionPolicy::PrivateBrowsingAllowed() const {
+ return mAllowPrivateBrowsingByDefault ||
+ 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<nsILoadContext> loadContext = do_QueryInterface(docShell);
+ return !(loadContext && loadContext->UsePrivateBrowsing());
+}
+
+void WebExtensionPolicy::GetReadyPromise(
+ JSContext* aCx, JS::MutableHandleObject aResult) const {
+ if (mReadyPromise) {
+ aResult.set(mReadyPromise->PromiseObj());
+ } else {
+ aResult.set(nullptr);
+ }
+}
+
+uint64_t WebExtensionPolicy::GetBrowsingContextGroupId() const {
+ MOZ_ASSERT(XRE_IsParentProcess() && mActive);
+ return mBrowsingContextGroupId;
+}
+
+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;
+}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_WEAK_PTR(WebExtensionPolicy, mParent,
+ mLocalizeCallback,
+ mHostPermissions,
+ mWebAccessiblePaths,
+ mContentScripts)
+
+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> MozDocumentMatcher::Constructor(
+ GlobalObject& aGlobal, const dom::MozDocumentMatcherInit& aInit,
+ ErrorResult& aRv) {
+ RefPtr<MozDocumentMatcher> matcher =
+ new MozDocumentMatcher(aGlobal, aInit, false, aRv);
+ if (aRv.Failed()) {
+ return nullptr;
+ }
+ return matcher.forget();
+}
+
+/* static */
+already_AddRefed<WebExtensionContentScript>
+WebExtensionContentScript::Constructor(GlobalObject& aGlobal,
+ WebExtensionPolicy& aExtension,
+ const ContentScriptInit& aInit,
+ ErrorResult& aRv) {
+ RefPtr<WebExtensionContentScript> 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),
+ 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;
+ }
+ }
+}
+
+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;
+}
+
+bool MozDocumentMatcher::Matches(const DocInfo& aDoc) const {
+ if (!mFrameID.IsNull()) {
+ if (aDoc.FrameID() != mFrameID.Value()) {
+ return false;
+ }
+ } else {
+ if (!mAllFrames && !aDoc.IsTopLevel()) {
+ return false;
+ }
+ }
+
+ // match browsing mode with policy
+ nsCOMPtr<nsILoadContext> loadContext = aDoc.GetLoadContext();
+ if (loadContext && mExtension && !mExtension->CanAccessContext(loadContext)) {
+ return false;
+ }
+
+ if (!mMatchAboutBlank && aDoc.URL().InheritsPrincipal()) {
+ return false;
+ }
+
+ // Top-level about:blank is a special case. We treat it as a match if
+ // matchAboutBlank is true and it has the null principal. In all other
+ // cases, we test the URL of the principal that it inherits.
+ if (mMatchAboutBlank && aDoc.IsTopLevel() &&
+ (aDoc.URL().Spec().EqualsLiteral("about:blank") ||
+ aDoc.URL().Scheme() == nsGkAtoms::data) &&
+ aDoc.Principal() && aDoc.Principal()->GetIsNullPrincipal()) {
+ return true;
+ }
+
+ if (mRestricted && mExtension->IsRestrictedDoc(aDoc)) {
+ return false;
+ }
+
+ auto& urlinfo = aDoc.PrincipalURL();
+ if (mHasActiveTabPermission && aDoc.ShouldMatchActiveTabPermission() &&
+ MatchPattern::MatchesAllURLs(urlinfo)) {
+ return true;
+ }
+
+ return MatchesURI(urlinfo);
+}
+
+bool MozDocumentMatcher::MatchesURI(const URLInfo& aURL) const {
+ if (!mMatches->Matches(aURL)) {
+ return false;
+ }
+
+ if (mExcludeMatches && mExcludeMatches->Matches(aURL)) {
+ return false;
+ }
+
+ if (!mIncludeGlobs.IsNull() && !mIncludeGlobs.Value().Matches(aURL.Spec())) {
+ return false;
+ }
+
+ if (!mExcludeGlobs.IsNull() && mExcludeGlobs.Value().Matches(aURL.Spec())) {
+ return false;
+ }
+
+ if (mRestricted && mExtension->IsRestrictedURI(aURL)) {
+ return false;
+ }
+
+ return true;
+}
+
+bool MozDocumentMatcher::MatchesWindowGlobal(WindowGlobalChild& aWindow) const {
+ if (aWindow.IsClosed() || !aWindow.IsCurrentGlobal()) {
+ return false;
+ }
+ nsGlobalWindowInner* inner = aWindow.GetWindowGlobal();
+ if (!inner || !inner->GetDocShell()) {
+ return false;
+ }
+ return Matches(inner->GetOuterWindow());
+}
+
+JSObject* MozDocumentMatcher::WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) {
+ return MozDocumentMatcher_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+JSObject* WebExtensionContentScript::WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) {
+ return WebExtensionContentScript_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(MozDocumentMatcher, mMatches,
+ mExcludeMatches, mIncludeGlobs,
+ mExcludeGlobs, 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> DocumentObserver::Constructor(
+ GlobalObject& aGlobal, dom::MozDocumentCallback& aCallbacks) {
+ RefPtr<DocumentObserver> matcher =
+ new DocumentObserver(aGlobal.GetAsSupports(), aCallbacks);
+ return matcher.forget();
+}
+
+void DocumentObserver::Observe(
+ const dom::Sequence<OwningNonNull<MozDocumentMatcher>>& 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::HandleObject 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) {
+ for (WindowContext* wc = aWin->GetCurrentInnerWindow()->GetWindowContext();
+ 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());
+}
+
+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<Document> 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<nsIURI> 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..9ebb51716f
--- /dev/null
+++ b/toolkit/components/extensions/WebExtensionPolicy.h
@@ -0,0 +1,221 @@
+/* -*- 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 "mozilla/dom/BindingDeclarations.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 "nsISupports.h"
+#include "nsWrapperCache.h"
+
+namespace mozilla {
+namespace dom {
+class Promise;
+} // namespace dom
+
+namespace extensions {
+
+using dom::WebExtensionInit;
+using dom::WebExtensionLocalizeCallback;
+
+class DocInfo;
+class WebExtensionContentScript;
+
+class WebExtensionPolicy final : public nsISupports,
+ public nsWrapperCache,
+ public SupportsWeakPtr {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(WebExtensionPolicy)
+
+ using ScriptArray = nsTArray<RefPtr<WebExtensionContentScript>>;
+
+ static already_AddRefed<WebExtensionPolicy> Constructor(
+ dom::GlobalObject& aGlobal, const WebExtensionInit& aInit,
+ ErrorResult& aRv);
+
+ nsAtom* Id() const { return mId; }
+ void GetId(nsAString& aId) const { aId = nsDependentAtomString(mId); };
+
+ const nsCString& MozExtensionHostname() const { return mHostname; }
+ void GetMozExtensionHostname(nsACString& aHostname) const {
+ aHostname = MozExtensionHostname();
+ }
+
+ void GetBaseURL(nsACString& aBaseURL) const {
+ MOZ_ALWAYS_SUCCEEDS(mBaseURI->GetSpec(aBaseURL));
+ }
+
+ bool IsPrivileged() { return mIsPrivileged; }
+
+ void GetURL(const nsAString& aPath, nsAString& aURL, ErrorResult& aRv) const;
+
+ Result<nsString, nsresult> 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;
+
+ bool IsPathWebAccessible(const nsAString& aPath) const {
+ return mWebAccessiblePaths.Matches(aPath);
+ }
+
+ bool HasPermission(const nsAtom* aPermission) const {
+ return mPermissions->Contains(aPermission);
+ }
+ bool HasPermission(const nsAString& aPermission) const {
+ return mPermissions->Contains(aPermission);
+ }
+
+ static bool IsRestrictedDoc(const DocInfo& aDoc);
+ static bool IsRestrictedURI(const URLInfo& aURI);
+
+ nsCString BackgroundPageHTML() const;
+
+ MOZ_CAN_RUN_SCRIPT
+ void Localize(const nsAString& aInput, nsString& aResult) const;
+
+ const nsString& Name() const { return mName; }
+ void GetName(nsAString& aName) const { aName = mName; }
+
+ uint32_t ManifestVersion() const { return mManifestVersion; }
+
+ const nsString& ExtensionPageCSP() const { return mExtensionPageCSP; }
+ void GetExtensionPageCSP(nsAString& aCSP) const { aCSP = mExtensionPageCSP; }
+
+ const nsString& BaseCSP() const { return mBaseCSP; }
+ void GetBaseCSP(nsAString& aCSP) const { aCSP = mBaseCSP; }
+
+ already_AddRefed<MatchPatternSet> AllowedOrigins() {
+ return do_AddRef(mHostPermissions);
+ }
+ void SetAllowedOrigins(MatchPatternSet& aAllowedOrigins) {
+ mHostPermissions = &aAllowedOrigins;
+ }
+
+ void GetPermissions(nsTArray<nsString>& aResult) const {
+ mPermissions->Get(aResult);
+ }
+ void SetPermissions(const nsTArray<nsString>& aPermissions) {
+ mPermissions = new AtomSet(aPermissions);
+ }
+
+ 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::MutableHandleObject aResult) const;
+ dom::Promise* ReadyPromise() const { return mReadyPromise; }
+
+ void GetBackgroundWorker(nsString& aScriptURL) const {
+ aScriptURL.Assign(mBackgroundWorkerScript);
+ }
+
+ bool IsManifestBackgroundWorker(const nsAString& aWorkerScriptURL) const {
+ return mBackgroundWorkerScript.Equals(aWorkerScriptURL);
+ }
+
+ uint64_t GetBrowsingContextGroupId() const;
+ uint64_t GetBrowsingContextGroupId(ErrorResult& aRv);
+
+ static void GetActiveExtensions(
+ dom::GlobalObject& aGlobal,
+ nsTArray<RefPtr<WebExtensionPolicy>>& aResults);
+
+ static already_AddRefed<WebExtensionPolicy> GetByID(
+ dom::GlobalObject& aGlobal, const nsAString& aID);
+
+ static already_AddRefed<WebExtensionPolicy> GetByHostname(
+ dom::GlobalObject& aGlobal, const nsACString& aHostname);
+
+ static already_AddRefed<WebExtensionPolicy> GetByURI(
+ dom::GlobalObject& aGlobal, nsIURI* aURI);
+
+ static bool IsRestrictedURI(dom::GlobalObject& aGlobal, const URLInfo& aURI) {
+ return IsRestrictedURI(aURI);
+ }
+
+ static bool UseRemoteWebExtensions(dom::GlobalObject& aGlobal);
+ static bool IsExtensionProcess(dom::GlobalObject& aGlobal);
+ static bool BackgroundServiceWorkerEnabled(dom::GlobalObject& aGlobal);
+
+ nsISupports* GetParentObject() const { return mParent; }
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::HandleObject aGivenProto) override;
+
+ protected:
+ virtual ~WebExtensionPolicy() = default;
+
+ private:
+ WebExtensionPolicy(dom::GlobalObject& aGlobal, const WebExtensionInit& aInit,
+ ErrorResult& aRv);
+
+ bool Enable();
+ bool Disable();
+ void InitializeBaseCSP();
+
+ nsCOMPtr<nsISupports> mParent;
+
+ RefPtr<nsAtom> mId;
+ nsCString mHostname;
+ nsCOMPtr<nsIURI> mBaseURI;
+
+ nsString mName;
+ uint32_t mManifestVersion = 2;
+ nsString mExtensionPageCSP;
+ nsString mBaseCSP;
+
+ uint64_t mBrowsingContextGroupId = 0;
+
+ bool mActive = false;
+ bool mAllowPrivateBrowsingByDefault = true;
+
+ RefPtr<WebExtensionLocalizeCallback> mLocalizeCallback;
+
+ bool mIsPrivileged;
+ RefPtr<AtomSet> mPermissions;
+ RefPtr<MatchPatternSet> mHostPermissions;
+ MatchGlobSet mWebAccessiblePaths;
+
+ dom::Nullable<nsTArray<nsString>> mBackgroundScripts;
+ nsString mBackgroundWorkerScript;
+
+ nsTArray<RefPtr<WebExtensionContentScript>> mContentScripts;
+
+ RefPtr<dom::Promise> mReadyPromise;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_WebExtensionPolicy_h
diff --git a/toolkit/components/extensions/WebNavigation.jsm b/toolkit/components/extensions/WebNavigation.jsm
new file mode 100644
index 0000000000..4012a056af
--- /dev/null
+++ b/toolkit/components/extensions/WebNavigation.jsm
@@ -0,0 +1,511 @@
+/* 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 EXPORTED_SYMBOLS = ["WebNavigation"];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserWindowTracker",
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "UrlbarUtils",
+ "resource:///modules/UrlbarUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ClickHandlerParent",
+ "resource:///actors/ClickHandlerParent.jsm"
+);
+
+// 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;
+
+var Manager = {
+ // Map[string -> Map[listener -> URLFilter]]
+ listeners: new Map(),
+
+ init() {
+ // Collect recent tab transition data in a WeakMap:
+ // browser -> tabTransitionData
+ this.recentTabTransitionData = new WeakMap();
+
+ // Collect the pending created navigation target events that still have to
+ // pair the message received from the source tab to the one received from
+ // the new tab.
+ this.createdNavigationTargetByOuterWindowId = new Map();
+
+ Services.obs.addObserver(this, "urlbar-user-start-navigation", true);
+
+ Services.obs.addObserver(this, "webNavigation-createdNavigationTarget");
+
+ if (AppConstants.MOZ_BUILD_APP == "browser") {
+ ClickHandlerParent.addContentClickListener(this);
+ }
+
+ Services.mm.addMessageListener("Extension:DOMContentLoaded", this);
+ Services.mm.addMessageListener("Extension:StateChange", this);
+ Services.mm.addMessageListener("Extension:DocumentChange", this);
+ Services.mm.addMessageListener("Extension:HistoryChange", this);
+ Services.mm.addMessageListener("Extension:CreatedNavigationTarget", this);
+
+ Services.mm.loadFrameScript(
+ "resource://gre/modules/WebNavigationContent.js",
+ true
+ );
+ },
+
+ 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") {
+ ClickHandlerParent.removeContentClickListener(this);
+ }
+
+ Services.mm.removeMessageListener("Extension:StateChange", this);
+ Services.mm.removeMessageListener("Extension:DocumentChange", this);
+ Services.mm.removeMessageListener("Extension:HistoryChange", this);
+ Services.mm.removeMessageListener("Extension:DOMContentLoaded", this);
+ Services.mm.removeMessageListener(
+ "Extension:CreatedNavigationTarget",
+ this
+ );
+
+ Services.mm.removeDelayedFrameScript(
+ "resource://gre/modules/WebNavigationContent.js"
+ );
+ Services.mm.broadcastAsyncMessage("Extension:DisableWebNavigation");
+
+ this.recentTabTransitionData = new WeakMap();
+ this.createdNavigationTargetByOuterWindowId.clear();
+ },
+
+ addListener(type, listener, filters, context) {
+ if (this.listeners.size == 0) {
+ this.init();
+ }
+
+ if (!this.listeners.has(type)) {
+ this.listeners.set(type, new Map());
+ }
+ let listeners = this.listeners.get(type);
+ listeners.set(listener, { filters, context });
+ },
+
+ 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([
+ "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,
+ {},
+ {
+ 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 UrlbarUtils.RESULT_TYPE.KEYWORD:
+ tabTransitionData.keyword = true;
+ break;
+ case UrlbarUtils.RESULT_TYPE.SEARCH:
+ tabTransitionData.generated = true;
+ break;
+ case UrlbarUtils.RESULT_TYPE.URL:
+ if (acData.result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS) {
+ tabTransitionData.auto_bookmark = true;
+ } else {
+ tabTransitionData.typed = true;
+ }
+ break;
+ case UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
+ // Remote tab are autocomplete results related to
+ // tab urls from a remote synchronized Firefox.
+ tabTransitionData.typed = true;
+ break;
+ case UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
+ // This "switchtab" autocompletion should be ignored, because
+ // it is not related to a navigation.
+ // Fall through.
+ case 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 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 = 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;
+ },
+
+ /**
+ * Receive messages from the WebNavigationContent.js framescript
+ * over message manager events.
+ */
+ receiveMessage({ name, data, target }) {
+ switch (name) {
+ case "Extension:StateChange":
+ this.onStateChange(target, data);
+ break;
+
+ case "Extension:DocumentChange":
+ this.onDocumentChange(target, data);
+ break;
+
+ case "Extension:HistoryChange":
+ this.onHistoryChange(target, data);
+ break;
+
+ case "Extension:DOMContentLoaded":
+ this.onLoad(target, data);
+ break;
+
+ case "Extension:CreatedNavigationTarget":
+ this.onCreatedNavigationTarget(target, data);
+ break;
+ }
+ },
+
+ 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(browser, data) {
+ const { createdOuterWindowId, isSourceTab, sourceFrameId, url } = data;
+
+ // We are going to receive two message manager messages for a single
+ // onCreatedNavigationTarget event related to a window.open that is happening
+ // in the child process (one from the source tab and one from the created tab),
+ // the unique createdWindowId (the outerWindowID of the created docShell)
+ // to pair them together.
+ const pairedMessage = this.createdNavigationTargetByOuterWindowId.get(
+ createdOuterWindowId
+ );
+
+ if (!isSourceTab) {
+ if (pairedMessage) {
+ // This should not happen, print a warning before overwriting the unexpected pending data.
+ Services.console.logStringMessage(
+ `Discarding onCreatedNavigationTarget for ${createdOuterWindowId}: ` +
+ "unexpected pending data while receiving the created tab data"
+ );
+ }
+
+ // Store a weak reference to the browser XUL element, so that we don't prevent
+ // it to be garbage collected if it has been destroyed.
+ const browserWeakRef = Cu.getWeakReference(browser);
+
+ this.createdNavigationTargetByOuterWindowId.set(createdOuterWindowId, {
+ browserWeakRef,
+ data,
+ });
+
+ return;
+ }
+
+ if (!pairedMessage) {
+ // The sourceTab should always be received after the message coming from the created
+ // top level frame because the "webNavigation-createdNavigationTarget-from-js" observers
+ // subscribed by WebNavigationContent.js are going to be executed in reverse order
+ // (See http://searchfox.org/mozilla-central/rev/f54c1723be/xpcom/ds/nsObserverList.cpp#76)
+ // and the observer subscribed to the created target will be the last one subscribed
+ // to the ObserverService (and the first one to be triggered).
+ Services.console.logStringMessage(
+ `Discarding onCreatedNavigationTarget for ${createdOuterWindowId}: ` +
+ "received source tab data without any created tab data available"
+ );
+
+ return;
+ }
+
+ this.createdNavigationTargetByOuterWindowId.delete(createdOuterWindowId);
+
+ let sourceTabBrowser = browser;
+ let createdTabBrowser = pairedMessage.browserWeakRef.get();
+
+ if (!createdTabBrowser) {
+ Services.console.logStringMessage(
+ `Discarding onCreatedNavigationTarget for ${createdOuterWindowId}: ` +
+ "the created tab has been already destroyed"
+ );
+
+ return;
+ }
+
+ this.fire(
+ "onCreatedNavigationTarget",
+ createdTabBrowser,
+ {},
+ {
+ sourceTabBrowser,
+ sourceFrameId,
+ url,
+ }
+ );
+ },
+
+ onStateChange(browser, data) {
+ let stateFlags = data.stateFlags;
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+ let url = data.requestURL;
+ if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ this.fire("onBeforeNavigate", browser, data, { url });
+ } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ if (Components.isSuccessCode(data.status)) {
+ this.fire("onCompleted", browser, data, { url });
+ } else {
+ let error = `Error code ${data.status}`;
+ this.fire("onErrorOccurred", browser, data, { error, url });
+ }
+ }
+ }
+ },
+
+ onDocumentChange(browser, data) {
+ let extra = {
+ url: data.location,
+ // Transition data which is coming from the content process.
+ frameTransitionData: data.frameTransitionData,
+ tabTransitionData: this.getAndForgetRecentTabTransitionData(browser),
+ };
+
+ this.fire("onCommitted", browser, data, extra);
+ },
+
+ onHistoryChange(browser, data) {
+ let extra = {
+ url: data.location,
+ // Transition data which is coming from the content process.
+ frameTransitionData: data.frameTransitionData,
+ tabTransitionData: this.getAndForgetRecentTabTransitionData(browser),
+ };
+
+ if (data.isReferenceFragmentUpdated) {
+ this.fire("onReferenceFragmentUpdated", browser, data, extra);
+ } else if (data.isHistoryStateUpdated) {
+ this.fire("onHistoryStateUpdated", browser, data, extra);
+ }
+ },
+
+ onLoad(browser, data) {
+ this.fire("onDOMContentLoaded", browser, data, { url: data.url });
+ },
+
+ fire(type, browser, data, extra) {
+ let listeners = this.listeners.get(type);
+ if (!listeners) {
+ return;
+ }
+
+ let details = {
+ browser,
+ frameId: data.frameId,
+ };
+
+ if (data.parentFrameId !== undefined) {
+ details.parentFrameId = data.parentFrameId;
+ }
+
+ for (let prop in extra) {
+ details[prop] = extra[prop];
+ }
+
+ for (let [listener, { filters, context }] of listeners) {
+ if (
+ context &&
+ !context.privateBrowsingAllowed &&
+ PrivateBrowsingUtils.isBrowserPrivate(browser)
+ ) {
+ continue;
+ }
+ // Call the listener if the listener has no filter or if its filter matches.
+ if (!filters || filters.matches(extra.url)) {
+ listener(details);
+ }
+ }
+ },
+};
+
+const EVENTS = [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ "onCreatedNavigationTarget",
+];
+
+var WebNavigation = {};
+
+for (let event of EVENTS) {
+ WebNavigation[event] = {
+ addListener: Manager.addListener.bind(Manager, event),
+ removeListener: Manager.removeListener.bind(Manager, event),
+ };
+}
diff --git a/toolkit/components/extensions/WebNavigationContent.js b/toolkit/components/extensions/WebNavigationContent.js
new file mode 100644
index 0000000000..08ada223d6
--- /dev/null
+++ b/toolkit/components/extensions/WebNavigationContent.js
@@ -0,0 +1,397 @@
+/* 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";
+
+/* eslint-env mozilla/frame-script */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "WebNavigationFrames",
+ "resource://gre/modules/WebNavigationFrames.jsm"
+);
+
+function loadListener(event) {
+ let document = event.target;
+ let window = document.defaultView;
+ let url = document.documentURI;
+ let frameId = WebNavigationFrames.getFrameId(window);
+ let parentFrameId = WebNavigationFrames.getParentFrameId(window);
+ sendAsyncMessage("Extension:DOMContentLoaded", {
+ frameId,
+ parentFrameId,
+ url,
+ });
+}
+
+addEventListener("DOMContentLoaded", loadListener);
+addMessageListener("Extension:DisableWebNavigation", () => {
+ removeEventListener("DOMContentLoaded", loadListener);
+});
+
+var CreatedNavigationTargetListener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ init() {
+ Services.obs.addObserver(
+ this,
+ "webNavigation-createdNavigationTarget-from-js"
+ );
+ },
+ uninit() {
+ Services.obs.removeObserver(
+ this,
+ "webNavigation-createdNavigationTarget-from-js"
+ );
+ },
+
+ observe(subject, topic, data) {
+ if (!(subject instanceof Ci.nsIPropertyBag2)) {
+ return;
+ }
+
+ let props = subject.QueryInterface(Ci.nsIPropertyBag2);
+
+ const createdDocShell = props.getPropertyAsInterface(
+ "createdTabDocShell",
+ Ci.nsIDocShell
+ );
+ const sourceDocShell = props.getPropertyAsInterface(
+ "sourceTabDocShell",
+ Ci.nsIDocShell
+ );
+
+ const isSourceTabDescendant =
+ sourceDocShell.sameTypeRootTreeItem === docShell;
+
+ if (
+ docShell !== createdDocShell &&
+ docShell !== sourceDocShell &&
+ !isSourceTabDescendant
+ ) {
+ // if the createdNavigationTarget is not related to this docShell
+ // (this docShell is not the newly created docShell, it is not the source docShell,
+ // and the source docShell is not a descendant of it)
+ // there is nothing to do here and return early.
+ return;
+ }
+
+ const isSourceTab = docShell === sourceDocShell || isSourceTabDescendant;
+
+ const sourceFrameId = WebNavigationFrames.getFrameId(
+ sourceDocShell.browsingContext
+ );
+ const createdOuterWindowId = sourceDocShell?.outerWindowID;
+
+ let url;
+ if (props.hasKey("url")) {
+ url = props.getPropertyAsACString("url");
+ }
+
+ sendAsyncMessage("Extension:CreatedNavigationTarget", {
+ url,
+ sourceFrameId,
+ createdOuterWindowId,
+ isSourceTab,
+ });
+ },
+};
+
+var FormSubmitListener = {
+ init() {
+ this.formSubmitWindows = new WeakSet();
+ addEventListener("DOMFormBeforeSubmit", this);
+ },
+
+ uninit() {
+ removeEventListener("DOMFormBeforeSubmit", this);
+ this.formSubmitWindows = new WeakSet();
+ },
+
+ handleEvent({ target: form }) {
+ this.formSubmitWindows.add(form.ownerGlobal);
+ },
+
+ hasAndForget: function(window) {
+ let has = this.formSubmitWindows.has(window);
+ this.formSubmitWindows.delete(window);
+ return has;
+ },
+};
+
+var WebProgressListener = {
+ init: function() {
+ // This WeakMap (DOMWindow -> nsIURI) keeps track of the pathname and hash
+ // of the previous location for all the existent docShells.
+ this.previousURIMap = new WeakMap();
+
+ // Populate the above previousURIMap by iterating over the docShells tree.
+ for (let currentDocShell of WebNavigationFrames.iterateDocShellTree(
+ docShell
+ )) {
+ let win = currentDocShell.domWindow;
+ let { currentURI } = currentDocShell.QueryInterface(Ci.nsIWebNavigation);
+
+ this.previousURIMap.set(win, currentURI);
+ }
+
+ // This WeakSet of DOMWindows keeps track of the attempted refresh.
+ this.refreshAttemptedDOMWindows = new WeakSet();
+
+ let webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_REFRESH |
+ Ci.nsIWebProgress.NOTIFY_LOCATION
+ );
+ },
+
+ uninit() {
+ if (!docShell) {
+ return;
+ }
+ let webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ webProgress.removeProgressListener(this);
+ },
+
+ onRefreshAttempted: function onRefreshAttempted(
+ webProgress,
+ URI,
+ delay,
+ sameURI
+ ) {
+ this.refreshAttemptedDOMWindows.add(webProgress.DOMWindow);
+
+ // If this function doesn't return true, the attempted refresh will be blocked.
+ return true;
+ },
+
+ onStateChange: function onStateChange(
+ webProgress,
+ request,
+ stateFlags,
+ status
+ ) {
+ let { originalURI, URI: locationURI } = request.QueryInterface(
+ Ci.nsIChannel
+ );
+
+ // Prevents "about", "chrome", "resource" and "moz-extension" URI schemes to be
+ // reported with the resolved "file" or "jar" URIs. (see Bug 1246125 for rationale)
+ if (locationURI.schemeIs("file") || locationURI.schemeIs("jar")) {
+ let shouldUseOriginalURI =
+ originalURI.schemeIs("about") ||
+ originalURI.schemeIs("chrome") ||
+ originalURI.schemeIs("resource") ||
+ originalURI.schemeIs("moz-extension");
+
+ locationURI = shouldUseOriginalURI ? originalURI : locationURI;
+ }
+
+ this.sendStateChange({ webProgress, locationURI, stateFlags, status });
+
+ // Based on the docs of the webNavigation.onCommitted event, it should be raised when:
+ // "The document might still be downloading, but at least part of
+ // the document has been received"
+ // and for some reason we don't fire onLocationChange for the
+ // initial navigation of a sub-frame.
+ // For the above two reasons, when the navigation event is related to
+ // a sub-frame we process the document change here and
+ // then send an "Extension:DocumentChange" message to the main process,
+ // where it will be turned into a webNavigation.onCommitted event.
+ // (see Bug 1264936 and Bug 125662 for rationale)
+ if (
+ webProgress.DOMWindow.top != webProgress.DOMWindow &&
+ stateFlags & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT
+ ) {
+ this.sendDocumentChange({ webProgress, locationURI, request });
+ }
+ },
+
+ onLocationChange: function onLocationChange(
+ webProgress,
+ request,
+ locationURI,
+ flags
+ ) {
+ let { DOMWindow } = webProgress;
+
+ // Get the previous URI loaded in the DOMWindow.
+ let previousURI = this.previousURIMap.get(DOMWindow);
+
+ // Update the URI in the map with the new locationURI.
+ this.previousURIMap.set(DOMWindow, locationURI);
+
+ let isSameDocument =
+ flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT;
+
+ // When a frame navigation doesn't change the current loaded document
+ // (which can be due to history.pushState/replaceState or to a changed hash in the url),
+ // it is reported only to the onLocationChange, for this reason
+ // we process the history change here and then we are going to send
+ // an "Extension:HistoryChange" to the main process, where it will be turned
+ // into a webNavigation.onHistoryStateUpdated/onReferenceFragmentUpdated event.
+ if (isSameDocument) {
+ this.sendHistoryChange({
+ webProgress,
+ previousURI,
+ locationURI,
+ request,
+ });
+ } else if (webProgress.DOMWindow.top == webProgress.DOMWindow) {
+ // We have to catch the document changes from top level frames here,
+ // where we can detect the "server redirect" transition.
+ // (see Bug 1264936 and Bug 125662 for rationale)
+ this.sendDocumentChange({ webProgress, locationURI, request });
+ }
+ },
+
+ sendStateChange({ webProgress, locationURI, stateFlags, status }) {
+ let data = {
+ requestURL: locationURI.spec,
+ frameId: WebNavigationFrames.getFrameId(webProgress.DOMWindow),
+ parentFrameId: WebNavigationFrames.getParentFrameId(
+ webProgress.DOMWindow
+ ),
+ status,
+ stateFlags,
+ };
+
+ sendAsyncMessage("Extension:StateChange", data);
+ },
+
+ sendDocumentChange({ webProgress, locationURI, request }) {
+ let { loadType, DOMWindow } = webProgress;
+ let frameTransitionData = this.getFrameTransitionData({
+ loadType,
+ request,
+ DOMWindow,
+ });
+
+ let data = {
+ frameTransitionData,
+ location: locationURI ? locationURI.spec : "",
+ frameId: WebNavigationFrames.getFrameId(webProgress.DOMWindow),
+ parentFrameId: WebNavigationFrames.getParentFrameId(
+ webProgress.DOMWindow
+ ),
+ };
+
+ sendAsyncMessage("Extension:DocumentChange", data);
+ },
+
+ sendHistoryChange({ webProgress, previousURI, locationURI, request }) {
+ let { loadType, DOMWindow } = webProgress;
+
+ let isHistoryStateUpdated = false;
+ let isReferenceFragmentUpdated = false;
+
+ let pathChanged = !(
+ previousURI && locationURI.equalsExceptRef(previousURI)
+ );
+ let hashChanged = !(previousURI && previousURI.ref == locationURI.ref);
+
+ // When the location changes but the document is the same:
+ // - path not changed and hash changed -> |onReferenceFragmentUpdated|
+ // (even if it changed using |history.pushState|)
+ // - path not changed and hash not changed -> |onHistoryStateUpdated|
+ // (only if it changes using |history.pushState|)
+ // - path changed -> |onHistoryStateUpdated|
+
+ if (!pathChanged && hashChanged) {
+ isReferenceFragmentUpdated = true;
+ } else if (loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) {
+ isHistoryStateUpdated = true;
+ } else if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) {
+ isHistoryStateUpdated = true;
+ }
+
+ if (isHistoryStateUpdated || isReferenceFragmentUpdated) {
+ let frameTransitionData = this.getFrameTransitionData({
+ loadType,
+ request,
+ DOMWindow,
+ });
+
+ let data = {
+ frameTransitionData,
+ isHistoryStateUpdated,
+ isReferenceFragmentUpdated,
+ location: locationURI ? locationURI.spec : "",
+ frameId: WebNavigationFrames.getFrameId(webProgress.DOMWindow),
+ parentFrameId: WebNavigationFrames.getParentFrameId(
+ webProgress.DOMWindow
+ ),
+ };
+
+ sendAsyncMessage("Extension:HistoryChange", data);
+ }
+ },
+
+ getFrameTransitionData({ loadType, request, DOMWindow }) {
+ let frameTransitionData = {};
+
+ if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) {
+ frameTransitionData.forward_back = true;
+ }
+
+ if (loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) {
+ frameTransitionData.reload = true;
+ }
+
+ if (request instanceof Ci.nsIChannel) {
+ if (request.loadInfo.redirectChain.length) {
+ frameTransitionData.server_redirect = true;
+ }
+ }
+
+ if (FormSubmitListener.hasAndForget(DOMWindow)) {
+ frameTransitionData.form_submit = true;
+ }
+
+ if (this.refreshAttemptedDOMWindows.has(DOMWindow)) {
+ this.refreshAttemptedDOMWindows.delete(DOMWindow);
+ frameTransitionData.client_redirect = true;
+ }
+
+ return frameTransitionData;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+var disabled = false;
+WebProgressListener.init();
+FormSubmitListener.init();
+CreatedNavigationTargetListener.init();
+addEventListener("unload", () => {
+ if (!disabled) {
+ disabled = true;
+ WebProgressListener.uninit();
+ FormSubmitListener.uninit();
+ CreatedNavigationTargetListener.uninit();
+ }
+});
+addMessageListener("Extension:DisableWebNavigation", () => {
+ if (!disabled) {
+ disabled = true;
+ WebProgressListener.uninit();
+ FormSubmitListener.uninit();
+ CreatedNavigationTargetListener.uninit();
+ }
+});
diff --git a/toolkit/components/extensions/WebNavigationFrames.jsm b/toolkit/components/extensions/WebNavigationFrames.jsm
new file mode 100644
index 0000000000..26f49f8e91
--- /dev/null
+++ b/toolkit/components/extensions/WebNavigationFrames.jsm
@@ -0,0 +1,122 @@
+/* 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 EXPORTED_SYMBOLS = ["WebNavigationFrames"];
+
+/* exported WebNavigationFrames */
+
+/**
+ * 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).
+ */
+
+/**
+ * A generator function which iterates over a docShell tree, given a root docShell.
+ *
+ * @param {nsIDocShell} docShell - the root docShell object
+ * @returns {Iterator<nsIDocShell>}
+ */
+function iterateDocShellTree(docShell) {
+ return docShell.getAllDocShellsInSubtree(
+ docShell.typeContent,
+ docShell.ENUMERATE_FORWARDS
+ );
+}
+
+/**
+ * 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 docShell object into its internal FrameDetail representation.
+ *
+ * @param {nsIDocShell} docShell - the docShell object to be converted into a FrameDetail JSON object.
+ * @returns {FrameDetail} the FrameDetail JSON object which represents the docShell.
+ */
+function convertDocShellToFrameDetail(docShell) {
+ let { browsingContext, domWindow: window } = docShell;
+
+ return {
+ frameId: getFrameId(browsingContext),
+ parentFrameId: getParentFrameId(browsingContext),
+ url: window.location.href,
+ };
+}
+
+/**
+ * Search for a frame starting from the passed root docShell and
+ * convert it to its related frame detail representation.
+ *
+ * @param {number} frameId - the frame ID of the frame to retrieve, as
+ * described in getFrameId.
+ * @param {nsIDocShell} rootDocShell - the root docShell object
+ * @returns {nsIDocShell?} the docShell with the given frameId, or null
+ * if no match.
+ */
+function findDocShell(frameId, rootDocShell) {
+ for (let docShell of iterateDocShellTree(rootDocShell)) {
+ if (frameId == getFrameId(docShell.browsingContext)) {
+ return docShell;
+ }
+ }
+
+ return null;
+}
+
+var WebNavigationFrames = {
+ iterateDocShellTree,
+
+ findDocShell,
+
+ getFrame(docShell, frameId) {
+ let result = findDocShell(frameId, docShell);
+ if (result) {
+ return convertDocShellToFrameDetail(result);
+ }
+ return null;
+ },
+
+ getFrameId,
+ getParentFrameId,
+
+ getAllFrames(docShell) {
+ return Array.from(
+ iterateDocShellTree(docShell),
+ convertDocShellToFrameDetail
+ );
+ },
+};
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..b7505cbb8a
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-backgroundPage.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";
+
+this.backgroundPage = class extends ExtensionAPI {
+ getAPI(context) {
+ function getBackgroundPage() {
+ for (let view of context.extension.views) {
+ if (
+ view.viewType == "background" &&
+ context.principal.subsumes(view.principal)
+ ) {
+ return view.contentWindow;
+ }
+ }
+ return null;
+ }
+ return {
+ extension: {
+ getBackgroundPage,
+ },
+
+ runtime: {
+ getBackgroundPage() {
+ 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-extension.js b/toolkit/components/extensions/child/ext-extension.js
new file mode 100644
index 0000000000..55efc4c321
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-extension.js
@@ -0,0 +1,67 @@
+/* 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 &&
+ view.windowId != fetchProperties.windowId
+ ) {
+ continue;
+ }
+
+ if (
+ fetchProperties.tabId !== null &&
+ view.tabId != fetchProperties.tabId
+ ) {
+ 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..8f1bfc0a93
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-identity.js
@@ -0,0 +1,86 @@
+/* -*- 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.defineModuleGetter(
+ this,
+ "CommonUtils",
+ "resource://services-common/utils.js"
+);
+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..dec16826a0
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-runtime.js
@@ -0,0 +1,94 @@
+/* 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";
+
+/**
+ * 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?}}
+ */
+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);
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-storage.js b/toolkit/components/extensions/child/ext-storage.js
new file mode 100644
index 0000000000..e9b1e3a71b
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-storage.js
@@ -0,0 +1,344 @@
+/* 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.defineModuleGetter(
+ this,
+ "ExtensionStorage",
+ "resource://gre/modules/ExtensionStorage.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionStorageIDB",
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionTelemetry",
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+// 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) {
+ return measureOp(
+ ExtensionTelemetry.storageLocalSetIDB,
+ context.extension,
+ async () => {
+ const db = await getDB();
+ const changes = await db.set(items, {
+ serialize: ExtensionStorage.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
+ );
+
+ 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 = {};
+ 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,
+
+ 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]
+ );
+ },
+ },
+
+ 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: new EventManager({
+ context,
+ name: "storage.onChanged",
+ register: fire => {
+ let onChanged = (data, area) => {
+ let changes = new context.cloneScope.Object();
+ for (let [key, value] of Object.entries(data)) {
+ changes[key] = deserialize(value);
+ }
+ fire.raw(changes, area);
+ };
+
+ let parent = context.childManager.getParentEvent(
+ "storage.onChanged"
+ );
+ parent.addListener(onChanged);
+ return () => {
+ parent.removeListener(onChanged);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/child/ext-test.js b/toolkit/components/extensions/child/ext-test.js
new file mode 100644
index 0000000000..914181fa8f
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-test.js
@@ -0,0 +1,255 @@
+/* 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.defineLazyGetter(this, "isXpcshell", function() {
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ return 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.
+ * - null, the function always returns true.
+ * @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 (expectedError === null) {
+ return true;
+ }
+
+ 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;
+};
+
+/**
+ * Calls .toSource() on the given value, but handles null, undefined,
+ * and errors.
+ *
+ * @param {*} value
+ * @returns {string}
+ */
+const toSource = value => {
+ if (value === null) {
+ return "null";
+ }
+ if (value === undefined) {
+ return "undefined";
+ }
+ if (typeof value === "string") {
+ return JSON.stringify(value);
+ }
+
+ try {
+ return String(value);
+ } catch (e) {
+ return "<unknown>";
+ }
+};
+
+this.test = class extends ExtensionAPI {
+ getAPI(context) {
+ const { extension } = context;
+
+ function getStack() {
+ return new context.Error().stack.replace(/^/gm, " ");
+ }
+
+ function assertTrue(value, msg) {
+ extension.emit("test-result", Boolean(value), String(msg), getStack());
+ }
+
+ class TestEventManager extends EventManager {
+ addListener(callback, ...args) {
+ super.addListener(function(...args) {
+ try {
+ callback.call(this, ...args);
+ } catch (e) {
+ assertTrue(false, `${e}\n${e.stack}`);
+ }
+ }, ...args);
+ }
+ }
+
+ 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());
+ },
+
+ notifyFail(msg) {
+ extension.emit("test-done", false, msg, getStack());
+ },
+
+ log(msg) {
+ extension.emit("test-log", true, msg, getStack());
+ },
+
+ fail(msg) {
+ assertTrue(false, msg);
+ },
+
+ succeed(msg) {
+ assertTrue(true, msg);
+ },
+
+ assertTrue(value, msg) {
+ assertTrue(value, msg);
+ },
+
+ assertFalse(value, msg) {
+ assertTrue(!value, msg);
+ },
+
+ 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()
+ );
+ },
+
+ assertRejects(promise, expectedError, msg) {
+ // Wrap in a native promise for consistency.
+ promise = Promise.resolve(promise);
+
+ if (msg) {
+ msg = `: ${msg}`;
+ }
+
+ return promise.then(
+ result => {
+ assertTrue(false, `Promise resolved, expected rejection${msg}`);
+ },
+ error => {
+ let errorMessage = toSource(error && error.message);
+
+ assertTrue(
+ errorMatches(error, expectedError, context),
+ `Promise rejected, expecting rejection to match ${toSource(
+ expectedError
+ )}, got ${errorMessage}${msg}`
+ );
+ }
+ );
+ },
+
+ assertThrows(func, expectedError, msg) {
+ if (msg) {
+ msg = `: ${msg}`;
+ }
+
+ try {
+ func();
+
+ assertTrue(false, `Function did not throw, expected error${msg}`);
+ } catch (error) {
+ let errorMessage = toSource(error && error.message);
+
+ assertTrue(
+ errorMatches(error, expectedError, context),
+ `Function threw, expecting error to match ${toSource(
+ expectedError
+ )}, got ${errorMessage}${msg}`
+ );
+ }
+ },
+
+ 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..65125fba25
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-toolkit.js
@@ -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/. */
+
+"use strict";
+
+var { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+// eslint-disable-next-line no-unused-vars
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+// These are defined on "global" which is used for the same scopes as the other
+// ext-c-*.js files.
+/* exported EventManager */
+/* global EventManager: false */
+
+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"]],
+ },
+ 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"]],
+ },
+ 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..b10d459eda
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-userScripts-content.js
@@ -0,0 +1,410 @@
+/* -*- 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.defineModuleGetter(
+ this,
+ "Schemas",
+ "resource://gre/modules/Schemas.jsm"
+);
+
+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} Error
+ * The Error constructor to use to report errors (defaults to the apiScript context's Error
+ * when missing).
+ * @param {Error} 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<textHash, blobURLs>
+ 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<blobURL, Set<scriptId>>
+ 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..881da28a17
--- /dev/null
+++ b/toolkit/components/extensions/child/ext-webRequest.js
@@ -0,0 +1,43 @@
+/* -*- 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";
+
+this.webRequest = class extends ExtensionAPI {
+ getAPI(context) {
+ let filters = new WeakSet();
+
+ context.callOnClose({
+ close() {
+ for (let filter of ChromeUtils.nondeterministicGetWeakSetKeys(
+ filters
+ )) {
+ try {
+ filter.disconnect();
+ } catch (e) {
+ // Ignore.
+ }
+ }
+ },
+ });
+
+ return {
+ webRequest: {
+ filterResponseData(requestId) {
+ requestId = parseInt(requestId, 10);
+
+ let streamFilter = context.cloneScope.StreamFilter.create(
+ requestId,
+ context.extension.id
+ );
+
+ filters.add(streamFilter);
+ return streamFilter;
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/docs/background.rst b/toolkit/components/extensions/docs/background.rst
new file mode 100644
index 0000000000..71ce2a59d0
--- /dev/null
+++ b/toolkit/components/extensions/docs/background.rst
@@ -0,0 +1,134 @@
+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
+`chrome privileges <https://developer.mozilla.org/en-US/docs/Security/Firefox_Security_Basics_For_Developers>`_.
+Browser internals are accessed using
+`XPCOM <https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM>`_
+or `ChromeOnly WebIDL features <https://developer.mozilla.org/en-US/docs/Mozilla/WebIDL_bindings#ChromeOnly>`_.
+
+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 <https://wiki.mozilla.org/WebExtensions/Vision>`_
+- `API Policies <https://wiki.mozilla.org/WebExtensions/policy>`_
+- `Process for creating new APIs <https://wiki.mozilla.org/WebExtensions/NewAPIs>`_
+
+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 <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs>`_
+ 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 <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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 <https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/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 Javscript functionality,
+see the
+`browser action <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/browserAction>`_
+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..a1f78b5c65
--- /dev/null
+++ b/toolkit/components/extensions/docs/basics.rst
@@ -0,0 +1,208 @@
+.. _basics:
+
+API Implementation Basics
+=========================
+This page describes some of the pieces involved when creating
+WebExtension 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 WebExtension 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 <http://json-schema.org/>`_ with some extensions to describe
+API functions and events.
+The next section describes the format of the schema in detail.
+
+The ExtensionAPI class
+----------------------
+Every WebExtension API is represented by an instance of the Javascript
+`ExtensionAPI <reference.html#extensionapi-class>`_ 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 WebExtension API.
+
+Built-in APIs versus Experiments
+--------------------------------
+A WebExtension API can be built directly into the browser or it can be
+contained in a special type of extension called a "WebExtension Experiment".
+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 WebExtension 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 WebExtension 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`.
+
+WebExtensions Experiments
+-------------------------
+A new API may also be implemented within an extension. An API implemented
+this way is called a WebExtension Experiment. Experiments can be useful
+when actively developing a new API, as they do not require building
+Firefox locally. Note that extensions that include experiments cannot be
+signed by addons.mozilla.org. They 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.
+
+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.
diff --git a/toolkit/components/extensions/docs/events.rst b/toolkit/components/extensions/docs/events.rst
new file mode 100644
index 0000000000..8fd01976cd
--- /dev/null
+++ b/toolkit/components/extensions/docs/events.rst
@@ -0,0 +1,314 @@
+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 <reference.html#eventmanager-class>`_ 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,
+ name: "myapi.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,
+ name: "myapi.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.
+
+
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 <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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 <reference.html#extensionapi-class>`_ 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 <reference.html#basecontext-class>`_,
+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 <https://developer.mozilla.org/en-US/docs/Tools/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/incognito.rst b/toolkit/components/extensions/docs/incognito.rst
new file mode 100644
index 0000000000..7d94795888
--- /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 <https://searchfox.org/mozilla-central/rev/78cd247b5d7a08832f87d786541d3e2204842e8e/browser/components/extensions/test/browser/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 <https://searchfox.org/mozilla-central/rev/78cd247b5d7a08832f87d786541d3e2204842e8e/browser/components/extensions/test/browser/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(widnow)
+
+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..f75eb5914e
--- /dev/null
+++ b/toolkit/components/extensions/docs/index.rst
@@ -0,0 +1,32 @@
+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 <https://developer.mozilla.org/en-US/Add-ons/WebExtensions>`_.
+
+To use this documentation, you should already be familiar with
+WebExtensions, including
+`the anatomy of a WebExtension <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Anatomy_of_a_WebExtension>`_
+and `permissions <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/permissions>`_.
+You should also be familiar with concepts from
+`Firefox development <https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide>`_
+including `e10s <https://developer.mozilla.org/en-US/Firefox/Multiprocess_Firefox>`_
+in particular.
+
+.. toctree::
+ :caption: WebExtension API Developers Guide
+ :maxdepth: 2
+
+ background
+ basics
+ schema
+ functions
+ events
+ manifest
+ lifecycle
+ incognito
+ other
+ reference
+
diff --git a/toolkit/components/extensions/docs/lifecycle.rst b/toolkit/components/extensions/docs/lifecycle.rst
new file mode 100644
index 0000000000..2384bada8d
--- /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 <reference.html#extension-class>`_ 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 corectness, the API
+must be built into the browser and not delievered 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 <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/windows>`__ API.
+See the reference docs `here <reference.html#windowmanager-class>`__.
+
+TabManager
+----------
+This class manages the mapping between the opaque tab identifiers used
+in the `browser.tabs <https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs>`__ API.
+See the reference docs `here <reference.html#tabmanager-class>`__.
+
+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..b55e918588
--- /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 <http://json-schema.org/>`_.
+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 re-usable 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 <http://json-schema.org/>`_.
+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 <http://json-schema.org/>`_ for details on how these
+items are defined in a schema.
diff --git a/toolkit/components/extensions/dummy.xhtml b/toolkit/components/extensions/dummy.xhtml
new file mode 100644
index 0000000000..73b4ed82b6
--- /dev/null
+++ b/toolkit/components/extensions/dummy.xhtml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<window id="documentElement"/>
diff --git a/toolkit/components/extensions/ext-browser-content.js b/toolkit/components/extensions/ext-browser-content.js
new file mode 100644
index 0000000000..56fbdfed87
--- /dev/null
+++ b/toolkit/components/extensions/ext-browser-content.js
@@ -0,0 +1,350 @@
+/* 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 { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
+ clearTimeout: "resource://gre/modules/Timer.jsm",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
+ setTimeout: "resource://gre/modules/Timer.jsm",
+});
+
+/* eslint-env mozilla/frame-script */
+
+// Minimum time between two resizes.
+const RESIZE_TIMEOUT = 100;
+
+/**
+ * Check if the provided color is fully opaque.
+ *
+ * @param {string} color
+ * Any valid CSS color.
+ * @returns {boolean} true if the color is opaque.
+ */
+const isOpaque = function(color) {
+ try {
+ if (/(rgba|hsla)/i.test(color)) {
+ // Match .123456, 123.456, 123456 with an optional % sign.
+ let numberRe = /(\.\d+|\d+\.?\d*)%?/g;
+ // hsla/rgba, opacity is the last number in the color string (can be a percentage).
+ let opacity = color.match(numberRe)[3];
+
+ // Convert to [0, 1] space if the opacity was expressed as a percentage.
+ if (opacity.includes("%")) {
+ opacity = opacity.slice(0, -1);
+ opacity = opacity / 100;
+ }
+
+ return opacity * 1 >= 1;
+ } else if (/^#[a-f0-9]{4}$/i.test(color)) {
+ // Hex color with 4 characters, opacity is one if last character is F
+ return color.toUpperCase().endsWith("F");
+ } else if (/^#[a-f0-9]{8}$/i.test(color)) {
+ // Hex color with 8 characters, opacity is one if last 2 characters are FF
+ return color.toUpperCase().endsWith("FF");
+ }
+ } catch (e) {
+ // Invalid color.
+ }
+ return true;
+};
+
+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();
+ }
+
+ // Force external links to open in tabs.
+ docShell.isAppTab = true;
+
+ 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.AGENT_SHEET
+ );
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMDocElementInserted":
+ if (this.blockingPromise) {
+ event.target.blockParsing(this.blockingPromise);
+ }
+ 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 <browser>s, we currently receive a load
+ // event on the <browser> 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 = doc.defaultView.getComputedStyle(body).backgroundColor;
+ if (!isOpaque(background)) {
+ // Ignore non-opaque backgrounds.
+ background = null;
+ }
+
+ if (background === null || background !== this.oldBackground) {
+ sendAsyncMessage("Extension:BrowserBackgroundChanged", { background });
+ }
+ this.oldBackground = background;
+
+ // Adjust the size of the browser based on its content's preferred size.
+ let { contentViewer } = docShell;
+ let ratio = content.devicePixelRatio;
+
+ let w = {},
+ h = {};
+ contentViewer.getContentSizeConstrained(
+ this.maxWidth * ratio,
+ this.maxHeight * ratio,
+ w,
+ h
+ );
+
+ let width = Math.ceil((w.value * zoom) / ratio);
+ let height = Math.ceil((h.value * zoom) / ratio);
+
+ result = { width, height, detail };
+ }
+
+ sendAsyncMessage("Extension:BrowserResized", result);
+ },
+};
+
+addMessageListener("Extension:InitBrowser", BrowserListener);
+addMessageListener("Extension:UnblockParser", BrowserListener);
+addMessageListener("Extension:GrabFocus", BrowserListener);
+
+var WebBrowserChrome = {
+ onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) {
+ // isAppTab is the value for the docShell that received the click. We're
+ // handling this in the top-level frame and want traversal behavior to
+ // match the value for this frame rather than any subframe, so we pass
+ // through the docShell.isAppTab value rather than what we were handed.
+ return BrowserUtils.onBeforeLinkTraversal(
+ originalTarget,
+ linkURI,
+ linkNode,
+ docShell.isAppTab
+ );
+ },
+
+ shouldLoadURI(docShell, URI, referrerInfo, hasPostData, triggeringPrincipal) {
+ return true;
+ },
+
+ shouldLoadURIInThisProcess(URI) {
+ return true;
+ },
+};
+
+if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+ let tabchild = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIBrowserChild);
+ tabchild.webBrowserChrome = WebBrowserChrome;
+}
+
+// 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..18a563303f
--- /dev/null
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -0,0 +1,235 @@
+{
+ "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"]
+ ]
+ },
+ "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"]
+ ]
+ },
+ "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"]
+ ]
+ },
+ "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"]
+ ]
+ }
+}
diff --git a/toolkit/components/extensions/extensionProcessScriptLoader.js b/toolkit/components/extensions/extensionProcessScriptLoader.js
new file mode 100644
index 0000000000..855dfc210e
--- /dev/null
+++ b/toolkit/components/extensions/extensionProcessScriptLoader.js
@@ -0,0 +1,6 @@
+/* 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.import("resource://gre/modules/ExtensionProcessScript.jsm");
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..cb69ee1bc0
--- /dev/null
+++ b/toolkit/components/extensions/jar.mn
@@ -0,0 +1,61 @@
+# 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-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-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-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-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/moz.build b/toolkit/components/extensions/moz.build
new file mode 100755
index 0000000000..6cca82a951
--- /dev/null
+++ b/toolkit/components/extensions/moz.build
@@ -0,0 +1,124 @@
+# -*- 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.jsm",
+ "ConduitsParent.jsm",
+ "Extension.jsm",
+ "ExtensionActions.jsm",
+ "ExtensionActivityLog.jsm",
+ "ExtensionChild.jsm",
+ "ExtensionChildDevToolsUtils.jsm",
+ "ExtensionCommon.jsm",
+ "ExtensionContent.jsm",
+ "ExtensionPageChild.jsm",
+ "ExtensionParent.jsm",
+ "ExtensionPermissions.jsm",
+ "ExtensionPreferencesManager.jsm",
+ "ExtensionProcessScript.jsm",
+ "extensionProcessScriptLoader.js",
+ "ExtensionSettingsStore.jsm",
+ "ExtensionShortcuts.jsm",
+ "ExtensionStorage.jsm",
+ "ExtensionStorageIDB.jsm",
+ "ExtensionStorageSync.jsm",
+ "ExtensionStorageSyncKinto.jsm",
+ "ExtensionTelemetry.jsm",
+ "ExtensionUtils.jsm",
+ "FindContent.jsm",
+ "MatchURLFilters.jsm",
+ "MessageChannel.jsm",
+ "MessageManagerProxy.jsm",
+ "NativeManifests.jsm",
+ "NativeMessaging.jsm",
+ "onExtensionBrowser.js",
+ "PerformanceCounters.jsm",
+ "ProxyChannelFilter.jsm",
+ "Schemas.jsm",
+ "WebNavigation.jsm",
+ "WebNavigationContent.js",
+ "WebNavigationFrames.jsm",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android":
+ EXTRA_JS_MODULES += [
+ "profiler_get_symbols.js",
+ "ProfilerGetSymbols-worker.js",
+ "ProfilerGetSymbols.jsm",
+ ]
+
+EXTRA_COMPONENTS += [
+ "extensions-toolkit.manifest",
+]
+
+TESTING_JS_MODULES += [
+ "ExtensionTestCommon.jsm",
+ "ExtensionXPCShellUtils.jsm",
+]
+
+DIRS += [
+ "schemas",
+ "storage",
+ "webrequest",
+]
+
+XPIDL_SOURCES += [
+ "mozIExtensionProcessScript.idl",
+]
+
+XPIDL_MODULE = "webextensions"
+
+EXPORTS.mozilla = [
+ "ExtensionPolicyService.h",
+]
+
+EXPORTS.mozilla.extensions = [
+ "DocumentObserver.h",
+ "MatchGlob.h",
+ "MatchPattern.h",
+ "WebExtensionContentScript.h",
+ "WebExtensionPolicy.h",
+]
+
+UNIFIED_SOURCES += [
+ "ExtensionPolicyService.cpp",
+ "MatchPattern.cpp",
+ "WebExtensionPolicy.cpp",
+]
+
+FINAL_LIBRARY = "xul"
+
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser-serviceworker.ini",
+ "test/browser/browser.ini",
+]
+
+MOCHITEST_MANIFESTS += [
+ "test/mochitest/mochitest-remote.ini",
+ "test/mochitest/mochitest.ini",
+]
+MOCHITEST_CHROME_MANIFESTS += ["test/mochitest/chrome.ini"]
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/xpcshell/native_messaging.ini",
+ "test/xpcshell/xpcshell-e10s.ini",
+ "test/xpcshell/xpcshell-legacy-ep.ini",
+ "test/xpcshell/xpcshell-remote.ini",
+ "test/xpcshell/xpcshell.ini",
+]
+
+SPHINX_TREES["webextensions"] = "docs"
+
+with Files("docs/**"):
+ SCHEDULES.exclusive = ["docs"]
+
+include("/ipc/chromium/chromium-config.mozbuild")
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/onExtensionBrowser.js b/toolkit/components/extensions/onExtensionBrowser.js
new file mode 100644
index 0000000000..feb3a8408e
--- /dev/null
+++ b/toolkit/components/extensions/onExtensionBrowser.js
@@ -0,0 +1,10 @@
+/* -*- 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+Services.obs.notifyObservers(this, "tab-content-frameloader-created");
diff --git a/toolkit/components/extensions/parent/.eslintrc.js b/toolkit/components/extensions/parent/.eslintrc.js
new file mode 100644
index 0000000000..356dda7c70
--- /dev/null
+++ b/toolkit/components/extensions/parent/.eslintrc.js
@@ -0,0 +1,31 @@
+/* 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,
+ 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..e86113e1b0
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-activityLog.js
@@ -0,0 +1,44 @@
+/* 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.defineModuleGetter(
+ this,
+ "ExtensionCommon",
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionActivityLog",
+ "resource://gre/modules/ExtensionActivityLog.jsm"
+);
+
+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..c5b1ea1c83
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-alarms.js
@@ -0,0 +1,150 @@
+/* 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).
+function Alarm(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;
+}
+
+Alarm.prototype = {
+ 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 ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ this.alarms = new Map();
+ this.callbacks = new Set();
+ }
+
+ onShutdown() {
+ for (let alarm of this.alarms.values()) {
+ alarm.clear();
+ }
+ }
+
+ 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,
+ name: "alarms.onAlarm",
+ register: fire => {
+ let callback = alarm => {
+ fire.sync(alarm.data);
+ };
+
+ self.callbacks.add(callback);
+ return () => {
+ self.callbacks.delete(callback);
+ };
+ },
+ }).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..9de2ad669c
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-backgroundPage.js
@@ -0,0 +1,231 @@
+/* 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.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+var { HiddenExtensionPage, promiseExtensionViewLoaded } = ExtensionParent;
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionTelemetry",
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "DELAYED_STARTUP",
+ "extensions.webextensions.background-delayed-startup"
+);
+
+XPCOMUtils.defineLazyGetter(this, "serviceWorkerManager", () => {
+ return Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+});
+
+// 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;
+
+ 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 = promiseExtensionViewLoaded(this.browser);
+ this.browser.loadURI(this.url, {
+ triggeringPrincipal: extension.principal,
+ });
+
+ context = await contextPromise;
+ } catch (e) {
+ // Extension was down before the background page has loaded.
+ Cu.reportError(e);
+ ExtensionTelemetry.backgroundPageLoad.stopwatchCancel(extension, this);
+ if (extension.persistentListeners) {
+ EventManager.clearPrimedListeners(this.extension, false);
+ }
+ extension.emit("background-page-aborted");
+ return;
+ }
+
+ ExtensionTelemetry.backgroundPageLoad.stopwatchFinish(extension, this);
+
+ if (context) {
+ // Wait until all event listeners registered by the script so far
+ // to be handled.
+ await Promise.all(context.listenerPromises);
+ context.listenerPromises = null;
+ }
+
+ if (extension.persistentListeners) {
+ // |this.extension| may be null if the extension was shut down.
+ // In that case, we still want to clear the primed listeners,
+ // but not update the persistent listeners in the startupData.
+ EventManager.clearPrimedListeners(extension, !!this.extension);
+ }
+
+ extension.emit("background-page-started");
+ }
+
+ shutdown() {
+ this.extension._backgroundPageFrameLoader = null;
+ super.shutdown();
+ }
+}
+
+// Responsible for the background.service_worker section of the manifest.
+class BackgroundWorker {
+ constructor(extension, options) {
+ this.registrationInfo = null;
+ this.extension = extension;
+ this.workerScript = options.service_worker;
+
+ if (!this.workerScript) {
+ throw new Error("Missing mandatory background.service_worker property");
+ }
+ }
+
+ async build() {
+ const regInfo = await serviceWorkerManager.registerForAddonPrincipal(
+ this.extension.principal
+ );
+ this.registrationInfo = regInfo.QueryInterface(
+ Ci.nsIServiceWorkerRegistrationInfo
+ );
+ }
+
+ shutdown() {
+ if (this.registrationInfo) {
+ this.registrationInfo.forceShutdown();
+ this.registrationInfo = null;
+ }
+ }
+}
+
+this.backgroundPage = class extends ExtensionAPI {
+ async build() {
+ if (this.bgInstance) {
+ return;
+ }
+
+ let { extension } = this;
+ let { manifest } = extension;
+
+ let BackgroundClass = manifest.background.service_worker
+ ? BackgroundWorker
+ : BackgroundPage;
+
+ this.bgInstance = new BackgroundClass(extension, manifest.background);
+ return this.bgInstance.build();
+ }
+
+ onManifestEntry(entryName) {
+ let { extension } = this;
+
+ this.bgInstance = null;
+
+ // 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;
+ }
+
+ // Used by runtime messaging to wait for background page listeners.
+ let bgStartupPromise = new Promise(resolve => {
+ let done = () => {
+ extension.off("background-page-started", done);
+ extension.off("background-page-aborted", done);
+ extension.off("shutdown", done);
+ resolve();
+ };
+ extension.on("background-page-started", done);
+ extension.on("background-page-aborted", done);
+ extension.on("shutdown", done);
+ });
+
+ extension.wakeupBackground = () => {
+ extension.emit("background-page-event");
+ extension.wakeupBackground = () => bgStartupPromise;
+ return bgStartupPromise;
+ };
+
+ if (extension.startupReason !== "APP_STARTUP" || !DELAYED_STARTUP) {
+ return this.build();
+ }
+
+ EventManager.primeListeners(extension);
+
+ extension.once("start-background-page", async () => {
+ if (!this.extension) {
+ // 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. Note that we have
+ // to touch browserPaintedPromise here to initialize the listener
+ // or else we can miss it if the event occurs after the first
+ // window is painted but before #2
+ // 2. After all windows have been restored.
+ extension.once("background-page-event", async () => {
+ await ExtensionParent.browserPaintedPromise;
+ extension.emit("start-background-page");
+ });
+
+ ExtensionParent.browserStartupPromise.then(() => {
+ extension.emit("start-background-page");
+ });
+ }
+
+ onShutdown() {
+ if (this.bgInstance) {
+ this.bgInstance.shutdown();
+ this.bgInstance = null;
+ } else {
+ EventManager.clearPrimedListeners(this.extension, false);
+ }
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-browserSettings.js b/toolkit/components/extensions/parent/ext-browserSettings.js
new file mode 100644
index 0000000000..946f08a93d
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-browserSettings.js
@@ -0,0 +1,463 @@
+/* -*- 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.defineModuleGetter(
+ this,
+ "AppConstants",
+ "resource://gre/modules/AppConstants.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AboutNewTab",
+ "resource:///modules/AboutNewTab.jsm"
+);
+
+var { ExtensionPreferencesManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+var { getSettingsAPI } = ExtensionPreferencesManager;
+
+const HOMEPAGE_OVERRIDE_SETTING = "homepage_override";
+const HOMEPAGE_URL_PREF = "browser.startup.homepage";
+const URL_STORE_TYPE = "url_overrides";
+const NEW_TAB_OVERRIDE_SETTING = "newTabURL";
+
+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;
+ },
+});
+
+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;
+ },
+});
+
+ExtensionPreferencesManager.addSetting("closeTabsByDoubleClick", {
+ permission: "browserSettings",
+ prefNames: ["browser.tabs.closeTabByDblclick"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("contextMenuShowEvent", {
+ permission: "browserSettings",
+ prefNames: ["ui.context_menus.after_mouseup"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value === "mouseup" };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("ftpProtocolEnabled", {
+ permission: "browserSettings",
+ prefNames: ["network.ftp.enabled"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("imageAnimationBehavior", {
+ permission: "browserSettings",
+ prefNames: ["image.animation_mode"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+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",
+ };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("openBookmarksInNewTabs", {
+ permission: "browserSettings",
+ prefNames: ["browser.tabs.loadBookmarksInTabs"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("openSearchResultsInNewTabs", {
+ permission: "browserSettings",
+ prefNames: ["browser.search.openintab"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("openUrlbarResultsInNewTabs", {
+ permission: "browserSettings",
+ prefNames: ["browser.urlbar.openintab"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("webNotificationsDisabled", {
+ permission: "browserSettings",
+ prefNames: ["permissions.default.desktop-notification"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value ? PERM_DENY_ACTION : undefined };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("overrideDocumentColors", {
+ permission: "browserSettings",
+ prefNames: ["browser.display.document_color_use"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("useDocumentFonts", {
+ permission: "browserSettings",
+ prefNames: ["browser.display.use_document_fonts"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("zoomFullPage", {
+ permission: "browserSettings",
+ prefNames: ["browser.zoom.full"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("zoomSiteSpecific", {
+ permission: "browserSettings",
+ prefNames: ["browser.zoom.siteSpecific"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+this.browserSettings = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ browserSettings: {
+ allowPopupsForUserEvents: getSettingsAPI({
+ context,
+ name: "allowPopupsForUserEvents",
+ callback() {
+ return Services.prefs.getCharPref("dom.popup_allowed_events") != "";
+ },
+ }),
+ cacheEnabled: getSettingsAPI({
+ context,
+ name: "cacheEnabled",
+ callback() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ },
+ }),
+ closeTabsByDoubleClick: getSettingsAPI({
+ context,
+ name: "closeTabsByDoubleClick",
+ callback() {
+ 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.`
+ );
+ }
+ },
+ }),
+ contextMenuShowEvent: Object.assign(
+ getSettingsAPI({
+ context,
+ name: "contextMenuShowEvent",
+ callback() {
+ if (AppConstants.platform === "win") {
+ return "mouseup";
+ }
+ let prefValue = Services.prefs.getBoolPref(
+ "ui.context_menus.after_mouseup",
+ null
+ );
+ return prefValue ? "mouseup" : "mousedown";
+ },
+ }),
+ {
+ 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",
+ callback() {
+ return Services.prefs.getBoolPref("network.ftp.enabled");
+ },
+ }),
+ homepageOverride: getSettingsAPI({
+ context,
+ name: HOMEPAGE_OVERRIDE_SETTING,
+ callback() {
+ return Services.prefs.getStringPref(HOMEPAGE_URL_PREF);
+ },
+ readOnly: true,
+ onChange: new ExtensionCommon.EventManager({
+ context,
+ name: `${HOMEPAGE_URL_PREF}.onChange`,
+ register: fire => {
+ let listener = () => {
+ fire.async({
+ levelOfControl: "not_controllable",
+ value: Services.prefs.getStringPref(HOMEPAGE_URL_PREF),
+ });
+ };
+ Services.prefs.addObserver(HOMEPAGE_URL_PREF, listener);
+ return () => {
+ Services.prefs.removeObserver(HOMEPAGE_URL_PREF, listener);
+ };
+ },
+ }).api(),
+ }),
+ imageAnimationBehavior: getSettingsAPI({
+ context,
+ name: "imageAnimationBehavior",
+ callback() {
+ return Services.prefs.getCharPref("image.animation_mode");
+ },
+ }),
+ newTabPosition: getSettingsAPI({
+ context,
+ name: "newTabPosition",
+ callback() {
+ if (Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")) {
+ return "afterCurrent";
+ }
+ if (
+ Services.prefs.getBoolPref(
+ "browser.tabs.insertRelatedAfterCurrent"
+ )
+ ) {
+ return "relatedAfterCurrent";
+ }
+ return "atEnd";
+ },
+ }),
+ newTabPageOverride: getSettingsAPI({
+ context,
+ name: NEW_TAB_OVERRIDE_SETTING,
+ callback() {
+ return AboutNewTab.newTabURL;
+ },
+ storeType: URL_STORE_TYPE,
+ readOnly: true,
+ onChange: new ExtensionCommon.EventManager({
+ context,
+ name: `${NEW_TAB_OVERRIDE_SETTING}.onChange`,
+ register: fire => {
+ let listener = (text, id) => {
+ fire.async({
+ levelOfControl: "not_controllable",
+ value: AboutNewTab.newTabURL,
+ });
+ };
+ Services.obs.addObserver(listener, "newtab-url-changed");
+ return () => {
+ Services.obs.removeObserver(listener, "newtab-url-changed");
+ };
+ },
+ }).api(),
+ }),
+ openBookmarksInNewTabs: getSettingsAPI({
+ context,
+ name: "openBookmarksInNewTabs",
+ callback() {
+ return Services.prefs.getBoolPref(
+ "browser.tabs.loadBookmarksInTabs"
+ );
+ },
+ }),
+ openSearchResultsInNewTabs: getSettingsAPI({
+ context,
+ name: "openSearchResultsInNewTabs",
+ callback() {
+ return Services.prefs.getBoolPref("browser.search.openintab");
+ },
+ }),
+ openUrlbarResultsInNewTabs: getSettingsAPI({
+ context,
+ name: "openUrlbarResultsInNewTabs",
+ callback() {
+ return Services.prefs.getBoolPref("browser.urlbar.openintab");
+ },
+ }),
+ webNotificationsDisabled: getSettingsAPI({
+ context,
+ name: "webNotificationsDisabled",
+ callback() {
+ let prefValue = Services.prefs.getIntPref(
+ "permissions.default.desktop-notification",
+ null
+ );
+ return prefValue === PERM_DENY_ACTION;
+ },
+ }),
+ overrideDocumentColors: Object.assign(
+ getSettingsAPI({
+ context,
+ name: "overrideDocumentColors",
+ callback() {
+ 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";
+ },
+ }),
+ {
+ 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
+ );
+ },
+ }
+ ),
+ useDocumentFonts: Object.assign(
+ getSettingsAPI({
+ context,
+ name: "useDocumentFonts",
+ callback() {
+ return (
+ Services.prefs.getIntPref(
+ "browser.display.use_document_fonts"
+ ) !== 0
+ );
+ },
+ }),
+ {
+ 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: getSettingsAPI({
+ context,
+ name: "zoomFullPage",
+ callback() {
+ return Services.prefs.getBoolPref("browser.zoom.full");
+ },
+ }),
+ zoomSiteSpecific: getSettingsAPI({
+ context,
+ name: "zoomSiteSpecific",
+ callback() {
+ return Services.prefs.getBoolPref("browser.zoom.siteSpecific");
+ },
+ }),
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-browsingData.js b/toolkit/components/extensions/parent/ext-browsingData.js
new file mode 100644
index 0000000000..9f19ed89be
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-browsingData.js
@@ -0,0 +1,416 @@
+/* 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 { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ LoginHelper: "resource://gre/modules/LoginHelper.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+ setTimeout: "resource://gre/modules/Timer.jsm",
+ ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm",
+ // This helper contains the platform-specific bits of browsingData.
+ BrowsingDataDelegate: "resource:///modules/ExtensionBrowsingData.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "quotaManagerService",
+ "@mozilla.org/dom/quota-manager-service;1",
+ "nsIQuotaManagerService"
+);
+
+/**
+ * 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) => {
+ quotaManagerService.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 = quotaManagerService.clearStoragesForPrincipal(
+ principal,
+ null,
+ "idb"
+ );
+ } else {
+ clearRequest = quotaManagerService.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..baf0e04e39
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-captivePortal.js
@@ -0,0 +1,136 @@
+/* -*- 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.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+
+var { getSettingsAPI } = ExtensionPreferencesManager;
+
+const CAPTIVE_URL_PREF = "captivedetect.canonicalURL";
+
+function 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";
+ }
+}
+
+var { ExtensionError } = ExtensionUtils;
+
+this.captivePortal = class extends ExtensionAPI {
+ getAPI(context) {
+ function checkEnabled() {
+ if (!gCaptivePortalEnabled) {
+ throw new ExtensionError("Captive Portal detection is not enabled");
+ }
+ }
+
+ return {
+ captivePortal: {
+ getState() {
+ checkEnabled();
+ return nameForCPSState(gCPS.state);
+ },
+ getLastChecked() {
+ checkEnabled();
+ return gCPS.lastChecked;
+ },
+ onStateChanged: new EventManager({
+ context,
+ name: "captivePortal.onStateChanged",
+ register: fire => {
+ checkEnabled();
+
+ let observer = (subject, topic) => {
+ fire.async({ state: nameForCPSState(gCPS.state) });
+ };
+
+ Services.obs.addObserver(
+ observer,
+ "ipc:network:captive-portal-set-state"
+ );
+ return () => {
+ Services.obs.removeObserver(
+ observer,
+ "ipc:network:captive-portal-set-state"
+ );
+ };
+ },
+ }).api(),
+ onConnectivityAvailable: new EventManager({
+ context,
+ name: "captivePortal.onConnectivityAvailable",
+ register: fire => {
+ checkEnabled();
+
+ let observer = (subject, topic, data) => {
+ fire.async({ status: data });
+ };
+
+ Services.obs.addObserver(
+ observer,
+ "network:captive-portal-connectivity"
+ );
+ return () => {
+ Services.obs.removeObserver(
+ observer,
+ "network:captive-portal-connectivity"
+ );
+ };
+ },
+ }).api(),
+ canonicalURL: getSettingsAPI({
+ context,
+ name: "captiveURL",
+ callback() {
+ return Services.prefs.getStringPref(CAPTIVE_URL_PREF);
+ },
+ readOnly: true,
+ onChange: new ExtensionCommon.EventManager({
+ context,
+ name: "captiveURL.onChange",
+ register: 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 () => {
+ Services.prefs.removeObserver(CAPTIVE_URL_PREF, listener);
+ };
+ },
+ }).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..048b704b4f
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-clipboard.js
@@ -0,0 +1,89 @@
+/* -*- 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.
+
+ // The length should not be zero. (Bug 1493292)
+ transferable.setTransferData(kNativeImageMime, img, 1);
+
+ 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..39da7ef366
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-contentScripts.js
@@ -0,0 +1,202 @@
+/* -*- 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";
+
+/* exported registerContentScript, unregisterContentScript */
+/* global registerContentScript, unregisterContentScript */
+
+var { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+var { ExtensionError, getUniqueId } = ExtensionUtils;
+
+/**
+ * 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: [],
+ };
+
+ 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<scriptId -> 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();
+
+ await extension.broadcast("Extension:RegisterContentScript", {
+ id: extension.id,
+ options: scriptOptions,
+ scriptId,
+ });
+
+ extension.registeredContentScripts.set(scriptId, scriptOptions);
+ extension.updateContentScripts();
+
+ 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..dfb4c6ea1b
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-contextualIdentities.js
@@ -0,0 +1,338 @@
+/* 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.defineModuleGetter(
+ this,
+ "ContextualIdentityService",
+ "resource://gre/modules/ContextualIdentityService.jsm"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "containersEnabled",
+ "privacy.userContext.enabled"
+);
+
+var { ExtensionPreferencesManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+const CONTAINER_PREF_INSTALL_DEFAULTS = {
+ "privacy.userContext.enabled": true,
+ "privacy.userContext.ui.enabled": true,
+ "privacy.usercontext.about_newtab_segregation.enabled": true,
+ "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 ExtensionAPI {
+ onStartup() {
+ let { extension } = this;
+
+ if (extension.hasPermission("contextualIdentities")) {
+ 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 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,
+ name: "contextualIdentities.onCreated",
+ register: fire => {
+ let observer = (subject, topic) => {
+ let convertedIdentity = convertIdentityFromObserver(subject);
+ if (convertedIdentity) {
+ fire.async({ contextualIdentity: convertedIdentity });
+ }
+ };
+
+ Services.obs.addObserver(observer, "contextual-identity-created");
+ return () => {
+ Services.obs.removeObserver(
+ observer,
+ "contextual-identity-created"
+ );
+ };
+ },
+ }).api(),
+
+ onUpdated: new EventManager({
+ context,
+ name: "contextualIdentities.onUpdated",
+ register: fire => {
+ let observer = (subject, topic) => {
+ let convertedIdentity = convertIdentityFromObserver(subject);
+ if (convertedIdentity) {
+ fire.async({ contextualIdentity: convertedIdentity });
+ }
+ };
+
+ Services.obs.addObserver(observer, "contextual-identity-updated");
+ return () => {
+ Services.obs.removeObserver(
+ observer,
+ "contextual-identity-updated"
+ );
+ };
+ },
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ name: "contextualIdentities.onRemoved",
+ register: fire => {
+ let observer = (subject, topic) => {
+ let convertedIdentity = convertIdentityFromObserver(subject);
+ if (convertedIdentity) {
+ fire.async({ contextualIdentity: convertedIdentity });
+ }
+ };
+
+ Services.obs.addObserver(observer, "contextual-identity-deleted");
+ return () => {
+ Services.obs.removeObserver(
+ observer,
+ "contextual-identity-deleted"
+ );
+ };
+ },
+ }).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..b8ade229b1
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-cookies.js
@@ -0,0 +1,613 @@
+/* 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.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+/* 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;
+
+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 || "",
+ };
+
+ 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;
+};
+
+/**
+ * Query the cookie store for matching cookies.
+ * @param {Object} detailsIn
+ * @param {Array} props Properties the extension is interested in matching against.
+ * @param {BaseContext} context The context making the query.
+ */
+const query = function*(detailsIn, props, context) {
+ let details = {};
+ props.forEach(property => {
+ if (detailsIn[property] !== null) {
+ details[property] = detailsIn[property];
+ }
+ });
+
+ if ("domain" in details) {
+ details.domain = details.domain.toLowerCase().replace(/^\./, "");
+ details.domain = dropBracketIfIPv6(details.domain);
+ }
+
+ let userContextId = 0;
+ let isPrivate = context.incognito;
+ if (details.storeId) {
+ if (!isValidCookieStoreId(details.storeId)) {
+ return;
+ }
+
+ if (isDefaultCookieStoreId(details.storeId)) {
+ isPrivate = false;
+ } else if (isPrivateCookieStoreId(details.storeId)) {
+ isPrivate = true;
+ } else if (isContainerCookieStoreId(details.storeId)) {
+ isPrivate = false;
+ userContextId = getContainerForCookieStoreId(details.storeId);
+ if (!userContextId) {
+ return;
+ }
+ }
+ }
+
+ let storeId = DEFAULT_STORE;
+ if (isPrivate) {
+ storeId = PRIVATE_STORE;
+ } else if ("storeId" in details) {
+ storeId = details.storeId;
+ }
+ if (storeId == PRIVATE_STORE && !context.privateBrowsingAllowed) {
+ throw new ExtensionError(
+ "Extension disallowed access to the private cookies storeId."
+ );
+ }
+
+ // We can use getCookiesFromHost for faster searching.
+ let cookies;
+ let host;
+ let url;
+ let originAttributes = {
+ userContextId,
+ privateBrowsingId: isPrivate ? 1 : 0,
+ };
+ if ("firstPartyDomain" in details) {
+ originAttributes.firstPartyDomain = details.firstPartyDomain;
+ }
+ 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 && "firstPartyDomain" in originAttributes) {
+ // 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 normalizeFirstPartyDomain = 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."
+ );
+ }
+
+ // When FPI is disabled, the "firstPartyDomain" attribute is optional
+ // and defaults to the empty string.
+ details.firstPartyDomain = "";
+};
+
+this.cookies = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ let self = {
+ cookies: {
+ get: function(details) {
+ normalizeFirstPartyDomain(details);
+
+ // FIXME: We don't sort by length of path and creation time.
+ let allowed = ["url", "name", "storeId", "firstPartyDomain"];
+ 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)) {
+ normalizeFirstPartyDomain(details);
+ }
+
+ let allowed = [
+ "url",
+ "name",
+ "domain",
+ "path",
+ "secure",
+ "session",
+ "storeId",
+ ];
+
+ // firstPartyDomain may be set to null or undefined to not filter by FPD.
+ if (details.firstPartyDomain != null) {
+ allowed.push("firstPartyDomain");
+ }
+
+ let result = Array.from(
+ query(details, allowed, context),
+ convertCookie
+ );
+
+ return Promise.resolve(result);
+ },
+
+ set: function(details) {
+ normalizeFirstPartyDomain(details);
+
+ 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 isPrivate = context.incognito;
+ let userContextId = 0;
+ if (isDefaultCookieStoreId(details.storeId)) {
+ isPrivate = false;
+ } else if (isPrivateCookieStoreId(details.storeId)) {
+ if (!context.privateBrowsingAllowed) {
+ return Promise.reject({
+ message:
+ "Extension disallowed access to the private cookies storeId.",
+ });
+ }
+ isPrivate = true;
+ } else if (isContainerCookieStoreId(details.storeId)) {
+ let containerId = getContainerForCookieStoreId(details.storeId);
+ if (containerId === null) {
+ return Promise.reject({
+ message: `Illegal storeId: ${details.storeId}`,
+ });
+ }
+ isPrivate = false;
+ userContextId = containerId;
+ } else if (details.storeId !== null) {
+ return Promise.reject({ message: "Unknown storeId" });
+ }
+
+ 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 originAttributes = {
+ userContextId,
+ privateBrowsingId: isPrivate ? 1 : 0,
+ firstPartyDomain: details.firstPartyDomain,
+ };
+
+ 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) {
+ normalizeFirstPartyDomain(details);
+
+ let allowed = ["url", "name", "storeId", "firstPartyDomain"];
+ for (let { cookie, storeId } of query(details, allowed, context)) {
+ if (
+ isPrivateCookieStoreId(details.storeId) &&
+ !context.privateBrowsingAllowed
+ ) {
+ return Promise.reject({ message: "Unknown storeId" });
+ }
+ 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: details.firstPartyDomain,
+ });
+ }
+
+ 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,
+ name: "cookies.onChanged",
+ register: fire => {
+ let observer = (subject, topic, data) => {
+ let notify = (removed, cookie, cause) => {
+ cookie.QueryInterface(Ci.nsICookie);
+
+ if (extension.allowedOrigins.matchesCookie(cookie)) {
+ fire.async({
+ removed,
+ cookie: convertCookie({
+ cookie,
+ isPrivate: topic == "private-cookie-changed",
+ }),
+ cause,
+ });
+ }
+ };
+
+ // We do our best effort here to map the incompatible states.
+ switch (data) {
+ case "deleted":
+ notify(true, subject, "explicit");
+ break;
+ case "added":
+ notify(false, subject, "explicit");
+ break;
+ case "changed":
+ notify(true, subject, "overwrite");
+ notify(false, subject, "explicit");
+ break;
+ case "batch-deleted":
+ subject.QueryInterface(Ci.nsIArray);
+ for (let i = 0; i < subject.length; i++) {
+ let cookie = subject.queryElementAt(i, Ci.nsICookie);
+ if (
+ !cookie.isSession &&
+ cookie.expiry * 1000 <= Date.now()
+ ) {
+ notify(true, cookie, "expired");
+ } else {
+ notify(true, cookie, "evicted");
+ }
+ }
+ break;
+ }
+ };
+
+ Services.obs.addObserver(observer, "cookie-changed");
+ if (context.privateBrowsingAllowed) {
+ Services.obs.addObserver(observer, "private-cookie-changed");
+ }
+ return () => {
+ Services.obs.removeObserver(observer, "cookie-changed");
+ if (context.privateBrowsingAllowed) {
+ Services.obs.removeObserver(observer, "private-cookie-changed");
+ }
+ };
+ },
+ }).api(),
+ },
+ };
+
+ return self;
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-dns.js b/toolkit/components/extensions/parent/ext-dns.js
new file mode 100644
index 0000000000..6af637924f
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-dns.js
@@ -0,0 +1,90 @@
+/* -*- 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) {
+ const dnss = Cc["@mozilla.org/network/dns-service;1"].getService(
+ Ci.nsIDNSService
+ );
+ 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 = dnss.asyncResolve(
+ hostname,
+ Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
+ dnsFlags,
+ null, // resolverInfo
+ 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..e6094d91ff
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-downloads.js
@@ -0,0 +1,1264 @@
+/* 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.defineModuleGetter(
+ this,
+ "AppConstants",
+ "resource://gre/modules/AppConstants.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Downloads",
+ "resource://gre/modules/Downloads.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "DownloadPaths",
+ "resource://gre/modules/DownloadPaths.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "DownloadLastDir",
+ "resource://gre/modules/DownloadLastDir.jsm"
+);
+
+var { EventEmitter, ignoreEvent } = ExtensionCommon;
+
+const DOWNLOAD_ITEM_FIELDS = [
+ "id",
+ "url",
+ "referrer",
+ "filename",
+ "incognito",
+ "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",
+];
+
+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
+ ? this.download.source.referrerInfo.originalReferrer
+ : null;
+
+ return uri && uri.spec;
+ }
+ get filename() {
+ return this.download.target.path;
+ }
+ get incognito() {
+ return this.download.source.isPrivate;
+ }
+ get danger() {
+ return "safe";
+ } // TODO
+ get mime() {
+ return this.download.contentType;
+ }
+ get startTime() {
+ return this.download.startTime;
+ }
+ get endTime() {
+ return null;
+ } // TODO
+ get estimatedEndTime() {
+ // Based on the code in summarizeDownloads() in DownloadsCommon.jsm
+ 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 ? this.extension.id : undefined;
+ }
+ get byExtensionName() {
+ return this.extension ? this.extension.name : undefined;
+ }
+
+ /**
+ * 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 jsm.
+// 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 == null) {
+ this.loadPromise = Downloads.getList(Downloads.ALL).then(list => {
+ let self = this;
+ return list
+ .addView({
+ onDownloadAdded(download) {
+ const item = self.newFromDownload(download, null);
+ self.emit("create", item);
+ item._storePrechange();
+ },
+
+ onDownloadRemoved(download) {
+ const item = self.byDownload.get(download);
+ if (item != null) {
+ self.emit("erase", item);
+ self.byDownload.delete(download);
+ self.byId.delete(item.id);
+ }
+ },
+
+ onDownloadChanged(download) {
+ const item = self.byDownload.get(download);
+ if (item == null) {
+ Cu.reportError(
+ "Got onDownloadChanged for unknown download object"
+ );
+ } else {
+ self.emit("change", item);
+ item._storePrechange();
+ }
+ },
+ })
+ .then(() => list.getAll())
+ .then(downloads => {
+ downloads.forEach(download => {
+ this.newFromDownload(download, null);
+ });
+ })
+ .then(() => list);
+ });
+ }
+ return this.loadPromise;
+ }
+
+ getDownloadList() {
+ return this.lazyInit();
+ }
+
+ getAll() {
+ return this.lazyInit().then(() => this.byId.values());
+ }
+
+ fromId(id, privateAllowed = true) {
+ const download = this.byId.get(id);
+ if (!download || (!privateAllowed && download.incognito)) {
+ throw new Error(`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;
+ }
+
+ erase(item) {
+ // TODO Bug 1255507: for now we only work with downloads in the DownloadList
+ // from getAll()
+ return this.getDownloadList().then(list => {
+ 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);
+ // const endedBefore = normalizeDownloadTime(query.endedBefore, true);
+ // const endedAfter = normalizeDownloadTime(query.endedAfter, false);
+
+ const totalBytesGreater =
+ query.totalBytesGreater !== null ? query.totalBytesGreater : -1;
+ const totalBytesLess =
+ query.totalBytesLess !== null ? 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 Error(`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",
+ "bytesReceived",
+ "totalBytes",
+ "fileSize",
+ "exists",
+ ];
+ for (let field of SIMPLE_ITEMS) {
+ if (query[field] != null && item[field] != query[field]) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+};
+
+const queryHelper = query => {
+ let matchFn;
+ try {
+ matchFn = downloadQuery(query);
+ } catch (err) {
+ return Promise.reject({ message: err.message });
+ }
+
+ let compareFn;
+ if (query.orderBy != null) {
+ 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)) {
+ return Promise.reject({
+ message: `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;
+ };
+ }
+
+ return DownloadMap.getAll().then(downloads => {
+ 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;
+ });
+};
+
+function downloadEventManagerAPI(context, name, event, listener) {
+ let register = fire => {
+ const handler = (what, item) => {
+ if (context.privateBrowsingAllowed || !item.incognito) {
+ listener(fire, what, item);
+ }
+ };
+ let registerPromise = DownloadMap.getDownloadList().then(() => {
+ DownloadMap.on(event, handler);
+ });
+ return () => {
+ registerPromise.then(() => {
+ DownloadMap.off(event, handler);
+ });
+ };
+ };
+
+ return new EventManager({ context, name, register }).api();
+}
+
+this.downloads = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ downloads: {
+ download(options) {
+ let { filename } = options;
+ if (filename && AppConstants.platform === "win") {
+ // cross platform javascript code uses "/"
+ filename = filename.replace(/\//g, "\\");
+ }
+
+ if (filename != null) {
+ if (!filename.length) {
+ return Promise.reject({ message: "filename must not be empty" });
+ }
+
+ let path = OS.Path.split(filename);
+ if (path.absolute) {
+ return Promise.reject({
+ message: "filename must not be an absolute path",
+ });
+ }
+
+ if (path.components.some(component => component == "..")) {
+ return Promise.reject({
+ message: "filename must not contain back-references (..)",
+ });
+ }
+
+ if (
+ path.components.some(component => {
+ let sanitized = DownloadPaths.sanitize(component, {
+ compressWhitespaces: false,
+ });
+ return component != sanitized;
+ })
+ ) {
+ return Promise.reject({
+ message: "filename must not contain illegal characters",
+ });
+ }
+ }
+
+ if (options.incognito && !context.privateBrowsingAllowed) {
+ return Promise.reject({
+ message: "private browsing access not allowed",
+ });
+ }
+
+ if (options.conflictAction == "prompt") {
+ // TODO
+ return Promise.reject({
+ message: "conflictAction prompt not yet implemented",
+ });
+ }
+
+ if (options.headers) {
+ for (let { name } of options.headers) {
+ if (
+ FORBIDDEN_HEADERS.includes(name.toUpperCase()) ||
+ name.match(FORBIDDEN_PREFIXES)
+ ) {
+ return Promise.reject({
+ message: "Forbidden request header name",
+ });
+ }
+ }
+ }
+
+ // 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)
+ );
+ }
+ }
+
+ let target = OS.Path.join(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 = OS.Path.dirname(target);
+ await OS.File.makeDir(dir, { from: downloadsDir });
+
+ if (await OS.File.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 OS.File.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 new Promise(resolve => {
+ downloadLastDir.getFileAsync(extension.baseURI, file => {
+ resolve(file);
+ });
+ });
+ }
+
+ 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 = OS.Path.basename(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);
+ }
+ });
+ });
+ }
+
+ let download;
+ return Downloads.getPreferredDownloadsDirectory()
+ .then(downloadsDir => createTarget(downloadsDir))
+ .then(target => {
+ 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,
+ };
+
+ // 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;
+ }
+
+ return Downloads.createDownload({
+ source,
+ target: {
+ path: target,
+ partFilePath: target + ".part",
+ },
+ });
+ })
+ .then(dl => {
+ download = dl;
+ return DownloadMap.getDownloadList();
+ })
+ .then(list => {
+ 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(e => {
+ if (e.name !== "DownloadError") {
+ Cu.reportError(e);
+ }
+ });
+
+ return item.id;
+ });
+ },
+
+ removeFile(id) {
+ return DownloadMap.lazyInit().then(() => {
+ let item;
+ try {
+ item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+ } catch (err) {
+ return Promise.reject({ message: `Invalid download id ${id}` });
+ }
+ if (item.state !== "complete") {
+ return Promise.reject({
+ message: `Cannot remove incomplete download id ${id}`,
+ });
+ }
+ return OS.File.remove(item.filename, { ignoreAbsent: false }).catch(
+ err => {
+ return Promise.reject({
+ message: `Could not remove download id ${item.id} because the file doesn't exist`,
+ });
+ }
+ );
+ });
+ },
+
+ search(query) {
+ if (!context.privateBrowsingAllowed) {
+ query.incognito = false;
+ }
+ return queryHelper(query).then(items =>
+ items.map(item => item.serialize())
+ );
+ },
+
+ pause(id) {
+ return DownloadMap.lazyInit().then(() => {
+ let item;
+ try {
+ item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+ } catch (err) {
+ return Promise.reject({ message: `Invalid download id ${id}` });
+ }
+ if (item.state != "in_progress") {
+ return Promise.reject({
+ message: `Download ${id} cannot be paused since it is in state ${item.state}`,
+ });
+ }
+
+ return item.download.cancel();
+ });
+ },
+
+ resume(id) {
+ return DownloadMap.lazyInit().then(() => {
+ let item;
+ try {
+ item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+ } catch (err) {
+ return Promise.reject({ message: `Invalid download id ${id}` });
+ }
+ if (!item.canResume) {
+ return Promise.reject({
+ message: `Download ${id} cannot be resumed`,
+ });
+ }
+
+ item.error = null;
+ return item.download.start();
+ });
+ },
+
+ cancel(id) {
+ return DownloadMap.lazyInit().then(() => {
+ let item;
+ try {
+ item = DownloadMap.fromId(id, context.privateBrowsingAllowed);
+ } catch (err) {
+ return Promise.reject({ message: `Invalid download id ${id}` });
+ }
+ if (item.download.succeeded) {
+ return Promise.reject({
+ message: `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);
+ },
+
+ erase(query) {
+ if (!context.privateBrowsingAllowed) {
+ query.incognito = false;
+ }
+ return queryHelper(query).then(items => {
+ let results = [];
+ let promises = [];
+ for (let item of items) {
+ promises.push(DownloadMap.erase(item));
+ results.push(item.id);
+ }
+ return Promise.all(promises).then(() => results);
+ });
+ },
+
+ open(downloadId) {
+ return DownloadMap.lazyInit()
+ .then(() => {
+ let download = DownloadMap.fromId(
+ downloadId,
+ context.privateBrowsingAllowed
+ ).download;
+ if (download.succeeded) {
+ return download.launch();
+ }
+ return Promise.reject({ message: "Download has not completed." });
+ })
+ .catch(error => {
+ return Promise.reject({ message: error.message });
+ });
+ },
+
+ show(downloadId) {
+ return DownloadMap.lazyInit()
+ .then(() => {
+ let download = DownloadMap.fromId(
+ downloadId,
+ context.privateBrowsingAllowed
+ );
+ return download.download.showContainingDirectory();
+ })
+ .then(() => {
+ return true;
+ })
+ .catch(error => {
+ return Promise.reject({ message: error.message });
+ });
+ },
+
+ getFileIcon(downloadId, options) {
+ return DownloadMap.lazyInit()
+ .then(() => {
+ let size = options && options.size ? options.size : 32;
+ let download = DownloadMap.fromId(
+ downloadId,
+ context.privateBrowsingAllowed
+ ).download;
+ let pathPrefix = "";
+ let path;
+
+ if (download.succeeded) {
+ let file = FileUtils.File(download.target.path);
+ path = Services.io.newFileURI(file).spec;
+ } else {
+ path = OS.Path.basename(download.target.path);
+ pathPrefix = "//";
+ }
+
+ return new Promise((resolve, reject) => {
+ let chromeWebNav = Services.appShell.createWindowlessBrowser(
+ true
+ );
+ let system = Services.scriptSecurityManager.getSystemPrincipal();
+ chromeWebNav.docShell.createAboutBlankContentViewer(
+ system,
+ system
+ );
+
+ let img = chromeWebNav.document.createElement("img");
+ img.width = size;
+ img.height = size;
+
+ let handleLoad;
+ let handleError;
+ const cleanup = () => {
+ img.removeEventListener("load", handleLoad);
+ img.removeEventListener("error", handleError);
+ chromeWebNav.close();
+ chromeWebNav = null;
+ };
+
+ handleLoad = () => {
+ let canvas = chromeWebNav.document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ let context = canvas.getContext("2d");
+ context.drawImage(img, 0, 0, size, size);
+ let dataURL = canvas.toDataURL("image/png");
+ cleanup();
+ resolve(dataURL);
+ };
+
+ handleError = error => {
+ Cu.reportError(error);
+ cleanup();
+ reject(new Error("An unexpected error occurred"));
+ };
+
+ img.addEventListener("load", handleLoad);
+ img.addEventListener("error", handleError);
+ img.src = `moz-icon:${pathPrefix}${path}?size=${size}`;
+ });
+ })
+ .catch(error => {
+ return Promise.reject({ message: error.message });
+ });
+ },
+
+ // When we do setShelfEnabled(), check for additional "downloads.shelf" permission.
+ // i.e.:
+ // setShelfEnabled(enabled) {
+ // if (!extension.hasPermission("downloads.shelf")) {
+ // throw new context.cloneScope.Error("Permission denied because 'downloads.shelf' permission is missing.");
+ // }
+ // ...
+ // }
+
+ onChanged: downloadEventManagerAPI(
+ context,
+ "downloads.onChanged",
+ "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: downloadEventManagerAPI(
+ context,
+ "downloads.onCreated",
+ "create",
+ (fire, what, item) => {
+ fire.async(item.serialize());
+ }
+ ),
+
+ onErased: downloadEventManagerAPI(
+ context,
+ "downloads.onErased",
+ "erase",
+ (fire, what, item) => {
+ fire.async(item.id);
+ }
+ ),
+
+ 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..7fef0b9ee8
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-geckoProfiler.js
@@ -0,0 +1,227 @@
+/* -*- 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "ProfilerGetSymbols",
+ "resource://gre/modules/ProfilerGetSymbols.jsm"
+);
+
+const PREF_ASYNC_STACK = "javascript.options.asyncstack";
+
+const ASYNC_STACKS_ENABLED = Services.prefs.getBoolPref(
+ PREF_ASYNC_STACK,
+ false
+);
+
+var { ExtensionError } = ExtensionUtils;
+
+const symbolCache = new Map();
+
+const primeSymbolStore = libs => {
+ for (const { path, debugName, debugPath, breakpadId } of libs) {
+ symbolCache.set(`${debugName}/${breakpadId}`, { path, debugPath });
+ }
+};
+
+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 fragments = [OS.Constants.Path.profileDir, "profiler", fileName];
+ let filePath = OS.Path.join(...fragments);
+
+ try {
+ 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) {
+ if (symbolCache.size === 0) {
+ primeSymbolStore(Services.profiler.sharedLibraries);
+ }
+
+ const cachedLibInfo = symbolCache.get(`${debugName}/${breakpadId}`);
+ if (!cachedLibInfo) {
+ throw new Error(
+ `The library ${debugName} ${breakpadId} is not in the Services.profiler.sharedLibraries list, ` +
+ "so the local path for it is not known and symbols for it can not be obtained. " +
+ "This usually happens if a content process uses a library that's not used in the parent " +
+ "process - Services.profiler.sharedLibraries only knows about libraries in the parent process."
+ );
+ }
+
+ const { path, debugPath } = cachedLibInfo;
+ if (!OS.Path.split(path).absolute) {
+ throw new Error(
+ `Services.profiler.sharedLibraries did not contain an absolute path for the library ${debugName} ${breakpadId}, ` +
+ "so symbols for this library can not be obtained."
+ );
+ }
+
+ return ProfilerGetSymbols.getSymbolTable(path, debugPath, 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..72ffc5b869
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-i18n.js
@@ -0,0 +1,47 @@
+/* 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.defineModuleGetter(
+ this,
+ "LanguageDetector",
+ "resource:///modules/translation/LanguageDetector.jsm"
+);
+
+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..68dea736e6
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-identity.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";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+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..ee0c0da374
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-idle.js
@@ -0,0 +1,97 @@
+/* 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"
+);
+
+// WeakMap[Extension -> Object]
+let observersMap = new WeakMap();
+
+const getIdleObserverInfo = (extension, context) => {
+ let observerInfo = observersMap.get(extension);
+ if (!observerInfo) {
+ observerInfo = {
+ observer: null,
+ detectionInterval: 60,
+ };
+ observersMap.set(extension, observerInfo);
+ context.callOnClose({
+ close: () => {
+ let { observer, detectionInterval } = observersMap.get(extension);
+ if (observer) {
+ idleService.removeIdleObserver(observer, detectionInterval);
+ }
+ observersMap.delete(extension);
+ },
+ });
+ }
+ return observerInfo;
+};
+
+const getIdleObserver = (extension, context) => {
+ let observerInfo = getIdleObserverInfo(extension, context);
+ let { observer, detectionInterval } = observerInfo;
+ if (!observer) {
+ observer = new (class extends ExtensionCommon.EventEmitter {
+ observe(subject, topic, data) {
+ if (topic == "idle" || topic == "active") {
+ this.emit("stateChanged", topic);
+ }
+ }
+ })();
+ idleService.addIdleObserver(observer, detectionInterval);
+ observerInfo.observer = observer;
+ observerInfo.detectionInterval = detectionInterval;
+ }
+ return observer;
+};
+
+const setDetectionInterval = (extension, context, newInterval) => {
+ let observerInfo = getIdleObserverInfo(extension, context);
+ let { observer, detectionInterval } = observerInfo;
+ if (observer) {
+ idleService.removeIdleObserver(observer, detectionInterval);
+ idleService.addIdleObserver(observer, newInterval);
+ }
+ observerInfo.detectionInterval = newInterval;
+};
+
+this.idle = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ idle: {
+ queryState: function(detectionIntervalInSeconds) {
+ if (idleService.idleTime < detectionIntervalInSeconds * 1000) {
+ return Promise.resolve("active");
+ }
+ return Promise.resolve("idle");
+ },
+ setDetectionInterval: function(detectionIntervalInSeconds) {
+ setDetectionInterval(extension, context, detectionIntervalInSeconds);
+ },
+ onStateChanged: new EventManager({
+ context,
+ name: "idle.onStateChanged",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.sync(data);
+ };
+
+ getIdleObserver(extension, context).on("stateChanged", listener);
+ return () => {
+ getIdleObserver(extension, context).off("stateChanged", listener);
+ };
+ },
+ }).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..afc01b558b
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-management.js
@@ -0,0 +1,381 @@
+/* -*- 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.defineLazyGetter(this, "strBundle", function() {
+ return Services.strings.createBundle(
+ "chrome://global/locale/extensions.properties"
+ );
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "promptService",
+ "@mozilla.org/embedcomp/prompt-service;1",
+ "nsIPromptService"
+);
+
+XPCOMUtils.defineLazyGetter(this, "GlobalManager", () => {
+ const { GlobalManager } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+ );
+ return GlobalManager;
+});
+
+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;
+};
+
+const listenerMap = new WeakMap();
+// 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 AddonListener extends ExtensionCommon.EventEmitter {
+ constructor() {
+ super();
+ AddonManager.addAddonListener(this);
+ }
+
+ release() {
+ AddonManager.removeAddonListener(this);
+ }
+
+ getExtensionInfo(addon) {
+ let ext = addon.isWebExtension && GlobalManager.extensionMap.get(addon.id);
+ 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));
+ }
+}
+
+let addonListener;
+
+const getManagementListener = (extension, context) => {
+ if (!listenerMap.has(extension)) {
+ if (!addonListener) {
+ addonListener = new AddonListener();
+ }
+ listenerMap.set(extension, {});
+ context.callOnClose({
+ close: () => {
+ listenerMap.delete(extension);
+ if (listenerMap.length === 0) {
+ addonListener.release();
+ addonListener = null;
+ }
+ },
+ });
+ }
+ return addonListener;
+};
+
+this.management = class extends ExtensionAPI {
+ 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 = GlobalManager.extensionMap.get(addon.id);
+ 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 = GlobalManager.extensionMap.get(addon.id);
+ 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 =
+ promptService.BUTTON_POS_0 *
+ promptService.BUTTON_TITLE_IS_STRING +
+ promptService.BUTTON_POS_1 * promptService.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,
+ name: "management.onDisabled",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.async(data);
+ };
+
+ getManagementListener(extension, context).on(
+ "onDisabled",
+ listener
+ );
+ return () => {
+ getManagementListener(extension, context).off(
+ "onDisabled",
+ listener
+ );
+ };
+ },
+ }).api(),
+
+ onEnabled: new EventManager({
+ context,
+ name: "management.onEnabled",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.async(data);
+ };
+
+ getManagementListener(extension, context).on("onEnabled", listener);
+ return () => {
+ getManagementListener(extension, context).off(
+ "onEnabled",
+ listener
+ );
+ };
+ },
+ }).api(),
+
+ onInstalled: new EventManager({
+ context,
+ name: "management.onInstalled",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.async(data);
+ };
+
+ getManagementListener(extension, context).on(
+ "onInstalled",
+ listener
+ );
+ return () => {
+ getManagementListener(extension, context).off(
+ "onInstalled",
+ listener
+ );
+ };
+ },
+ }).api(),
+
+ onUninstalled: new EventManager({
+ context,
+ name: "management.onUninstalled",
+ register: fire => {
+ let listener = (event, data) => {
+ fire.async(data);
+ };
+
+ getManagementListener(extension, context).on(
+ "onUninstalled",
+ listener
+ );
+ return () => {
+ getManagementListener(extension, context).off(
+ "onUninstalled",
+ listener
+ );
+ };
+ },
+ }).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..e0733f9819
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-networkStatus.js
@@ -0,0 +1,89 @@
+/* -*- 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_2G:
+ return "2g";
+ case gNetworkLinkService.LINK_TYPE_3G:
+ return "3g";
+ case gNetworkLinkService.LINK_TYPE_4G:
+ return "4g";
+ 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..68c50d2c43
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-notifications.js
@@ -0,0 +1,190 @@
+/* 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.defineModuleGetter(
+ ToolkitModules,
+ "EventEmitter",
+ "resource://gre/modules/EventEmitter.jsm"
+);
+
+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..cfb2276c1b
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-permissions.js
@@ -0,0 +1,173 @@
+/* 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.defineLazyModuleGetters(this, {
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+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 ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ permissions: {
+ async request(perms) {
+ let { permissions, origins } = perms;
+
+ let manifestPermissions =
+ context.extension.manifest.optional_permissions;
+ for (let perm of permissions) {
+ if (!manifestPermissions.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 optional_permissions`
+ );
+ }
+ }
+
+ 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,
+ icon: context.extension.iconURL,
+ 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,
+ name: "permissions.onAdded",
+ register: fire => {
+ 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 () => {
+ extensions.off("change-permissions", callback);
+ };
+ },
+ }).api(),
+
+ onRemoved: new EventManager({
+ context,
+ name: "permissions.onRemoved",
+ register: fire => {
+ 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 () => {
+ extensions.off("change-permissions", callback);
+ };
+ },
+ }).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..94f0763a1f
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-privacy.js
@@ -0,0 +1,496 @@
+/* -*- 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.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+
+var { ExtensionPreferencesManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+
+var { ExtensionError } = ExtensionUtils;
+var { getSettingsAPI } = 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,
+ };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.httpsOnlyMode", {
+ permission: "privacy",
+ prefNames: [
+ "dom.security.https_only_mode",
+ "dom.security.https_only_mode_pbm",
+ ],
+
+ 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;
+ },
+});
+
+ExtensionPreferencesManager.addSetting("network.peerConnectionEnabled", {
+ permission: "privacy",
+ prefNames: ["media.peerconnection.enabled"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+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;
+ },
+});
+
+ExtensionPreferencesManager.addSetting("services.passwordSavingEnabled", {
+ permission: "privacy",
+ prefNames: ["signon.rememberSignons"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.cookieConfig", {
+ permission: "privacy",
+ prefNames: ["network.cookie.cookieBehavior", "network.cookie.lifetimePolicy"],
+
+ 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`
+ );
+ }
+ return {
+ "network.cookie.cookieBehavior": cookieBehavior,
+ "network.cookie.lifetimePolicy": value.nonPersistentCookies
+ ? cookieSvc.ACCEPT_SESSION
+ : cookieSvc.ACCEPT_NORMALLY,
+ };
+ },
+});
+
+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 };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.hyperlinkAuditingEnabled", {
+ permission: "privacy",
+ prefNames: ["browser.send_pings"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+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 };
+ },
+});
+
+ExtensionPreferencesManager.addSetting("websites.resistFingerprinting", {
+ permission: "privacy",
+ prefNames: ["privacy.resistFingerprinting"],
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+});
+
+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;
+ },
+});
+
+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;
+ },
+});
+
+this.privacy = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ privacy: {
+ network: {
+ networkPredictionEnabled: getSettingsAPI({
+ context,
+ name: "network.networkPredictionEnabled",
+ callback() {
+ return (
+ getBoolPref("network.predictor.enabled") &&
+ getBoolPref("network.prefetch-next") &&
+ getIntPref("network.http.speculative-parallel-limit") > 0 &&
+ !getBoolPref("network.dns.disablePrefetch")
+ );
+ },
+ }),
+ httpsOnlyMode: getSettingsAPI({
+ context,
+ name: "network.httpsOnlyMode",
+ callback() {
+ if (getBoolPref("dom.security.https_only_mode")) {
+ return "always";
+ }
+ if (getBoolPref("dom.security.https_only_mode_pbm")) {
+ return "private_browsing";
+ }
+ return "never";
+ },
+ readOnly: true,
+ }),
+ peerConnectionEnabled: getSettingsAPI({
+ context,
+ name: "network.peerConnectionEnabled",
+ callback() {
+ return getBoolPref("media.peerconnection.enabled");
+ },
+ }),
+ webRTCIPHandlingPolicy: getSettingsAPI({
+ context,
+ name: "network.webRTCIPHandlingPolicy",
+ callback() {
+ 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";
+ },
+ }),
+ tlsVersionRestriction: getSettingsAPI({
+ context,
+ name: "network.tlsVersionRestriction",
+ callback() {
+ 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() {
+ if (!context.extension.isPrivileged) {
+ throw new ExtensionError(
+ "tlsVersionRestriction can be set by privileged extensions only."
+ );
+ }
+ },
+ }),
+ },
+
+ services: {
+ passwordSavingEnabled: getSettingsAPI({
+ context,
+ name: "services.passwordSavingEnabled",
+ callback() {
+ return getBoolPref("signon.rememberSignons");
+ },
+ }),
+ },
+
+ websites: {
+ cookieConfig: getSettingsAPI({
+ context,
+ name: "websites.cookieConfig",
+ callback() {
+ let prefValue = getIntPref("network.cookie.cookieBehavior");
+ return {
+ behavior: Array.from(cookieBehaviorValues.entries()).find(
+ entry => entry[1] === prefValue
+ )[0],
+ nonPersistentCookies:
+ getIntPref("network.cookie.lifetimePolicy") ===
+ cookieSvc.ACCEPT_SESSION,
+ };
+ },
+ }),
+ firstPartyIsolate: getSettingsAPI({
+ context,
+ name: "websites.firstPartyIsolate",
+ callback() {
+ return getBoolPref("privacy.firstparty.isolate");
+ },
+ }),
+ hyperlinkAuditingEnabled: getSettingsAPI({
+ context,
+ name: "websites.hyperlinkAuditingEnabled",
+ callback() {
+ return getBoolPref("browser.send_pings");
+ },
+ }),
+ referrersEnabled: getSettingsAPI({
+ context,
+ name: "websites.referrersEnabled",
+ callback() {
+ return getIntPref("network.http.sendRefererHeader") !== 0;
+ },
+ }),
+ resistFingerprinting: getSettingsAPI({
+ context,
+ name: "websites.resistFingerprinting",
+ callback() {
+ return getBoolPref("privacy.resistFingerprinting");
+ },
+ }),
+ trackingProtectionMode: getSettingsAPI({
+ context,
+ name: "websites.trackingProtectionMode",
+ callback() {
+ if (getBoolPref("privacy.trackingprotection.enabled")) {
+ return "always";
+ } else if (
+ getBoolPref("privacy.trackingprotection.pbmode.enabled")
+ ) {
+ return "private_browsing";
+ }
+ return "never";
+ },
+ }),
+ },
+ },
+ };
+ }
+};
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..acb0ba3675
--- /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.defineModuleGetter(
+ this,
+ "ProxyChannelFilter",
+ "resource://gre/modules/ProxyChannelFilter.jsm"
+);
+var { ExtensionPreferencesManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+
+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],
+ ["ftp", 21],
+ ["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.ftp",
+ "network.proxy.ftp_port",
+ "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", "ftp", "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 => {
+ 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 ExtensionAPI {
+ primeListener(extension, event, fire, params) {
+ if (event === "onRequest") {
+ return registerProxyFilterEvent(undefined, extension, fire, ...params);
+ }
+ }
+
+ getAPI(context) {
+ let { extension } = context;
+
+ return {
+ proxy: {
+ onRequest: new EventManager({
+ context,
+ name: `proxy.onRequest`,
+ persistent: {
+ module: "proxy",
+ event: "onRequest",
+ },
+ register: (fire, filter, info) => {
+ return registerProxyFilterEvent(
+ context,
+ context.extension,
+ fire,
+ filter,
+ info
+ ).unregister;
+ },
+ }).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", "ftp", "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.
+ for (let prop of ["ftp", "ssl"]) {
+ value[prop] = value.http;
+ }
+ }
+
+ for (let prop of ["http", "ftp", "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..96892c1655
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-runtime.js
@@ -0,0 +1,178 @@
+/* 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.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManagerPrivate",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "DevToolsShim",
+ "chrome://devtools-startup/content/DevToolsShim.jsm"
+);
+
+this.runtime = class extends ExtensionAPI {
+ getAPI(context) {
+ let { extension } = context;
+ return {
+ runtime: {
+ onStartup: new EventManager({
+ context,
+ name: "runtime.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 = () => fire.sync();
+ extension.on("background-page-started", listener);
+ return () => {
+ extension.off("background-page-started", listener);
+ };
+ },
+ }).api(),
+
+ onInstalled: new EventManager({
+ context,
+ name: "runtime.onInstalled",
+ register: fire => {
+ 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-page-started", listener);
+ return () => {
+ extension.off("background-page-started", listener);
+ };
+ },
+ }).api(),
+
+ onUpdateAvailable: new EventManager({
+ context,
+ name: "runtime.onUpdateAvailable",
+ register: fire => {
+ let instanceID = extension.addonData.instanceID;
+ AddonManager.addUpgradeListener(instanceID, upgrade => {
+ extension.upgrade = upgrade;
+ let details = {
+ version: upgrade.version,
+ };
+ fire.sync(details);
+ });
+ return () => {
+ AddonManager.removeUpgradeListener(instanceID);
+ };
+ },
+ }).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();
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-storage.js b/toolkit/components/extensions/parent/ext-storage.js
new file mode 100644
index 0000000000..ac75e9d6d1
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-storage.js
@@ -0,0 +1,228 @@
+/* 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.defineLazyModuleGetters(this, {
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
+ ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+ ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
+ NativeManifests: "resource://gre/modules/NativeManifests.jsm",
+});
+
+var { ExtensionError } = ExtensionUtils;
+
+XPCOMUtils.defineLazyGetter(this, "extensionStorageSync", () => {
+ let url = Services.prefs.getBoolPref("webextensions.storage.sync.kinto")
+ ? "resource://gre/modules/ExtensionStorageSyncKinto.jsm"
+ : "resource://gre/modules/ExtensionStorageSync.jsm";
+
+ const { extensionStorageSync } = ChromeUtils.import(url, {});
+ 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<SerializableMap?>]
+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 ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+
+ const messageName = `Extension:StorageLocalOnChanged:${extension.uuid}`;
+ Services.ppmm.addMessageListener(messageName, this);
+ this.clearStorageChangedListener = () => {
+ Services.ppmm.removeMessageListener(messageName, this);
+ };
+ }
+
+ 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);
+ },
+ },
+ },
+
+ 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);
+ },
+ },
+
+ 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(data, keys);
+ },
+ },
+
+ onChanged: new EventManager({
+ context,
+ name: "storage.onChanged",
+ register: fire => {
+ let listenerLocal = changes => {
+ fire.raw(changes, "local");
+ };
+ let listenerSync = changes => {
+ fire.async(changes, "sync");
+ };
+
+ ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
+ ExtensionStorageIDB.addOnChangedListener(
+ extension.id,
+ listenerLocal
+ );
+ extensionStorageSync.addOnChangedListener(
+ extension,
+ listenerSync,
+ context
+ );
+ return () => {
+ ExtensionStorage.removeOnChangedListener(
+ extension.id,
+ listenerLocal
+ );
+ ExtensionStorageIDB.removeOnChangedListener(
+ extension.id,
+ listenerLocal
+ );
+ extensionStorageSync.removeOnChangedListener(
+ extension,
+ listenerSync
+ );
+ };
+ },
+ }).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..20fc92601e
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-tabs-base.js
@@ -0,0 +1,2332 @@
+/* -*- 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 */
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+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}`);
+ }
+ }
+
+ /**
+ * Sends a message, via the given context, to the ExtensionContent running in
+ * this tab. The tab's current innerWindowID is automatically added to the
+ * recipient filter for the message, and is used to ensure that the message is
+ * not processed if the content process navigates to a different content page
+ * before the message is received.
+ *
+ * @param {BaseContext} context
+ * The context through which to send the message.
+ * @param {string} messageName
+ * The name of the message to send.
+ * @param {object} [data = {}]
+ * Arbitrary, structured-clonable message data to send.
+ * @param {object} [options]
+ * An options object, as accepted by BaseContext.sendMessage.
+ *
+ * @returns {Promise}
+ */
+ sendMessage(context, messageName, data = {}, options = null) {
+ let { browser, innerWindowID } = this;
+
+ options = Object.assign({}, options);
+ options.recipient = Object.assign({ innerWindowID }, options.recipient);
+
+ return context.sendMessage(
+ browser.messageManager,
+ messageName,
+ data,
+ options
+ );
+ }
+
+ /**
+ * 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<string>}
+ */
+ 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);
+
+ let wgp = this.browsingContext.currentWindowGlobal;
+ let image = await wgp.drawSnapshot(rect, scale * zoom, "white");
+
+ 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.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 {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 {boolean} selected
+ * An alias for `active`.
+ * @readonly
+ * @abstract
+ */
+ get selected() {
+ 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 {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",
+ "cookieStoreId",
+ "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.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,
+ 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
+ * @param {number} options.frameID
+ * @param {boolean} options.allFrames
+ * @returns {Promise[]}
+ */
+ async queryContent(message, options) {
+ let { allFrames, frameID } = options;
+
+ /** @type {Map<nsIDOMProcessParent, innerWindowId[]>} */
+ let byProcess = new DefaultMap(() => []);
+
+ // Recursively walk the tab's BC tree, find all frames, group by process.
+ function visit(bc) {
+ let win = bc.currentWindowGlobal;
+ if (win?.domProcess && (!frameID || frameID === bc.id)) {
+ byProcess.get(win.domProcess).push(win.innerWindowId);
+ }
+ if (allFrames || (frameID && !byProcess.size)) {
+ bc.children.forEach(visit);
+ }
+ }
+ visit(this.browsingContext);
+
+ 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] || "<anonymous code>";
+ let message = `Script '${fileName}' result is non-structured-clonable data`;
+ return Promise.reject({ message, fileName });
+ }
+ throw err;
+ });
+ results = results.flat();
+
+ if (!results.length) {
+ if (frameID) {
+ throw new ExtensionError("Frame not found, or missing host permission");
+ }
+
+ let frames = allFrames ? ", and any iframes" : "";
+ throw new ExtensionError(`Missing host permission for the tab${frames}`);
+ }
+
+ if (!allFrames && 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 = details.allFrames;
+ }
+ if (details.frameId !== null) {
+ options.frameID = details.frameId;
+ }
+ 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;
+ 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<TabBase>}
+ */
+ getTabs() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns an iterator of TabBase objects for each highlighted tab in this window.
+ *
+ * @returns {Iterator<TabBase>}
+ */
+ 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 <browser> belongs to.
+ *
+ * @typedef {Object} BrowserData
+ * @property {integer} tabId
+ * The numeric ID of the tab that a <browser> belongs to, or -1 if it
+ * does not belong to a tab.
+ * @property {integer} windowId
+ * The numeric ID of the browser window that a <browser> 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)} 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<DOMWindow>}
+ */
+ /* 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)} 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)} 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)} 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)} 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) {
+ if (this.extension.hasPermission("activeTab")) {
+ // 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.
+ let tab = this.getWrapper(nativeTab);
+ 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;
+ }
+
+ /**
+ * 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<TabBase>}
+ */
+ *query(queryInfo = null, context = null) {
+ if (queryInfo) {
+ if (queryInfo.url !== null) {
+ queryInfo.url = parseMatchPatterns([].concat(queryInfo.url), {
+ restrictSchemes: false,
+ });
+ }
+
+ 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) {
+ yield windowWrapper.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} id
+ * 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<WindowBase>}
+ */
+ *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} id
+ * 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<WindowBase>}
+ * @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}`
+ );
+ }
+ 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..6c87e79ed8
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-telemetry.js
@@ -0,0 +1,211 @@
+/* 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.defineModuleGetter(
+ this,
+ "TelemetryController",
+ "resource://gre/modules/TelemetryController.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "TelemetryUtils",
+ "resource://gre/modules/TelemetryUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Services",
+ "resource://gre/modules/Services.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionUtils",
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+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..84433910da
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-theme.js
@@ -0,0 +1,507 @@
+/* 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 */
+
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "LightweightThemeManager",
+ "resource://gre/modules/LightweightThemeManager.jsm"
+);
+
+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 {string} extension Extension that created the theme.
+ * @param {Integer} windowId The windowId where the theme is applied.
+ */
+ 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.experimentsAllowed) {
+ this.lwtStyles.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.
+ *
+ * @param {Object} details Theme part of the manifest. Supported
+ * properties can be found in the schema under ThemeType.
+ */
+ 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_separator":
+ 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_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;
+ }
+ 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 ExtensionAPI {
+ 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,
+ name: "theme.onUpdated",
+ register: fire => {
+ 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 () => {
+ onUpdatedEmitter.off("theme-updated", callback);
+ };
+ },
+ }).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..15b4c5c7e5
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-toolkit.js
@@ -0,0 +1,100 @@
+/* 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.defineModuleGetter(
+ this,
+ "ContextualIdentityService",
+ "resource://gre/modules/ContextualIdentityService.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+var { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+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 (ContextualIdentityService.getPublicIdentityFromId(containerId)) {
+ return parseInt(containerId, 10);
+ }
+
+ return null;
+};
+
+global.isValidCookieStoreId = function(storeId) {
+ return (
+ isDefaultCookieStoreId(storeId) ||
+ isPrivateCookieStoreId(storeId) ||
+ isContainerCookieStoreId(storeId)
+ );
+};
diff --git a/toolkit/components/extensions/parent/ext-userScripts.js b/toolkit/components/extensions/parent/ext-userScripts.js
new file mode 100644
index 0000000000..70255d9ae7
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-userScripts.js
@@ -0,0 +1,148 @@
+/* -*- 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.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+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,
+ },
+ };
+
+ return options;
+ }
+
+ serialize() {
+ return this.options;
+ }
+}
+
+this.userScripts = class extends ExtensionAPI {
+ constructor(...args) {
+ super(...args);
+
+ // Map<scriptId -> 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);
+
+ const scriptOptions = userScript.serialize();
+
+ await extension.broadcast("Extension:RegisterContentScript", {
+ id: extension.id,
+ options: scriptOptions,
+ scriptId,
+ });
+
+ extension.registeredContentScripts.set(scriptId, scriptOptions);
+ extension.updateContentScripts();
+
+ 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..4a0c1ea275
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-webNavigation.js
@@ -0,0 +1,294 @@
+/* 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.defineModuleGetter(
+ this,
+ "MatchURLFilters",
+ "resource://gre/modules/MatchURLFilters.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "WebNavigation",
+ "resource://gre/modules/WebNavigation.jsm"
+);
+
+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;
+ }
+};
+
+// Similar to WebRequestEventManager but for WebNavigation.
+class WebNavigationEventManager extends EventManager {
+ constructor(context, eventName) {
+ let name = `webNavigation.${eventName}`;
+ let register = (fire, urlFilters) => {
+ // Don't create a MatchURLFilters instance if the listener does not include any filter.
+ let filters = urlFilters ? new MatchURLFilters(urlFilters.url) : null;
+
+ let listener = data => {
+ if (!data.browser) {
+ return;
+ }
+
+ let data2 = {
+ url: data.url,
+ timeStamp: Date.now(),
+ };
+
+ if (eventName == "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;
+ }
+
+ if (data.sourceTabBrowser) {
+ data2.sourceTabId = tabTracker.getBrowserData(
+ data.sourceTabBrowser
+ ).tabId;
+ }
+
+ fillTransitionProperties(eventName, data, data2);
+
+ fire.async(data2);
+ };
+
+ WebNavigation[eventName].addListener(listener, filters, context);
+ return () => {
+ WebNavigation[eventName].removeListener(listener);
+ };
+ };
+
+ super({ context, name, register });
+ }
+}
+
+const convertGetFrameResult = (tabId, data) => {
+ return {
+ errorOccurred: data.errorOccurred,
+ url: data.url,
+ tabId,
+ frameId: data.frameId,
+ parentFrameId: data.parentFrameId,
+ };
+};
+
+this.webNavigation = class extends ExtensionAPI {
+ getAPI(context) {
+ let { tabManager } = context.extension;
+
+ return {
+ webNavigation: {
+ onTabReplaced: new EventManager({
+ context,
+ name: "webNavigation.onTabReplaced",
+ register: fire => {
+ return () => {};
+ },
+ }).api(),
+ onBeforeNavigate: new WebNavigationEventManager(
+ context,
+ "onBeforeNavigate"
+ ).api(),
+ onCommitted: new WebNavigationEventManager(
+ context,
+ "onCommitted"
+ ).api(),
+ onDOMContentLoaded: new WebNavigationEventManager(
+ context,
+ "onDOMContentLoaded"
+ ).api(),
+ onCompleted: new WebNavigationEventManager(
+ context,
+ "onCompleted"
+ ).api(),
+ onErrorOccurred: new WebNavigationEventManager(
+ context,
+ "onErrorOccurred"
+ ).api(),
+ onReferenceFragmentUpdated: new WebNavigationEventManager(
+ context,
+ "onReferenceFragmentUpdated"
+ ).api(),
+ onHistoryStateUpdated: new WebNavigationEventManager(
+ context,
+ "onHistoryStateUpdated"
+ ).api(),
+ onCreatedNavigationTarget: new WebNavigationEventManager(
+ context,
+ "onCreatedNavigationTarget"
+ ).api(),
+ getAllFrames(details) {
+ let tab = tabManager.get(details.tabId);
+
+ try {
+ if (tab.discarded) {
+ return null;
+ }
+ } catch (e) {
+ // accessing the tab.discarded getter may reject if not implemented
+ // on the current platform.
+ }
+
+ let { innerWindowID, messageManager } = tab.browser;
+ let recipient = { innerWindowID };
+
+ return context
+ .sendMessage(
+ messageManager,
+ "WebNavigation:GetAllFrames",
+ {},
+ { recipient }
+ )
+ .then(results =>
+ results.map(convertGetFrameResult.bind(null, details.tabId))
+ );
+ },
+ getFrame(details) {
+ let tab = tabManager.get(details.tabId);
+
+ try {
+ if (tab.discarded) {
+ return null;
+ }
+ } catch (e) {
+ // accessing the tab.discarded getter may reject if not implemented
+ // on the current platform.
+ }
+
+ let recipient = {
+ innerWindowID: tab.browser.innerWindowID,
+ };
+
+ let mm = tab.browser.messageManager;
+ return context
+ .sendMessage(
+ mm,
+ "WebNavigation:GetFrame",
+ { options: details },
+ { recipient }
+ )
+ .then(result => {
+ return result
+ ? convertGetFrameResult(details.tabId, result)
+ : Promise.reject({
+ message: `No frame found with frameId: ${details.frameId}`,
+ });
+ });
+ },
+ },
+ };
+ }
+};
diff --git a/toolkit/components/extensions/parent/ext-webRequest.js b/toolkit/components/extensions/parent/ext-webRequest.js
new file mode 100644
index 0000000000..b139326f26
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-webRequest.js
@@ -0,0 +1,162 @@
+/* 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.defineModuleGetter(
+ this,
+ "WebRequest",
+ "resource://gre/modules/WebRequest.jsm"
+);
+
+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 makeWebRequestEvent(context, name) {
+ return new EventManager({
+ context,
+ name: `webRequest.${name}`,
+ persistent: {
+ module: "webRequest",
+ event: name,
+ },
+ register: (fire, filter, info) => {
+ return registerEvent(
+ context.extension,
+ name,
+ fire,
+ filter,
+ info,
+ context.xulBrowser.frameLoader.remoteTab
+ ).unregister;
+ },
+ }).api();
+}
+
+this.webRequest = class extends ExtensionAPI {
+ primeListener(extension, event, fire, params) {
+ return registerEvent(extension, event, fire, ...params);
+ }
+
+ getAPI(context) {
+ return {
+ webRequest: {
+ onBeforeRequest: makeWebRequestEvent(context, "onBeforeRequest"),
+ onBeforeSendHeaders: makeWebRequestEvent(
+ context,
+ "onBeforeSendHeaders"
+ ),
+ onSendHeaders: makeWebRequestEvent(context, "onSendHeaders"),
+ onHeadersReceived: makeWebRequestEvent(context, "onHeadersReceived"),
+ onAuthRequired: makeWebRequestEvent(context, "onAuthRequired"),
+ onBeforeRedirect: makeWebRequestEvent(context, "onBeforeRedirect"),
+ onResponseStarted: makeWebRequestEvent(context, "onResponseStarted"),
+ onErrorOccurred: makeWebRequestEvent(context, "onErrorOccurred"),
+ onCompleted: makeWebRequestEvent(context, "onCompleted"),
+ 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/profiler_get_symbols.js b/toolkit/components/extensions/profiler_get_symbols.js
new file mode 100644
index 0000000000..5f498a5a73
--- /dev/null
+++ b/toolkit/components/extensions/profiler_get_symbols.js
@@ -0,0 +1,426 @@
+/* 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 AUTOGENERATED by wasm-bindgen.
+//
+// Generated from:
+// https://github.com/mstange/profiler-get-symbols/commit/90ee39f1d18d2727f07dc57bd93cff6bc73ce8a0
+// by following the instructions in that repository's Readme.md
+//
+
+(function() {
+ const __exports = {};
+ let wasm;
+
+ let cachegetInt32Memory = null;
+ function getInt32Memory() {
+ if (cachegetInt32Memory === null || cachegetInt32Memory.buffer !== wasm.memory.buffer) {
+ cachegetInt32Memory = new Int32Array(wasm.memory.buffer);
+ }
+ return cachegetInt32Memory;
+ }
+
+ let cachegetUint32Memory = null;
+ function getUint32Memory() {
+ if (cachegetUint32Memory === null || cachegetUint32Memory.buffer !== wasm.memory.buffer) {
+ cachegetUint32Memory = new Uint32Array(wasm.memory.buffer);
+ }
+ return cachegetUint32Memory;
+ }
+
+ function getArrayU32FromWasm(ptr, len) {
+ return getUint32Memory().subarray(ptr / 4, ptr / 4 + len);
+ }
+
+ let cachegetUint8Memory = null;
+ function getUint8Memory() {
+ if (cachegetUint8Memory === null || cachegetUint8Memory.buffer !== wasm.memory.buffer) {
+ cachegetUint8Memory = new Uint8Array(wasm.memory.buffer);
+ }
+ return cachegetUint8Memory;
+ }
+
+ function getArrayU8FromWasm(ptr, len) {
+ return getUint8Memory().subarray(ptr / 1, ptr / 1 + len);
+ }
+
+ const heap = new Array(32);
+
+ heap.fill(undefined);
+
+ heap.push(undefined, null, true, false);
+
+ let stack_pointer = 32;
+
+ function addBorrowedObject(obj) {
+ if (stack_pointer == 1) throw new Error('out of js stack');
+ heap[--stack_pointer] = obj;
+ return stack_pointer;
+ }
+
+ function _assertClass(instance, klass) {
+ if (!(instance instanceof klass)) {
+ throw new Error(`expected instance of ${klass.name}`);
+ }
+ return instance.ptr;
+ }
+
+ let WASM_VECTOR_LEN = 0;
+
+ let cachedTextEncoder = new TextEncoder('utf-8');
+
+ let passStringToWasm;
+ if (typeof cachedTextEncoder.encodeInto === 'function') {
+ passStringToWasm = function(arg) {
+
+
+ let size = arg.length;
+ let ptr = wasm.__wbindgen_malloc(size);
+ let offset = 0;
+ {
+ const mem = getUint8Memory();
+ for (; offset < arg.length; offset++) {
+ const code = arg.charCodeAt(offset);
+ if (code > 0x7F) break;
+ mem[ptr + offset] = code;
+ }
+ }
+
+ if (offset !== arg.length) {
+ arg = arg.slice(offset);
+ ptr = wasm.__wbindgen_realloc(ptr, size, size = offset + arg.length * 3);
+ const view = getUint8Memory().subarray(ptr + offset, ptr + size);
+ const ret = cachedTextEncoder.encodeInto(arg, view);
+
+ offset += ret.written;
+ }
+ WASM_VECTOR_LEN = offset;
+ return ptr;
+ };
+ } else {
+ passStringToWasm = function(arg) {
+
+
+ let size = arg.length;
+ let ptr = wasm.__wbindgen_malloc(size);
+ let offset = 0;
+ {
+ const mem = getUint8Memory();
+ for (; offset < arg.length; offset++) {
+ const code = arg.charCodeAt(offset);
+ if (code > 0x7F) break;
+ mem[ptr + offset] = code;
+ }
+ }
+
+ if (offset !== arg.length) {
+ const buf = cachedTextEncoder.encode(arg.slice(offset));
+ ptr = wasm.__wbindgen_realloc(ptr, size, size = offset + buf.length);
+ getUint8Memory().set(buf, ptr + offset);
+ offset += buf.length;
+ }
+ WASM_VECTOR_LEN = offset;
+ return ptr;
+ };
+ }
+ /**
+ * @param {WasmMemBuffer} binary_data
+ * @param {WasmMemBuffer} debug_data
+ * @param {string} breakpad_id
+ * @returns {CompactSymbolTable}
+ */
+ __exports.get_compact_symbol_table = function(binary_data, debug_data, breakpad_id) {
+ _assertClass(binary_data, WasmMemBuffer);
+ _assertClass(debug_data, WasmMemBuffer);
+ const ret = wasm.get_compact_symbol_table(binary_data.ptr, debug_data.ptr, passStringToWasm(breakpad_id), WASM_VECTOR_LEN);
+ return CompactSymbolTable.__wrap(ret);
+ };
+
+function getObject(idx) { return heap[idx]; }
+
+function debugString(val) {
+ // primitive types
+ const type = typeof val;
+ if (type == 'number' || type == 'boolean' || val == null) {
+ return `${val}`;
+ }
+ if (type == 'string') {
+ return `"${val}"`;
+ }
+ if (type == 'symbol') {
+ const description = val.description;
+ if (description == null) {
+ return 'Symbol';
+ } else {
+ return `Symbol(${description})`;
+ }
+ }
+ if (type == 'function') {
+ const name = val.name;
+ if (typeof name == 'string' && name.length > 0) {
+ return `Function(${name})`;
+ } else {
+ return 'Function';
+ }
+ }
+ // objects
+ if (Array.isArray(val)) {
+ const length = val.length;
+ let debug = '[';
+ if (length > 0) {
+ debug += debugString(val[0]);
+ }
+ for(let i = 1; i < length; i++) {
+ debug += ', ' + debugString(val[i]);
+ }
+ debug += ']';
+ return debug;
+ }
+ // Test for built-in
+ const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
+ let className;
+ if (builtInMatches.length > 1) {
+ className = builtInMatches[1];
+ } else {
+ // Failed to match the standard '[object ClassName]'
+ return toString.call(val);
+ }
+ if (className == 'Object') {
+ // we're a user defined class or Object
+ // JSON.stringify avoids problems with cycles, and is generally much
+ // easier than looping through ownProperties of `val`.
+ try {
+ return 'Object(' + JSON.stringify(val) + ')';
+ } catch (_) {
+ return 'Object';
+ }
+ }
+ // errors
+ if (val instanceof Error) {
+ return `${val.name}: ${val.message}\n${val.stack}`;
+ }
+ // TODO we could test for more things here, like `Set`s and `Map`s.
+ return className;
+}
+
+let cachedTextDecoder = new TextDecoder('utf-8');
+
+function getStringFromWasm(ptr, len) {
+ return cachedTextDecoder.decode(getUint8Memory().subarray(ptr, ptr + len));
+}
+
+let heap_next = heap.length;
+
+function dropObject(idx) {
+ if (idx < 36) return;
+ heap[idx] = heap_next;
+ heap_next = idx;
+}
+
+function takeObject(idx) {
+ const ret = getObject(idx);
+ dropObject(idx);
+ return ret;
+}
+
+function addHeapObject(obj) {
+ if (heap_next === heap.length) heap.push(heap.length + 1);
+ const idx = heap_next;
+ heap_next = heap[idx];
+
+ heap[idx] = obj;
+ return idx;
+}
+
+function handleError(e) {
+ wasm.__wbindgen_exn_store(addHeapObject(e));
+}
+/**
+*/
+class CompactSymbolTable {
+
+ static __wrap(ptr) {
+ const obj = Object.create(CompactSymbolTable.prototype);
+ obj.ptr = ptr;
+
+ return obj;
+ }
+
+ free() {
+ const ptr = this.ptr;
+ this.ptr = 0;
+
+ wasm.__wbg_compactsymboltable_free(ptr);
+ }
+ /**
+ * @returns {CompactSymbolTable}
+ */
+ constructor() {
+ const ret = wasm.compactsymboltable_new();
+ return CompactSymbolTable.__wrap(ret);
+ }
+ /**
+ * @returns {Uint32Array}
+ */
+ take_addr() {
+ const retptr = 8;
+ const ret = wasm.compactsymboltable_take_addr(retptr, this.ptr);
+ const memi32 = getInt32Memory();
+ const v0 = getArrayU32FromWasm(memi32[retptr / 4 + 0], memi32[retptr / 4 + 1]).slice();
+ wasm.__wbindgen_free(memi32[retptr / 4 + 0], memi32[retptr / 4 + 1] * 4);
+ return v0;
+ }
+ /**
+ * @returns {Uint32Array}
+ */
+ take_index() {
+ const retptr = 8;
+ const ret = wasm.compactsymboltable_take_index(retptr, this.ptr);
+ const memi32 = getInt32Memory();
+ const v0 = getArrayU32FromWasm(memi32[retptr / 4 + 0], memi32[retptr / 4 + 1]).slice();
+ wasm.__wbindgen_free(memi32[retptr / 4 + 0], memi32[retptr / 4 + 1] * 4);
+ return v0;
+ }
+ /**
+ * @returns {Uint8Array}
+ */
+ take_buffer() {
+ const retptr = 8;
+ const ret = wasm.compactsymboltable_take_buffer(retptr, this.ptr);
+ const memi32 = getInt32Memory();
+ const v0 = getArrayU8FromWasm(memi32[retptr / 4 + 0], memi32[retptr / 4 + 1]).slice();
+ wasm.__wbindgen_free(memi32[retptr / 4 + 0], memi32[retptr / 4 + 1] * 1);
+ return v0;
+ }
+}
+__exports.CompactSymbolTable = CompactSymbolTable;
+/**
+* WasmMemBuffer lets you allocate a chunk of memory on the wasm heap and
+* directly initialize it from JS without a copy. The constructor takes the
+* allocation size and a callback function which does the initialization.
+* This is useful if you need to get very large amounts of data from JS into
+* wasm (for example, the contents of a 1.7GB libxul.so).
+*/
+class WasmMemBuffer {
+
+ static __wrap(ptr) {
+ const obj = Object.create(WasmMemBuffer.prototype);
+ obj.ptr = ptr;
+
+ return obj;
+ }
+
+ free() {
+ const ptr = this.ptr;
+ this.ptr = 0;
+
+ wasm.__wbg_wasmmembuffer_free(ptr);
+ }
+ /**
+ * Create the buffer and initialize it synchronously in the callback function.
+ * f is called with one argument: the Uint8Array that wraps our buffer.
+ * f should not return anything; its return value is ignored.
+ * f must not call any exported wasm functions! Anything that causes the
+ * wasm heap to resize will invalidate the typed array\'s internal buffer!
+ * Do not hold on to the array that is passed to f after f completes.
+ * @param {number} byte_length
+ * @param {any} f
+ * @returns {WasmMemBuffer}
+ */
+ constructor(byte_length, f) {
+ try {
+ const ret = wasm.wasmmembuffer_new(byte_length, addBorrowedObject(f));
+ return WasmMemBuffer.__wrap(ret);
+ } finally {
+ heap[stack_pointer++] = undefined;
+ }
+ }
+}
+__exports.WasmMemBuffer = WasmMemBuffer;
+
+function init(module) {
+
+ let result;
+ const imports = {};
+ imports.wbg = {};
+ imports.wbg.__wbindgen_debug_string = function(arg0, arg1) {
+ const ret = debugString(getObject(arg1));
+ const ret0 = passStringToWasm(ret);
+ const ret1 = WASM_VECTOR_LEN;
+ getInt32Memory()[arg0 / 4 + 0] = ret0;
+ getInt32Memory()[arg0 / 4 + 1] = ret1;
+ };
+ imports.wbg.__wbindgen_throw = function(arg0, arg1) {
+ throw new Error(getStringFromWasm(arg0, arg1));
+ };
+ imports.wbg.__wbindgen_rethrow = function(arg0) {
+ throw takeObject(arg0);
+ };
+ imports.wbg.__wbindgen_memory = function() {
+ const ret = wasm.memory;
+ return addHeapObject(ret);
+ };
+ imports.wbg.__wbg_buffer_aa8ebea80955a01a = function(arg0) {
+ const ret = getObject(arg0).buffer;
+ return addHeapObject(ret);
+ };
+ imports.wbg.__wbg_newwithbyteoffsetandlength_3e607c21646a8aef = function(arg0, arg1, arg2) {
+ const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);
+ return addHeapObject(ret);
+ };
+ imports.wbg.__wbindgen_object_drop_ref = function(arg0) {
+ takeObject(arg0);
+ };
+ imports.wbg.__wbg_call_9c879b23724d007e = function(arg0, arg1, arg2) {
+ try {
+ const ret = getObject(arg0).call(getObject(arg1), getObject(arg2));
+ return addHeapObject(ret);
+ } catch (e) {
+ handleError(e)
+ }
+ };
+ imports.wbg.__wbindgen_json_parse = function(arg0, arg1) {
+ const ret = JSON.parse(getStringFromWasm(arg0, arg1));
+ return addHeapObject(ret);
+ };
+
+ if (module instanceof URL || typeof module === 'string' || module instanceof Request) {
+
+ const response = fetch(module);
+ if (typeof WebAssembly.instantiateStreaming === 'function') {
+ result = WebAssembly.instantiateStreaming(response, imports)
+ .catch(e => {
+ console.warn("`WebAssembly.instantiateStreaming` failed. Assuming this is because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
+ return response
+ .then(r => r.arrayBuffer())
+ .then(bytes => WebAssembly.instantiate(bytes, imports));
+ });
+ } else {
+ result = response
+ .then(r => r.arrayBuffer())
+ .then(bytes => WebAssembly.instantiate(bytes, imports));
+ }
+ } else {
+
+ result = WebAssembly.instantiate(module, imports)
+ .then(result => {
+ if (result instanceof WebAssembly.Instance) {
+ return { instance: result, module };
+ } else {
+ return result;
+ }
+ });
+ }
+ return result.then(({instance, module}) => {
+ wasm = instance.exports;
+ init.__wbindgen_wasm_module = module;
+
+ return wasm;
+ });
+}
+
+self.wasm_bindgen = Object.assign(init, __exports);
+
+})();
diff --git a/toolkit/components/extensions/schemas/LICENSE b/toolkit/components/extensions/schemas/LICENSE
new file mode 100644
index 0000000000..9314092fdc
--- /dev/null
+++ b/toolkit/components/extensions/schemas/LICENSE
@@ -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/activity_log.json b/toolkit/components/extensions/schemas/activity_log.json
new file mode 100644
index 0000000000..dfbb22df9c
--- /dev/null
+++ b/toolkit/components/extensions/schemas/activity_log.json
@@ -0,0 +1,87 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [{
+ "$extend": "PermissionNoPrompt",
+ "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..77eaf1ec1d
--- /dev/null
+++ b/toolkit/components/extensions/schemas/alarms.json
@@ -0,0 +1,149 @@
+// 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 file.
+
+[
+ {
+ "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..d9b93d4775
--- /dev/null
+++ b/toolkit/components/extensions/schemas/browser_action.json
@@ -0,0 +1,481 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "browser_action": {
+ "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,
+ "default": false
+ },
+ "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
+ }
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "browserAction",
+ "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:browser_action"],
+ "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 <code>canvas</code> 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 <code>[255, 0, 0, 255]</code>. Can also be a string with a CSS value, with opaque red being <code>#FF0000</code> or <code>#F00</code>.",
+ "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": "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 <b>path</b> or the <b>imageData</b> 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 <code>scale</code>, then image with size <code>scale</code> * 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 <code>scale</code>, then image with size <code>scale</code> * 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",
+ "requireUserInput": true,
+ "description": "Opens the extension popup window in the active window.",
+ "async": true,
+ "parameters": []
+ }
+ ],
+ "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
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/components/extensions/schemas/browser_settings.json b/toolkit/components/extensions/schemas/browser_settings.json
new file mode 100644
index 0000000000..cba5d47782
--- /dev/null
+++ b/toolkit/components/extensions/schemas/browser_settings.json
@@ -0,0 +1,111 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [
+ {
+ "type": "string",
+ "enum": [
+ "browserSettings"
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "browserSettings",
+ "description": "Use the <code>browser.browserSettings</code> 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."
+ }
+ ],
+ "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 <code>mouseup</code> and <code>mousedown</code>."
+ },
+ "ftpProtocolEnabled": {
+ "$ref": "types.Setting",
+ "description": "This boolean setting controls whether the FTP protocol is enabled."
+ },
+ "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 <code>normal</code>."
+ },
+ "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."
+ },
+ "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."
+ }
+ }
+ }
+]
diff --git a/toolkit/components/extensions/schemas/browsing_data.json b/toolkit/components/extensions/schemas/browsing_data.json
new file mode 100644
index 0000000000..ac00f825df
--- /dev/null
+++ b/toolkit/components/extensions/schemas/browsing_data.json
@@ -0,0 +1,423 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "browsingData"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "browsingData",
+ "description": "Use the <code>chrome.browsingData</code> 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 <code>getTime</code> method of the JavaScript <code>Date</code> 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 <em>really</em> 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 <code>false</code>.",
+ "properties": {
+ "cache": {
+ "type": "boolean",
+ "optional": true,
+ "description": "The browser's cache. Note: when removing data, this clears the <em>entire</em> 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 <code>true</code> if they are both selected to be removed and permitted to be removed, otherwise <code>false</code>."
+ },
+ "dataRemovalPermitted": {
+ "$ref": "DataTypeSet",
+ "description": "All of the types will be present in the result, with values of <code>true</code> if they are permitted to be removed (e.g., by enterprise policy) and <code>false</code> 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 (<em>not</em> 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..b6f4392d43
--- /dev/null
+++ b/toolkit/components/extensions/schemas/captive_portal.json
@@ -0,0 +1,75 @@
+[
+ {
+ "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..c1425de417
--- /dev/null
+++ b/toolkit/components/extensions/schemas/content_scripts.json
@@ -0,0 +1,87 @@
+/* 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/. */
+
+[
+ {
+ "namespace": "contentScripts",
+ "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 <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> 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 <code>false</code>."},
+ "runAt": {
+ "$ref": "extensionTypes.RunAt",
+ "optional": true,
+ "description": "The soonest that the JavaScript or CSS will be injected into the tab. Defaults to \"document_idle\"."
+ }
+ }
+ },
+ {
+ "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..17096515dc
--- /dev/null
+++ b/toolkit/components/extensions/schemas/contextual_identities.json
@@ -0,0 +1,169 @@
+/* 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/. */
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "PermissionNoPrompt",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "contextualIdentities"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "contextualIdentities",
+ "description": "Use the <code>browser.contextualIdentities</code> 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": "remove",
+ "type": "function",
+ "description": "Deletes a contetual 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..712a989c49
--- /dev/null
+++ b/toolkit/components/extensions/schemas/cookies.json
@@ -0,0 +1,239 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermissionNoPrompt",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "cookies"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "cookies",
+ "description": "Use the <code>browser.cookies</code> 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": "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."}
+ }
+ },
+ {
+ "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."}
+ }
+ },
+ {
+ "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`."}
+ }
+ },
+ {
+ "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."}
+ }
+ },
+ {
+ "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."}
+ }
+ },
+ {
+ "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."}
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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/dns.json b/toolkit/components/extensions/schemas/dns.json
new file mode 100644
index 0000000000..a2d17edbcb
--- /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..ae1e0356a9
--- /dev/null
+++ b/toolkit/components/extensions/schemas/downloads.json
@@ -0,0 +1,807 @@
+[
+ {
+ "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": "<dl><dt>file</dt><dd>The download's filename is suspicious.</dd><dt>url</dt><dd>The download's URL is known to be malicious.</dd><dt>content</dt><dd>The downloaded file is known to be malicious.</dd><dt>uncommon</dt><dd>The download's URL is not commonly downloaded and could be dangerous.</dd><dt>safe</dt><dd>The download presents no known danger to the user's computer.</dd></dl>These string constants will never change, however the set of DangerTypes may change."
+ },
+ {
+ "id": "State",
+ "type": "string",
+ "enum": [
+ "in_progress",
+ "interrupted",
+ "complete"
+ ],
+ "description": "<dl><dt>in_progress</dt><dd>The download is currently receiving data from the server.</dd><dt>interrupted</dt><dd>An error broke the connection with the file host.</dd><dt>complete</dt><dd>The download completed successfully.</dd></dl>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"
+ },
+ "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 <a href='#type-DownloadItem'>DownloadItems</a> whose <code>filename</code> or <code>url</code> 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 <a href='#type-DownloadItem'>DownloadItems</a> whose <code>filename</code> matches the given regular expression.",
+ "optional": true,
+ "type": "string"
+ },
+ "urlRegex": {
+ "description": "Limits results to <a href='#type-DownloadItem'>DownloadItems</a> whose <code>url</code> matches the given regular expression.",
+ "optional": true,
+ "type": "string"
+ },
+ "limit": {
+ "description": "Setting this integer limits the number of results. Otherwise, all matching <a href='#type-DownloadItem'>DownloadItems</a> will be returned.",
+ "optional": true,
+ "type": "integer"
+ },
+ "orderBy": {
+ "description": "Setting elements of this array to <a href='#type-DownloadItem'>DownloadItem</a> properties in order to sort the search results. For example, setting <code>orderBy='startTime'</code> sorts the <a href='#type-DownloadItem'>DownloadItems</a> by their start time in ascending order. To specify descending order, prefix <code>orderBy</code> 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"
+ },
+ "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 <code>filename</code> and <code>saveAs</code> are specified, then the Save As dialog will be displayed, pre-populated with the specified <code>filename</code>. If the download started successfully, <code>callback</code> will be called with the new <a href='#type-DownloadItem'>DownloadItem</a>'s <code>downloadId</code>. If there was an error starting the download, then <code>callback</code> will be called with <code>downloadId=undefined</code> and <a href='extension.html#property-lastError'>chrome.extension.lastError</a> 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"
+ },
+ "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 <code>browser.download.useDownloadDir</code> is set to <code>false</code>).",
+ "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 <code>name</code> and either <code>value</code> or <code>binaryValue</code>, 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 <code>true</code>, then the browser will allow downloads to proceed after encountering HTTP errors such as <code>404 Not Found</code>.",
+ "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 <a href='#type-DownloadItem'>DownloadItems</a>. Set <code>query</code> to the empty object to get all <a href='#type-DownloadItem'>DownloadItems</a>. To get a specific <a href='#type-DownloadItem'>DownloadItem</a>, set only the <code>id</code> 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 <a href='extension.html#property-lastError'>chrome.extension.lastError</a> 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 <a href='extension.html#property-lastError'>chrome.extension.lastError</a> 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 <code>callback</code> 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 <a href='#event-onCreated'>onCreated</a> 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, <a href='extension.html#property-lastError'>chrome.extension.lastError</a> 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 <a href='#type-DownloadItem'>DownloadItems</a> 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. <code>acceptDanger()</code> 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 <a href='#type-DownloadItem'>DownloadItem</a> object when a download begins.",
+ "name": "onCreated",
+ "parameters": [
+ {
+ "$ref": "DownloadItem",
+ "name": "downloadItem"
+ }
+ ],
+ "type": "function"
+ },
+ {
+ "description": "Fires with the <code>downloadId</code> when a download is erased from history.",
+ "name": "onErased",
+ "parameters": [
+ {
+ "name": "downloadId",
+ "description": "The <code>id</code> of the <a href='#type-DownloadItem'>DownloadItem</a> that was erased.",
+ "type": "integer"
+ }
+ ],
+ "type": "function"
+ },
+ {
+ "name": "onChanged",
+ "description": "When any of a <a href='#type-DownloadItem'>DownloadItem</a>'s properties except <code>bytesReceived</code> changes, this event fires with the <code>downloadId</code> and an object containing the properties that changed.",
+ "parameters": [
+ {
+ "name": "downloadDelta",
+ "type": "object",
+ "properties": {
+ "id": {
+ "description": "The <code>id</code> of the <a href='#type-DownloadItem'>DownloadItem</a> that changed.",
+ "type": "integer"
+ },
+ "url": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>url</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "filename": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>filename</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "danger": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>danger</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "mime": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>mime</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "startTime": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>startTime</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "endTime": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>endTime</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "state": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>state</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "canResume": {
+ "optional": true,
+ "$ref": "BooleanDelta"
+ },
+ "paused": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>paused</code>.",
+ "optional": true,
+ "$ref": "BooleanDelta"
+ },
+ "error": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>error</code>.",
+ "optional": true,
+ "$ref": "StringDelta"
+ },
+ "totalBytes": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>totalBytes</code>.",
+ "optional": true,
+ "$ref": "DoubleDelta"
+ },
+ "fileSize": {
+ "description": "Describes a change in a <a href='#type-DownloadItem'>DownloadItem</a>'s <code>fileSize</code>.",
+ "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..ea3cbb5d29
--- /dev/null
+++ b/toolkit/components/extensions/schemas/events.json
@@ -0,0 +1,322 @@
+// 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 file.
+
+[
+ {
+ "namespace": "events",
+ "description": "The <code>chrome.events</code> 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 <em>callback</em> 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 <em>callback</em> 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 <em>callback</em> 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 <webview> 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 <webview> 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 <webview> 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 <a href='events#filtered'>event filtering</a>. 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 <a href=\"https://github.com/google/re2/blob/master/doc/syntax.txt\">RE2 syntax</a>.",
+ "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 <a href=\"https://github.com/google/re2/blob/master/doc/syntax.txt\">RE2 syntax</a>.",
+ "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 <code>[80, 443, [1000, 1200]]</code> 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..a46c8d770a
--- /dev/null
+++ b/toolkit/components/extensions/schemas/experiments.json
@@ -0,0 +1,128 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "Permission",
+ "choices": [
+ {
+ "type": "string",
+ "pattern": "^experiments(\\.\\w+)+$"
+ }
+ ]
+ },
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "experiment_apis": {
+ "type": "object",
+ "additionalProperties": {"$ref": "experiments.ExperimentAPI"},
+ "optional": 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..7aa464d053
--- /dev/null
+++ b/toolkit/components/extensions/schemas/extension.json
@@ -0,0 +1,181 @@
+// 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 file.
+
+[
+ {
+ "namespace": "extension",
+ "allowedContexts": ["content", "devtools"],
+ "description": "The <code>browser.extension</code> 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,
+ "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 <var>undefined</var>.",
+ "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",
+ "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 <code>onRequest</code> 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..8fa20fb598
--- /dev/null
+++ b/toolkit/components/extensions/schemas/extension_protocol_handlers.json
@@ -0,0 +1,51 @@
+[
+ {
+ "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", "geo", "gopher", "im", "ipfs", "ipns", "irc", "ircs", "magnet",
+ "mailto", "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..e94ecd9e0e
--- /dev/null
+++ b/toolkit/components/extensions/schemas/extension_types.json
@@ -0,0 +1,144 @@
+// Copyright 2014 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 file.
+
+[
+ {
+ "namespace": "extensionTypes",
+ "description": "The <code>browser.extensionTypes</code> 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 <code>\"jpeg\"</code>."
+ },
+ "quality": {
+ "type": "integer",
+ "optional": true,
+ "minimum": 0,
+ "maximum": 100,
+ "description": "When format is <code>\"jpeg\"</code>, 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 <code>devicePixelRatio</code>."
+ }
+ }
+ },
+ {
+ "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.<br><br><b>Warning:</b><br>Be careful using the <code>code</code> parameter. Incorrect use of it may open your extension to <a href=\"https://en.wikipedia.org/wiki/Cross-site_scripting\">cross site scripting</a> attacks."},
+ "file": {"type": "string", "optional": true, "description": "JavaScript or CSS file to inject."},
+ "allFrames": {"type": "boolean", "optional": true, "description": "If allFrames is <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> 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 <code>false</code>."},
+ "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 <code>allFrames</code>."
+ },
+ "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..80ffb4cced
--- /dev/null
+++ b/toolkit/components/extensions/schemas/geckoProfiler.json
@@ -0,0 +1,190 @@
+[
+ {
+ "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",
+ "leaf",
+ "mainthreadio",
+ "responsiveness",
+ "screenshots",
+ "seqstyle",
+ "stackwalk",
+ "tasktracer",
+ "threads",
+ "jstracer",
+ "jsallocations",
+ "nostacksampling",
+ "nativeallocations",
+ "preferencereads",
+ "ipcmessages",
+ "fileio",
+ "fileioall",
+ "noiostacks",
+ "audiocallbacktracing",
+ "cpu"
+ ]
+ },
+ {
+ "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..1ec86798cc
--- /dev/null
+++ b/toolkit/components/extensions/schemas/i18n.json
@@ -0,0 +1,139 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "default_locale": {
+ "type": "string",
+ "optional": "true"
+ },
+ "l10n_resources": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "optional": true
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "i18n",
+ "allowedContexts": ["content", "devtools"],
+ "defaultContexts": ["content", "devtools"],
+ "description": "Use the <code>browser.i18n</code> infrastructure to implement internationalization across your whole app or extension.",
+ "types": [
+ {
+ "id": "LanguageCode",
+ "type": "string",
+ "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. For an unknown language, <code>und</code> 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 <code>getMessage()</code> call is wrong &mdash; for example, <em>messageName</em> is not a string or the <em>substitutions</em> array has more than 9 elements &mdash; this method returns <code>undefined</code>.",
+ "parameters": [
+ {
+ "type": "string",
+ "name": "messageName",
+ "description": "The name of the message, as specified in the <code>$(topic:i18n-messages)[messages.json]</code> 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..3a377315e6
--- /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..e0b3b951ee
--- /dev/null
+++ b/toolkit/components/extensions/schemas/idle.json
@@ -0,0 +1,70 @@
+// 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 file.
+
+[
+ {
+ "namespace": "idle",
+ "description": "Use the <code>browser.idle</code> 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..2ac6423115
--- /dev/null
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -0,0 +1,52 @@
+# 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/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/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..c3265fe57e
--- /dev/null
+++ b/toolkit/components/extensions/schemas/management.json
@@ -0,0 +1,368 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "management"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace":"management",
+ "description": "The <code>browser.management</code> 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 <code>?grayscale=true</code> 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<br><var>development</var>: The extension was loaded unpacked in developer mode,<br><var>normal</var>: The extension was installed normally via an .xpi file,<br><var>sideload</var>: The extension was installed by other software on the machine,<br><var>other</var>: 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 <a href='manifest/version'>version</a> of this extension.",
+ "type": "string"
+ },
+ "versionName": {
+ "description": "The <a href='manifest/version#version_name'>version name</a> 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 <a href='manifest/icons'>manifest documentation on icons</a> 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..952264b017
--- /dev/null
+++ b/toolkit/components/extensions/schemas/manifest.json
@@ -0,0 +1,652 @@
+[
+ {
+ "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": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "gecko": {
+ "$ref": "FirefoxSpecificProperties",
+ "optional": true
+ }
+ }
+ },
+
+ "browser_specific_settings": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "gecko": {
+ "$ref": "FirefoxSpecificProperties",
+ "optional": true
+ },
+ "edge": {
+ "type": "object",
+ "additionalProperties": { "type": "any" },
+ "optional": true
+ }
+ },
+ "additionalProperties": { "$ref": "UnrecognizedProperty" }
+ },
+
+ "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
+ },
+
+ "homepage_url": {
+ "type": "string",
+ "format": "url",
+ "optional": true,
+ "preprocess": "localize"
+ }
+ }
+ },
+ {
+ "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,
+ "$ref": "PersistentBackgroundProperty"
+ }
+ },
+ "additionalProperties": { "$ref": "UnrecognizedProperty" }
+ },
+ {
+ "type": "object",
+ "properties": {
+ "scripts": {
+ "type": "array",
+ "items": { "$ref": "ExtensionURL" }
+ },
+ "persistent": {
+ "optional": true,
+ "$ref": "PersistentBackgroundProperty"
+ }
+ },
+ "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,
+ "default": true
+ },
+ "chrome_style": {
+ "type": "boolean",
+ "optional": true
+ },
+ "open_in_tab": {
+ "type": "boolean",
+ "optional": true
+ }
+ },
+
+ "additionalProperties": {
+ "type": "any",
+ "deprecated": "An unexpected property was found in the WebExtension manifest"
+ }
+ },
+
+ "content_scripts": {
+ "type": "array",
+ "optional": true,
+ "items": { "$ref": "ContentScript" }
+ },
+
+ "content_security_policy": {
+ "optional": true,
+ "onError": "warn",
+ "choices": [
+ {
+ "type": "string",
+ "format": "contentSecurityPolicy"
+ },
+ {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "UnrecognizedProperty"
+ },
+ "properties": {
+ "extension_pages": {
+ "type": "string",
+ "optional": true,
+ "format": "contentSecurityPolicy",
+ "description": "The Content Security Policy used for extension pages."
+ }
+ }
+ }
+ ]
+ },
+
+ "permissions": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "$ref": "PermissionOrOrigin",
+ "onError": "warn"
+ },
+ "optional": true
+ },
+
+ "optional_permissions": {
+ "type": "array",
+ "items": {
+ "$ref": "OptionalPermissionOrOrigin",
+ "onError": "warn"
+ },
+ "optional": true,
+ "default": []
+ },
+
+ "web_accessible_resources": {
+ "type": "array",
+ "items": { "type": "string" },
+ "optional": true
+ },
+
+ "developer": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "name": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize"
+ },
+ "url": {
+ "type": "string",
+ "optional": true,
+ "preprocess": "localize"
+ }
+ }
+ },
+
+ "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": {
+ "homepage_url": {
+ "type": "string",
+ "format": "url",
+ "optional": true,
+ "preprocess": "localize"
+ },
+
+ "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": {
+ "homepage_url": {
+ "type": "string",
+ "format": "url",
+ "optional": true,
+ "preprocess": "localize"
+ },
+
+ "dictionaries": {
+ "type": "object",
+ "patternProperties": {
+ "^[a-z]{2}[a-zA-Z-]*$": {
+ "type": "string",
+ "format": "strictRelativeUrl",
+ "pattern": "\\.dic$"
+ }
+ }
+ }
+ }
+ },
+ {
+ "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": "PermissionNoPrompt",
+ "choices": [
+ { "$ref": "OptionalPermission" },
+ {
+ "type": "string",
+ "enum": [
+ "alarms",
+ "mozillaAddons",
+ "storage",
+ "unlimitedStorage"
+ ]
+ }
+ ]
+ },
+ {
+ "id": "Permission",
+ "choices": [
+ { "$ref": "PermissionNoPrompt" },
+ { "$ref": "OptionalPermission" }
+ ]
+ },
+ {
+ "id": "PermissionOrOrigin",
+ "choices": [
+ { "$ref": "Permission" },
+ { "$ref": "MatchPattern" }
+ ]
+ },
+ {
+ "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": "MatchPattern",
+ "choices": [
+ {
+ "type": "string",
+ "enum": ["<all_urls>"]
+ },
+ {
+ "$ref": "MatchPatternRestricted"
+ },
+ {
+ "$ref": "MatchPatternUnestricted"
+ }
+ ]
+ },
+ {
+ "id": "MatchPatternRestricted",
+ "description": "Same as MatchPattern above, but excludes <all_urls>",
+ "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 <code>true</code>, implies that the JavaScript or CSS should be injected into all frames of current page. By default, it's <code>false</code> 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 <code>false</code>."},
+ "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."
+ },
+ {
+ "id": "PersistentBackgroundProperty",
+ "choices": [
+ {
+ "type": "boolean",
+ "enum": [true]
+ },
+ {
+ "type": "boolean",
+ "enum": [false],
+ "deprecated": "Event pages are not currently supported. This will run as a persistent background page."
+ }
+ ]
+ }
+ ]
+ }
+]
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..a637ba9431
--- /dev/null
+++ b/toolkit/components/extensions/schemas/native_manifest.json
@@ -0,0 +1,64 @@
+[
+ {
+ "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..e26e98dfda
--- /dev/null
+++ b/toolkit/components/extensions/schemas/network_status.json
@@ -0,0 +1,66 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "PermissionNoPrompt",
+ "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", "2g", "3g", "4g"],
+ "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"
+ }
+ ]
+ }
+ ]
+ }
+] \ No newline at end of file
diff --git a/toolkit/components/extensions/schemas/notifications.json b/toolkit/components/extensions/schemas/notifications.json
new file mode 100644
index 0000000000..34a48d5a61
--- /dev/null
+++ b/toolkit/components/extensions/schemas/notifications.json
@@ -0,0 +1,429 @@
+// 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 file.
+
+[
+ {
+ "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..d924b1b5d2
--- /dev/null
+++ b/toolkit/components/extensions/schemas/page_action.json
@@ -0,0 +1,317 @@
+// 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 file.
+
+[
+ {
+ "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,
+ "default": false
+ },
+ "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 <code>browser.pageAction</code> 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 <code>canvas</code> 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 <b>path</b> or the <b>imageData</b> 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 <code>scale</code>, then image with size <code>scale</code> * 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 <code>scale</code>, then image with size <code>scale</code> * 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..dbd1296483
--- /dev/null
+++ b/toolkit/components/extensions/schemas/permissions.json
@@ -0,0 +1,152 @@
+[
+ {
+ "namespace": "permissions",
+ "permissions": ["manifest:optional_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..15f3ef7d59
--- /dev/null
+++ b/toolkit/components/extensions/schemas/privacy.json
@@ -0,0 +1,182 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "privacy"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "privacy",
+ "permissions": ["privacy"]
+ },
+ {
+ "namespace": "privacy.network",
+ "description": "Use the <code>browser.privacy</code> 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 (<code>&lt;link rel='prefetch' ...&gt;</code>), and preemptively opening TCP and SSL connections to servers. This preference's value is a boolean, defaulting to <code>true</code>."
+ },
+ "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 <code>default</code>."
+ },
+ "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 <code>never</code>."
+ }
+ }
+ },
+ {
+ "namespace": "privacy.services",
+ "description": "Use the <code>browser.privacy</code> 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 <code>true</code>."
+ }
+ }
+ },
+ {
+ "namespace": "privacy.websites",
+ "description": "Use the <code>browser.privacy</code> 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."
+ }
+ }
+ }
+ ],
+ "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 <code>true</code>.",
+ "unsupported": true
+ },
+ "hyperlinkAuditingEnabled": {
+ "$ref": "types.Setting",
+ "description": "If enabled, the browser sends auditing pings when requested by a website (<code>&lt;a ping&gt;</code>). The value of this preference is of type boolean, and the default value is <code>true</code>."
+ },
+ "referrersEnabled": {
+ "$ref": "types.Setting",
+ "description": "If enabled, the browser sends <code>referer</code> 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 <code>true</code>."
+ },
+ "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 <code>false</code>."
+ },
+ "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 <code>false</code>."
+ },
+ "protectedContentEnabled": {
+ "$ref": "types.Setting",
+ "description": "<strong>Available on Windows and ChromeOS only</strong>: 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 <code>true</code>.",
+ "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 <code>private_browsing_only</code>."
+ },
+ "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..2677a914e5
--- /dev/null
+++ b/toolkit/components/extensions/schemas/proxy.json
@@ -0,0 +1,165 @@
+[
+ {
+ "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,
+ "description": "The address of the ftp proxy, can include a port."
+ },
+ "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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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..50c343a77b
--- /dev/null
+++ b/toolkit/components/extensions/schemas/runtime.json
@@ -0,0 +1,598 @@
+// Copyright 2014 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "Permission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "nativeMessaging"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "runtime",
+ "allowedContexts": ["content", "devtools"],
+ "description": "Use the <code>browser.runtime</code> 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 <b>only</b> 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 <strong>only</strong> be present when the connection was opened from a tab (including content scripts), and <strong>only</strong> 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 <code>tab</code> 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"],
+ "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"]
+ }
+ ],
+ "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": "<p>Open your Extension's options page, if possible.</p><p>The precise behavior may depend on your manifest's <code>$(topic:optionsV2)[options_ui]</code> or <code>$(topic:options)[options_page]</code> key, or what the browser happens to support at the time.</p><p>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).</p>",
+ "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": "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 255 characters.",
+ "async": "callback",
+ "parameters": [
+ {
+ "type": "string",
+ "name": "url",
+ "optional": true,
+ "maxLength": 255,
+ "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",
+ "unsupported": true,
+ "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",
+ "unsupported": true,
+ "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 <code>onMessage</code> 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 <code>sendResponse</code> is called)." }
+ ],
+ "returns": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Return true from the event listener if you wish to call <code>sendResponse</code> 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 <code>onMessage</code> 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 <code>sendResponse</code> is called)." }
+ ],
+ "returns": {
+ "type": "boolean",
+ "optional": true,
+ "description": "Return true from the event listener if you wish to call <code>sendResponse</code> 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."
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/components/extensions/schemas/storage.json b/toolkit/components/extensions/schemas/storage.json
new file mode 100644
index 0000000000..fd7134c676
--- /dev/null
+++ b/toolkit/components/extensions/schemas/storage.json
@@ -0,0 +1,363 @@
+// Copyright 2014 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 file.
+
+[
+ {
+ "namespace": "storage",
+ "allowedContexts": ["content", "devtools"],
+ "defaultContexts": ["content", "devtools"],
+ "description": "Use the <code>browser.storage</code> 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 <code>null</code> 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 <code>null</code> 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": "<p>An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.</p><p>Primitive values such as numbers will serialize as expected. Values with a <code>typeof</code> <code>\"object\"</code> and <code>\"function\"</code> will typically serialize to <code>{}</code>, with the exception of <code>Array</code> (serializes as expected), <code>Date</code>, and <code>Regex</code> (serialize using their <code>String</code> representation).</p>"
+ },
+ {
+ "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
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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 <code>null</code> 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 <code>null</code> 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": "<p>An object which gives each key/value pair to update storage with. Any other key/value pairs in storage will not be affected.</p><p>Primitive values such as numbers will serialize as expected. Values with a <code>typeof</code> <code>\"object\"</code> and <code>\"function\"</code> will typically serialize to <code>{}</code>, with the exception of <code>Array</code> (serializes as expected), <code>Date</code>, and <code>Regex</code> (serialize using their <code>String</code> representation).</p>"
+ },
+ {
+ "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."
+ },
+ {
+ "name": "areaName",
+ "type": "string",
+ "description": "The name of the storage area (<code>\"sync\"</code>, <code>\"local\"</code> or <code>\"managed\"</code>) the changes are for."
+ }
+ ]
+ }
+ ],
+ "properties": {
+ "sync": {
+ "$ref": "StorageAreaSync",
+ "description": "Items in the <code>sync</code> 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": "<p>The maximum number of <code>set</code>, <code>remove</code>, or <code>clear</code> 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.</p><p>Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).</p>"
+ },
+ "MAX_WRITE_OPERATIONS_PER_MINUTE": {
+ "value": 120,
+ "description": "<p>The maximum number of <code>set</code>, <code>remove</code>, or <code>clear</code> 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.</p><p>Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError).</p>"
+ },
+ "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 <code>local</code> 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 <code>unlimitedStorage</code> permission. Updates that would cause this limit to be exceeded fail immediately and set $(ref:runtime.lastError)."
+ }
+ }
+ },
+ "managed": {
+ "$ref": "StorageArea",
+ "description": "Items in the <code>managed</code> 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."
+ }
+ }
+ }
+ }
+ }
+]
diff --git a/toolkit/components/extensions/schemas/telemetry.json b/toolkit/components/extensions/schemas/telemetry.json
new file mode 100644
index 0000000000..30f811bd66
--- /dev/null
+++ b/toolkit/components/extensions/schemas/telemetry.json
@@ -0,0 +1,460 @@
+[
+ {
+ "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": "PermissionNoPrompt",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "telemetry"
+ ]
+ }]
+ }]
+ },
+ {
+ "namespace": "telemetry",
+ "description": "Use the <code>browser.telemetry</code> 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 <code>nsITelemetry.SCALAR_TYPE_*</code>."
+ }, {
+ "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 <code>submitExternalPing</code> inside TelemetryController.jsm 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..8b0e575f1b
--- /dev/null
+++ b/toolkit/components/extensions/schemas/test.json
@@ -0,0 +1,223 @@
+// Copyright 2014 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 file.
+
+[
+ {
+ "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": "checkDeepEq",
+ "type": "function",
+ "unsupported": true,
+ "allowAmbiguousOptionalArguments": true,
+ "parameters": [
+ {"type": "any", "name": "expected"},
+ {"type": "any", "name": "actual"}
+ ]
+ },
+ {
+ "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",
+ "optional": true
+ },
+ {
+ "name": "message",
+ "type": "string",
+ "optional": true
+ }
+ ]
+ },
+ {
+ "name": "assertThrows",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "func",
+ "type": "function"
+ },
+ {
+ "name": "expectedError",
+ "$ref": "ExpectedError",
+ "optional": true
+ },
+ {
+ "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..a651f3aad7
--- /dev/null
+++ b/toolkit/components/extensions/schemas/theme.json
@@ -0,0 +1,436 @@
+// 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/.
+
+[
+ {
+ "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
+ },
+ "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_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
+ }
+ },
+ "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..2cf38f5777
--- /dev/null
+++ b/toolkit/components/extensions/schemas/types.json
@@ -0,0 +1,162 @@
+// 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 file.
+
+[
+ {
+ "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<ul><li><var>regular</var>: setting for the regular profile (which is inherited by the incognito profile if not overridden elsewhere),</li><li><var>regular_only</var>: setting for the regular profile only (not inherited by the incognito profile),</li><li><var>incognito_persistent</var>: setting for the incognito profile that survives browser restarts (overrides regular preferences),</li><li><var>incognito_session_only</var>: 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).</li></ul> Only <var>regular</var> 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<ul><li><var>not_controllable</var>: cannot be controlled by any extension</li><li><var>controlled_by_other_extensions</var>: controlled by extensions with higher precedence</li><li><var>controllable_by_this_extension</var>: can be controlled by this extension</li><li><var>controlled_by_this_extension</var>: controlled by this extension</li></ul>"
+ },
+ {
+ "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.<br/>This property will <em>only</em> be present if the <var>incognito</var> property in the <var>details</var> parameter of <code>get()</code> 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. <br/>Note that every setting has a specific value type, which is described together with the setting. An extension should <em>not</em> 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.<br/>This property will <em>only</em> 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..f4ecbd367e
--- /dev/null
+++ b/toolkit/components/extensions/schemas/user_scripts.json
@@ -0,0 +1,120 @@
+/* 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/. */
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "user_scripts": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "api_script": {
+ "optional": true,
+ "$ref": "manifest.ExtensionURL"
+ }
+ },
+ "additionalProperties": { "$ref": "UnrecognizedProperty" }
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "userScripts",
+ "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 <code>true</code>, implies that the JavaScript should be injected into all frames of current page. By default, it's <code>false</code> 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 <code>false</code>."
+ },
+ "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\"."
+ }
+ }
+ },
+ {
+ "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..9048a8d010
--- /dev/null
+++ b/toolkit/components/extensions/schemas/user_scripts_content.json
@@ -0,0 +1,61 @@
+/* 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/. */
+
+[
+ {
+ "namespace": "userScripts",
+ "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..41daec7592
--- /dev/null
+++ b/toolkit/components/extensions/schemas/web_navigation.json
@@ -0,0 +1,398 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermission",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "webNavigation"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "webNavigation",
+ "description": "Use the <code>browser.webNavigation</code> 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 <code>\"start_page\"</code> in place of <code>\"auto_toplevel\"</code> (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 &lt;iframe&gt; or a &lt;frame&gt; 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": {"unsupported": true, "$ref": "TransitionType", "description": "Cause of the navigation."},
+ "transitionQualifiers": {"unsupported": true, "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": {"unsupported": true, "$ref": "TransitionType", "description": "Cause of the navigation."},
+ "transitionQualifiers": {"unsupported": true, "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": {"unsupported": true, "$ref": "TransitionType", "description": "Cause of the navigation."},
+ "transitionQualifiers": {"unsupported": true, "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..fbaa5db58e
--- /dev/null
+++ b/toolkit/components/extensions/schemas/web_request.json
@@ -0,0 +1,901 @@
+// 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 file.
+
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "OptionalPermissionNoPrompt",
+ "choices": [{
+ "type": "string",
+ "enum": [
+ "webRequest",
+ "webRequestBlocking"
+ ]
+ }]
+ }
+ ]
+ },
+ {
+ "namespace": "webRequest",
+ "description": "Use the <code>browser.webRequest</code> 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 <code>handlerBehaviorChanged</code> can be called per 10 minute sustained interval. <code>handlerBehaviorChanged</code> 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 <code>name</code> and either <code>value</code> or <code>binaryValue</code>.",
+ "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 <code>redirectUrl</code> during onBeforeRequest, <code>upgradeToSecure</code> 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 <code>responseHeaders</code> if you really want to modify the headers in order to limit the number of conflicts (only one extension may modify <code>responseHeaders</code> 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
+ },
+ "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 <code>certificateChain</code> is passed as an option.",
+ "type": "array",
+ "items": { "$ref": "CertificateInfo" }
+ },
+ "isDomainMismatch": {
+ "description": "The domain name does not match the certificate domain.",
+ "type": "boolean",
+ "optional": true
+ },
+ "isExtendedValidation": {
+ "type": "boolean",
+ "optional": true
+ },
+ "isNotValidAtThisTime": {
+ "description": "The certificate is either expired or is not yet valid. See <code>CertificateInfo.validity</code> for start and end dates.",
+ "type": "boolean",
+ "optional": true
+ },
+ "isUntrusted": {
+ "type": "boolean",
+ "optional": true
+ },
+ "certificateTransparencyStatus": {
+ "description": "Certificate transparency compliance per RFC 6962. See <code>https://www.certificate-transparency.org/what-is-ct</code> 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
+ }
+ }
+ },
+ {
+ "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",
+ "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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 (<code>type</code> is <code>main_frame</code> or <code>sub_frame</code>), <code>frameId</code> 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 <em>not</em> 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<T>`, 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<mozIExtensionStorageArea> NewSyncArea() {
+ nsCOMPtr<mozIExtensionStorageArea> 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.jsm b/toolkit/components/extensions/storage/ExtensionStorageComponents.jsm
new file mode 100644
index 0000000000..7fd890d0c7
--- /dev/null
+++ b/toolkit/components/extensions/storage/ExtensionStorageComponents.jsm
@@ -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/. */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+ FileUtils: "resource://gre/modules/FileUtils.jsm",
+});
+
+const EXPORTED_SYMBOLS = ["StorageSyncService"];
+
+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
+ */
+function StorageSyncService() {
+ if (StorageSyncService._singleton) {
+ return StorageSyncService._singleton;
+ }
+
+ let file = FileUtils.getFile("ProfD", ["storage-sync-v2.sqlite"]);
+ let kintoFile = FileUtils.getFile("ProfD", ["storage-sync.sqlite"]);
+ this._storageArea = new StorageSyncArea(file, kintoFile);
+
+ // Register a blocker to close the storage connection on shutdown.
+ this._shutdownBound = () => this._shutdown();
+ 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 {
+ 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..c45547b662
--- /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'],
+ 'jsm': 'resource://gre/modules/ExtensionStorageComponents.jsm',
+ '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..1723dc1074
--- /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.jsm",
+ ]
+
+ 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..5b19883c29
--- /dev/null
+++ b/toolkit/components/extensions/storage/webext_storage_bridge/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "webext_storage_bridge"
+description = "The WebExtension `storage.sync` bindings for Firefox"
+version = "0.1.0"
+authors = ["The Firefox Sync Developers <sync-team@mozilla.com>"]
+edition = "2018"
+
+[dependencies]
+atomic_refcell = "0.1"
+cstr = "0.2"
+golden_gate = { path = "../../../../../services/sync/golden_gate" }
+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 = { git = "https://github.com/mozilla/application-services", rev = "8a576fbe79199fa8664f64285524017f74ebcc5f" }
+webext-storage = { git = "https://github.com/mozilla/application-services", rev = "8a576fbe79199fa8664f64285524017f74ebcc5f" }
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..ceb87aff9d
--- /dev/null
+++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/area.rs
@@ -0,0 +1,479 @@
+/* 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, 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<PathBuf> {
+ 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`.
+#[derive(xpcom)]
+#[xpimplements(
+ mozIExtensionStorageArea,
+ mozIConfigurableExtensionStorageArea,
+ mozISyncedExtensionStorageArea,
+ mozIInterruptible,
+ mozIBridgedSyncEngine
+)]
+#[refcnt = "nonatomic"]
+pub struct InitStorageSyncArea {
+ /// 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<nsISerialEventTarget>,
+ /// The store is lazily initialized on the task queue the first time it's
+ /// used.
+ store: RefCell<Option<Arc<LazyStore>>>,
+}
+
+/// `mozIExtensionStorageArea` implementation.
+impl StorageSyncArea {
+ /// Creates a storage area and its task queue.
+ pub fn new() -> Result<RefPtr<StorageSyncArea>> {
+ 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<Ref<'_, Arc<LazyStore>>> {
+ 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<LazyStore>,
+ 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<RefPtr<mozIServicesLogSink>> {
+ 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<i32> {
+ 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<bool> {
+ 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.store()?, 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.store()?, 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.store()?, 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.store()?, 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.store()?, 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.store()?, 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<nsCString>>,
+ callback: &mozIBridgedSyncEngineCallback,
+ ) -> Result<()> {
+ Ok(FerryTask::for_store_incoming(
+ &*self.store()?,
+ 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.store()?, 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<nsCString>>,
+ callback: &mozIBridgedSyncEngineCallback,
+ ) -> Result<()> {
+ Ok(FerryTask::for_set_uploaded(
+ &*self.store()?,
+ 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.store()?, callback)?.dispatch(&self.queue)?)
+ }
+
+ xpcom_method!(
+ reset => Reset(
+ callback: *const mozIBridgedSyncEngineCallback
+ )
+ );
+ fn reset(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> {
+ Ok(FerryTask::for_reset(&*self.store()?, callback)?.dispatch(&self.queue)?)
+ }
+
+ xpcom_method!(
+ wipe => Wipe(
+ callback: *const mozIBridgedSyncEngineCallback
+ )
+ );
+ fn wipe(&self, callback: &mozIBridgedSyncEngineCallback) -> Result<()> {
+ Ok(FerryTask::for_wipe(&*self.store()?, callback)?.dispatch(&self.queue)?)
+ }
+}
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..cc2c41e866
--- /dev/null
+++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/error.rs
@@ -0,0 +1,125 @@
+/* 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;
+use webext_storage::error::ErrorKind as WebextStorageErrorKind;
+
+/// A specialized `Result` type for extension storage operations.
+pub type Result<T> = result::Result<T, Error>;
+
+/// 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<dyn error::Error + Send + Sync + 'static>),
+ 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<nsresult> for Error {
+ fn from(result: nsresult) -> Error {
+ Error::Nsresult(result)
+ }
+}
+
+impl From<WebextStorageError> for Error {
+ fn from(error: WebextStorageError) -> Error {
+ Error::WebextStorage(error)
+ }
+}
+
+impl From<GoldenGateError> for Error {
+ fn from(error: GoldenGateError) -> Error {
+ Error::GoldenGate(error)
+ }
+}
+
+impl From<Utf8Error> for Error {
+ fn from(error: Utf8Error) -> Error {
+ Error::MalformedString(error.into())
+ }
+}
+
+impl From<FromUtf16Error> for Error {
+ fn from(error: FromUtf16Error) -> Error {
+ Error::MalformedString(error.into())
+ }
+}
+
+impl From<JsonError> for Error {
+ fn from(error: JsonError) -> Error {
+ Error::MalformedString(error.into())
+ }
+}
+
+impl From<Error> for nsresult {
+ fn from(error: Error) -> nsresult {
+ match error {
+ Error::Nsresult(result) => result,
+ Error::WebextStorage(e) => match e.kind() {
+ WebextStorageErrorKind::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, "`{}` already ran on the background thread", what),
+ Error::DidNotRun(what) => write!(f, "`{}` didn't run on the background thread", what),
+ 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::<mozIExtensionStorageArea>()).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..61feec71de
--- /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<Change>,
+ value: Option<String>,
+}
+
+/// 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<T: Borrow<S>, S: Serialize>(ext_id: &str, changes: T) -> Result<Self> {
+ 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<Change>) -> Self {
+ PuntResult {
+ changes,
+ value: None,
+ }
+ }
+
+ /// Creates a result with no changes to pass to `onChanged`, and a return
+ /// value for `handleSuccess`.
+ fn with_value<T: Borrow<S>, S: Serialize>(value: T) -> Result<Self> {
+ 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<LazyStore>,
+ punt: AtomicRefCell<Option<Punt>>,
+ callback: ThreadPtrHandle<mozIExtensionStorageCallback>,
+ result: AtomicRefCell<Result<PuntResult>>,
+}
+
+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<LazyStore>,
+ punt: Punt,
+ callback: &mozIExtensionStorageCallback,
+ ) -> Result<Self> {
+ 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<Arc<LazyStore>> {
+ 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<PuntResult> {
+ 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::<mozIExtensionStorageListener>() {
+ 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<Option<Arc<LazyStore>>>,
+ callback: ThreadPtrHandle<mozIExtensionStorageCallback>,
+ result: AtomicRefCell<Result<()>>,
+}
+
+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<LazyStore>, callback: &mozIExtensionStorageCallback) -> Result<Self> {
+ 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<LazyStore>) -> 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..5df295d89d
--- /dev/null
+++ b/toolkit/components/extensions/storage/webext_storage_bridge/src/store.rs
@@ -0,0 +1,249 @@
+/* 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,
+ mem,
+ path::PathBuf,
+ sync::{Mutex, MutexGuard},
+};
+
+use golden_gate::{ApplyResults, BridgedEngine, Guid, IncomingEnvelope};
+use once_cell::sync::OnceCell;
+use sql_support::SqlInterruptHandle;
+use webext_storage::store::Store;
+
+use crate::error::{Error, Result};
+
+/// 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<InterruptStore>,
+ config: OnceCell<LazyStoreConfig>,
+}
+
+/// An `InterruptStore` wraps an inner extension store, and its interrupt
+/// handle. The inner store is protected by a mutex, which we don't want to
+/// lock on the main thread because it'll block waiting on any storage
+/// operations running on the background thread, which defeats the point of the
+/// interrupt. The interrupt handle is safe to access on the main thread, and
+/// doesn't require locking.
+struct InterruptStore {
+ inner: Mutex<Store>,
+ handle: SqlInterruptHandle,
+}
+
+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) -> 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) -> Result<MutexGuard<'_, 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: Mutex::new(store),
+ handle,
+ })
+ }
+ None => Err(Error::NotConfigured),
+ })?
+ .inner
+ .lock()
+ .unwrap())
+ }
+
+ /// 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) -> Result<()> {
+ if let Some(store) = self
+ .store
+ .into_inner()
+ .map(|outer| outer.inner.into_inner().unwrap())
+ {
+ if let Err((store, error)) = store.close() {
+ // Since we're most likely being called during shutdown, leak
+ // the store on error...it'll be cleaned up when the process
+ // quits, anyway. We don't want to drop it, because its
+ // destructor will try to close it again, and panic on error.
+ // That'll become a shutdown crash, which we want to avoid.
+ mem::forget(store);
+ return Err(error.into());
+ }
+ }
+ Ok(())
+ }
+}
+
+// `Store::bridged_engine()` returns a `BridgedEngine` implementation, but we
+// provide our own for `LazyStore` that forwards to it. This is for three
+// reasons.
+//
+// 1. We need to override the associated `Error` type, since Golden Gate has
+// an `Into<nsresult>` bound for errors. We can't satisfy this bound in
+// `webext_storage` because `nsresult` is a Gecko-only type. We could try
+// to reduce the boilerplate by declaring an `AsBridgedEngine` trait, with
+// associated types for `Error` and `Engine`, but then we run into...
+// 2. `Store::bridged_engine()` returns a type with a lifetime parameter,
+// because the engine can't outlive the store. But we can't represent that
+// in our `AsBridgedEngine` trait without an associated type constructor,
+// which Rust doesn't support yet (rust-lang/rfcs#1598).
+// 3. Related to (2), our store is lazily initialized behind a mutex, so
+// `LazyStore::get()` returns a guard. But now, `Store::bridged_engine()`
+// must return a type that lives only as long as the guard...which it
+// can't, because it doesn't know that! This is another case where
+// higher-kinded types would be helpful, so that our hypothetical
+// `AsBridgedEngine::bridged_engine()` could return either a `T<'a>` or
+// `MutexGuard<'_, T<'a>>`, but that's not possible now.
+//
+// There are workarounds for Rust's lack of HKTs, but they all introduce
+// indirection and cognitive overhead. So we do the simple thing and implement
+// `BridgedEngine`, with a bit more boilerplate.
+impl BridgedEngine for LazyStore {
+ type Error = Error;
+
+ fn last_sync(&self) -> Result<i64> {
+ Ok(self.get()?.bridged_engine().last_sync()?)
+ }
+
+ fn set_last_sync(&self, last_sync_millis: i64) -> Result<()> {
+ Ok(self
+ .get()?
+ .bridged_engine()
+ .set_last_sync(last_sync_millis)?)
+ }
+
+ fn sync_id(&self) -> Result<Option<String>> {
+ Ok(self.get()?.bridged_engine().sync_id()?)
+ }
+
+ fn reset_sync_id(&self) -> Result<String> {
+ Ok(self.get()?.bridged_engine().reset_sync_id()?)
+ }
+
+ fn ensure_current_sync_id(&self, new_sync_id: &str) -> Result<String> {
+ Ok(self
+ .get()?
+ .bridged_engine()
+ .ensure_current_sync_id(new_sync_id)?)
+ }
+
+ fn sync_started(&self) -> Result<()> {
+ Ok(self.get()?.bridged_engine().sync_started()?)
+ }
+
+ fn store_incoming(&self, envelopes: &[IncomingEnvelope]) -> Result<()> {
+ Ok(self.get()?.bridged_engine().store_incoming(envelopes)?)
+ }
+
+ fn apply(&self) -> Result<ApplyResults> {
+ Ok(self.get()?.bridged_engine().apply()?)
+ }
+
+ fn set_uploaded(&self, server_modified_millis: i64, ids: &[Guid]) -> Result<()> {
+ Ok(self
+ .get()?
+ .bridged_engine()
+ .set_uploaded(server_modified_millis, ids)?)
+ }
+
+ fn sync_finished(&self) -> Result<()> {
+ Ok(self.get()?.bridged_engine().sync_finished()?)
+ }
+
+ fn reset(&self) -> Result<()> {
+ Ok(self.get()?.bridged_engine().reset()?)
+ }
+
+ fn wipe(&self) -> Result<()> {
+ Ok(self.get()?.bridged_engine().wipe()?)
+ }
+}
+
+// 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) -> Result<Store> {
+ 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((store, e)) = store.close() {
+ // welp, this probably isn't going to end well...
+ println!(
+ "extension-storage: failed to close the store after migration failure: {}",
+ e
+ );
+ // I don't think we should hit this in this case - I guess we
+ // could sleep and retry if we thought we were.
+ mem::drop(store);
+ }
+ 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.ini b/toolkit/components/extensions/test/browser/browser-serviceworker.ini
new file mode 100644
index 0000000000..58e9082f7b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser-serviceworker.ini
@@ -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.ini b/toolkit/components/extensions/test/browser/browser.ini
new file mode 100644
index 0000000000..0cfb5fcd89
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser.ini
@@ -0,0 +1,50 @@
+[DEFAULT]
+support-files =
+ head.js
+ data/**
+
+[browser_ext_background_serviceworker_pref_disabled.js]
+[browser_ext_downloads_filters.js]
+[browser_ext_downloads_referrer.js]
+[browser_ext_management_themes.js]
+skip-if = verify
+[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_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_separators.js]
+[browser_ext_themes_tab_text.js]
+[browser_ext_themes_toolbar_fields_focus.js]
+[browser_ext_themes_toolbar_fields.js]
+[browser_ext_themes_toolbarbutton_colors.js]
+[browser_ext_themes_toolbarbutton_icons.js]
+[browser_ext_themes_toolbars.js]
+[browser_ext_themes_theme_transition.js]
+[browser_ext_themes_warnings.js]
+[browser_ext_thumbnails_bg_extension.js]
+support-files = !/toolkit/components/thumbnails/test/head.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..a43a49cc0a
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js
@@ -0,0 +1,292 @@
+/* -*- 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": "<!DOCTYPE html><script src='page.js'></script>",
+ "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();
+
+ // 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 also
+ // conditioned 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",
+ },
+ applications: { gecko: { id: "test-bg-sw@mochi.test" } },
+ },
+ files: {
+ "page.html": "<!DOCTYPE html><body></body>",
+ "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..a2d9004801
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js
@@ -0,0 +1,122 @@
+/* -*- 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": "<!DOCTYPE html><script src='page.js'></script>",
+ "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 {
+ await window.caches.open("test-cache-api");
+ browser.test.fail(
+ `An extension page should not be allowed to use the Cache API 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"
+ );
+ } finally {
+ browser.test.sendMessage("test-cache-api-disallowed");
+ }
+ },
+ });
+
+ 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..f8672597cd
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js
@@ -0,0 +1,138 @@
+/* -*- 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()}`],
+ true
+ );
+
+ 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_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..b2931e0b6f
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js
@@ -0,0 +1,82 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+const { BrowserTestUtils } = ChromeUtils.import(
+ "resource://testing-common/BrowserTestUtils.jsm"
+);
+
+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 }) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ selector,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ let saveLinkCommand = window.document.getElementById("context-savelink");
+ saveLinkCommand.doCommand();
+}
+
+add_task(function test_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);
+ });
+
+ // 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");
+ });
+
+ 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..f74f418ace
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js
@@ -0,0 +1,149 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PromiseTestUtils.jsm"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+add_task(async function test_management_themes() {
+ 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");
+ // We get the 4 built-in themes plus the lwt and our addon.
+ browser.test.assertEq(5, themes.length, "got expected addons");
+ // We should also get our test extension.
+ let testExtension = addons.find(addon => {
+ return addon.id === TEST_ID;
+ });
+ browser.test.assertTrue(
+ !!testExtension,
+ `The extension with id ${TEST_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: {
+ applications: {
+ 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"), "Default", "default disabled");
+
+ extension.sendMessage("test");
+ is(await extension.awaitMessage("onEnabled"), "Default", "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"), "Default", "default disabled");
+ await extension.awaitMessage("done");
+
+ await Promise.all([theme.unload(), extension.awaitMessage("onUninstalled")]);
+
+ is(await extension.awaitMessage("onEnabled"), "Default", "default enabled");
+ await extension.unload();
+});
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..fc71cacc66
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_test_mock.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";
+
+// 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: { applications: { 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..f265a724e5
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js
@@ -0,0 +1,102 @@
+"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 docEl = document.documentElement;
+ let rootCS = window.getComputedStyle(docEl);
+
+ Assert.equal(
+ rootCS.getPropertyValue("background-position"),
+ RIGHT_TOP,
+ "root only contains theme_frame alignment property"
+ );
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ Assert.equal(
+ toolboxCS.getPropertyValue("background-position"),
+ RIGHT_TOP,
+ toolbox.id +
+ " only contains default additional backgrounds alignment property"
+ );
+
+ 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 docEl = document.documentElement;
+ let rootCS = window.getComputedStyle(docEl);
+
+ Assert.equal(
+ rootCS.getPropertyValue("background-position"),
+ RIGHT_TOP,
+ "root only contains theme_frame alignment property"
+ );
+
+ let toolbox = document.querySelector("#navigator-toolbox");
+ let toolboxCS = window.getComputedStyle(toolbox);
+
+ Assert.equal(
+ toolboxCS.getPropertyValue("background-position"),
+ LEFT_BOTTOM + ", " + CENTER_CENTER + ", " + RIGHT_TOP,
+ toolbox.id + " contains 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..65e3a6c9bf
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js
@@ -0,0 +1,34 @@
+"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();
+
+ // Add the event listener before loading the extension
+ let docEl = window.document.documentElement;
+ let style = window.getComputedStyle(docEl);
+
+ Assert.equal(
+ style.backgroundColor,
+ "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..e7024b0479
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js
@@ -0,0 +1,99 @@
+"use strict";
+
+function openIdentityPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityBox.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.shadowRoot.querySelector(
+ ".panel-arrowcontent"
+ );
+ 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"
+ );
+
+ Assert.equal(
+ arrowContentComputedStyle.getPropertyValue("--panel-description-color"),
+ `rgba(${hexToRGB(POPUP_TEXT_COLOR).join(", ")}, 0.65)`,
+ "Popup text description color should have been themed"
+ );
+
+ // Ensure popup border color was set properly
+ if (AppConstants.platform == "macosx") {
+ Assert.ok(
+ arrowContentComputedStyle
+ .getPropertyValue("box-shadow")
+ .includes(`rgb(${hexToRGB(POPUP_BORDER_COLOR).join(", ")})`),
+ "Popup border color should be set"
+ );
+ } else {
+ 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..2c5a0123f4
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js
@@ -0,0 +1,170 @@
+"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 = "#85A400";
+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 = "#1c78d4";
+const POPUP_ACTION_COLOR_DARK = "#008f8a";
+const POPUP_URL_COLOR_BRIGHT = "#74c0ff";
+const POPUP_ACTION_COLOR_BRIGHT = "#30e60b";
+
+const SEARCH_TERM = "urlbar-reflows-" + Date.now();
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.jsm",
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm",
+});
+
+add_task(async function setup() {
+ 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 extension with brighttext not set
+ 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,
+ 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._content);
+
+ 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. 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,
+ 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..4366764a20
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js
@@ -0,0 +1,161 @@
+"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("lwthemetextcolor"),
+ "dark",
+ "LWT text color attribute should be set"
+ );
+
+ let style = window.getComputedStyle(docEl);
+ Assert.ok(
+ style.backgroundImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${style.backgroundImage}`
+ );
+ Assert.equal(
+ style.backgroundColor,
+ "rgb(" + FRAME_COLOR.join(", ") + ")",
+ "Expected correct background color"
+ );
+ Assert.equal(
+ style.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("lwthemetextcolor"),
+ "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;
+ let style = window.getComputedStyle(docEl);
+
+ Assert.equal(
+ style.backgroundColor,
+ "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(
+ style.backgroundColor,
+ "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;
+ let style = window.getComputedStyle(docEl);
+
+ Assert.equal(
+ style.backgroundColor,
+ "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(
+ style.backgroundColor,
+ "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..34e719262d
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js
@@ -0,0 +1,185 @@
+"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 style = window.getComputedStyle(docEl);
+
+ if (isLWT) {
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwthemetextcolor"),
+ "bright",
+ "LWT text color attribute should be set"
+ );
+ }
+
+ Assert.ok(
+ style.backgroundImage.includes(backgroundImage),
+ "Expected correct background image"
+ );
+ if (accentColor.startsWith("#")) {
+ accentColor = hexToRGB(accentColor);
+ }
+ if (textColor.startsWith("#")) {
+ textColor = hexToRGB(textColor);
+ }
+ Assert.equal(
+ style.backgroundColor,
+ accentColor,
+ "Expected correct accent color"
+ );
+ Assert.equal(style.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 defaultStyle = window.getComputedStyle(window.document.documentElement);
+ 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 { backgroundImage, backgroundColor, color } = defaultStyle;
+ 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 defaultStyle = window.getComputedStyle(window.document.documentElement);
+ 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 { backgroundImage, backgroundColor, color } = defaultStyle;
+ 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..7b362498e7
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js
@@ -0,0 +1,401 @@
+"use strict";
+
+const { AddonSettings } = ChromeUtils.import(
+ "resource://gre/modules/addons/AddonSettings.jsm"
+);
+
+// This test checks whether the theme experiments work
+add_task(async function test_experiment_static_theme() {
+ let extension = ExtensionTestUtils.loadExtension({
+ 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({
+ 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({
+ 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"
+ );
+});
+
+add_task(async function cleanup() {
+ 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..fe9689bc74
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js
@@ -0,0 +1,217 @@
+"use strict";
+
+// This test checks whether applied WebExtension themes that attempt to change
+// the toolbar and toolbar_field properties also theme the findbar.
+
+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 button text color is set as toolbar text color");
+ Assert.equal(
+ window.getComputedStyle(gFindBar).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Findbar text color should be the same as toolbar text color."
+ );
+ Assert.equal(
+ window.getComputedStyle(findbar_button).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Findbar button text color should be the same as 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() {
+ 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");
+
+ let findbar_prev_button = gFindBar.getElement("find-previous");
+
+ let findbar_next_button = gFindBar.getElement("find-next");
+
+ 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);
+ testBorderColor(findbar_prev_button, TOOLBAR_FIELD_BORDER_COLOR);
+ testBorderColor(findbar_next_button, TOOLBAR_FIELD_BORDER_COLOR);
+
+ await extension.unload();
+});
+
+// Test that theme properties are *not* applied with a theme_frame (see bug 1506913)
+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 *not* set as toolbar color");
+ Assert.notEqual(
+ window.getComputedStyle(gFindBar).backgroundColor,
+ hexToCSS(ACCENT_COLOR),
+ "Findbar background color should not be set by theme."
+ );
+
+ info(
+ "Checking findbar and button text color is *not* set as toolbar text color"
+ );
+ Assert.notEqual(
+ window.getComputedStyle(gFindBar).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Findbar text color should not be set by theme."
+ );
+ Assert.notEqual(
+ window.getComputedStyle(findbar_button).color,
+ hexToCSS(TOOLBAR_TEXT_COLOR),
+ "Findbar button text color should not 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.notEqual(
+ window.getComputedStyle(findbar_textbox).backgroundColor,
+ hexToCSS(TOOLBAR_FIELD_COLOR),
+ "Findbar textbox background color should not be set by theme."
+ );
+
+ Assert.notEqual(
+ window.getComputedStyle(findbar_textbox).color,
+ hexToCSS(TOOLBAR_FIELD_TEXT_COLOR),
+ "Findbar textbox text color should not 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..981f32d7fb
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js
@@ -0,0 +1,66 @@
+"use strict";
+
+// This test checks whether browser.theme.getCurrent() works correctly when theme
+// does not originate from extension querying the theme.
+
+add_task(async function test_getcurrent() {
+ 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(() => {
+ browser.theme.getCurrent().then(theme => {
+ browser.test.sendMessage("theme-updated", theme);
+ });
+ });
+ },
+ });
+
+ await extension.startup();
+
+ info("Testing getCurrent after static theme startup");
+ let updatedPromise = extension.awaitMessage("theme-updated");
+ 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"
+ );
+
+ 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();
+});
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..083eb85486
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js
@@ -0,0 +1,61 @@
+"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.
+ChromeUtils.import(
+ "resource://testing-common/CustomizableUITestUtils.jsm",
+ this
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+add_task(async function setup() {
+ await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ 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"),
+ ].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 background colors and colors for ${fields.length} toolbar input fields.`
+ );
+ for (let field of fields) {
+ info(`Testing ${field.id || field.className}`);
+ 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..4917f6f830
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js
@@ -0,0 +1,81 @@
+"use strict";
+
+add_task(async function test_theme_incognito_not_allowed() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.allowPrivateBrowsingByDefault", false]],
+ });
+
+ 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..af2eef6ffb
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js
@@ -0,0 +1,57 @@
+"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 style = 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.equal(
+ docEl.getAttribute("lwthemetextcolor"),
+ "dark",
+ "LWT text color attribute should not be set on deprecated textcolor alias"
+ );
+
+ Assert.equal(
+ style.backgroundColor,
+ DEFAULT_THEME_BG_COLOR,
+ "Expected default theme background color"
+ );
+ Assert.equal(
+ style.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..1395647683
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js
@@ -0,0 +1,216 @@
+"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("lwthemetextcolor"),
+ "bright",
+ "LWT text color attribute should be set"
+ );
+
+ let toolboxCS = window.getComputedStyle(toolbox);
+ let rootCS = window.getComputedStyle(docEl);
+ let rootBgImage = rootCS.backgroundImage.split(",")[0].trim();
+ let bgImage = toolboxCS.backgroundImage.split(",")[0].trim();
+ Assert.ok(
+ rootBgImage.includes("face1.png"),
+ `The backgroundImage should use face1.png. Actual value is: ${rootBgImage}`
+ );
+ Assert.equal(
+ toolboxCS.backgroundImage,
+ Array(3)
+ .fill(bgImage)
+ .join(", "),
+ "The backgroundImage should use face2.png three times."
+ );
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "0% 0%, 50% 0%, 100% 100%",
+ "The backgroundPosition should use the three values provided."
+ );
+ Assert.equal(
+ toolboxCS.backgroundRepeat,
+ "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(rootCS.backgroundImage, "none");
+ Assert.equal(rootCS.backgroundPosition, "0% 0%");
+ Assert.equal(rootCS.backgroundRepeat, "repeat");
+ 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("lwthemetextcolor"),
+ "bright",
+ "LWT text color attribute should be set"
+ );
+
+ let rootCS = window.getComputedStyle(docEl);
+ let toolboxCS = window.getComputedStyle(toolbox);
+ let bgImage = rootCS.backgroundImage.split(",")[0].trim();
+ Assert.ok(
+ bgImage.includes("face0.png"),
+ `The backgroundImage should use face.png. Actual value is: ${bgImage}`
+ );
+ Assert.equal(
+ [1, 2, 3].map(num => bgImage.replace(/face[\d]*/, `face${num}`)).join(", "),
+ toolboxCS.backgroundImage,
+ "The backgroundImage should use face.png three times."
+ );
+ Assert.equal(
+ rootCS.backgroundPosition,
+ "100% 0%",
+ "The backgroundPosition should use the default value for root."
+ );
+ Assert.equal(
+ toolboxCS.backgroundPosition,
+ "100% 0%",
+ "The backgroundPosition should use the default value for navigator-toolbox."
+ );
+ Assert.equal(
+ rootCS.backgroundRepeat,
+ "no-repeat",
+ "The backgroundRepeat should use the default values for root."
+ );
+ Assert.equal(
+ toolboxCS.backgroundRepeat,
+ "repeat-x, repeat-y, repeat",
+ "The backgroundRepeat should use the three values provided for navigator-toolbox."
+ );
+
+ 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("lwthemetextcolor"),
+ "bright",
+ "LWT text color attribute should be set"
+ );
+
+ let rootCS = window.getComputedStyle(docEl);
+ let toolboxCS = window.getComputedStyle(toolbox);
+ let bgImage = rootCS.backgroundImage.split(",")[0];
+ Assert.ok(
+ bgImage.includes("face.png"),
+ `The backgroundImage should use face.png. Actual value is: ${bgImage}`
+ );
+ Assert.equal(
+ "none",
+ toolboxCS.backgroundImage,
+ "The backgroundImage should not use face.png."
+ );
+ Assert.equal(
+ rootCS.backgroundPosition,
+ "100% 0%",
+ "The backgroundPosition should use the default value."
+ );
+ Assert.equal(
+ rootCS.backgroundRepeat,
+ "no-repeat",
+ "The backgroundPosition should use only one (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..3e5d789709
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js
@@ -0,0 +1,157 @@
+"use strict";
+
+// This test checks whether the new tab page color properties work.
+
+/**
+ * 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, originalColor } = await SpecialPowers.spawn(
+ browser,
+ [],
+ function() {
+ let doc = content.document;
+ ok(
+ !doc.body.hasAttribute("lwt-newtab"),
+ "New tab page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.body.hasAttribute("lwt-newtab-brighttext"),
+ `New tab page should not have lwt-newtab-brighttext attribute`
+ );
+
+ return {
+ originalBackground: content.getComputedStyle(doc.body).backgroundColor,
+ originalColor: content.getComputedStyle(
+ doc.querySelector(".outer-wrapper")
+ ).color,
+ };
+ }
+ );
+
+ await extension.startup();
+
+ Services.ppmm.sharedData.flush();
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ isBrightText,
+ background: hexToCSS(theme.colors.ntp_background),
+ color: hexToCSS(theme.colors.ntp_text),
+ },
+ ],
+ function({ isBrightText, background, color }) {
+ let doc = content.document;
+ ok(
+ doc.body.hasAttribute("lwt-newtab"),
+ "New tab page should have lwt-newtab attribute"
+ );
+ is(
+ doc.body.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(".outer-wrapper")).color,
+ color,
+ "New tab page text color should be set."
+ );
+ }
+ );
+
+ await extension.unload();
+
+ Services.ppmm.sharedData.flush();
+
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ originalBackground,
+ originalColor,
+ },
+ ],
+ function({ originalBackground, originalColor }) {
+ let doc = content.document;
+ ok(
+ !doc.body.hasAttribute("lwt-newtab"),
+ "New tab page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.body.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(".outer-wrapper")).color,
+ originalColor,
+ "New tab page text color should be reset."
+ );
+ }
+ );
+}
+
+add_task(async function test_support_ntp_colors() {
+ // BrowserTestUtils.withNewTab waits for about:newtab to load
+ // so we disable preloading before running the test.
+ SpecialPowers.setBoolPref("browser.newtab.preload", false);
+ registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("browser.newtab.preload");
+ });
+ NewTabPagePreloading.removePreloadedBrowser(window);
+ for (let url of ["about:newtab", "about:home", "about:welcome"]) {
+ info("Opening url: " + url);
+ await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => {
+ await test_ntp_theme(
+ {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ ntp_background: "#add8e6",
+ ntp_text: "#00008b",
+ },
+ },
+ false,
+ url
+ );
+
+ await test_ntp_theme(
+ {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ ntp_background: "#00008b",
+ 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..bf204632ec
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js
@@ -0,0 +1,249 @@
+"use strict";
+
+// This test checks whether the new tab page color properties work per-window.
+
+/**
+ * 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),
+ color: hexToCSS(theme.colors.ntp_text),
+ },
+ ],
+ function({ isBrightText, background, color }) {
+ let doc = content.document;
+ ok(
+ doc.body.hasAttribute("lwt-newtab"),
+ "New tab page should have lwt-newtab attribute"
+ );
+ is(
+ doc.body.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(".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();
+ if (url === "about:welcome") {
+ return SpecialPowers.spawn(
+ browser,
+ [
+ {
+ background: hexToCSS("#EDEDF0"),
+ color: hexToCSS("#0C0C0D"),
+ },
+ ],
+ function({ background, color }) {
+ let doc = content.document;
+ ok(
+ !doc.body.hasAttribute("lwt-newtab"),
+ "About:welcome page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.body.hasAttribute("lwt-newtab-brighttext"),
+ `About:welcome page should not have lwt-newtab-brighttext attribute`
+ );
+
+ is(
+ content.getComputedStyle(doc.body).backgroundColor,
+ background,
+ "About:welcome page background should be reset."
+ );
+ is(
+ content.getComputedStyle(doc.querySelector(".outer-wrapper")).color,
+ color,
+ "About:welcome page text color should be reset."
+ );
+ }
+ );
+ }
+ return SpecialPowers.spawn(
+ browser,
+ [
+ {
+ background: hexToCSS("#F9F9FA"),
+ color: hexToCSS("#0C0C0D"),
+ },
+ ],
+ function({ background, color }) {
+ let doc = content.document;
+ ok(
+ !doc.body.hasAttribute("lwt-newtab"),
+ "New tab page should not have lwt-newtab attribute"
+ );
+ ok(
+ !doc.body.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_text: "#000",
+ },
+ };
+
+ const brightTextTheme = {
+ colors: {
+ frame: "#00008b",
+ tab_background_text: "#add8e6",
+ ntp_background: "#00008b",
+ 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.
+ 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.jsm
+ // treats them specially.
+ for (let url of ["about:newtab", "about:home", "about:welcome"]) {
+ info("Opening url: " + url);
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url },
+ async browser => {
+ 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);
+ await SpecialPowers.setBoolPref("browser.aboutwelcome.enabled", true);
+ registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("browser.newtab.preload");
+ SpecialPowers.clearUserPref("browser.aboutwelcome.enabled");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("perwindow-ntp-theme");
+ await extension.unload();
+});
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..bc72609acd
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js
@@ -0,0 +1,58 @@
+"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 style = window.getComputedStyle(docEl);
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwthemetextcolor"),
+ "bright",
+ "LWT text color attribute should be set"
+ );
+ Assert.ok(
+ style.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;
+ style = window2.getComputedStyle(docEl);
+
+ Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set");
+ Assert.equal(
+ docEl.getAttribute("lwthemetextcolor"),
+ "bright",
+ "LWT text color attribute should be set"
+ );
+ Assert.ok(
+ style.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..36658cd5b4
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js
@@ -0,0 +1,175 @@
+"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();
+
+ let docEl = document.documentElement;
+ Assert.equal(
+ window.getComputedStyle(docEl).backgroundColor,
+ "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..4266a982d8
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js
@@ -0,0 +1,69 @@
+"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,
+ toolbar_field_separator: SEPARATOR_FIELD_COLOR,
+ toolbar_bottom_separator: SEPARATOR_BOTTOM_COLOR,
+ },
+ },
+ },
+ files: {
+ "image1.png": BACKGROUND,
+ },
+ });
+
+ await extension.startup();
+
+ 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 mainWin = document.querySelector("#main-window");
+ Assert.equal(
+ window
+ .getComputedStyle(mainWin)
+ .getPropertyValue("--urlbar-separator-color"),
+ `rgb(${hexToRGB(SEPARATOR_FIELD_COLOR).join(", ")})`,
+ "Toolbar field separator color properly set"
+ );
+
+ let panelUIButton = document.querySelector("#PanelUI-button");
+ Assert.ok(
+ window
+ .getComputedStyle(panelUIButton)
+ .getPropertyValue("border-image-source")
+ .includes(`rgb(${hexToRGB(SEPARATOR_VERTICAL_COLOR).join(", ")})`),
+ "Vertical separator color properly set"
+ );
+
+ 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..3d814d1082
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js
@@ -0,0 +1,274 @@
+"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 brighttext attribute should be set
+ */
+async function test_sidebar_theme(theme, isBrightText) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme,
+ },
+ });
+
+ const sidebarBox = document.getElementById("sidebar-box");
+ const content = SidebarUI.browser.contentWindow;
+ const root = content.document.documentElement;
+
+ ok(
+ !sidebarBox.hasAttribute("lwt-sidebar"),
+ "Sidebar box should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar"),
+ "Sidebar should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar-brighttext"),
+ "Sidebar 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(
+ sidebarBox.hasAttribute("lwt-sidebar"),
+ isCustomSidebar,
+ `Sidebar box should${
+ !isCustomSidebar ? " not" : ""
+ } have lwt-sidebar attribute`
+ );
+ is(
+ root.hasAttribute("lwt-sidebar"),
+ isCustomSidebar,
+ `Sidebar should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute`
+ );
+ is(
+ root.hasAttribute("lwt-sidebar-brighttext"),
+ isBrightText,
+ `Sidebar should${
+ !isBrightText ? " not" : ""
+ } have lwt-sidebar-brighttext 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(
+ !sidebarBox.hasAttribute("lwt-sidebar"),
+ "Sidebar box should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar"),
+ "Sidebar should not have lwt-sidebar attribute"
+ );
+ ok(
+ !root.hasAttribute("lwt-sidebar-brighttext"),
+ "Sidebar should not have lwt-sidebar-brighttext 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..081322faa3
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js
@@ -0,0 +1,66 @@
+"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();
+});
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..928fa4edee
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js
@@ -0,0 +1,50 @@
+"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() {
+ for (let protonTabsEnabled of [true, false]) {
+ SpecialPowers.pushPrefEnv({
+ set: [["browser.proton.tabs.enabled", protonTabsEnabled]],
+ });
+ let newWin = await BrowserTestUtils.openNewWindowWithFlushedXULCacheForMozSupports();
+
+ const TAB_LINE_COLOR = "#9400ff";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: ACCENT_COLOR,
+ tab_background_text: TEXT_COLOR,
+ tab_line: TAB_LINE_COLOR,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ info("Checking selected tab line color");
+ let selectedTab = newWin.document.querySelector(
+ ".tabbrowser-tab[selected]"
+ );
+ let line = selectedTab.querySelector(".tab-line");
+ if (protonTabsEnabled) {
+ Assert.equal(
+ newWin.getComputedStyle(line).display,
+ "none",
+ "Tab line should not be displayed when Proton is enabled"
+ );
+ } else {
+ Assert.equal(
+ newWin.getComputedStyle(line).backgroundColor,
+ `rgb(${hexToRGB(TAB_LINE_COLOR).join(", ")})`,
+ "Tab line should have theme color"
+ );
+ }
+
+ 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..1e402dbcc6
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js
@@ -0,0 +1,51 @@
+"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=true]"
+ );
+
+ 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..21f3c6d38b
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js
@@ -0,0 +1,54 @@
+"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=true]"
+ );
+ let openTabBackground = openTab.querySelector(".tab-background");
+
+ let selectedTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ let selectedTabBackground = selectedTab.querySelector(".tab-background");
+
+ let openTabGradient = window
+ .getComputedStyle(openTabBackground)
+ .getPropertyValue("background-image");
+ let selectedTabGradient = window
+ .getComputedStyle(selectedTabBackground)
+ .getPropertyValue("background-image");
+
+ let rgbRegex = /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/g;
+ let selectedTabColors = selectedTabGradient.match(rgbRegex);
+
+ Assert.equal(
+ selectedTabColors[0],
+ "rgb(" + hexToRGB(TAB_BACKGROUND_COLOR).join(", ") + ")",
+ "Selected tab background color should be set."
+ );
+ Assert.equal(openTabGradient, "none");
+
+ gBrowser.removeTab(selectedTab);
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js
new file mode 100644
index 0000000000..722c7dd99c
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js
@@ -0,0 +1,38 @@
+"use strict";
+
+add_task(async function test_support_tab_separators() {
+ const TAB_SEPARATOR_COLOR = "#FF0000";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {
+ colors: {
+ frame: "#000",
+ tab_background_text: "#9400ff",
+ tab_background_separator: TAB_SEPARATOR_COLOR,
+ },
+ },
+ },
+ });
+ await extension.startup();
+
+ info("Checking background tab separator color");
+
+ let tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+
+ Assert.equal(
+ window.getComputedStyle(tab, "::before").borderLeftColor,
+ `rgb(${hexToRGB(TAB_SEPARATOR_COLOR).join(", ")})`,
+ "Left separator has right color."
+ );
+
+ Assert.equal(
+ window.getComputedStyle(tab, "::after").borderLeftColor,
+ `rgb(${hexToRGB(TAB_SEPARATOR_COLOR).join(", ")})`,
+ "Right separator has right color."
+ );
+
+ gBrowser.removeTab(tab);
+
+ 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..cd4d08c38f
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js
@@ -0,0 +1,145 @@
+"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.
+
+ChromeUtils.import(
+ "resource://testing-common/CustomizableUITestUtils.jsm",
+ this
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+add_task(async function setup() {
+ 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.ok(
+ !root.hasAttribute("lwt-toolbar-field-brighttext"),
+ "Brighttext attribute should not be set"
+ );
+
+ 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.ok(
+ root.hasAttribute("lwt-toolbar-field-brighttext"),
+ "Brighttext attribute should be set"
+ );
+
+ 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..05b6a186d2
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js
@@ -0,0 +1,102 @@
+"use strict";
+
+add_task(async function setup() {
+ // 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");
+
+ Assert.equal(
+ window.getComputedStyle(urlBar).backgroundColor,
+ `rgb(${hexToRGB(TOOLBAR_FOCUS_BACKGROUND).join(", ")})`,
+ "Background Color is changed"
+ );
+ Assert.equal(
+ window.getComputedStyle(urlBar).color,
+ `rgb(${hexToRGB(TOOLBAR_FOCUS_TEXT).join(", ")})`,
+ "Text Color is changed"
+ );
+ testBorderColor(urlBar, TOOLBAR_FOCUS_BORDER);
+
+ gURLBar.textbox.removeAttribute("focused");
+
+ Assert.equal(
+ window.getComputedStyle(urlBar).backgroundColor,
+ `rgb(${hexToRGB(TOOLBAR_FIELD_BACKGROUND).join(", ")})`,
+ "Background Color is set back to initial"
+ );
+ Assert.equal(
+ window.getComputedStyle(urlBar).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..f31e0fce8a
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js
@@ -0,0 +1,56 @@
+"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 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..11643412dd
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js
@@ -0,0 +1,107 @@
+"use strict";
+
+// This test checks applied WebExtension themes that attempt to change
+// icon color properties
+
+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);
+ Assert.equal(
+ toolbarbuttonCS.getPropertyValue("--lwt-toolbarbutton-icon-fill"),
+ "",
+ "Icon fill should not be set when the value is not specified in the manifest."
+ );
+ 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..64155006d9
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js
@@ -0,0 +1,143 @@
+"use strict";
+
+const { AddonSettings } = ChromeUtils.import(
+ "resource://gre/modules/addons/AddonSettings.jsm"
+);
+
+// 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_task(async function setup() {
+ 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({
+ 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_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": '<!DOCTYPE html><script src="test.js"></script>',
+ "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..21ef6bf460
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js
@@ -0,0 +1,61 @@
+"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 extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name,
+ permissions: ["tabs"],
+ },
+
+ async background() {
+ let popup;
+
+ // Called after the popup loads
+ browser.runtime.onMessage.addListener(async ({ docTitle }) => {
+ const { id } = await popup;
+ const { title } = await browser.windows.get(id);
+ browser.windows.remove(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"
+ );
+
+ browser.test.notifyPass("popup-window-title");
+ });
+
+ popup = browser.windows.create({
+ url: "/index.html",
+ type: "popup",
+ });
+ },
+ files: {
+ "index.html": `<!doctype html>
+ <meta charset="utf-8">
+ <title>${docTitle}</title>,
+ <script src="index.js"></script>
+ `,
+ "index.js": `addEventListener(
+ "load",
+ () => browser.runtime.sendMessage({docTitle: document.title})
+ );`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("popup-window-title");
+ await extension.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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Test downloads referrer</title>
+ </head>
+ <body>
+ <a href="test-download.txt" class="test-link">test link</a>
+ </body>
+</html>
diff --git a/toolkit/components/extensions/test/browser/head.js b/toolkit/components/extensions/test/browser/head.js
new file mode 100644
index 0000000000..0dd1a1666c
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/head.js
@@ -0,0 +1,103 @@
+/* exported ACCENT_COLOR, BACKGROUND, ENCODED_IMAGE_DATA, FRAME_COLOR, TAB_TEXT_COLOR,
+ TEXT_COLOR, TAB_BACKGROUND_TEXT_COLOR, imageBufferFromDataURI, hexToCSS, hexToRGB, testBorderColor,
+ waitForTransition, loadTestSubscript */
+
+"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 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);
+}
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..012dcfe284
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/head_serviceworker.js
@@ -0,0 +1,123 @@
+"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..5ed13a1b18
--- /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",
+ "applications": {
+ "gecko": { "id": "extension-with-bg-sw@test" }
+ },
+ "background": {
+ "service_worker": "sw.js"
+ }
+} \ No newline at end of file
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.ini b/toolkit/components/extensions/test/marionette/manifest.ini
new file mode 100644
index 0000000000..78006ccf1e
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/manifest.ini
@@ -0,0 +1 @@
+[test_extension_serviceworkers_purged_on_pref_disabled.py] \ No newline at end of file
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..64e376adf0
--- /dev/null
+++ b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py
@@ -0,0 +1,82 @@
+# 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_driver import Wait
+from marionette_driver.addons import Addons
+from marionette_harness import MarionetteTestCase
+
+import os
+
+EXT_ID = "extension-with-bg-sw@test"
+EXT_DIR_PATH = "extension-with-bg-sw"
+PREF_BG_SW_ENABLED = "extensions.backgroundServiceWorker.enabled"
+
+
+class PurgeExtensionServiceWorkersOnPrefDisabled(MarionetteTestCase):
+ 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.restart(in_app=True)
+
+ def tearDown(self):
+ self.marionette.restart(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",
+ )
+
+ 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/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.ini b/toolkit/components/extensions/test/mochitest/chrome.ini
new file mode 100644
index 0000000000..209f11b864
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -0,0 +1,37 @@
+[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.jsm
+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]
+skip-if = (os == 'linux' && bits == 64) #Bug 1393920
+[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')
+[test_chrome_ext_downloads_uniquify.html]
+[test_chrome_ext_permissions.html]
+skip-if = os == 'android' # Bug 1350559
+[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..397996b15c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js
@@ -0,0 +1,66 @@
+"use strict";
+
+/* global addMessageListener, sendAsyncMessage */
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+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 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<script>
+"use strict";
+</script>
+</head>
+</html>
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..2b9344f463
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<iframe src="http://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html">
+</iframe>
+
+</body>
+</html>
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..c1112acbd8
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contains_img.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<img src="file_image_good.png"/>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe id="emptyframe"></iframe>
+ <iframe id="regularframe" src="http://test1.example.com/"></iframe>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe srcdoc="<iframe src='http://test1.example.com/'&gt;</iframe&gt;"></iframe>
+</body>
+</html>
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..dda5169d69
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe id="frame" src="http://test2.example.com/"></iframe>
+</body>
+</html>
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 @@
+<meta charset=utf-8>
+<title>Super green test page</title>
+<body style="background: #0f0">
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
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_bad.png
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_good.png
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_great.png
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_image_redirect.png
Binary files 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 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <script>
+"use strict";
+
+const objectStoreName = "Objects";
+
+let test = {key: 0, value: "test"};
+
+let request = indexedDB.open("WebExtensionTest", 1);
+request.onupgradeneeded = event => {
+ let db = event.target.result;
+ let objectStore = db.createObjectStore(objectStoreName,
+ {autoIncrement: 0});
+ request = objectStore.add(test.value, test.key);
+ request.onsuccess = event => {
+ db.close();
+ window.postMessage("indexedDBCreated", "*");
+ };
+};
+ </script>
+ </head>
+ <body>
+ This is a test page.
+ </body>
+<html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+<img id="bad-image" src="http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png" />
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>1450965 Skip Cors Check for Early WebExtention Redirects</title>
+</head>
+<body>
+ <pre id="c">
+ Fetching ...
+ </pre>
+ <script>
+ "use strict";
+ let c = document.querySelector("#c");
+ const channel = new BroadcastChannel("test_bus");
+ function l(t) { c.innerText += `${t}\n`; }
+
+ fetch("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_cors_blocked.txt")
+ .then(r => r.text())
+ .then(t => {
+ // This Request should have been redirected to /file_sample.txt in
+ // onBeforeRequest. So the text should be 'Sample'
+ l(`Loaded: ${t}`);
+ channel.postMessage(t);
+ }).catch(e => {
+ // The Redirect Failed, most likly due to a CORS Error
+ l(`e`);
+ channel.postMessage(e.toString());
+ });
+ </script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title>
+</head>
+<body>
+ <div id="testdiv">foo</div>
+</body>
+</html>
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 @@
+<!DOCTYPE>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <script>
+ "use strict";
+ var response = {
+ tabs: false,
+ cookie: document.cookie,
+ };
+ try {
+ browser.tabs.create({url: "file_sample.html"});
+ response.tabs = true;
+ } catch (e) {
+ // ok
+ }
+ window.parent.postMessage(response, "*");
+ </script>
+ </head>
+</html>
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..a20e49a1f0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_sample.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <script>
+ "use strict";
+
+ navigator.serviceWorker.register("serviceWorker.js").then(() => {
+ window.postMessage("serviceWorkerRegistered", "*");
+ });
+ </script>
+ </head>
+ <body>
+ This is a test page.
+ </body>
+<html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "/xhr_sandboxed");
+req.send();
+
+let sandbox = document.createElement("iframe");
+sandbox.setAttribute("sandbox", "allow-scripts");
+sandbox.setAttribute("src", "file_simple_sandboxed_subframe.html");
+document.documentElement.appendChild(sandbox);
+</script>
+<img src="file_image_great.png"/>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+</body>
+</html>
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..f6ef67277d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "http://example.org/example.txt");
+req.send();
+</script>
+<img src="file_image_good.png"/>
+<iframe src="file_simple_xhr_frame.html"/>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html
new file mode 100644
index 0000000000..7f38247ac0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "/xhr_resource");
+req.send();
+</script>
+<img src="file_image_bad.png"/>
+<iframe src="file_simple_xhr_frame2.html"/>
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html
new file mode 100644
index 0000000000..6174a0b402
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+let req = new XMLHttpRequest();
+req.open("GET", "/xhr_resource_2");
+req.send();
+
+let sandbox = document.createElement("iframe");
+sandbox.setAttribute("sandbox", "allow-scripts");
+sandbox.setAttribute("src", "file_simple_sandboxed_frame.html");
+document.documentElement.appendChild(sandbox);
+</script>
+<img src="file_image_redirect.png"/>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <title>The Title</title>
+</head>
+<body>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+ <title>Another Title</title>
+ <link href="file_image_great.png" rel="icon" type="image/png" />
+</head>
+<body>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+
+"use strict"
+
+let url = new URL(location);
+let img = new Image();
+img.src = `http://${url.searchParams.get("domain")}/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png`;
+document.body.appendChild(img);
+
+</script>
+
+</body>
+</html>
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 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body style="background: #ff9">
+ &nbsp;
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+ <head>
+ <meta http-equiv="refresh" content="1;dummy_page.html">
+ </head>
+ <body>
+ </body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+ <head>
+ </head>
+ <body>
+ </body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_webNavigation_clientRedirect.html" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="redirection.sjs" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<body>
+
+<iframe src="file_webNavigation_manualSubframe_page1.html" width="200" height="200"></iframe>
+
+<form>
+</form>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+
+<html>
+ <body>
+ <h1>page1</h1>
+ <a href="file_webNavigation_manualSubframe_page2.html">page2</a>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+
+<html>
+ <body>
+ <h1>page2</h1>
+ </body>
+</html>
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 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <iframe id="a_b" src="about:blank"></iframe>
+ <iframe srcdoc="galactica actual" src="adama"></iframe>
+</body>
+</html>
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 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <img src="https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_image_good.png">
+ <img src="http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_image_great.png">
+</body>
+</html>
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..d0d2f02e2d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html
@@ -0,0 +1,6 @@
+<!DOCTYPE HTML>
+<meta charset="utf-8">
+
+<img src="file_image_great.png"/>
+Load a cross-origin iframe from example.net <p>
+<iframe src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.html"></iframe>
diff --git a/toolkit/components/extensions/test/mochitest/head.js b/toolkit/components/extensions/test/mochitest/head.js
new file mode 100644
index 0000000000..2d26de34c7
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head.js
@@ -0,0 +1,123 @@
+"use strict";
+
+/* exported AppConstants, Assert */
+
+var { AppConstants } = SpecialPowers.Cu.import(
+ "resource://gre/modules/AppConstants.jsm",
+ {}
+);
+
+let remote = SpecialPowers.getBoolPref("extensions.webextensions.remote");
+if (remote) {
+ // 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.
+ SpecialPowers.setIntPref("dom.ipc.keepProcessesAlive.extension", 1);
+}
+
+{
+ 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.jsm. 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 = `
+const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});
+(${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();
+ }
+ });
+ });
+}
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..0c8cf24350
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_notifications.js
@@ -0,0 +1,169 @@
+"use strict";
+
+/* exported MockAlertsService */
+
+function mockServicesChromeScript() {
+ const MOCK_ALERTS_CID = Components.ID(
+ "{48068bc2-40ab-4904-8afd-4cdfb3a385f3}"
+ );
+ const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1";
+
+ const { setTimeout } = ChromeUtils.import(
+ "resource://gre/modules/Timer.jsm",
+ {}
+ );
+ 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(outer, iid) {
+ if (outer != null) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION);
+ }
+ 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..73b98b68ae
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js
@@ -0,0 +1,50 @@
+/* -*- 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.Cu.import(
+ "resource://gre/modules/NetUtil.jsm",
+ {}
+);
+
+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"
+ ),
+ indexedDB: Services.perms.testPermissionFromPrincipal(
+ principal,
+ "indexedDB"
+ ),
+ 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..f6c6530e41
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js
@@ -0,0 +1,482 @@
+"use strict";
+
+let commonEvents = {
+ onBeforeRequest: [{ urls: ["<all_urls>"] }, ["blocking"]],
+ onBeforeSendHeaders: [
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"],
+ ],
+ onSendHeaders: [{ urls: ["<all_urls>"] }, ["requestHeaders"]],
+ onBeforeRedirect: [{ urls: ["<all_urls>"] }],
+ onHeadersReceived: [
+ { urls: ["<all_urls>"] },
+ ["blocking", "responseHeaders"],
+ ],
+ // Auth tests will need to set their own events object
+ // "onAuthRequired": [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]],
+ onResponseStarted: [{ urls: ["<all_urls>"] }],
+ onCompleted: [{ urls: ["<all_urls>"] }, ["responseHeaders"]],
+ onErrorOccurred: [{ urls: ["<all_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", "<all_urls>"],
+ },
+ 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..636f331882
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/hsts.sjs
@@ -0,0 +1,8 @@
+function handleRequest(request, response) {
+ let page = "<!DOCTYPE html><html><body><p>HSTS page</p></body></html>";
+ 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.ini b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
new file mode 100644
index 0000000000..2e32a951e2
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini
@@ -0,0 +1,206 @@
+[DEFAULT]
+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_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_sandboxed_frame.html
+ file_simple_sandboxed_subframe.html
+ file_simple_xhr.html
+ file_simple_xhr_frame.html
+ file_simple_xhr_frame2.html
+ 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_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
+prefs =
+ security.mixed_content.upgrade_display_content=false
+ browser.chrome.guess_favicon=true
+
+[test_ext_activityLog.html]
+skip-if =
+ os == 'android'
+ tsan # Times out on TSan, bug 1612707
+ xorigin # Inconsistent pass/fail in opt and debug
+[test_ext_async_clipboard.html]
+skip-if = toolkit == 'android' || tsan # near-permafail after landing bug 1270059: Bug 1523131. tsan: bug 1612707
+[test_ext_background_canvas.html]
+[test_ext_background_page.html]
+skip-if = (toolkit == 'android') # android doesn't have devtools
+[test_ext_browsingData_indexedDB.html]
+[test_ext_browsingData_localStorage.html]
+[test_ext_browsingData_pluginData.html]
+[test_ext_browsingData_serviceWorkers.html]
+[test_ext_browsingData_settings.html]
+[test_ext_canvas_resistFingerprinting.html]
+[test_ext_clipboard.html]
+skip-if = os == 'android'
+[test_ext_clipboard_image.html]
+skip-if = headless # Bug 1405872
+[test_ext_contentscript_about_blank.html]
+skip-if = os == 'android' # bug 1369440
+[test_ext_contentscript_activeTab.html]
+skip-if = os == 'android' || fission
+[test_ext_contentscript_cache.html]
+skip-if = (os == 'linux' && debug) || (toolkit == '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') || (verify && debug && (os == 'linux')) # Bug 1617062
+[test_ext_contentscript_devtools_metadata.html]
+[test_ext_contentscript_fission_frame.html]
+[test_ext_contentscript_incognito.html]
+skip-if = os == 'android' # Android does not support multiple windows.
+[test_ext_contentscript_permission.html]
+skip-if = tsan # Times out on TSan, bug 1612707
+[test_ext_cookies.html]
+skip-if = os == 'android' || tsan # Times out on TSan intermittently, bug 1615184; not supported on Android yet
+[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 Android does not support multiple windows.
+[test_ext_cookies_permissions_bad.html]
+[test_ext_cookies_permissions_good.html]
+[test_ext_downloads_download.html]
+[test_ext_embeddedimg_iframe_frameAncestors.html]
+[test_ext_exclude_include_globs.html]
+[test_ext_external_messaging.html]
+[test_ext_generate.html]
+[test_ext_geolocation.html]
+skip-if = os == 'android' # Android support Bug 1336194
+[test_ext_identity.html]
+skip-if = os == 'android' || tsan # unsupported. 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' # Android does not support multiple windows.
+[test_ext_listener_proxies.html]
+[test_ext_new_tab_processType.html]
+skip-if = verify && debug && (os == 'linux' || os == 'mac')
+[test_ext_notifications.html]
+skip-if = os == 'android' # Not supported on Android yet
+[test_ext_protocolHandlers.html]
+skip-if = (toolkit == 'android') # bug 1342577
+[test_ext_redirect_jar.html]
+skip-if = os == 'win' && (debug || asan) # Bug 1563440
+[test_ext_request_urlClassification.html]
+skip-if = os == 'android' # Bug 1615427
+[test_ext_runtime_connect.html]
+[test_ext_runtime_connect_twoway.html]
+[test_ext_runtime_connect2.html]
+[test_ext_runtime_disconnect.html]
+[test_ext_sendmessage_doublereply.html]
+[test_ext_sendmessage_frameId.html]
+[test_ext_sendmessage_no_receiver.html]
+[test_ext_sendmessage_reply.html]
+[test_ext_sendmessage_reply2.html]
+skip-if = os == 'android'
+[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}
+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]
+[test_ext_subframes_privileges.html]
+skip-if = os == 'android' || verify # bug 1489771
+[test_ext_tabs_captureTab.html]
+[test_ext_tabs_query_popup.html]
+[test_ext_tabs_permissions.html]
+[test_ext_tabs_sendMessage.html]
+[test_ext_test.html]
+[test_ext_unlimitedStorage.html]
+skip-if = os == 'android'
+[test_ext_unlimitedStorage_legacy_persistent_indexedDB.html]
+# IndexedDB persistent storage mode is not allowed on Fennec from a non-chrome privileged code
+# (it has only been enabled for apps and privileged code). See Bug 1119462 for additional info.
+skip-if = os == 'android'
+[test_ext_web_accessible_resources.html]
+skip-if = (os == 'android' && debug) || fission || (os == "linux" && bits == 64) # bug 1397615, bug 1588284, bug 1618231
+[test_ext_web_accessible_incognito.html]
+skip-if = (os == 'android') || fission # Crashes intermittently: @ mozilla::dom::BrowsingContext::CreateFromIPC(mozilla::dom::BrowsingContext::IPCInitializer&&, mozilla::dom::BrowsingContextGroup*, mozilla::dom::ContentParent*), bug 1588284, bug 1397615 and bug 1513544
+[test_ext_webnavigation.html]
+skip-if = (os == 'android' && debug) # bug 1397615
+[test_ext_webnavigation_filters.html]
+skip-if = (os == 'android' && debug) || (verify && (os == 'linux' || os == 'mac')) # bug 1397615
+[test_ext_webnavigation_incognito.html]
+skip-if = os == 'android' # bug 1513544
+[test_ext_webrequest_and_proxy_filter.html]
+[test_ext_webrequest_auth.html]
+skip-if = os == 'android'
+[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}]
+[test_ext_webrequest_errors.html]
+skip-if = tsan
+[test_ext_webrequest_filter.html]
+skip-if = os == 'android' && debug || tsan # bug 1452348. tsan: bug 1612707
+[test_ext_webrequest_frameId.html]
+skip-if = (webrender && os == 'linux') # Bug 1482983 caused by Bug 1480951
+[test_ext_webrequest_hsts.html]
+skip-if = os == 'android' || os == 'linux' || os == 'mac' #Bug 1605515
+[test_ext_webrequest_upgrade.html]
+[test_ext_webrequest_upload.html]
+skip-if = os == 'android' # Currently fails in emulator tests
+[test_ext_webrequest_redirect_bypass_cors.html]
+[test_ext_webrequest_redirect_data_uri.html]
+[test_ext_window_postMessage.html]
diff --git a/toolkit/components/extensions/test/mochitest/mochitest-remote.ini b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini
new file mode 100644
index 0000000000..2828eb2182
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+tags = webextensions remote-webextensions
+skip-if = !e10s || (os == 'android') # Bug 1620091: disable on android until extension process is done
+prefs =
+ extensions.webextensions.remote=true
+
+[test_verify_remote_mode.html]
+[include:mochitest-common.ini]
diff --git a/toolkit/components/extensions/test/mochitest/mochitest.ini b/toolkit/components/extensions/test/mochitest/mochitest.ini
new file mode 100644
index 0000000000..4612cac657
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+tags = webextensions in-process-webextensions
+prefs =
+ extensions.webextensions.remote=false
+dupe-manifest = true
+
+[test_verify_non_remote_mode.html]
+[test_ext_storage_cleanup.html]
+# Bug 1426514 storage_cleanup: clearing localStorage fails with oop
+
+[include:mochitest-common.ini]
+skip-if = os == 'win' # Windows WebExtensions always run OOP
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..e4be8acd69
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/mochitest_console.js
@@ -0,0 +1,53 @@
+"use strict";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <script>
+ "use strict";
+
+ onload = () => {
+ let url = new URL(location);
+ if (url.searchParams.get("post")) {
+ let server_redirect = `${url.searchParams.get("server_uri")}?redirect_uri=${encodeURIComponent(url.searchParams.get("redirect_uri"))}`;
+ let form = document.forms.testform;
+ form.setAttribute("action", server_redirect);
+ form.submit();
+ } else {
+ let end = new URL(url.searchParams.get("redirect_uri"));
+ end.searchParams.set("access_token", "here ya go");
+ location.href = end.href;
+ }
+ };
+ </script>
+</head>
+<body>
+ <form name="testform" action="" method="POST">
+ </form>
+</body>
+</html>
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..27d249f022
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+Components.utils.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..370ecd213f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/redirection.sjs
@@ -0,0 +1,4 @@
+function handleRequest(aRequest, aResponse) {
+ aResponse.setStatusLine(aRequest.httpVersion, 302);
+ aResponse.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..54e2e5fb4d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/return_headers.sjs
@@ -0,0 +1,20 @@
+/* -*- 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
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/serviceWorker.js
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..290d6ca1de
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/slow_response.sjs
@@ -0,0 +1,55 @@
+/* -*- 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 */
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+
+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 = [
+ `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>`,
+ "Lorem ipsum dolor sit amet, <br>",
+ "consectetur adipiscing elit, <br>",
+ "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>",
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>",
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>",
+ "Excepteur sint occaecat cupidatat non proident, <br>",
+ "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>",
+ `
+ </body>
+ </html>`,
+];
+
+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_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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test content script matching a data: URI</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+add_task(async function test_contentscript_data_uri() {
+ const target = ExtensionTestUtils.loadExtension({
+ files: {
+ "page.html": `<!DOCTYPE html>
+ <meta charset="utf-8">
+ <iframe id="inherited" src="data:text/html;charset=utf-8,inherited"></iframe>
+ `,
+ },
+ background() {
+ browser.test.sendMessage("page", browser.runtime.getURL("page.html"));
+ },
+ });
+
+ const scripts = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webNavigation"],
+ content_scripts: [{
+ all_frames: true,
+ matches: ["<all_urls>"],
+ run_at: "document_start",
+ css: ["all_urls.css"],
+ js: ["all_urls.js"],
+ }],
+ },
+ files: {
+ "all_urls.css": `
+ body { background: yellow; }
+ `,
+ "all_urls.js": function() {
+ document.body.style.color = "red";
+ browser.test.assertTrue(location.protocol !== "data:",
+ `Matched document not a data URI: ${location.href}`);
+ },
+ },
+ background() {
+ browser.webNavigation.onCompleted.addListener(({url, frameId}) => {
+ browser.test.log(`Document loading complete: ${url}`);
+ if (frameId === 0) {
+ browser.test.sendMessage("tab-ready", url);
+ }
+ });
+ },
+ });
+
+ await target.startup();
+ await scripts.startup();
+
+ // Test extension page with a data: iframe.
+ const page = await target.awaitMessage("page");
+
+ // Hold on to the tab by the browser, as extension loads are COOP loads, and
+ // will break WindowProxy references.
+ let win = window.open();
+ const browserFrame = win.browsingContext.embedderElement;
+ win.location.href = page;
+
+ await scripts.awaitMessage("tab-ready");
+ win = browserFrame.contentWindow;
+ is(win.location.href, page, "Extension page loaded into a tab");
+ is(win.document.readyState, "complete", "Page finished loading");
+
+ const iframe = win.document.getElementById("inherited").contentWindow;
+ is(iframe.document.readyState, "complete", "iframe finished loading");
+
+ const style1 = iframe.getComputedStyle(iframe.document.body);
+ is(style1.color, "rgb(0, 0, 0)", "iframe text color is unmodified");
+ is(style1.backgroundColor, "rgba(0, 0, 0, 0)", "iframe background unmodified");
+
+ // Test extension tab navigated to a data: URI.
+ const data = "data:text/html;charset=utf-8,also-inherits";
+ win.location.href = data;
+
+ await scripts.awaitMessage("tab-ready");
+ win = browserFrame.contentWindow;
+ is(win.location.href, data, "Extension tab navigated to a data: URI");
+ is(win.document.readyState, "complete", "Tab finished loading");
+
+ const style2 = win.getComputedStyle(win.document.body);
+ is(style2.color, "rgb(0, 0, 0)", "Tab text color is unmodified");
+ is(style2.backgroundColor, "rgba(0, 0, 0, 0)", "Tab background unmodified");
+
+ win.close();
+ await target.unload();
+ await scripts.unload();
+});
+
+</script>
+
+</body>
+</html>
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..198b8e85cf
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for telemetry for content script injection</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS";
+
+add_task(async function test_contentscript_telemetry() {
+ // Turn on telemetry and reset it to the previous state once the test is completed.
+ const telemetryCanRecordBase = SpecialPowers.Services.telemetry.canRecordBase;
+ SpecialPowers.Services.telemetry.canRecordBase = true;
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.Services.telemetry.canRecordBase = telemetryCanRecordBase;
+ });
+
+ function background() {
+ browser.test.onMessage.addListener(() => {
+ browser.tabs.executeScript({code: 'browser.test.sendMessage("content-script-run");'});
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ background,
+ };
+
+ let win = window.open("http://example.com/");
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let histogram = SpecialPowers.Services.telemetry.getHistogramById(HISTOGRAM);
+ histogram.clear();
+ is(histogram.snapshot().sum, 0,
+ `No data recorded for histogram: ${HISTOGRAM}.`);
+
+ await extension.startup();
+ is(histogram.snapshot().sum, 0,
+ `No data recorded for histogram after startup: ${HISTOGRAM}.`);
+
+ extension.sendMessage();
+ await extension.awaitMessage("content-script-run");
+
+ let histogramSum = histogram.snapshot().sum;
+ ok(histogramSum > 0,
+ `Data recorded for first extension for histogram: ${HISTOGRAM}.`);
+
+ win.close();
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script unrecognized property on manifest</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const BASE = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest";
+
+add_task(async function test_contentscript() {
+ function background() {
+ browser.runtime.onMessage.addListener(async (msg) => {
+ if (msg == "loaded") {
+ // NOTE: we're removing the tab from here because doing a win.close()
+ // from the chrome test code is raising a "TypeError: can't access
+ // dead object" exception.
+ let tabs = await browser.tabs.query({active: true, currentWindow: true});
+ await browser.tabs.remove(tabs[0].id);
+
+ browser.test.notifyPass("content-script-loaded");
+ }
+ });
+ }
+
+ function contentScript() {
+ chrome.runtime.sendMessage("loaded");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ "unrecognized_property": "with-a-random-value",
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [{
+ message: /Reading manifest: Warning processing content_scripts.*.unrecognized_property: An unexpected property was found/,
+ }]);
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ window.open(`${BASE}/file_sample.html`);
+
+ await Promise.all([extension.awaitFinish("content-script-loaded")]);
+ info("test page loaded");
+
+ await extension.unload();
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_downloads_open_permission() {
+ function backgroundScript() {
+ browser.test.assertEq(browser.downloads.open, undefined,
+ "`downloads.open` permission is required.");
+ browser.test.notifyPass("downloads tests");
+ }
+
+ let extensionData = {
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("downloads tests");
+ await extension.unload();
+});
+
+add_task(async function test_downloads_open_requires_user_interaction() {
+ async function backgroundScript() {
+ await browser.test.assertRejects(
+ browser.downloads.open(10),
+ "downloads.open may only be called from a user input handler",
+ "The error is informative.");
+
+ browser.test.notifyPass("downloads tests");
+ }
+
+ let extensionData = {
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads", "downloads.open"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("downloads tests");
+ await extension.unload();
+});
+
+add_task(async function downloads_open_invalid_id() {
+ async function pageScript() {
+ window.addEventListener("keypress", async function handler() {
+ try {
+ await browser.downloads.open(10);
+ browser.test.sendMessage("download-open.result", {success: true});
+ } catch (e) {
+ browser.test.sendMessage("download-open.result", {
+ success: false,
+ error: e.message,
+ });
+ }
+ window.removeEventListener("keypress", handler);
+ });
+
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extensionData = {
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "foo.txt": "It's the file called foo.txt.",
+ "page.html": `<html><head>
+ <script src="page.js"><\/script>
+ </head></html>`,
+ "page.js": pageScript,
+ },
+ manifest: {
+ permissions: ["downloads", "downloads.open"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let url = await extension.awaitMessage("ready");
+ let win = window.open();
+ let browserFrame = win.browsingContext.embedderElement;
+ win.location.href = url;
+ await extension.awaitMessage("page-ready");
+
+ synthesizeKey("a", {}, browserFrame.contentWindow);
+ let result = await extension.awaitMessage("download-open.result");
+
+ is(result.success, false, "Opening download fails.");
+ is(result.error, "Invalid download id 10", "The error is informative.");
+
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..64cfcfd289
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html
@@ -0,0 +1,257 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test downloads.download() saveAs option</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {FileUtils} = ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
+
+const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir";
+
+const DOWNLOAD_FILENAME = "file_download.nonext.txt";
+const DEFAULT_SUBDIR = "subdir";
+
+// We need to be able to distinguish files downloaded by the file picker from
+// files downloaded without it.
+let pickerDir;
+let pbPickerDir; // for incognito downloads
+let defaultDir;
+
+add_task(async function setup() {
+ // Reset DownloadLastDir preferences in case other tests set them.
+ SpecialPowers.Services.obs.notifyObservers(
+ null,
+ "browser:purge-session-history"
+ );
+
+ // Set up temporary directories.
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ pickerDir = downloadDir.clone();
+ pickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using file picker download directory ${pickerDir.path}`);
+ pbPickerDir = downloadDir.clone();
+ pbPickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using private browsing file picker download directory ${pbPickerDir.path}`);
+ defaultDir = downloadDir.clone();
+ defaultDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using default download directory ${defaultDir.path}`);
+ let subDir = defaultDir.clone();
+ subDir.append(DEFAULT_SUBDIR);
+ subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ isnot(pickerDir.path, defaultDir.path,
+ "Should be able to distinguish between files saved with or without the file picker");
+ isnot(pickerDir.path, pbPickerDir.path,
+ "Should be able to distinguish between files saved in and out of private browsing mode");
+
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["browser.download.folderList", 2],
+ ["browser.download.dir", defaultDir.path],
+ ]});
+
+ SimpleTest.registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ pickerDir.remove(true);
+ pbPickerDir.remove(true);
+ defaultDir.remove(true); // This also removes DEFAULT_SUBDIR.
+ });
+});
+
+add_task(async function test_downloads_saveAs() {
+ const pickerFile = pickerDir.clone();
+ pickerFile.append(DOWNLOAD_FILENAME);
+
+ const pbPickerFile = pbPickerDir.clone();
+ pbPickerFile.append(DOWNLOAD_FILENAME);
+
+ const defaultFile = defaultDir.clone();
+ defaultFile.append(DOWNLOAD_FILENAME);
+
+ const {MockFilePicker} = SpecialPowers;
+ MockFilePicker.init(window);
+
+ function mockFilePickerCallback(expectedStartingDir, pickedFile) {
+ return fp => {
+ // Assert that the downloads API correctly sets the starting directory.
+ ok(fp.displayDirectory.equals(expectedStartingDir), "Got the expected FilePicker displayDirectory");
+
+ // Assert that the downloads API configures both default properties.
+ is(fp.defaultString, DOWNLOAD_FILENAME, "Got the expected FilePicker defaultString");
+ is(fp.defaultExtension, "txt", "Got the expected FilePicker defaultExtension");
+
+ MockFilePicker.setFiles([pickedFile]);
+ };
+ }
+
+ function background() {
+ const url = URL.createObjectURL(new Blob(["file content"]));
+ browser.test.onMessage.addListener(async (filename, saveAs, isPrivate) => {
+ try {
+ let options = {
+ url,
+ filename,
+ incognito: isPrivate,
+ };
+ // Only define the saveAs option if the argument was actually set
+ if (saveAs !== undefined) {
+ options.saveAs = saveAs;
+ }
+ let id = await browser.downloads.download(options);
+ browser.downloads.onChanged.addListener(delta => {
+ if (delta.id == id && delta.state.current === "complete") {
+ browser.test.sendMessage("done", {ok: true, id});
+ }
+ });
+ } catch ({message}) {
+ browser.test.sendMessage("done", {ok: false, message});
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ const manifest = {
+ background,
+ incognitoOverride: "spanning",
+ manifest: {permissions: ["downloads"]},
+ };
+ const extension = ExtensionTestUtils.loadExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // options should have the following properties:
+ // saveAs (Boolean or undefined)
+ // isPrivate (Boolean)
+ // fileName (string)
+ // expectedStartingDir (nsIFile)
+ // destinationFile (nsIFile)
+ async function testExpectFilePicker(options) {
+ ok(!options.destinationFile.exists(), "the file should have been cleaned up properly previously");
+
+ MockFilePicker.showCallback = mockFilePickerCallback(
+ options.expectedStartingDir,
+ options.destinationFile
+ );
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ extension.sendMessage(options.fileName, options.saveAs, options.isPrivate);
+ let result = await extension.awaitMessage("done");
+ ok(result.ok, `downloads.download() works with saveAs=${options.saveAs}`);
+
+ ok(options.destinationFile.exists(), "the file exists.");
+ is(options.destinationFile.fileSize, 12, "downloaded file is the correct size");
+ options.destinationFile.remove(false);
+ MockFilePicker.reset();
+
+ // Test the user canceling the save dialog.
+ MockFilePicker.returnValue = MockFilePicker.returnCancel;
+
+ extension.sendMessage(options.fileName, options.saveAs, options.isPrivate);
+ result = await extension.awaitMessage("done");
+
+ ok(!result.ok, "download rejected if the user cancels the dialog");
+ is(result.message, "Download canceled by the user", "with the correct message");
+ ok(!options.destinationFile.exists(), "file was not downloaded");
+ MockFilePicker.reset();
+ }
+
+ async function testNoFilePicker(saveAs) {
+ ok(!defaultFile.exists(), "the file should have been cleaned up properly previously");
+
+ extension.sendMessage(DOWNLOAD_FILENAME, saveAs, false);
+ let result = await extension.awaitMessage("done");
+ ok(result.ok, `downloads.download() works with saveAs=${saveAs}`);
+
+ ok(defaultFile.exists(), "the file exists.");
+ is(defaultFile.fileSize, 12, "downloaded file is the correct size");
+ defaultFile.remove(false);
+ }
+
+ info("Testing that saveAs=true uses the file picker as expected");
+ let expectedStartingDir = defaultDir;
+ let fpOptions = {
+ saveAs: true,
+ isPrivate: false,
+ fileName: DOWNLOAD_FILENAME,
+ expectedStartingDir: expectedStartingDir,
+ destinationFile: pickerFile,
+ };
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveas=true reuses last file picker directory");
+ fpOptions.expectedStartingDir = pickerDir;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true in PB reuses last directory");
+ let nonPBStartingDir = fpOptions.expectedStartingDir;
+ fpOptions.isPrivate = true;
+ fpOptions.destinationFile = pbPickerFile;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true in PB uses a separate last directory");
+ fpOptions.expectedStartingDir = pbPickerDir;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true in Permanent PB mode ignores the incognito option");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.privatebrowsing.autostart", true]],
+ });
+ fpOptions.isPrivate = false;
+ fpOptions.expectedStartingDir = pbPickerDir;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveas=true reuses the non-PB last directory after private download");
+ await SpecialPowers.popPrefEnv();
+ fpOptions.isPrivate = false;
+ fpOptions.expectedStartingDir = nonPBStartingDir;
+ fpOptions.destinationFile = pickerFile;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=true does not reuse last directory when filename contains a path separator");
+ fpOptions.fileName = DEFAULT_SUBDIR + "/" + DOWNLOAD_FILENAME;
+ let destinationFile = defaultDir.clone();
+ destinationFile.append(DEFAULT_SUBDIR);
+ fpOptions.expectedStartingDir = destinationFile.clone();
+ destinationFile.append(DOWNLOAD_FILENAME);
+ fpOptions.destinationFile = destinationFile;
+ await testExpectFilePicker(fpOptions);
+
+ info("Testing that saveAs=false does not use the file picker");
+ fpOptions.saveAs = false;
+ await testNoFilePicker(fpOptions.saveAs);
+
+ // When saveAs is not set, the behavior should be determined by the Firefox
+ // pref that normally determines whether the "Save As" prompt should be
+ // displayed.
+ info(`Testing that the file picker is used when saveAs is not specified ` +
+ `but ${PROMPTLESS_DOWNLOAD_PREF} is disabled`);
+ fpOptions.saveAs = undefined;
+ await SpecialPowers.pushPrefEnv({"set": [
+ [PROMPTLESS_DOWNLOAD_PREF, false],
+ ]});
+ await testExpectFilePicker(fpOptions);
+
+ info(`Testing that the file picker is NOT used when saveAs is not ` +
+ `specified but ${PROMPTLESS_DOWNLOAD_PREF} is enabled`);
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({"set": [
+ [PROMPTLESS_DOWNLOAD_PREF, true],
+ ]});
+ await testNoFilePicker(fpOptions.saveAs);
+
+ await extension.unload();
+ MockFilePicker.cleanup();
+});
+
+</script>
+
+</body>
+</html>
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..b5fedee7ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test downloads.download() uniquify option</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {FileUtils} = ChromeUtils.import("resource://gre/modules/FileUtils.jsm");
+
+let directory;
+
+add_task(async function setup() {
+ directory = FileUtils.getDir("TmpD", ["downloads"]);
+ directory.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`Using download directory ${directory.path}`);
+
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["browser.download.folderList", 2],
+ ["browser.download.dir", directory.path],
+ ]});
+
+ SimpleTest.registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ directory.remove(true);
+ });
+});
+
+add_task(async function test_downloads_uniquify() {
+ const file = directory.clone();
+ file.append("file_download.txt");
+
+ const unique = directory.clone();
+ unique.append("file_download(1).txt");
+
+ const {MockFilePicker} = SpecialPowers;
+ MockFilePicker.init(window);
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ MockFilePicker.showCallback = fp => {
+ let file = directory.clone();
+ file.append(fp.defaultString);
+ MockFilePicker.setFiles([file]);
+ };
+
+ function background() {
+ const url = URL.createObjectURL(new Blob(["file content"]));
+ browser.test.onMessage.addListener(async (filename, saveAs) => {
+ try {
+ let id = await browser.downloads.download({
+ url,
+ filename,
+ saveAs,
+ conflictAction: "uniquify",
+ });
+ browser.downloads.onChanged.addListener(delta => {
+ if (delta.id == id && delta.state.current === "complete") {
+ browser.test.sendMessage("done", {ok: true, id});
+ }
+ });
+ } catch ({message}) {
+ browser.test.sendMessage("done", {ok: false, message});
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ const manifest = {background, manifest: {permissions: ["downloads"]}};
+ const extension = ExtensionTestUtils.loadExtension(manifest);
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ async function testUniquify(saveAs) {
+ info(`Testing conflictAction:"uniquify" with saveAs=${saveAs}`);
+
+ ok(!file.exists(), "downloaded file should have been cleaned up before test ran");
+ ok(!unique.exists(), "uniquified file should have been cleaned up before test ran");
+
+ // Test download without uniquify and create a conflicting file so we can
+ // test with uniquify.
+ extension.sendMessage("file_download.txt", saveAs);
+ let result = await extension.awaitMessage("done");
+ ok(result.ok, "downloads.download() works with saveAs");
+
+ ok(file.exists(), "the file exists.");
+ is(file.fileSize, 12, "downloaded file is the correct size");
+
+ // Now that a conflicting file exists, test the uniquify behavior
+ extension.sendMessage("file_download.txt", saveAs);
+ result = await extension.awaitMessage("done");
+ ok(result.ok, "downloads.download() works with saveAs and uniquify");
+
+ ok(unique.exists(), "the file exists.");
+ is(unique.fileSize, 12, "downloaded file is the correct size");
+
+ file.remove(false);
+ unique.remove(false);
+ }
+ await testUniquify(true);
+ await testUniquify(false);
+
+ await extension.unload();
+ MockFilePicker.cleanup();
+});
+
+</script>
+
+</body>
+</html>
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..47761784b1
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html
@@ -0,0 +1,176 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function makeTest(manifestPermissions, optionalPermissions, checkFetch = true) {
+ return async function() {
+ function pageScript() {
+ /* global PERMISSIONS */
+ /* eslint-disable mozilla/balanced-listeners */
+ window.addEventListener("keypress", () => {
+ browser.permissions.request(PERMISSIONS).then(result => {
+ browser.test.sendMessage("request.result", result);
+ }, {once: true});
+ });
+ /* eslint-enable mozilla/balanced-listeners */
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "set-cookie") {
+ try {
+ await browser.cookies.set({
+ url: "http://example.com/",
+ name: "COOKIE",
+ value: "NOM NOM",
+ });
+ browser.test.sendMessage("set-cookie.result", {success: true});
+ } catch (err) {
+ dump(`set cookie failed with ${err.message}\n`);
+ browser.test.sendMessage("set-cookie.result",
+ {success: false, message: err.message});
+ }
+ } else if (msg == "remove") {
+ browser.permissions.remove(PERMISSIONS).then(result => {
+ browser.test.sendMessage("remove.result", result);
+ });
+ }
+ });
+
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+
+ manifest: {
+ permissions: manifestPermissions,
+ optional_permissions: [...(optionalPermissions.permissions || []),
+ ...(optionalPermissions.origins || [])],
+
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["content_script.js"],
+ }],
+ },
+
+ files: {
+ "content_script.js": async () => {
+ let url = new URL(window.location.pathname, "http://example.com/");
+ fetch(url, {}).then(response => {
+ browser.test.sendMessage("fetch.result", response.ok);
+ }).catch(err => {
+ browser.test.sendMessage("fetch.result", false);
+ });
+ },
+
+ "page.html": `<html><head>
+ <script src="page.js"><\/script>
+ </head></html>`,
+
+ "page.js": `const PERMISSIONS = ${JSON.stringify(optionalPermissions)}; (${pageScript})();`,
+ },
+ });
+
+ await extension.startup();
+
+ function call(method) {
+ extension.sendMessage(method);
+ return extension.awaitMessage(`${method}.result`);
+ }
+
+ let base = window.location.href.replace(/^chrome:\/\/mochitests\/content/,
+ "http://mochi.test:8888");
+ let file = new URL("file_sample.html", base);
+
+ async function testContentScript() {
+ let win = window.open(file);
+ let result = await extension.awaitMessage("fetch.result");
+ win.close();
+ return result;
+ }
+
+ let url = await extension.awaitMessage("ready");
+ let win = window.open();
+ let browserFrame = win.browsingContext.embedderElement;
+ win.location.href = url;
+ await extension.awaitMessage("page-ready");
+
+ // Using the cookies API from an extension page should fail
+ let result = await call("set-cookie");
+ is(result.success, false, "setting cookie failed");
+ if (manifestPermissions.includes("cookies")) {
+ ok(/^Permission denied/.test(result.message),
+ "setting cookie failed with an appropriate error due to missing host permission");
+ } else {
+ ok(/browser\.cookies is undefined/.test(result.message),
+ "setting cookie failed since cookies API is not present");
+ }
+
+ // Making a cross-origin request from a content script should fail
+ if (checkFetch) {
+ result = await testContentScript();
+ is(result, false, "fetch() failed from content script due to lack of host permission");
+ }
+
+ synthesizeKey("a", {}, browserFrame.contentWindow);
+ result = await extension.awaitMessage("request.result");
+ is(result, true, "permissions.request() succeeded");
+
+ // Using the cookies API from an extension page should succeed
+ result = await call("set-cookie");
+ is(result.success, true, "setting cookie succeeded");
+
+ // Making a cross-origin request from a content script should succeed
+ if (checkFetch) {
+ result = await testContentScript();
+ is(result, true, "fetch() succeeded from content script due to lack of host permission");
+ }
+
+ // Now revoke our permissions
+ result = await call("remove");
+
+ // The cookies API should once again fail
+ result = await call("set-cookie");
+ is(result.success, false, "setting cookie failed");
+
+ // As should the cross-origin request from a content script
+ if (checkFetch) {
+ result = await testContentScript();
+ is(result, false, "fetch() failed from content script due to lack of host permission");
+ }
+
+ await extension.unload();
+ };
+}
+
+add_task(function setup() {
+ // Don't bother with prompts in this test.
+ return SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextOptionalPermissionPrompts", false]],
+ });
+});
+
+const ORIGIN = "*://example.com/";
+add_task(makeTest([], {
+ permissions: ["cookies"],
+ origins: [ORIGIN],
+}));
+
+add_task(makeTest(["cookies"], {origins: [ORIGIN]}));
+add_task(makeTest([ORIGIN], {permissions: ["cookies"]}, false));
+
+</script>
+
+</body>
+</html>
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..580ea5e793
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html
@@ -0,0 +1,98 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+var {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+
+function tp_background(expectFail = true) {
+ fetch("https://tracking.example.com/example.txt").then(() => {
+ browser.test.assertTrue(!expectFail, "fetch received");
+ browser.test.sendMessage("done");
+ }, () => {
+ browser.test.assertTrue(expectFail, "fetch failure");
+ browser.test.sendMessage("done");
+ });
+}
+
+async function test_permission(permissions, expectFail) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+ background: `(${tp_background})(${expectFail})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+}
+
+add_task(async function setup() {
+ await UrlClassifierTestUtils.addTestTrackers();
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.trackingprotection.enabled", true]],
+ });
+});
+
+// Fetch would be blocked with these tests
+add_task(async function() { await test_permission([], true); });
+add_task(async function() { await test_permission(["http://*/"], true); });
+add_task(async function() { await test_permission(["http://*.example.com/"], true); });
+add_task(async function() { await test_permission(["http://localhost/*"], true); });
+// Fetch will not be blocked if the extension has host permissions.
+add_task(async function() { await test_permission(["<all_urls>"], false); });
+add_task(async function() { await test_permission(["*://tracking.example.com/*"], false); });
+
+add_task(async function test_contentscript() {
+ function contentScript() {
+ fetch("https://tracking.example.com/example.txt").then(() => {
+ browser.test.notifyPass("fetch received");
+ }, () => {
+ browser.test.notifyFail("fetch failure");
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["*://tracking.example.com/*"],
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+ const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ let win = window.open(url);
+ await extension.awaitFinish();
+ win.close();
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+});
+</script>
+
+</body>
+</html>
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..a06709d807
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html
@@ -0,0 +1,81 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function webnav_unresolved_uri_on_expected_URI_scheme() {
+ function background() {
+ let checkURLs;
+
+ browser.webNavigation.onCompleted.addListener(async msg => {
+ if (checkURLs.length) {
+ let expectedURL = checkURLs.shift();
+ browser.test.assertEq(expectedURL, msg.url, "Got the expected URL");
+ await browser.tabs.remove(msg.tabId);
+ browser.test.sendMessage("next");
+ }
+ });
+
+ browser.test.onMessage.addListener((name, urls) => {
+ if (name == "checkURLs") {
+ checkURLs = urls;
+ }
+ });
+
+ browser.test.sendMessage("ready", browser.runtime.getURL("/tab.html"));
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background,
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ </html>
+ `,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let checkURLs = [
+ "resource://gre/modules/Services.jsm",
+ "chrome://mochikit/content/tests/SimpleTest/SimpleTest.js",
+ "about:mozilla",
+ ];
+
+ let tabURL = await extension.awaitMessage("ready");
+ checkURLs.push(tabURL);
+
+ extension.sendMessage("checkURLs", checkURLs);
+
+ for (let url of checkURLs) {
+ window.open(url);
+ await extension.awaitMessage("next");
+ }
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..a9dfb0a902
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {webrequest_test} = ChromeUtils.import(SimpleTest.getTestFileURL("webrequest_test.jsm"));
+let {testFetch, testXHR} = webrequest_test;
+
+// Here we test that any requests originating from a system principal are not
+// accessible through WebRequest. text_ext_webrequest_background_events tests
+// non-system principal requests.
+
+let testExtension = {
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ let eventNames = [
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ ];
+
+ function listener(name, details) {
+ // If we get anything, we failed. Removing the system principal check
+ // in ext-webrequest triggers this failure.
+ browser.test.fail(`received ${name}`);
+ }
+
+ for (let name of eventNames) {
+ browser.webRequest[name].addListener(
+ listener.bind(null, name),
+ {urls: ["https://example.com/*"]}
+ );
+ }
+ },
+};
+
+add_task(async function test_webRequest_chromeworker_events() {
+ let extension = ExtensionTestUtils.loadExtension(testExtension);
+ await extension.startup();
+ await new Promise(resolve => {
+ let worker = new ChromeWorker("webrequest_chromeworker.js");
+ worker.onmessage = event => {
+ ok("chrome worker fetch finished");
+ resolve();
+ };
+ worker.postMessage("go");
+ });
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_chromepage_events() {
+ let extension = ExtensionTestUtils.loadExtension(testExtension);
+ await extension.startup();
+ await new Promise(resolve => {
+ fetch("https://example.com/example.txt").then(() => {
+ ok("test page loaded");
+ resolve();
+ });
+ });
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_jsm_events() {
+ let extension = ExtensionTestUtils.loadExtension(testExtension);
+ await extension.startup();
+ await testFetch("https://example.com/example.txt").then(() => {
+ ok("fetch page loaded");
+ });
+ await testXHR("https://example.com/example.txt").then(() => {
+ ok("xhr page loaded");
+ });
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test webRequest checks host permissions</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webRequest_host_permissions() {
+ function background() {
+ function png(details) {
+ browser.test.sendMessage("png", details.url);
+ }
+ browser.webRequest.onBeforeRequest.addListener(png, {urls: ["*://*/*.png"]});
+ browser.test.sendMessage("ready");
+ }
+
+ const all = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "<all_urls>"]}});
+ const example = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "https://example.com/"]}});
+ const mochi_test = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "http://mochi.test/"]}});
+
+ await all.startup();
+ await example.startup();
+ await mochi_test.startup();
+
+ await all.awaitMessage("ready");
+ await example.awaitMessage("ready");
+ await mochi_test.awaitMessage("ready");
+
+ const win1 = window.open("https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html");
+ let urls = [await all.awaitMessage("png"),
+ await all.awaitMessage("png")];
+ ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png");
+ ok((await example.awaitMessage("png")).endsWith("good.png"), "example permission sees same-origin example.com image");
+ ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png");
+
+ // Clear the in-memory image cache, it can prevent listeners from receiving events.
+ const imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ imgTools.getImgCacheForDocument(win1.document).clearCache(false);
+ win1.close();
+
+ const win2 = window.open("http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html");
+ urls = [await all.awaitMessage("png"),
+ await all.awaitMessage("png")];
+ ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png");
+ ok((await mochi_test.awaitMessage("png")).endsWith("great.png"), "mochi.test permission sees same-origin mochi.test image");
+ ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png");
+ win2.close();
+
+ await all.unload();
+ await example.unload();
+ await mochi_test.unload();
+});
+
+add_task(async function test_webRequest_filter_permissions_warning() {
+ const manifest = {
+ permissions: ["webRequest", "http://example.com/"],
+ };
+
+ async function background() {
+ await browser.webRequest.onBeforeRequest.addListener(() => {}, {urls: ["http://example.org/"]});
+ browser.test.notifyPass();
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({manifest, background});
+
+ const warning = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [{message: /filter doesn't overlap with host permissions/}]);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+
+ SimpleTest.endMonitorConsole();
+ await warning;
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
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..4c19359d8b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html
@@ -0,0 +1,193 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test moz-extension protocol use</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let peakAchu;
+add_task(async function setup() {
+ peakAchu = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ // ID for the extension in the tests. Try to observe it to ensure we cannot.
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`);
+ }, {urls: ["<all_urls>", "moz-extension://*/*"]});
+
+ browser.test.onMessage.addListener((msg, extensionUrl) => {
+ browser.test.log(`spying for ${extensionUrl}`);
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`);
+ }, {urls: [extensionUrl]});
+ });
+ },
+ });
+ await peakAchu.startup();
+});
+
+add_task(async function test_webRequest_no_mozextension_permission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "tabs",
+ "moz-extension://c9e007e0-e518-ed4c-8202-83849981dd21/*",
+ "moz-extension://*/*",
+ ],
+ },
+ background() {
+ browser.test.notifyPass("loaded");
+ },
+ });
+
+ let messages = [
+ {message: /processing permissions\.2: Value "moz-extension:\/\/c9e007e0-e518-ed4c-8202-83849981dd21\/\*"/},
+ {message: /processing permissions\.3: Value "moz-extension:\/\/\*\/\*"/},
+ ];
+
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, messages);
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("loaded");
+ await extension.unload();
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+});
+
+add_task(async function test_webRequest_mozextension_fetch() {
+ function background() {
+ let page = browser.extension.getURL("fetched.html");
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.assertEq(details.url, page, "got correct url in onBeforeRequest");
+ browser.test.sendMessage("request-started");
+ }, {urls: [browser.extension.getURL("*")]}, ["blocking"]);
+ browser.webRequest.onCompleted.addListener(details => {
+ browser.test.assertEq(details.url, page, "got correct url in onCompleted");
+ browser.test.sendMessage("request-complete");
+ }, {urls: [browser.extension.getURL("*")]});
+
+ browser.test.onMessage.addListener((msg, data) => {
+ fetch(page).then(() => {
+ browser.test.notifyPass("fetch success");
+ browser.test.sendMessage("done");
+ }, () => {
+ browser.test.fail("fetch failed");
+ browser.test.sendMessage("done");
+ });
+ });
+ browser.test.sendMessage("extensionUrl", browser.extension.getURL("*"));
+ }
+
+ // Use webrequest to monitor moz-extension:// requests
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "tabs",
+ "<all_urls>",
+ ],
+ },
+ files: {
+ "fetched.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>moz-extension file</h1>
+ </body>
+ </html>
+ `.trim(),
+ },
+ background,
+ });
+
+ await extension.startup();
+ // send the url for this extension to the monitoring extension
+ peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl"));
+
+ extension.sendMessage("testFetch");
+ await extension.awaitMessage("request-started");
+ await extension.awaitMessage("request-complete");
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_mozextension_tab_query() {
+ function background() {
+ browser.test.sendMessage("extensionUrl", browser.extension.getURL("*"));
+ let page = browser.extension.getURL("tab.html");
+
+ async function onUpdated(tabId, tabInfo, tab) {
+ if (tabInfo.status !== "complete") {
+ return;
+ }
+ browser.test.log(`tab created ${tabId} ${JSON.stringify(tabInfo)} ${tab.url}`);
+ let tabs = await browser.tabs.query({url: browser.extension.getURL("*")});
+ browser.test.assertEq(1, tabs.length, "got one tab");
+ browser.test.assertEq(tabs.length && tabs[0].id, tab.id, "got the correct tab");
+ browser.test.assertEq(tabs.length && tabs[0].url, page, "got correct url in tab");
+ browser.tabs.remove(tabId);
+ browser.tabs.onUpdated.removeListener(onUpdated);
+ browser.test.sendMessage("tabs-done");
+ }
+ browser.tabs.onUpdated.addListener(onUpdated);
+ browser.tabs.create({url: page});
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "tabs",
+ "<all_urls>",
+ ],
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>moz-extension file</h1>
+ </body>
+ </html>
+ `.trim(),
+ },
+ background,
+ });
+
+ await extension.startup();
+ peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl"));
+ await extension.awaitMessage("tabs-done");
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ await peakAchu.unload();
+});
+</script>
+
+</body>
+</html>
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..78359747ce
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {OS} = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+// Test that the default paths searched for native host manifests
+// are the ones we expect.
+add_task(async function test_default_paths() {
+ let expectUser, expectGlobal;
+ switch (AppConstants.platform) {
+ case "macosx": {
+ expectUser = OS.Path.join(OS.Constants.Path.homeDir,
+ "Library/Application Support/Mozilla");
+ expectGlobal = "/Library/Application Support/Mozilla";
+
+ break;
+ }
+
+ case "linux": {
+ expectUser = OS.Path.join(OS.Constants.Path.homeDir, ".mozilla");
+
+ const libdir = AppConstants.HAVE_USR_LIB64_DIR ? "lib64" : "lib";
+ expectGlobal = OS.Path.join("/usr", libdir, "mozilla");
+ break;
+ }
+
+ default:
+ // Fixed filesystem paths are only defined for MacOS and Linux,
+ // there's nothing to test on other platforms.
+ ok(false, `This test does not apply on ${AppConstants.platform}`);
+ break;
+ }
+
+ let userDir = Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile).path;
+ is(userDir, expectUser, "user-specific native messaging directory is correct");
+
+ let globalDir = Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile).path;
+ is(globalDir, expectGlobal, "system-wide native messaing directory is correct");
+});
+
+</script>
+
+</body>
+</html>
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..ce4689540d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html
@@ -0,0 +1,390 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension activityLog test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_api() {
+ let URL =
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ // Test that an unspecified extension is not logged by the watcher extension.
+ let unlogged = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ applications: { gecko: { id: "unlogged@tests.mozilla.org" } },
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ // This privileged test extension should not affect the webRequest
+ // data received by non-privileged extensions (See Bug 1576272).
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ return { cancel: false };
+ },
+ { urls: ["http://mochi.test/*/file_sample.html"] },
+ ["blocking"]
+ );
+ },
+ });
+ await unlogged.startup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "watched@tests.mozilla.org" } },
+ permissions: [
+ "tabs",
+ "tabHide",
+ "storage",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ content_scripts: [
+ {
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+ files: {
+ "content_script.js": () => {
+ browser.test.sendMessage("content_script");
+ },
+ "registered_script.js": () => {
+ browser.test.sendMessage("registered_script");
+ },
+ },
+ async background() {
+ let listen = () => {};
+ async function runTest() {
+ // Test activity for a child function call.
+ browser.test.assertEq(
+ undefined,
+ browser.activityLog,
+ "activityLog requires permission"
+ );
+
+ // Test a child event manager.
+ browser.storage.onChanged.addListener(listen);
+ browser.storage.onChanged.removeListener(listen);
+
+ // Test a parent event manager.
+ let webRequestListener = details => {
+ browser.webRequest.onBeforeRequest.removeListener(webRequestListener);
+ return { cancel: false };
+ };
+ browser.webRequest.onBeforeRequest.addListener(
+ webRequestListener,
+ { urls: ["http://mochi.test/*/file_sample.html"] },
+ ["blocking"]
+ );
+
+ // A manifest based content script is already
+ // registered, we do a dynamic registration here.
+ await browser.contentScripts.register({
+ js: [{ file: "registered_script.js" }],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ runAt: "document_start",
+ });
+ browser.test.sendMessage("ready");
+ }
+ browser.test.onMessage.addListener((msg, data) => {
+ // Logging has started here so this listener is logged, but the
+ // call adding it was not. We do an additional onMessage.addListener
+ // call in the test function to validate child based event managers.
+ if (msg == "runtest") {
+ browser.test.assertTrue(true, msg);
+ runTest();
+ }
+ if (msg == "hideTab") {
+ browser.tabs.hide(data);
+ }
+ });
+ browser.test.sendMessage("url", browser.extension.getURL(""));
+ },
+ });
+
+ async function backgroundScript(expectedUrl, extensionUrl) {
+ let expecting = [
+ // Test child-only api_call.
+ {
+ type: "api_call",
+ name: "test.assertTrue",
+ data: { args: [true, "runtest"] },
+ },
+
+ // Test child-only api_call.
+ {
+ type: "api_call",
+ name: "test.assertEq",
+ data: {
+ args: [undefined, undefined, "activityLog requires permission"],
+ },
+ },
+ // Test child addListener calls.
+ {
+ type: "api_call",
+ name: "storage.onChanged.addListener",
+ data: {
+ args: [],
+ },
+ },
+ {
+ type: "api_call",
+ name: "storage.onChanged.removeListener",
+ data: {
+ args: [],
+ },
+ },
+ // Test parent addListener calls.
+ {
+ type: "api_call",
+ name: "webRequest.onBeforeRequest.addListener",
+ data: {
+ args: [
+ {
+ incognito: null,
+ tabId: null,
+ types: null,
+ urls: ["http://mochi.test/*/file_sample.html"],
+ windowId: null,
+ },
+ ["blocking"],
+ ],
+ },
+ },
+ // Test an api that makes use of callParentAsyncFunction.
+ {
+ type: "api_call",
+ name: "contentScripts.register",
+ data: {
+ args: [
+ {
+ allFrames: null,
+ css: null,
+ excludeGlobs: null,
+ excludeMatches: null,
+ includeGlobs: null,
+ js: [
+ {
+ file: `${extensionUrl}registered_script.js`,
+ },
+ ],
+ matchAboutBlank: null,
+ matches: ["http://mochi.test/*/file_sample.html"],
+ runAt: "document_start",
+ },
+ ],
+ },
+ },
+ // Test child api_event calls.
+ {
+ type: "api_event",
+ name: "test.onMessage",
+ data: { args: ["runtest"] },
+ },
+ {
+ type: "api_call",
+ name: "test.sendMessage",
+ data: { args: ["ready"] },
+ },
+ // Test parent api_event calls.
+ {
+ type: "api_call",
+ name: "webRequest.onBeforeRequest.removeListener",
+ data: {
+ args: [],
+ },
+ },
+ {
+ type: "api_event",
+ name: "webRequest.onBeforeRequest",
+ data: {
+ args: [
+ {
+ url: expectedUrl,
+ method: "GET",
+ type: "main_frame",
+ frameId: 0,
+ parentFrameId: -1,
+ incognito: false,
+ thirdParty: false,
+ ip: null,
+ frameAncestors: [],
+ urlClassification: { firstParty: [], thirdParty: [] },
+ requestSize: 0,
+ responseSize: 0,
+ },
+ ],
+ result: {
+ cancel: false,
+ },
+ },
+ },
+ // Test manifest based content script.
+ {
+ type: "content_script",
+ name: "content_script.js",
+ data: { url: expectedUrl, tabId: 1 },
+ },
+ // registered script test
+ {
+ type: "content_script",
+ name: `${extensionUrl}registered_script.js`,
+ data: { url: expectedUrl, tabId: 1 },
+ },
+ {
+ type: "api_call",
+ name: "test.sendMessage",
+ data: { args: ["registered_script"], tabId: 1 },
+ },
+ {
+ type: "api_call",
+ name: "test.sendMessage",
+ data: { args: ["content_script"], tabId: 1 },
+ },
+ // Child api call
+ {
+ type: "api_call",
+ name: "tabs.hide",
+ data: { args: ["__TAB_ID"] },
+ },
+ {
+ type: "api_event",
+ name: "test.onMessage",
+ data: { args: ["hideTab", "__TAB_ID"] },
+ },
+ ];
+ browser.test.assertTrue(browser.activityLog, "activityLog is privileged");
+
+ // Slightly less than a normal deep equal, we want to know that the values
+ // in our expected data are the same in the actual data, but we don't care
+ // if actual data has additional data or if data is in the same order in objects.
+ // This allows us to ignore keys that may be variable, or that are set in
+ // the api with an undefined value.
+ function deepEquivalent(a, b) {
+ if (a === b) {
+ return true;
+ }
+ if (
+ typeof a != "object" ||
+ typeof b != "object" ||
+ a === null ||
+ b === null
+ ) {
+ return false;
+ }
+ for (let k in a) {
+ if (!deepEquivalent(a[k], b[k])) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ let tab;
+ let handler = async details => {
+ browser.test.log(`onExtensionActivity ${JSON.stringify(details)}`);
+ let test = expecting.shift();
+ if (!test) {
+ browser.test.notifyFail(`no test for ${details.name}`);
+ }
+
+ // On multiple runs, tabId will be different. Set the current
+ // tabId where we need it.
+ if (test.data.tabId !== undefined) {
+ test.data.tabId = tab.id;
+ }
+ if (test.data.args !== undefined) {
+ test.data.args = test.data.args.map(value =>
+ value === "__TAB_ID" ? tab.id : value
+ );
+ }
+
+ browser.test.assertEq(test.type, details.type, "type matches");
+ if (test.type == "content_script") {
+ browser.test.assertTrue(
+ details.name.includes(test.name),
+ "content script name matches"
+ );
+ } else {
+ browser.test.assertEq(test.name, details.name, "name matches");
+ }
+
+ browser.test.assertTrue(
+ deepEquivalent(test.data, details.data),
+ `expected ${JSON.stringify(
+ test.data
+ )} included in actual ${JSON.stringify(details.data)}`
+ );
+ if (!expecting.length) {
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("activity");
+ }
+ };
+ browser.activityLog.onExtensionActivity.addListener(
+ handler,
+ "watched@tests.mozilla.org"
+ );
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "opentab") {
+ tab = await browser.tabs.create({ url: expectedUrl });
+ browser.test.sendMessage("tabid", tab.id);
+ }
+ if (msg === "done") {
+ browser.activityLog.onExtensionActivity.removeListener(
+ handler,
+ "watched@tests.mozilla.org"
+ );
+ }
+ });
+ }
+
+ await extension.startup();
+ let extensionUrl = await extension.awaitMessage("url");
+
+ let logger = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ applications: { gecko: { id: "watcher@tests.mozilla.org" } },
+ permissions: ["activityLog"],
+ },
+ background: `(${backgroundScript})("${URL}", "${extensionUrl}")`,
+ });
+ await logger.startup();
+ extension.sendMessage("runtest");
+ await extension.awaitMessage("ready");
+ logger.sendMessage("opentab");
+ let id = await logger.awaitMessage("tabid");
+
+ await Promise.all([
+ extension.awaitMessage("content_script"),
+ extension.awaitMessage("registered_script"),
+ ]);
+
+ extension.sendMessage("hideTab", id);
+ await logger.awaitFinish("activity");
+
+ // Stop watching because we get extra calls on extension shutdown
+ // such as listener removal.
+ logger.sendMessage("done");
+
+ await extension.unload();
+ await unlogged.unload();
+ await logger.unload();
+});
+</script>
+
+</body>
+</html>
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..62933bf008
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js
@@ -0,0 +1,181 @@
+/* -*- 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.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.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.onStartup",
+ "runtime.onUpdateAvailable",
+ "runtime.openOptionsPage",
+ "runtime.reload",
+ "runtime.setUninstallURL",
+ "theme.getCurrent",
+ "theme.onUpdated",
+ "types.LevelOfControl",
+ "types.SettingScope",
+];
+
+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";
+ }
+ 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 (let key in obj) {
+ let val = obj[key];
+ if (typeof val == "object" && val !== null && mayRecurse(key, val)) {
+ diveDeeper(`${path}.${key}`, val);
+ } else if (val !== undefined) {
+ results.push(`${path}.${key}`);
+ }
+ }
+ }
+ diveDeeper("browser", browser);
+ diveDeeper("chrome", chrome);
+ browser.test.sendMessage("allApis", results.sort());
+}
+
+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");
+
+ 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");
+
+ await extension.unload();
+});
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..ffa421e042
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html
@@ -0,0 +1,376 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Async Clipboard permissions tests</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+// Bug 1479956 - On android-debug verify this test times out
+SimpleTest.requestLongerTimeout(2);
+
+/* globals clipboardWriteText, clipboardWrite, clipboardReadText, clipboardRead */
+function shared() {
+ this.clipboardWriteText = function(txt) {
+ return navigator.clipboard.writeText(txt);
+ };
+
+ this.clipboardWrite = function(dt) {
+ return navigator.clipboard.write(dt);
+ };
+
+ this.clipboardReadText = function() {
+ return navigator.clipboard.readText();
+ };
+
+ this.clipboardRead = function() {
+ return navigator.clipboard.read();
+ };
+}
+
+/**
+ * Clear the clipboard.
+ *
+ * This is needed because Services.clipboard.emptyClipboard() does not clear the actual system clipboard.
+ */
+function clearClipboard() {
+ if (AppConstants.platform == "android") {
+ // On android, this clears the actual system clipboard
+ SpecialPowers.Services.clipboard.emptyClipboard(SpecialPowers.Services.clipboard.kGlobalClipboard);
+ return;
+ }
+ // Need to do this hack on other platforms to clear the actual system clipboard
+ let transf = SpecialPowers.Cc["@mozilla.org/widget/transferable;1"]
+ .createInstance(SpecialPowers.Ci.nsITransferable);
+ transf.init(null);
+ // Empty transferables may cause crashes, so just add an unknown type.
+ const TYPE = "text/x-moz-place-empty";
+ transf.addDataFlavor(TYPE);
+ transf.setTransferData(TYPE, {}, 0);
+ SpecialPowers.Services.clipboard.setData(transf, null, SpecialPowers.Services.clipboard.kGlobalClipboard);
+}
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["dom.events.asyncClipboard", true],
+ ["dom.events.asyncClipboard.dataTransfer", true],
+ ]});
+});
+
+// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in background script
+add_task(async function test_background_async_clipboard_no_permissions() {
+ function backgroundScript() {
+ let dt = new DataTransfer();
+ dt.items.add("Howdy", "text/plain");
+ browser.test.assertRejects(clipboardRead(), undefined, "Read should be denied without permission");
+ browser.test.assertRejects(clipboardWrite(dt), undefined, "Write should be denied without permission");
+ browser.test.assertRejects(clipboardWriteText("blabla"), undefined, "WriteText should be denied without permission");
+ browser.test.assertRejects(clipboardReadText(), undefined, "ReadText should be denied without permission");
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ background: [shared, backgroundScript],
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extension.unload();
+});
+
+// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in content script
+add_task(async function test_contentscript_async_clipboard_no_permission() {
+ function contentScript() {
+ let dt = new DataTransfer();
+ dt.items.add("Howdy", "text/plain");
+ browser.test.assertRejects(clipboardRead(), undefined, "Read should be denied without permission");
+ browser.test.assertRejects(clipboardWrite(dt), undefined, "Write should be denied without permission");
+ browser.test.assertRejects(clipboardWriteText("blabla"), undefined, "WriteText should be denied without permission");
+ browser.test.assertRejects(clipboardReadText(), undefined, "ReadText should be denied without permission");
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use writeText in content script
+add_task(async function test_contentscript_clipboard_permission_writetext() {
+ function contentScript() {
+ let str = "HI";
+ clipboardWriteText(str).then(function() {
+ // nothing here
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("WriteText promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboardWriteText
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ const actual = SpecialPowers.getClipboardData("text/unicode");
+ is(actual, "HI", "right string copied by write");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use readText in content script
+add_task(async function test_contentscript_clipboard_permission_readtext() {
+ function contentScript() {
+ let str = "HI";
+ clipboardReadText().then(function(strData) {
+ if (strData == str) {
+ browser.test.succeed("Successfully read from clipboard");
+ } else {
+ browser.test.fail("ReadText read the wrong thing from clipboard:" + strData);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("ReadText promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboardReadText
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ await SimpleTest.promiseClipboardChange("HI", () => {
+ SpecialPowers.clipboardCopyString("HI");
+ }, "text/unicode");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use write in content script
+add_task(async function test_contentscript_clipboard_permission_write() {
+ function contentScript() {
+ let str = "HI";
+ let dt = new DataTransfer();
+ dt.items.add(str, "text/plain");
+ clipboardWrite(dt).then(function() {
+ // nothing here
+ browser.test.sendMessage("ready");
+ }, function(err) { // clipboardWrite promise error function
+ browser.test.fail("Write promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboard write
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ const actual = SpecialPowers.getClipboardData("text/unicode");
+ is(actual, "HI", "right string copied by write");
+ win.close();
+ await extension.unload();
+});
+
+// Test that with enough permissions, we are allowed to use read in content script
+add_task(async function test_contentscript_clipboard_permission_read() {
+ function contentScript() {
+ clipboardRead().then(function(dt) {
+ let s = dt.getData("text/plain");
+ if (s == "HELLO") {
+ browser.test.succeed("Read promise successfully read the right thing");
+ } else {
+ browser.test.fail("Read read the wrong string from clipboard:" + s);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) { // clipboardRead promise error function
+ browser.test.fail("Read promise rejected");
+ browser.test.sendMessage("ready");
+ }); // clipboard read
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ await SimpleTest.promiseClipboardChange("HELLO", () => {
+ SpecialPowers.clipboardCopyString("HELLO");
+ }, "text/unicode");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that performing readText(...) when the clipboard is empty returns an empty string
+add_task(async function test_contentscript_clipboard_nocontents_readtext() {
+ function contentScript() {
+ clipboardReadText().then(function(strData) {
+ if (strData == "") {
+ browser.test.succeed("ReadText successfully read correct thing from an empty clipboard");
+ } else {
+ browser.test.fail("ReadText should have read an empty string, but read:" + strData);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("ReadText promise rejected: " + err);
+ browser.test.sendMessage("ready");
+ });
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+
+ await SimpleTest.promiseClipboardChange("", () => {
+ clearClipboard();
+ }, "text/x-moz-place-empty");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+
+// Test that performing read(...) when the clipboard is empty returns an empty data transfer
+add_task(async function test_contentscript_clipboard_nocontents_read() {
+ function contentScript() {
+ clipboardRead().then(function(dataT) {
+ // On macOS if we clear the clipboard and read from it, there will be
+ // no items in the data transfer object.
+ // On linux with e10s enabled clearing of the clipboard does not happen in
+ // the same way as it does on other platforms. So when we clear the clipboard
+ // and read from it, the data transfer object contains an item of type
+ // text/plain and kind string, but we can't call getAsString on it to verify
+ // that at least it is an empty string because the callback never gets invoked.
+ if (!dataT.items.length ||
+ (dataT.items.length == 1 && dataT.items[0].type == "text/plain" &&
+ dataT.items[0].kind == "string")) {
+ browser.test.succeed("Read promise successfully resolved");
+ } else {
+ browser.test.fail("Read read the wrong thing from clipboard, " +
+ "data transfer has this many items:" + dataT.items.length);
+ }
+ browser.test.sendMessage("ready");
+ }, function(err) {
+ browser.test.fail("Read promise rejected: " + err);
+ browser.test.sendMessage("ready");
+ });
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["https://example.com/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardRead",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+
+ await SimpleTest.promiseClipboardChange("", () => {
+ clearClipboard();
+ }, "text/x-moz-place-empty");
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..8b6fba25bb
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for background page canvas rendering</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_background_canvas() {
+ function background() {
+ try {
+ let canvas = document.createElement("canvas");
+
+ let context = canvas.getContext("2d");
+
+ // This ensures that we have a working PresShell, and can successfully
+ // calculate font metrics.
+ context.font = "8pt fixed";
+
+ browser.test.notifyPass("background-canvas");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("background-canvas");
+ }
+ }
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "background_canvas@tests.mozilla.org" } },
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ await extension.awaitFinish("background-canvas");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..9cafd8a61a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html
@@ -0,0 +1,84 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js" type="text/javascript"></script>
+ <link href="/tests/SimpleTest/test.css" rel="stylesheet"/>
+ </head>
+ <body>
+
+ <script type="text/javascript">
+ "use strict";
+
+ /* eslint-disable mozilla/balanced-listeners */
+
+ add_task(async function testAlertNotShownInBackgroundWindow() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background: function () {
+ alert("I am an alert in the background.");
+
+ browser.test.notifyPass("alertCalled");
+ }
+ });
+
+ let consoleOpened = loadChromeScript(() => {
+ const {sendAsyncMessage, assert} = this;
+ assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present at the start of the test.");
+
+ Services.obs.addObserver(function observer() {
+ sendAsyncMessage("web-console-created");
+ Services.obs.removeObserver(observer, "web-console-created");
+ }, "web-console-created");
+ });
+ let opened = consoleOpened.promiseOneMessage("web-console-created");
+
+ consoleMonitor.start([
+ {
+ message: /alert\(\) is not supported in background windows/
+ }, {
+ message: /I am an alert in the background/
+ }
+ ]);
+
+ await extension.startup();
+ await extension.awaitFinish("alertCalled");
+
+ let chromeScript = loadChromeScript(async () => {
+ const {assert} = this;
+ assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present after calling alert().");
+ });
+ chromeScript.destroy();
+
+ await consoleMonitor.finished();
+
+ await opened;
+ consoleOpened.destroy();
+
+ chromeScript = loadChromeScript(async () => {
+ const {sendAsyncMessage} = this;
+ let {require} = ChromeUtils.import ("resource://devtools/shared/Loader.jsm");
+ require("devtools/client/framework/devtools-browser");
+ let {BrowserConsoleManager} = require("devtools/client/webconsole/browser-console-manager");
+
+ // And then double check that we have an actual browser console.
+ let haveConsole = !!BrowserConsoleManager.getBrowserConsole();
+
+ if (haveConsole) {
+ await BrowserConsoleManager.toggleBrowserConsole();
+ }
+ sendAsyncMessage("done", haveConsole);
+ });
+
+ let consoleShown = await chromeScript.promiseOneMessage("done");
+ ok(consoleShown, "console was shown");
+ chromeScript.destroy();
+
+ await extension.unload();
+ });
+ </script>
+
+ </body>
+</html>
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..f7d36633db
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html
@@ -0,0 +1,161 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function testIndexedDB() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+
+ async function background() {
+ const PAGE =
+ "/tests/toolkit/components/extensions/test/mochitest/file_indexedDB.html";
+
+ let tabs = [];
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "cleanup") {
+ await Promise.all(tabs.map(tabId => browser.tabs.remove(tabId)));
+ browser.test.sendMessage("done");
+ return;
+ }
+
+ await browser.browsingData.remove(msg, { indexedDB: true });
+ browser.test.sendMessage("indexedDBRemoved");
+ });
+
+ // Create two tabs.
+ let tab = await browser.tabs.create({ url: `http://mochi.test:8888${PAGE}` });
+ tabs.push(tab.id);
+
+ tab = await browser.tabs.create({ url: `http://example.com${PAGE}` });
+ tabs.push(tab.id);
+
+ // Create tab with cookieStoreId "firefox-container-1"
+ tab = await browser.tabs.create({ url: `http://example.net${PAGE}`, cookieStoreId: 'firefox-container-1' });
+ tabs.push(tab.id);
+ }
+
+ function contentScript() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener(
+ "message",
+ msg => {
+ browser.test.sendMessage("indexedDBCreated");
+ },
+ true
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background,
+ manifest: {
+ applications: { gecko: { id: "indexedDb@tests.mozilla.org" } },
+ permissions: ["browsingData", "tabs", "cookies"],
+ content_scripts: [
+ {
+ matches: [
+ "http://mochi.test/*/file_indexedDB.html",
+ "http://example.com/*/file_indexedDB.html",
+ "http://example.net/*/file_indexedDB.html",
+ ],
+ js: ["script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("indexedDBCreated");
+ await extension.awaitMessage("indexedDBCreated");
+ await extension.awaitMessage("indexedDBCreated");
+
+ function getUsage() {
+ return new Promise(resolve => {
+ let qms = SpecialPowers.Services.qms;
+ let cb = SpecialPowers.wrapCallback(request => resolve(request.result));
+ qms.getUsage(cb);
+ });
+ }
+
+ async function getOrigins() {
+ let origins = [];
+ let result = await getUsage();
+ for (let i = 0; i < result.length; ++i) {
+ if (result[i].usage === 0) {
+ continue;
+ }
+ if (
+ result[i].origin.startsWith("http://mochi.test") ||
+ result[i].origin.startsWith("http://example.com") ||
+ result[i].origin.startsWith("http://example.net")
+ ) {
+ origins.push(result[i].origin);
+ }
+ }
+ return origins.sort();
+ }
+
+ let origins = await getOrigins();
+ is(origins.length, 3, "IndexedDB databases have been populated.");
+
+ // Deleting private browsing mode data is silently ignored.
+ extension.sendMessage({ cookieStoreId: "firefox-private" });
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 3, "All indexedDB remains after clearing firefox-private");
+
+ // Delete by hostname
+ extension.sendMessage({ hostnames: ["example.com"] });
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 2, "IndexedDB data only for only two domains left");
+ ok(origins[0].startsWith("http://example.net"), "example.net not deleted");
+ ok(origins[1].startsWith("http://mochi.test"), "mochi.test not deleted");
+
+ // TODO: Bug 1643740
+ if (AppConstants.platform != "android") {
+ // Delete by cookieStoreId
+ extension.sendMessage({ cookieStoreId: "firefox-container-1" });
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 1, "IndexedDB data only for only one domain");
+ ok(origins[0].startsWith("http://mochi.test"), "mochi.test not deleted");
+ }
+
+ // Delete all
+ extension.sendMessage({});
+ await extension.awaitMessage("indexedDBRemoved");
+
+ origins = await getOrigins();
+ is(origins.length, 0, "All IndexedDB data has been removed.");
+
+ await extension.sendMessage("cleanup");
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..cf6c420366
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html
@@ -0,0 +1,322 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ // make sure userContext is enabled.
+ return SpecialPowers.pushPrefEnv({
+ set: [["privacy.userContext.enabled", true]],
+ });
+});
+
+add_task(async function testLocalStorage() {
+ async function background() {
+ function waitForTabs() {
+ return new Promise(resolve => {
+ let tabs = {};
+
+ let listener = async (msg, { tab }) => {
+ if (msg !== "content-script-ready") {
+ return;
+ }
+
+ tabs[tab.url] = tab;
+ if (Object.keys(tabs).length == 3) {
+ browser.runtime.onMessage.removeListener(listener);
+ resolve(tabs);
+ }
+ };
+ browser.runtime.onMessage.addListener(listener);
+ });
+ }
+
+ function sendMessageToTabs(tabs, message) {
+ return Promise.all(
+ Object.values(tabs).map(tab => {
+ return browser.tabs.sendMessage(tab.id, message);
+ })
+ );
+ }
+
+ let tabs = await waitForTabs();
+
+ browser.test.assertRejects(
+ browser.browsingData.removeLocalStorage({ since: Date.now() }),
+ "Firefox does not support clearing localStorage with 'since'.",
+ "Expected error received when using unimplemented parameter 'since'."
+ );
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await browser.browsingData.removeLocalStorage({
+ hostnames: ["example.com"],
+ });
+ await browser.tabs.sendMessage(tabs["http://example.com/"].id, "checkLocalStorageCleared");
+ await browser.tabs.sendMessage(tabs["http://example.net/"].id, "checkLocalStorageSet");
+
+ if (
+ SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled ===
+ false
+ ) {
+ // This assertion fails when localStorage is using the legacy
+ // implementation (See Bug 1595431).
+ browser.test.log("Skipped assertion on nextGenLocalStorageEnabled=false");
+ } else {
+ await browser.tabs.sendMessage(tabs["http://test1.example.com/"].id, "checkLocalStorageSet");
+ }
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ await browser.browsingData.removeLocalStorage({});
+ await sendMessageToTabs(tabs, "checkLocalStorageCleared");
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ await browser.browsingData.remove({}, { localStorage: true });
+ await sendMessageToTabs(tabs, "checkLocalStorageCleared");
+
+ // Can only delete cookieStoreId with LSNG enabled.
+ if (SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled) {
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ await browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-container-1",
+ });
+ await browser.tabs.sendMessage(tabs["http://example.com/"].id, "checkLocalStorageSet");
+ await browser.tabs.sendMessage(tabs["http://example.net/"].id, "checkLocalStorageSet");
+
+ // TODO: containers support is lacking on GeckoView (Bug 1643740)
+ if (!navigator.userAgent.includes("Android")) {
+ await browser.tabs.sendMessage(tabs["http://test1.example.com/"].id, "checkLocalStorageCleared");
+ }
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ // Hostname doesn't match, so nothing cleared.
+ await browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-container-1",
+ hostnames: ["example.net"],
+ });
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+
+ await sendMessageToTabs(tabs, "resetLocalStorage");
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ // Deleting private browsing mode data is silently ignored.
+ await browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-private",
+ });
+ await sendMessageToTabs(tabs, "checkLocalStorageSet");
+ } else {
+ await browser.test.assertRejects(
+ browser.browsingData.removeLocalStorage({
+ cookieStoreId: "firefox-container-1",
+ }),
+ "removeLocalStorage with cookieStoreId requires LSNG"
+ );
+ }
+
+ // Cleanup (checkLocalStorageCleared creates empty LS databases).
+ await browser.browsingData.removeLocalStorage({});
+
+ browser.test.notifyPass("done");
+ }
+
+ function contentScript() {
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg === "resetLocalStorage") {
+ localStorage.clear();
+ localStorage.setItem("test", "test");
+ } else if (msg === "checkLocalStorageSet") {
+ browser.test.assertEq(
+ "test",
+ localStorage.getItem("test"),
+ `checkLocalStorageSet: ${location.href}`
+ );
+ } else if (msg === "checkLocalStorageCleared") {
+ browser.test.assertEq(
+ null,
+ localStorage.getItem("test"),
+ `checkLocalStorageCleared: ${location.href}`
+ );
+ }
+ });
+ browser.runtime.sendMessage("content-script-ready");
+ }
+
+ // This extension is responsible for opening tabs with a specified
+ // cookieStoreId, we use a separate extension to make sure that browsingData
+ // works without the cookies permission.
+ let openTabsExtension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ name: "Open tabs",
+ applications: { gecko: { id: "open-tabs@tests.mozilla.org" }, },
+ permissions: ["cookies"],
+ },
+ async background() {
+ const TABS = [
+ { url: "http://example.com" },
+ { url: "http://example.net" },
+ {
+ url: "http://test1.example.com",
+ cookieStoreId: 'firefox-container-1',
+ },
+ ];
+
+ function awaitLoad(tabId) {
+ return new Promise(resolve => {
+ browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) {
+ if (tabId == tabId_ && changed.status == "complete") {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ let tabs = [];
+ let loaded = [];
+ for (let options of TABS) {
+ let tab = await browser.tabs.create(options);
+ loaded.push(awaitLoad(tab.id));
+ tabs.push(tab);
+ }
+
+ await Promise.all(loaded);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "cleanup") {
+ const tabIds = tabs.map(tab => tab.id);
+ let removedTabs = 0;
+ browser.tabs.onRemoved.addListener(tabId => {
+ browser.test.log(`Removing tab ${tabId}.`);
+ if (tabIds.includes(tabId)) {
+ removedTabs++;
+ if (removedTabs == tabIds.length) {
+ browser.test.sendMessage("done");
+ }
+ }
+ });
+ await browser.tabs.remove(tabIds);
+ }
+ });
+ }
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background,
+ manifest: {
+ name: "Test Extension",
+ applications: { gecko: { id: "localStorage@tests.mozilla.org" } },
+ permissions: ["browsingData", "tabs"],
+ content_scripts: [
+ {
+ matches: [
+ "http://example.com/",
+ "http://example.net/",
+ "http://test1.example.com/",
+ ],
+ js: ["content-script.js"],
+ run_at: "document_end",
+ },
+ ],
+ },
+ files: {
+ "content-script.js": contentScript,
+ },
+ });
+
+ await openTabsExtension.startup();
+
+ await extension.startup();
+ await extension.awaitFinish("done");
+ await extension.unload();
+
+ await openTabsExtension.sendMessage("cleanup");
+ await openTabsExtension.awaitMessage("done");
+ await openTabsExtension.unload();
+});
+
+// Verify that browsingData.removeLocalStorage doesn't break on data stored
+// in about:newtab or file principals.
+add_task(async function test_browserData_on_aboutnewtab_and_file_data() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ async background() {
+ await browser.browsingData.removeLocalStorage({}).catch(err => {
+ browser.test.fail(`${err} :: ${err.stack}`);
+ });
+ browser.test.sendMessage("done");
+ },
+ manifest: {
+ applications: { gecko: { id: "indexed-db-file@test.mozilla.org" } },
+ permissions: ["browsingData"],
+ },
+ });
+
+ await new Promise(resolve => {
+ const chromeScript = SpecialPowers.loadChromeScript(async () => {
+ const { SiteDataTestUtils } = ChromeUtils.import(
+ "resource://testing-common/SiteDataTestUtils.jsm"
+ );
+ await SiteDataTestUtils.addToIndexedDB("about:newtab");
+ await SiteDataTestUtils.addToIndexedDB("file:///fake/file");
+ // eslint-disable-next-line no-undef
+ sendAsyncMessage("done");
+ });
+
+ chromeScript.addMessageListener("done", () => {
+ chromeScript.destroy();
+ resolve();
+ });
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_browserData_should_not_remove_extension_data() {
+ if (!SpecialPowers.getBoolPref("dom.storage.next_gen")) {
+ // When LSNG isn't enabled, the browsingData API does still clear
+ // all the extensions localStorage if called without a list of specific
+ // origins to clear.
+ info("Test skipped because LSNG is currently disabled");
+ return;
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ async background() {
+ window.localStorage.setItem("key", "value");
+ await browser.browsingData.removeLocalStorage({}).catch(err => {
+ browser.test.fail(`${err} :: ${err.stack}`);
+ });
+ browser.test.sendMessage("done", window.localStorage.getItem("key"));
+ },
+ manifest: {
+ applications: { gecko: { id: "extension-data@tests.mozilla.org" } },
+ permissions: ["browsingData"],
+ },
+ });
+
+ await extension.startup();
+ const lsValue = await extension.awaitMessage("done");
+ is(lsValue, "value", "Got the expected localStorage data");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..ff75ca7b9f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html
@@ -0,0 +1,71 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// NB: Since plugins are disabled, there is never any data to clear.
+// We are really testing that these operations are no-ops.
+
+add_task(async function testPluginData() {
+ async function background() {
+ const REFERENCE_DATE = Date.now();
+ const TEST_CASES = [
+ // Clear plugin data with no since value.
+ {},
+ // Clear pluginData with recent since value.
+ { since: REFERENCE_DATE - 20000 },
+ // Clear pluginData with old since value.
+ { since: REFERENCE_DATE - 1000000 },
+ // Clear pluginData for specific hosts.
+ { hostnames: ["bar.com", "baz.com"] },
+ // Clear pluginData for no hosts.
+ { hostnames: [] },
+ ];
+
+ for (let method of ["removePluginData", "remove"]) {
+ for (let options of TEST_CASES) {
+ browser.test.log(`Testing ${method} with ${JSON.stringify(options)}`);
+ if (method == "removePluginData") {
+ await browser.browsingData.removePluginData(options);
+ } else {
+ await browser.browsingData.remove(options, { pluginData: true });
+ }
+ }
+ }
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background,
+ manifest: {
+ applications: { gecko: { id: "remove-plugin@tests.mozilla.org" } },
+ permissions: ["tabs", "browsingData"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ // This test has no assertions because it's only meant to check that we don't
+ // throw when calling removePluginData and remove with pluginData: true.
+ ok(true, "dummy check");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..a97a62a0f4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html
@@ -0,0 +1,141 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.remove indexedDB</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const { TestUtils } = SpecialPowers.Cu.import("resource://testing-common/TestUtils.jsm");
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.serviceWorkers.exemptFromPerDomainMax", true],
+ ["dom.serviceWorkers.enabled", true],
+ ["dom.serviceWorkers.testing.enabled", true],
+ ],
+ });
+});
+
+add_task(async function testServiceWorkers() {
+ async function background() {
+ const PAGE =
+ "/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html";
+
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.sendMessage("serviceWorkerRegistered");
+ });
+
+ let tabs = [];
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "cleanup") {
+ await browser.tabs.remove(tabs.map(tab => tab.id));
+ browser.test.sendMessage("done");
+ return;
+ }
+
+ await browser.browsingData.remove(
+ { hostnames: msg.hostnames },
+ { serviceWorkers: true }
+ );
+ browser.test.sendMessage("serviceWorkersRemoved");
+ });
+
+ // Create two serviceWorkers.
+ let tab = await browser.tabs.create({ url: `http://mochi.test:8888${PAGE}` });
+ tabs.push(tab);
+
+ tab = await browser.tabs.create({ url: `http://example.com${PAGE}` });
+ tabs.push(tab);
+ }
+
+ function contentScript() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener(
+ "message",
+ msg => {
+ if (msg.data == "serviceWorkerRegistered") {
+ browser.runtime.sendMessage("serviceWorkerRegistered");
+ }
+ },
+ true
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background,
+ manifest: {
+ applications: { gecko: { id: "service-workers@tests.mozilla.org" } },
+ permissions: ["browsingData", "tabs"],
+ content_scripts: [
+ {
+ matches: [
+ "http://mochi.test/*/file_serviceWorker.html",
+ "http://example.com/*/file_serviceWorker.html",
+ ],
+ js: ["script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("serviceWorkerRegistered");
+ await extension.awaitMessage("serviceWorkerRegistered");
+
+ // Even though we await the registrations by waiting for the messages,
+ // sometimes the serviceWorkers are still not registered at this point.
+ async function getRegistrations(count) {
+ await TestUtils.waitForCondition(
+ async () => (await SpecialPowers.registeredServiceWorkers()).length === count,
+ `Wait for ${count} service workers to be registered`
+ );
+ return SpecialPowers.registeredServiceWorkers();
+ }
+
+ let serviceWorkers = await getRegistrations(2);
+ is(serviceWorkers.length, 2, "ServiceWorkers have been registered.");
+
+ extension.sendMessage({ hostnames: ["example.com"] });
+ await extension.awaitMessage("serviceWorkersRemoved");
+
+ serviceWorkers = await getRegistrations(1);
+ is(
+ serviceWorkers.length,
+ 1,
+ "ServiceWorkers for example.com have been removed."
+ );
+
+ let { scriptSpec } = serviceWorkers[0];
+ dump(`Service worker spec: ${scriptSpec}`);
+ ok(scriptSpec.startsWith("http://mochi.test:8888/"),
+ "ServiceWorkers for example.com have been removed.");
+
+ extension.sendMessage({});
+ await extension.awaitMessage("serviceWorkersRemoved");
+
+ serviceWorkers = await getRegistrations(0);
+ is(serviceWorkers.length, 0, "All ServiceWorkers have been removed.");
+
+ extension.sendMessage("cleanup");
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..3b1d5e1af9
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html
@@ -0,0 +1,67 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test browsingData.settings</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const SETTINGS_LIST = [
+ "cache",
+ "cookies",
+ "history",
+ "formData",
+ "downloads",
+].sort();
+
+add_task(async function testSettings() {
+ async function background() {
+ browser.browsingData.settings().then(settings => {
+ browser.test.sendMessage("settings", settings);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background,
+ manifest: {
+ applications: { gecko: { id: "browsingData-settings@tests.mozilla.org" } },
+ permissions: ["browsingData"],
+ },
+ });
+
+ await extension.startup();
+ let settings = await extension.awaitMessage("settings");
+
+ // Verify that we get the keys back we expect.
+ isDeeply(
+ Object.entries(settings.dataToRemove)
+ .filter(([key, value]) => value)
+ .map(([key, value]) => key)
+ .sort(),
+ SETTINGS_LIST,
+ "dataToRemove contains expected properties."
+ );
+ isDeeply(
+ Object.entries(settings.dataRemovalPermitted)
+ .filter(([key, value]) => value)
+ .map(([key, value]) => key)
+ .sort(),
+ SETTINGS_LIST,
+ "dataToRemove contains expected properties."
+ );
+ is("since" in settings.options, true, "options contains |since|");
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..7116d03235
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html
@@ -0,0 +1,64 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_cookies.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.resistFingerprinting", true]],
+ });
+});
+
+add_task(async function test_contentscript() {
+ function contentScript() {
+ let canvas = document.createElement("canvas");
+ canvas.width = canvas.height = "100";
+
+ let ctx = canvas.getContext("2d");
+ ctx.fillStyle = "green";
+ ctx.fillRect(0, 0, 100, 100);
+ let data = ctx.getImageData(0, 0, 100, 100);
+
+ browser.test.sendMessage("data-color", data.data[1]);
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+ const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html";
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ let win = window.open(url);
+ let color = await extension.awaitMessage("data-color");
+ is(color, 128, "Got correct pixel data for green");
+ win.close();
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Clipboard permissions tests</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+/* globals doCopy, doPaste */
+function shared() {
+ let field = document.createElement("textarea");
+ document.body.appendChild(field);
+ field.contentEditable = true;
+
+ this.doCopy = function(txt) {
+ field.value = txt;
+ field.select();
+ return document.execCommand("copy");
+ };
+
+ this.doPaste = function() {
+ field.select();
+ return document.execCommand("paste") && field.value;
+ };
+}
+
+add_task(async function test_background_clipboard_permissions() {
+ function backgroundScript() {
+ browser.test.assertEq(false, doCopy("whatever"),
+ "copy should be denied without permission");
+ browser.test.assertEq(false, doPaste(),
+ "paste should be denied without permission");
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ background: [shared, backgroundScript],
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("ready");
+
+ await extension.unload();
+});
+
+add_task(async function test_background_clipboard_copy() {
+ function backgroundScript() {
+ browser.test.onMessage.addListener(txt => {
+ browser.test.assertEq(true, doCopy(txt),
+ "copy should be allowed with permission");
+ });
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ background: `(${shared})();(${backgroundScript})();`,
+ manifest: {
+ permissions: [
+ "clipboardWrite",
+ ],
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ const DUMMY_STR = "dummy string to copy";
+ await new Promise(resolve => {
+ SimpleTest.waitForClipboard(DUMMY_STR, () => {
+ extension.sendMessage(DUMMY_STR);
+ }, resolve, resolve);
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_clipboard_permissions() {
+ function contentScript() {
+ browser.test.assertEq(false, doCopy("whatever"),
+ "copy should be denied without permission");
+ browser.test.assertEq(false, doPaste(),
+ "paste should be denied without permission");
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitMessage("ready");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_clipboard_copy() {
+ function contentScript() {
+ browser.test.onMessage.addListener(txt => {
+ browser.test.assertEq(true, doCopy(txt),
+ "copy should be allowed with permission");
+ });
+ browser.test.sendMessage("ready");
+ }
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ js: ["shared.js", "contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ permissions: [
+ "clipboardWrite",
+ ],
+ },
+ files: {
+ "shared.js": shared,
+ "contentscript.js": contentScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitMessage("ready");
+
+ const DUMMY_STR = "dummy string to copy in content script";
+ await new Promise(resolve => {
+ SimpleTest.waitForClipboard(DUMMY_STR, () => {
+ extension.sendMessage(DUMMY_STR);
+ }, resolve, resolve);
+ });
+
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_clipboard_paste() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "clipboardRead",
+ ],
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["shared.js", "content_script.js"],
+ }],
+ },
+ files: {
+ "shared.js": shared,
+ "content_script.js": () => {
+ browser.test.sendMessage("paste", doPaste());
+ },
+ },
+ });
+
+ const STRANGE = "A Strange Thing";
+ SpecialPowers.clipboardCopyString(STRANGE);
+
+ await extension.startup();
+ const win = window.open("file_sample.html");
+
+ const paste = await extension.awaitMessage("paste");
+ is(paste, STRANGE, "the correct string was pasted");
+
+ win.close();
+ await extension.unload();
+});
+
+add_task(async function test_background_clipboard_paste() {
+ function background() {
+ browser.test.sendMessage("paste", doPaste());
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["clipboardRead"],
+ },
+ background: [shared, background],
+ });
+
+ const STRANGE = "Stranger Things";
+ SpecialPowers.clipboardCopyString(STRANGE);
+
+ await extension.startup();
+
+ const paste = await extension.awaitMessage("paste");
+ is(paste, STRANGE, "the correct string was pasted");
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Clipboard permissions tests</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+/**
+ * This cannot be a xpcshell test, because:
+ * - On Android, copyString of nsIClipboardHelper segfaults because
+ * widget/android/nsClipboard.cpp calls java::Clipboard::SetText, which is
+ * unavailable in xpcshell.
+ * - On Windows, the clipboard is unavailable to xpcshell.
+ */
+
+function resetClipboard() {
+ SpecialPowers.clipboardCopyString(
+ "This is the default value of the clipboard in the test.");
+}
+
+async function checkClipboardHasTestImage(imageType) {
+ async function backgroundScript(imageType) {
+ async function verifyImage(img) {
+ // Checks whether the image is a 1x1 red image.
+ browser.test.assertEq(1, img.naturalWidth, "image width should match");
+ browser.test.assertEq(1, img.naturalHeight, "image height should match");
+
+ let canvas = document.createElement("canvas");
+ canvas.width = 1;
+ canvas.height = 1;
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0); // Draw without scaling.
+ let [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
+ let expectedColor;
+ if (imageType === "png") {
+ expectedColor = [255, 0, 0];
+ } else if (imageType === "jpeg") {
+ expectedColor = [254, 0, 0];
+ }
+ let {os} = await browser.runtime.getPlatformInfo();
+ if (os === "mac") {
+ // Due to https://bugzil.la/1396587, the pasted image differs from the
+ // original/expected image.
+ // Once that bug is fixed, this whole macOS-only branch can be removed.
+ if (imageType === "png") {
+ expectedColor = [255, 38, 0];
+ } else if (imageType === "jpeg") {
+ expectedColor = [255, 38, 0];
+ }
+ }
+ browser.test.assertEq(expectedColor[0], r, "pixel should be red");
+ browser.test.assertEq(expectedColor[1], g, "pixel should not contain green");
+ browser.test.assertEq(expectedColor[2], b, "pixel should not contain blue");
+ browser.test.assertEq(255, a, "pixel should be opaque");
+ }
+
+ let editable = document.body;
+ editable.contentEditable = true;
+ let file;
+ await new Promise(resolve => {
+ document.addEventListener("paste", function(event) {
+ browser.test.assertEq(1, event.clipboardData.types.length, "expected one type");
+ browser.test.assertEq("Files", event.clipboardData.types[0], "expected type");
+ browser.test.assertEq(1, event.clipboardData.files.length, "expected one file");
+
+ // After returning from the paste event, event.clipboardData is cleaned, so we
+ // have to store the file in a separate variable.
+ file = event.clipboardData.files[0];
+ resolve();
+ }, {once: true});
+
+ document.execCommand("paste"); // requires clipboardWrite permission.
+ });
+
+ // When image data is copied, its first frame is decoded and exported to the
+ // clipboard. The pasted result is always an unanimated PNG file, regardless
+ // of the input.
+ browser.test.assertEq("image/png", file.type, "expected file.type");
+
+ // event.files[0] should be an accurate representation of the input image.
+ {
+ let img = new Image();
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = () => reject(new Error(`Failed to load image ${img.src} of size ${file.size}`));
+ img.src = URL.createObjectURL(file);
+ });
+
+ await verifyImage(img);
+ }
+
+ // This confirms that an image was put on the clipboard.
+ // In contrast, when document.execCommand('copy') + clipboardData.setData
+ // is used, then the 'paste' event will also have the image data (as tested
+ // above), but the contentEditable area will be empty.
+ {
+ let imgs = editable.querySelectorAll("img");
+ browser.test.assertEq(1, imgs.length, "should have pasted one image");
+ await verifyImage(imgs[0]);
+ }
+ browser.test.sendMessage("tested image on clipboard");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})("${imageType}");`,
+ manifest: {
+ permissions: ["clipboardRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("tested image on clipboard");
+ await extension.unload();
+}
+
+add_task(async function test_without_clipboard_permission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.assertEq(undefined, browser.clipboard,
+ "clipboard API requires the clipboardWrite permission.");
+ browser.test.notifyPass();
+ },
+ manifest: {
+ permissions: ["clipboardRead"],
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_copy_png() {
+ if (AppConstants.platform === "android") {
+ return; // Android does not support images on the clipboard.
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // A 1x1 red PNG image.
+ let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==";
+ let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer;
+ await browser.clipboard.setImageData(imageData, "png");
+ browser.test.sendMessage("Called setImageData with PNG");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ resetClipboard();
+
+ await extension.startup();
+ await extension.awaitMessage("Called setImageData with PNG");
+ await extension.unload();
+
+ await checkClipboardHasTestImage("png");
+});
+
+add_task(async function test_copy_jpeg() {
+ if (AppConstants.platform === "android") {
+ return; // Android does not support images on the clipboard.
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // A 1x1 red JPEG image, created using: convert xc:red red.jpg.
+ // JPEG is lossy, and the red pixel value is actually #FE0000 instead of
+ // #FF0000 (also seen using: convert red.jpg text:-).
+ let b64data = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==";
+ let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer;
+ await browser.clipboard.setImageData(imageData, "jpeg");
+ browser.test.sendMessage("Called setImageData with JPEG");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ resetClipboard();
+
+ await extension.startup();
+ await extension.awaitMessage("Called setImageData with JPEG");
+ await extension.unload();
+
+ await checkClipboardHasTestImage("jpeg");
+});
+
+add_task(async function test_copy_invalid_image() {
+ if (AppConstants.platform === "android") {
+ // Android does not support images on the clipboard.
+ return;
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // This is a PNG image.
+ let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==";
+ let pngImageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer;
+ await browser.test.assertRejects(
+ browser.clipboard.setImageData(pngImageData, "jpeg"),
+ "Data is not a valid jpeg image",
+ "Image data that is not valid for the given type should be rejected.");
+ browser.test.sendMessage("finished invalid image");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished invalid image");
+ await extension.unload();
+});
+
+add_task(async function test_copy_invalid_image_type() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // setImageData expects "png" or "jpeg", but we pass "image/png" here.
+ browser.test.assertThrows(
+ () => { browser.clipboard.setImageData(new ArrayBuffer(0), "image/png"); },
+ "Type error for parameter imageType (Invalid enumeration value \"image/png\") for clipboard.setImageData.",
+ "An invalid type for setImageData should be rejected.");
+ browser.test.sendMessage("finished invalid type");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished invalid type");
+ await extension.unload();
+});
+
+if (AppConstants.platform === "android") {
+ add_task(async function test_setImageData_unsupported_on_android() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ // Android does not support images on the clipboard,
+ // so it should not try to decode an image but fail immediately.
+ await browser.test.assertRejects(
+ browser.clipboard.setImageData(new ArrayBuffer(0), "png"),
+ "Writing images to the clipboard is not supported on Android",
+ "Should get an error when setImageData is called on Android.");
+ browser.test.sendMessage("finished unsupported setImageData");
+ },
+ manifest: {
+ permissions: ["clipboardWrite"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished unsupported setImageData");
+ await extension.unload();
+ });
+}
+
+</script>
+</body>
+</html>
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..04946ceeaf
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test content script match_about_blank option</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript_about_blank() {
+ const manifest = {
+ content_scripts: [
+ {
+ match_about_blank: true,
+ matches: ["http://mochi.test/*/file_with_about_blank.html", "http://example.com/*"],
+ all_frames: true,
+ css: ["all.css"],
+ js: ["all.js"],
+ }, {
+ matches: ["http://mochi.test/*/file_with_about_blank.html"],
+ css: ["mochi_without.css"],
+ js: ["mochi_without.js"],
+ all_frames: true,
+ }, {
+ match_about_blank: true,
+ matches: ["http://mochi.test/*/file_with_about_blank.html"],
+ css: ["mochi_with.css"],
+ js: ["mochi_with.js"],
+ all_frames: true,
+ },
+ ],
+ };
+
+ const files = {
+ "all.js": function() {
+ browser.runtime.sendMessage("all");
+ },
+ "all.css": `
+ body { color: red; }
+ `,
+ "mochi_without.js": function() {
+ browser.runtime.sendMessage("mochi_without");
+ },
+ "mochi_without.css": `
+ body { background: yellow; }
+ `,
+ "mochi_with.js": function() {
+ browser.runtime.sendMessage("mochi_with");
+ },
+ "mochi_with.css": `
+ body { text-align: right; }
+ `,
+ };
+
+ function background() {
+ browser.runtime.onMessage.addListener((script, {url}) => {
+ const kind = url.startsWith("about:") ? url : "top";
+ browser.test.sendMessage("script", [script, kind, url]);
+ browser.test.sendMessage(`${script}:${kind}`);
+ });
+ }
+
+ const PATH = "tests/toolkit/components/extensions/test/mochitest/file_with_about_blank.html";
+ const extension = ExtensionTestUtils.loadExtension({manifest, files, background});
+ await extension.startup();
+
+ let count = 0;
+ extension.onMessage("script", script => {
+ info(`script ran: ${script}`);
+ count++;
+ });
+
+ let win = window.open("http://example.com/" + PATH);
+ await Promise.all([
+ extension.awaitMessage("all:top"),
+ extension.awaitMessage("all:about:blank"),
+ extension.awaitMessage("all:about:srcdoc"),
+ ]);
+ is(count, 3, "exactly 3 scripts ran");
+ win.close();
+
+ win = window.open("http://mochi.test:8888/" + PATH);
+ await Promise.all([
+ extension.awaitMessage("all:top"),
+ extension.awaitMessage("all:about:blank"),
+ extension.awaitMessage("all:about:srcdoc"),
+ extension.awaitMessage("mochi_without:top"),
+ extension.awaitMessage("mochi_with:top"),
+ extension.awaitMessage("mochi_with:about:blank"),
+ extension.awaitMessage("mochi_with:about:srcdoc"),
+ ]);
+
+ let style = win.getComputedStyle(win.document.body);
+ is(style.color, "rgb(255, 0, 0)", "top window text color is red");
+ is(style.backgroundColor, "rgb(255, 255, 0)", "top window background is yellow");
+ is(style.textAlign, "right", "top window text is right-aligned");
+
+ let a_b = win.document.getElementById("a_b");
+ style = a_b.contentWindow.getComputedStyle(a_b.contentDocument.body);
+ is(style.color, "rgb(255, 0, 0)", "about:blank iframe text color is red");
+ is(style.backgroundColor, "rgba(0, 0, 0, 0)", "about:blank iframe background is transparent");
+ is(style.textAlign, "right", "about:blank text is right-aligned");
+
+ is(count, 10, "exactly 7 more scripts ran");
+ win.close();
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..306f093fe1
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html
@@ -0,0 +1,371 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// Create a test extension with the provided function as the background
+// script. The background script will have a few helpful functions
+// available.
+/* global awaitLoad, gatherFrameSources */
+function makeExtension(background) {
+ // Wait for a webNavigation.onCompleted event where the details for the
+ // loaded page match the attributes of `filter`.
+ function awaitLoad(filter) {
+ return new Promise(resolve => {
+ const listener = details => {
+ if (Object.keys(filter).every(key => details[key] === filter[key])) {
+ browser.webNavigation.onCompleted.removeListener(listener);
+ resolve();
+ }
+ };
+ browser.webNavigation.onCompleted.addListener(listener);
+ });
+ }
+
+ // Return a string with a (sorted) list of the source of all frames
+ // in the given tab into which this extension can inject scripts
+ // (ie all frames for which it has the activeTab permission).
+ // Source is the hostname for frames in http sources, or the full
+ // location href in other documents (eg about: pages)
+ async function gatherFrameSources(tabid) {
+ let result = await browser.tabs.executeScript(tabid, {
+ allFrames: true,
+ matchAboutBlank: true,
+ code: "window.location.hostname || window.location.href;",
+ });
+ return String(result.sort());
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["activeTab", "webNavigation"],
+ },
+ background: `${awaitLoad}\n${gatherFrameSources}\n${ExtensionTestCommon.serializeScript(background)}`,
+ });
+}
+
+// Test that executeScript() fails without the activeTab permission
+// (or any specific origin permissions).
+add_task(async function test_no_activeTab() {
+ let extension = makeExtension(async function background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ try {
+ await gatherFrameSources(tab.id);
+ browser.test.fail("executeScript() should fail without activeTab permission");
+ } catch (err) {
+ browser.test.assertTrue(/^Missing host permission/.test(err.message),
+ "executeScript() without activeTab permission failed");
+ }
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("no-active-tab");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("no-active-tab");
+ await extension.unload();
+});
+
+// Test that dynamically created iframes do not get the activeTab permission
+add_task(async function test_dynamic_frames() {
+ let extension = makeExtension(async function background() {
+ const BASE_HOST = "www.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: `http://${BASE_HOST}/`}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ function inject() {
+ let nframes = 4;
+ function frameLoaded() {
+ nframes--;
+ if (nframes == 0) {
+ browser.runtime.sendMessage("frames-loaded");
+ }
+ }
+
+ let frame = document.createElement("iframe");
+ frame.addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(frame);
+
+ let div = document.createElement("div");
+ div.innerHTML = "<iframe src='http://test1.example.com/'></iframe>";
+ let framelist = div.getElementsByTagName("iframe");
+ browser.test.assertEq(1, framelist.length, "Found 1 frame inside div");
+ framelist[0].addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(div);
+
+ let div2 = document.createElement("div");
+ div2.innerHTML = "<iframe srcdoc=\"<iframe src='http://test2.example.com/'&gt;</iframe&gt;\"></iframe>";
+ framelist = div2.getElementsByTagName("iframe");
+ browser.test.assertEq(1, framelist.length, "Found 1 frame inside div");
+ framelist[0].addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(div2);
+
+ const URL = "http://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html";
+
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", URL);
+ xhr.responseType = "document";
+ xhr.overrideMimeType("text/html");
+
+ xhr.addEventListener("load", () => {
+ if (xhr.readyState != 4) {
+ return;
+ }
+ if (xhr.status != 200) {
+ browser.runtime.sendMessage("error");
+ }
+
+ let frame = xhr.response.getElementById("frame");
+ browser.test.assertTrue(frame, "Found frame in response document");
+ frame.addEventListener("load", frameLoaded, {once: true});
+ document.body.appendChild(frame);
+ }, {once: true});
+ xhr.addEventListener("error", () => {
+ browser.runtime.sendMessage("error");
+ }, {once: true});
+ xhr.send();
+ }
+
+ browser.test.onMessage.addListener(async () => {
+ let loadedPromise = new Promise((resolve, reject) => {
+ let listener = msg => {
+ let unlisten = () => browser.runtime.onMessage.removeListener(listener);
+ if (msg == "frames-loaded") {
+ unlisten();
+ resolve();
+ } else if (msg == "error") {
+ unlisten();
+ reject();
+ }
+ };
+ browser.runtime.onMessage.addListener(listener);
+ });
+
+ await browser.tabs.executeScript(tab.id, {
+ code: `(${inject})();`,
+ });
+
+ await loadedPromise;
+
+ let result = await gatherFrameSources(tab.id);
+ browser.test.assertEq(String([BASE_HOST]), result,
+ "Script is not injected into dynamically created frames");
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("dynamic-frames");
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("dynamic-frames");
+
+ await extension.unload();
+});
+
+// Test that an iframe created from an <iframe srcdoc> gets the
+// activeTab permission.
+add_task(async function test_srcdoc() {
+ let extension = makeExtension(async function background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html";
+ const OUTER_SOURCE = "about:srcdoc";
+ const PAGE_SOURCE = "mochi.test";
+ const FRAME_SOURCE = "test1.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "go") {
+ let result = await gatherFrameSources(tab.id);
+ browser.test.assertEq(String([OUTER_SOURCE, PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "Script is injected into frame created from <iframe srcdoc>");
+
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("srcdoc");
+ }
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("srcdoc");
+
+ await extension.unload();
+});
+
+// Test that navigating frames by setting the src attribute from the
+// parent page revokes the activeTab permission.
+add_task(async function test_navigate_by_src() {
+ let extension = makeExtension(async function background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html";
+ const PAGE_SOURCE = "mochi.test";
+ const EMPTY_SOURCE = "about:blank";
+ const FRAME_SOURCE = "test1.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "go") {
+ let result = await gatherFrameSources(tab.id);
+ browser.test.assertEq(String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "In original page, script is injected into base page and original frames");
+
+ let loadedPromise = awaitLoad({tabId: tab.id});
+ await browser.tabs.executeScript(tab.id, {
+ code: "document.getElementById('emptyframe').src = 'http://test2.example.com/';",
+ });
+ await loadedPromise;
+
+ result = await gatherFrameSources(tab.id);
+ browser.test.assertEq(String([PAGE_SOURCE, FRAME_SOURCE]), result,
+ "Script is not injected into initially empty frame after navigation");
+
+ loadedPromise = awaitLoad({tabId: tab.id});
+ await browser.tabs.executeScript(tab.id, {
+ code: "document.getElementById('regularframe').src = 'http://test2.example.com/';",
+ });
+ await loadedPromise;
+
+ result = await gatherFrameSources(tab.id);
+ browser.test.assertEq(String([PAGE_SOURCE]), result,
+ "Script is not injected into regular frame after navigation");
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("test-scripts");
+ }
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("test-scripts");
+
+ await extension.unload();
+});
+
+// Test that navigating frames by setting window.location from inside the
+// frame revokes the activeTab permission.
+add_task(async function test_navigate_by_window_location() {
+ let extension = makeExtension(async function background() {
+ const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html";
+ const PAGE_SOURCE = "mochi.test";
+ const EMPTY_SOURCE = "about:blank";
+ const FRAME_SOURCE = "test1.example.com";
+
+ let [tab] = await Promise.all([
+ browser.tabs.create({url: URL}),
+ awaitLoad({frameId: 0}),
+ ]);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "go") {
+ let result = await gatherFrameSources(tab.id);
+ browser.test.assertEq(String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]),
+ result,
+ "Script initially injected into all frames");
+
+ let nframes = 0;
+ let frames = await browser.webNavigation.getAllFrames({tabId: tab.id});
+ for (let frame of frames) {
+ if (frame.parentFrameId == -1) {
+ continue;
+ }
+
+ let loadPromise = awaitLoad({
+ tabId: tab.id,
+ frameId: frame.frameId,
+ });
+
+ await browser.tabs.executeScript(tab.id, {
+ frameId: frame.frameId,
+ matchAboutBlank: true,
+ code: "window.location.href = 'https://test2.example.com/';",
+ });
+ await loadPromise;
+
+ try {
+ result = await browser.tabs.executeScript(tab.id, {
+ frameId: frame.frameId,
+ matchAboutBlank: true,
+ code: "window.location.hostname;",
+ });
+
+ browser.test.fail("executeScript should have failed on navigated frame");
+ } catch (err) {
+ browser.test.assertEq("Frame not found, or missing host permission", err.message);
+ }
+
+ nframes++;
+ }
+ browser.test.assertEq(2, nframes, "Found 2 frames");
+
+ await browser.tabs.remove(tab.id);
+ browser.test.notifyPass("scripted-navigation");
+ }
+ });
+
+ browser.test.sendMessage("ready", tab.id);
+ });
+
+ await extension.startup();
+
+ let tabId = await extension.awaitMessage("ready");
+ extension.grantActiveTab(tabId);
+
+ extension.sendMessage("go");
+ await extension.awaitFinish("scripted-navigation");
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..e8bb638d95
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html
@@ -0,0 +1,113 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script caching</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// This file defines content scripts.
+/* eslint-env mozilla/frame-script */
+
+const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+
+add_task(async function test_contentscript_cache() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+
+ permissions: ["<all_urls>", "tabs"],
+ },
+
+ async background() {
+ // Force our extension instance to be initialized for the current content process.
+ await browser.tabs.insertCSS({code: ""});
+
+ browser.test.sendMessage("origin", location.origin);
+ },
+
+ files: {
+ "content_script.js": function() {
+ browser.test.sendMessage("content-script-loaded");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let origin = await extension.awaitMessage("origin");
+ let scriptUrl = `${origin}/content_script.js`;
+
+ let {ExtensionManager} = SpecialPowers.Cu.import("resource://gre/modules/ExtensionChild.jsm", {});
+ let ext = ExtensionManager.extensions.get(extension.id);
+
+ ext.staticScripts.expiryTimeout = 3000;
+ is(ext.staticScripts.size, 0, "Should have no cached scripts");
+
+ let win = window.open(`${BASE}/file_sample.html`);
+ await extension.awaitMessage("content-script-loaded");
+
+ if (AppConstants.platform !== "android") {
+ is(ext.staticScripts.size, 1, "Should have one cached script");
+ ok(ext.staticScripts.has(scriptUrl), "Script cache should contain script URL");
+ }
+
+ let chromeScript, chromeScriptDone;
+ let {appinfo} = SpecialPowers.Services;
+ if (appinfo.processType === appinfo.PROCESS_TYPE_CONTENT) {
+ /* globals addMessageListener, assert */
+ chromeScript = SpecialPowers.loadChromeScript(() => {
+ addMessageListener("check-script-cache", extensionId => {
+ let {ExtensionManager} = ChromeUtils.import("resource://gre/modules/ExtensionChild.jsm", null);
+ let ext = ExtensionManager.extensions.get(extensionId);
+
+ if (ext && ext.staticScripts) {
+ assert.equal(ext.staticScripts.size, 0, "Should have no cached scripts in the parent process");
+ }
+
+ sendAsyncMessage("done");
+ });
+ });
+ chromeScript.sendAsyncMessage("check-script-cache", extension.id);
+ chromeScriptDone = chromeScript.promiseOneMessage("done");
+ }
+
+ SimpleTest.requestFlakyTimeout("Required to test expiry timeout");
+ await new Promise(resolve => setTimeout(resolve, 3000));
+ is(ext.staticScripts.size, 0, "Should have no cached scripts");
+
+ if (chromeScript) {
+ await chromeScriptDone;
+ chromeScript.destroy();
+ }
+
+ win.close();
+
+ win = window.open(`${BASE}/file_sample.html`);
+ await extension.awaitMessage("content-script-loaded");
+
+ is(ext.staticScripts.size, 1, "Should have one cached script");
+ ok(ext.staticScripts.has(scriptUrl));
+
+ SpecialPowers.Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize");
+
+ is(ext.staticScripts.size, 0, "Should have no cached scripts after heap-minimize");
+
+ win.close();
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..c4b6b5a256
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html
@@ -0,0 +1,138 @@
+<!doctype html>
+<html>
+<head>
+ <title>Test content script access to canvas drawWindow()</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_drawWindow() {
+ const permissions = [
+ "<all_urls>",
+ ];
+
+ const content_scripts = [{
+ matches: ["https://example.org/*"],
+ js: ["content_script.js"],
+ }];
+
+ const files = {
+ "content_script.js": () => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ try {
+ ctx.drawWindow(window, 0, 0, 10, 10, "red");
+ const {data} = ctx.getImageData(0, 0, 10, 10);
+ browser.test.sendMessage("success", data.slice(0, 3).join());
+ } catch (e) {
+ browser.test.sendMessage("error", e.message);
+ }
+ },
+ };
+
+ const first = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "draw_window_first@tests.mozilla.org" } },
+ permissions,
+ content_scripts
+ },
+ files
+ });
+ const second = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "draw_window_second@tests.mozilla.org" } },
+ content_scripts
+ },
+ files
+ });
+
+ await first.startup();
+ await second.startup();
+
+ const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html");
+
+ const colour = await first.awaitMessage("success");
+ is(colour, "255,255,153", "drawWindow() call was successful: #ff9 == rgb(255,255,153)");
+
+ const error = await second.awaitMessage("error");
+ is(error, "ctx.drawWindow is not a function", "drawWindow() method not awailable without permission");
+
+ win.close();
+ await first.unload();
+ await second.unload();
+});
+
+add_task(async function test_tainted_canvas() {
+ const permissions = [
+ "<all_urls>",
+ ];
+
+ const content_scripts = [{
+ matches: ["https://example.org/*"],
+ js: ["content_script.js"],
+ }];
+
+ const files = {
+ "content_script.js": () => {
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ const img = new Image();
+
+ img.onload = function() {
+ ctx.drawImage(img, 0, 0);
+ try {
+ const png = canvas.toDataURL();
+ const {data} = ctx.getImageData(0, 0, 10, 10);
+ browser.test.sendMessage("success", {png, colour: data.slice(0, 4).join()});
+ } catch (e) {
+ browser.test.log(`Exception: ${e.message}`);
+ browser.test.sendMessage("error", e.message);
+ }
+ };
+
+ // Cross-origin image from example.com.
+ img.src = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_good.png";
+ },
+ };
+
+ const first = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "draw_window_first@tests.mozilla.org" } },
+ permissions,
+ content_scripts
+ },
+ files
+ });
+ const second = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "draw_window_second@tests.mozilla.org" } },
+ content_scripts
+ },
+ files
+ });
+
+ await first.startup();
+ await second.startup();
+
+ const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html");
+
+ const {png, colour} = await first.awaitMessage("success");
+ ok(png.startsWith("data:image/png;base64,"), "toDataURL() call was successful.");
+ is(colour, "0,0,0,0", "getImageData() returned the correct colour (transparent).");
+
+ const error = await second.awaitMessage("error");
+ is(error, "The operation is insecure.", "toDataURL() throws without permission.");
+
+ win.close();
+ await first.unload();
+ await second.unload();
+});
+
+</script>
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..f2a2de0e05
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html
@@ -0,0 +1,77 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for Sandbox metadata on WebExtensions ContentScripts</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript_devtools_sandbox_metadata() {
+ function contentScript() {
+ browser.runtime.sendMessage("contentScript.executed");
+ }
+
+ function background() {
+ browser.runtime.onMessage.addListener((msg) => {
+ if (msg == "contentScript.executed") {
+ browser.test.notifyPass("contentScript.executed");
+ }
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ },
+ ],
+ },
+
+ background,
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ let innerWindowID = SpecialPowers.wrap(win).windowGlobalChild.innerWindowId;
+
+ await extension.awaitFinish("contentScript.executed");
+
+ const {ExtensionContent} = SpecialPowers.Cu.import(
+ "resource://gre/modules/ExtensionContent.jsm", {}
+ );
+
+ let res = ExtensionContent.getContentScriptGlobals(win);
+ is(res.length, 1, "Got the expected array of globals");
+ let metadata = SpecialPowers.Cu.getSandboxMetadata(res[0]) || {};
+
+ is(metadata.addonId, extension.id, "Got the expected addonId");
+ is(metadata["inner-window-id"], innerWindowID, "Got the expected inner-window-id");
+
+ await extension.unload();
+ info("extension unloaded");
+
+ res = ExtensionContent.getContentScriptGlobals(win);
+ is(res.length, 0, "No content scripts globals found once the extension is unloaded");
+
+ win.close();
+});
+</script>
+
+</body>
+</html>
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..702456a798
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html
@@ -0,0 +1,100 @@
+<!doctype html>
+<head>
+ <title>Test content script in cross-origin frame</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_content_script_cross_origin_frame() {
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ matches: ["http://example.net/*/file_sample.html"],
+ all_frames: true,
+ js: ["cs.js"],
+ }],
+ permissions: ["http://example.net/"],
+ },
+
+ background() {
+ browser.runtime.onConnect.addListener(port => {
+ port.onMessage.addListener(async num => {
+ let { tab, url, frameId } = port.sender;
+
+ browser.test.assertTrue(frameId > 0, "sender frameId is ok");
+ browser.test.assertTrue(url.endsWith("file_sample.html"), "url is ok");
+
+ let shared = await browser.tabs.executeScript(tab.id, {
+ allFrames: true,
+ code: `window.sharedVal`,
+ });
+ browser.test.assertEq(shared[0], 357, "CS runs in a shared Sandbox");
+
+ let code = "does.not.exist";
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { allFrames: true, code }),
+ /does is not defined/,
+ "Got the expected rejection from tabs.executeScript"
+ );
+
+ code = "() => {}";
+ await browser.test.assertRejects(
+ browser.tabs.executeScript(tab.id, { allFrames: true, code }),
+ /Script .* result is non-structured-clonable data/,
+ "Got the expected rejection from tabs.executeScript"
+ );
+
+ let result = await browser.tabs.sendMessage(tab.id, num);
+ port.postMessage(result);
+ port.disconnect();
+ });
+ });
+ },
+
+ files: {
+ "cs.js"() {
+ let text = document.body.innerText;
+ browser.test.assertEq(text, "Sample text", "CS can access page DOM");
+
+ let manifest = browser.runtime.getManifest();
+ browser.test.assertEq(manifest.version, "1.0");
+ browser.test.assertEq(manifest.name, "Generated extension");
+
+ browser.runtime.onMessage.addListener(async num => {
+ browser.test.log("content script received tabs.sendMessage");
+ return num * 3;
+ })
+
+ let response;
+ window.sharedVal = 357;
+
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(num => {
+ response = num;
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(response, 21, "Got correct response");
+ browser.test.notifyPass();
+ });
+ port.postMessage(7);
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let base = "http://example.org/tests/toolkit/components/extensions/test";
+ let win = window.open(`${base}/mochitest/file_with_xorigin_frame.html`);
+
+ await extension.awaitFinish();
+ win.close();
+
+ await extension.unload();
+});
+
+</script>
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..63dd23b151
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html
@@ -0,0 +1,105 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script private browsing ID</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ChromeTask.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function test_contentscript_incognito() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["extensions.allowPrivateBrowsingByDefault", false],
+ ]});
+
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ },
+ ],
+ },
+
+ background() {
+ let windowId;
+
+ browser.test.onMessage.addListener(([msg, url]) => {
+ if (msg === "open-window") {
+ browser.windows.create({url, incognito: true}).then(window => {
+ windowId = window.id;
+ });
+ } else if (msg === "close-window") {
+ browser.windows.remove(windowId).then(() => {
+ browser.test.sendMessage("done");
+ });
+ }
+ });
+ },
+
+ files: {
+ "content_script.js": async () => {
+ const COOKIE = "foo=florgheralzps";
+ document.cookie = COOKIE;
+
+ let url = new URL("return_headers.sjs", location.href);
+
+ let responses = [
+ new Promise(resolve => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = () => resolve(JSON.parse(xhr.responseText));
+ xhr.send();
+ }),
+
+ fetch(url, {credentials: "include"}).then(body => body.json()),
+ ];
+
+ try {
+ for (let response of await Promise.all(responses)) {
+ browser.test.assertEq(COOKIE, response.cookie, "Got expected cookie header");
+ }
+ browser.test.notifyPass("cookies");
+ } catch (e) {
+ browser.test.fail(`Error: ${e}`);
+ browser.test.notifyFail("cookies");
+ }
+ },
+ },
+ });
+
+ await extension.startup();
+
+ extension.sendMessage(["open-window", SimpleTest.getTestFileURL("file_sample.html")]);
+
+ await extension.awaitFinish("cookies");
+
+ extension.sendMessage(["close-window"]);
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+}
+
+add_task(async function() {
+ await test_contentscript_incognito();
+});
+
+add_task(async function() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["network.cookie.cookieBehavior", 3],
+ ]});
+ await test_contentscript_incognito();
+});
+
+</script>
+
+</body>
+</html>
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..e6bc48800c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript() {
+ function background() {
+ browser.test.onMessage.addListener(async url => {
+ let tab = await browser.tabs.create({url});
+
+ let executed = true;
+ try {
+ await browser.tabs.executeScript(tab.id, {code: "true;"});
+ } catch (e) {
+ executed = false;
+ }
+
+ await browser.tabs.remove([tab.id]);
+ browser.test.sendMessage("executed", executed);
+ });
+ }
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "contentscript@tests.mozilla.org" } },
+ permissions: ["<all_urls>"],
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ extension.sendMessage("https://example.com");
+ let result = await extension.awaitMessage("executed");
+ is(result, true, "Content script can be run in a page without mozAddonManager");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+
+ extension.sendMessage("https://example.com");
+ result = await extension.awaitMessage("executed");
+ is(result, false, "Content script cannot be run in a page with mozAddonManager");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..c9dd05a41c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html
@@ -0,0 +1,366 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_cookies() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["extensions.allowPrivateBrowsingByDefault", false],
+ ]});
+
+ async function background() {
+ function assertExpected(expected, cookie) {
+ for (let key of Object.keys(cookie)) {
+ browser.test.assertTrue(key in expected, `found property ${key}`);
+ browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`);
+ }
+ browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found");
+ }
+
+ async function getDocumentCookie(tabId) {
+ let results = await browser.tabs.executeScript(tabId, {
+ code: "document.cookie",
+ });
+ browser.test.assertEq(1, results.length, "executeScript returns one result");
+ return results[0];
+ }
+
+ async function testIpCookie(ipAddress, setHostOnly) {
+ const IP_TEST_HOST = ipAddress;
+ const IP_TEST_URL = `http://${IP_TEST_HOST}/`;
+ const IP_THE_FUTURE = Date.now() + 5 * 60;
+ const IP_STORE_ID = "firefox-default";
+
+ let expectedCookie = {
+ name: "name1",
+ value: "value1",
+ domain: IP_TEST_HOST,
+ hostOnly: true,
+ path: "/",
+ secure: false,
+ httpOnly: false,
+ sameSite: "no_restriction",
+ session: false,
+ expirationDate: IP_THE_FUTURE,
+ storeId: IP_STORE_ID,
+ firstPartyDomain: "",
+ };
+
+ await browser.browsingData.removeCookies({});
+ let ip_cookie = await browser.cookies.set({
+ url: IP_TEST_URL,
+ domain: setHostOnly ? ipAddress : undefined,
+ name: "name1",
+ value: "value1",
+ expirationDate: IP_THE_FUTURE,
+ });
+ assertExpected(expectedCookie, ip_cookie);
+
+ let ip_cookies = await browser.cookies.getAll({name: "name1"});
+ browser.test.assertEq(1, ip_cookies.length, "ip cookie can be added");
+ assertExpected(expectedCookie, ip_cookies[0]);
+
+ ip_cookies = await browser.cookies.getAll({domain: IP_TEST_HOST, name: "name1"});
+ browser.test.assertEq(1, ip_cookies.length, "can get ip cookie by host");
+ assertExpected(expectedCookie, ip_cookies[0]);
+
+ let ip_details = await browser.cookies.remove({url: IP_TEST_URL, name: "name1"});
+ assertExpected({url: IP_TEST_URL, name: "name1", storeId: IP_STORE_ID, firstPartyDomain: ""}, ip_details);
+
+ ip_cookies = await browser.cookies.getAll({name: "name1"});
+ browser.test.assertEq(0, ip_cookies.length, "ip cookie can be removed");
+ }
+
+ async function openPrivateWindowAndTab(TEST_URL) {
+ // Add some random suffix to make sure that we select the right tab.
+ const PRIVATE_TEST_URL = TEST_URL + "?random" + Math.random();
+
+ let tabReadyPromise = new Promise((resolve) => {
+ browser.webNavigation.onDOMContentLoaded.addListener(function listener({tabId}) {
+ browser.webNavigation.onDOMContentLoaded.removeListener(listener);
+ resolve(tabId);
+ }, {
+ url: [{
+ urlPrefix: PRIVATE_TEST_URL,
+ }],
+ });
+ });
+ // This tab is opened for two purposes:
+ // 1. To allow tests to run content scripts in the context of a tab,
+ // for fetching the value of document.cookie.
+ // 2. TODO Bug 1309637 To work around cookies in incognito windows,
+ // based on the analysis in comment 8.
+ let {id: windowId} = await browser.windows.create({
+ incognito: true,
+ url: PRIVATE_TEST_URL,
+ });
+ let tabId = await tabReadyPromise;
+ return {windowId, tabId};
+ }
+
+ function changePort(href, port) {
+ let url = new URL(href);
+ url.port = port;
+ return url.href;
+ }
+
+ await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", false);
+ await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", true);
+ await testIpCookie("192.168.1.1", false);
+ await testIpCookie("192.168.1.1", true);
+
+ const TEST_URL = "http://example.org/";
+ const TEST_SECURE_URL = "https://example.org/";
+ const THE_FUTURE = Date.now() + 5 * 60;
+ const TEST_PATH = "set_path";
+ const TEST_URL_WITH_PATH = TEST_URL + TEST_PATH;
+ const TEST_COOKIE_PATH = `/${TEST_PATH}`;
+ const STORE_ID = "firefox-default";
+ const PRIVATE_STORE_ID = "firefox-private";
+
+ let expected = {
+ name: "name1",
+ value: "value1",
+ domain: "example.org",
+ hostOnly: true,
+ path: "/",
+ secure: false,
+ httpOnly: false,
+ sameSite: "no_restriction",
+ session: false,
+ expirationDate: THE_FUTURE,
+ storeId: STORE_ID,
+ firstPartyDomain: "",
+ };
+
+ // Remove all cookies before starting the test.
+ await browser.browsingData.removeCookies({});
+
+ let cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", expirationDate: THE_FUTURE});
+ assertExpected(expected, cookie);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ assertExpected(expected, cookie);
+
+ let cookies = await browser.cookies.getAll({name: "name1"});
+ browser.test.assertEq(1, cookies.length, "one cookie found for matching name");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({domain: "example.org"});
+ browser.test.assertEq(1, cookies.length, "one cookie found for matching domain");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({domain: "example.net"});
+ browser.test.assertEq(0, cookies.length, "no cookies found for non-matching domain");
+
+ cookies = await browser.cookies.getAll({secure: false});
+ browser.test.assertEq(1, cookies.length, "one non-secure cookie found");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({secure: true});
+ browser.test.assertEq(0, cookies.length, "no secure cookies found");
+
+ cookies = await browser.cookies.getAll({storeId: STORE_ID});
+ browser.test.assertEq(1, cookies.length, "one cookie found for valid storeId");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({storeId: "invalid_id"});
+ browser.test.assertEq(0, cookies.length, "no cookies found for invalid storeId");
+
+ let details = await browser.cookies.remove({url: TEST_URL, name: "name1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ // Ports in cookie URLs should be ignored. Every API call uses a different port number for better coverage.
+ cookie = await browser.cookies.set({url: changePort(TEST_URL, 1234), name: "name1", value: "value1", expirationDate: THE_FUTURE});
+ assertExpected(expected, cookie);
+
+ cookie = await browser.cookies.get({url: changePort(TEST_URL, 65535), name: "name1"});
+ assertExpected(expected, cookie);
+
+ cookies = await browser.cookies.getAll({url: TEST_URL});
+ browser.test.assertEq(cookies.length, 1, "Found cookie using getAll without port");
+ assertExpected(expected, cookies[0]);
+
+ cookies = await browser.cookies.getAll({url: changePort(TEST_URL, 1)});
+ browser.test.assertEq(cookies.length, 1, "Found cookie using getAll with port");
+ assertExpected(expected, cookies[0]);
+
+ // .remove should return the URL of the API call, so the port is included in the return value.
+ const TEST_URL_TO_REMOVE = changePort(TEST_URL, 1023);
+ details = await browser.cookies.remove({url: TEST_URL_TO_REMOVE, name: "name1"});
+ assertExpected({url: TEST_URL_TO_REMOVE, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ let stores = await browser.cookies.getAllCookieStores();
+ browser.test.assertEq(1, stores.length, "expected number of stores returned");
+ browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned");
+ browser.test.assertEq(1, stores[0].tabIds.length, "one tabId returned for store");
+ browser.test.assertEq("number", typeof stores[0].tabIds[0], "tabId is a number");
+
+ // TODO bug 1372178: Opening private windows/tabs is not supported on Android
+ if (browser.windows) {
+ let {windowId} = await openPrivateWindowAndTab(TEST_URL);
+ let stores = await browser.cookies.getAllCookieStores();
+
+ browser.test.assertEq(2, stores.length, "expected number of stores returned");
+ browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned");
+ browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for store");
+ browser.test.assertEq(PRIVATE_STORE_ID, stores[1].id, "expected private store id returned");
+ browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for private store");
+
+ await browser.windows.remove(windowId);
+ }
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name2", domain: ".example.org", expirationDate: THE_FUTURE});
+ browser.test.assertEq(false, cookie.hostOnly, "cookie is not a hostOnly cookie");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "name2"});
+ assertExpected({url: TEST_URL, name: "name2", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ // Create a session cookie.
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1"});
+ browser.test.assertEq(true, cookie.session, "session cookie set");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(true, cookie.session, "got session cookie");
+
+ cookies = await browser.cookies.getAll({session: true});
+ browser.test.assertEq(1, cookies.length, "one session cookie found");
+ browser.test.assertEq(true, cookies[0].session, "found session cookie");
+
+ cookies = await browser.cookies.getAll({session: false});
+ browser.test.assertEq(0, cookies.length, "no non-session cookies found");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "name1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ cookie = await browser.cookies.set({url: TEST_SECURE_URL, name: "name1", value: "value1", secure: true});
+ browser.test.assertEq(true, cookie.secure, "secure cookie set");
+
+ cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"});
+ browser.test.assertEq(true, cookie.session, "got secure cookie");
+
+ cookies = await browser.cookies.getAll({secure: true});
+ browser.test.assertEq(1, cookies.length, "one secure cookie found");
+ browser.test.assertEq(true, cookies[0].secure, "found secure cookie");
+
+ cookies = await browser.cookies.getAll({secure: false});
+ browser.test.assertEq(0, cookies.length, "no non-secure cookies found");
+
+ details = await browser.cookies.remove({url: TEST_SECURE_URL, name: "name1"});
+ assertExpected({url: TEST_SECURE_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ cookie = await browser.cookies.set({url: TEST_URL_WITH_PATH, path: TEST_COOKIE_PATH, name: "name1", value: "value1", expirationDate: THE_FUTURE});
+ browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "created cookie with path");
+
+ cookie = await browser.cookies.get({url: TEST_URL_WITH_PATH, name: "name1"});
+ browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "got cookie with path");
+
+ cookies = await browser.cookies.getAll({path: TEST_COOKIE_PATH});
+ browser.test.assertEq(1, cookies.length, "one cookie with path found");
+ browser.test.assertEq(TEST_COOKIE_PATH, cookies[0].path, "found cookie with path");
+
+ cookie = await browser.cookies.get({url: TEST_URL + "invalid_path", name: "name1"});
+ browser.test.assertEq(null, cookie, "get with invalid path returns null");
+
+ cookies = await browser.cookies.getAll({path: "/invalid_path"});
+ browser.test.assertEq(0, cookies.length, "getAll with invalid path returns 0 cookies");
+
+ details = await browser.cookies.remove({url: TEST_URL_WITH_PATH, name: "name1"});
+ assertExpected({url: TEST_URL_WITH_PATH, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: true});
+ browser.test.assertEq(true, cookie.httpOnly, "httpOnly cookie set");
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: false});
+ browser.test.assertEq(false, cookie.httpOnly, "non-httpOnly cookie set");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "name1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.set({url: TEST_URL});
+ browser.test.assertEq("", cookie.name, "default name set");
+ browser.test.assertEq("", cookie.value, "default value set");
+ browser.test.assertEq(true, cookie.session, "no expiry date created session cookie");
+
+ // TODO bug 1372178: Opening private windows/tabs is not supported on Android
+ if (browser.windows) {
+ let {tabId, windowId} = await openPrivateWindowAndTab(TEST_URL);
+
+ browser.test.assertEq("", await getDocumentCookie(tabId), "initially no cookie");
+
+ let cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "private", expirationDate: THE_FUTURE, storeId: PRIVATE_STORE_ID});
+ browser.test.assertEq("private", cookie.value, "set the private cookie");
+
+ cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "default", expirationDate: THE_FUTURE, storeId: STORE_ID});
+ browser.test.assertEq("default", cookie.value, "set the default cookie");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID});
+ browser.test.assertEq("private", cookie.value, "get the private cookie");
+ browser.test.assertEq(PRIVATE_STORE_ID, cookie.storeId, "get the private cookie storeId");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID});
+ browser.test.assertEq("default", cookie.value, "get the default cookie");
+ browser.test.assertEq(STORE_ID, cookie.storeId, "get the default cookie storeId");
+
+ browser.test.assertEq("store=private", await getDocumentCookie(tabId), "private document.cookie should be set");
+
+ let details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: STORE_ID});
+ assertExpected({url: TEST_URL, name: "store", storeId: STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID});
+ browser.test.assertEq(null, cookie, "deleted the default cookie");
+
+ details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID});
+ assertExpected({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID, firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID});
+ browser.test.assertEq(null, cookie, "deleted the private cookie");
+
+ browser.test.assertEq("", await getDocumentCookie(tabId), "private document.cookie should be removed");
+
+ await browser.windows.remove(windowId);
+ }
+
+ browser.test.notifyPass("cookies");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ incognitoOverride: "spanning",
+ background,
+ manifest: {
+ applications: { gecko: { id: "cookies@tests.mozilla.org" } },
+ permissions: ["cookies", "*://example.org/", "*://[2a03:4000:6:310e:216:3eff:fe53:99b]/", "*://192.168.1.1/", "webNavigation", "browsingData"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("cookies");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..db12a97854
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html
@@ -0,0 +1,97 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ // make sure userContext is enabled.
+ await SpecialPowers.pushPrefEnv({"set": [
+ ["privacy.userContext.enabled", true],
+ ]});
+});
+
+add_task(async function test_cookie_containers() {
+ async function background() {
+ // Sometimes there is a cookie without name/value when running tests.
+ let cookiesAtStart = await browser.cookies.getAll({storeId: "firefox-default"});
+
+ function assertExpected(expected, cookie) {
+ for (let key of Object.keys(cookie)) {
+ browser.test.assertTrue(key in expected, `found property ${key}`);
+ browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`);
+ }
+ browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found");
+ }
+
+ const TEST_URL = "http://example.org/";
+ const THE_FUTURE = Date.now() + 5 * 60;
+
+ let expected = {
+ name: "name1",
+ value: "value1",
+ domain: "example.org",
+ hostOnly: true,
+ path: "/",
+ secure: false,
+ httpOnly: false,
+ sameSite: "no_restriction",
+ session: false,
+ expirationDate: THE_FUTURE,
+ storeId: "firefox-container-1",
+ firstPartyDomain: "",
+ };
+
+ let cookie = await browser.cookies.set({
+ url: TEST_URL, name: "name1", value: "value1",
+ expirationDate: THE_FUTURE, storeId: "firefox-container-1",
+ });
+ browser.test.assertEq("firefox-container-1", cookie.storeId, "the cookie has the correct storeId");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1"});
+ browser.test.assertEq(null, cookie, "get() without storeId returns null");
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"});
+ assertExpected(expected, cookie);
+
+ let cookies = await browser.cookies.getAll({storeId: "firefox-default"});
+ browser.test.assertEq(0, cookiesAtStart.length - cookies.length, "getAll() with default storeId hasn't added cookies");
+
+ cookies = await browser.cookies.getAll({storeId: "firefox-container-1"});
+ browser.test.assertEq(1, cookies.length, "one cookie found for matching domain");
+ assertExpected(expected, cookies[0]);
+
+ let details = await browser.cookies.remove({url: TEST_URL, name: "name1", storeId: "firefox-container-1"});
+ assertExpected({url: TEST_URL, name: "name1", storeId: "firefox-container-1", firstPartyDomain: ""}, details);
+
+ cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"});
+ browser.test.assertEq(null, cookie, "removed cookie not found");
+
+ browser.test.notifyPass("cookies");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("cookies");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension cookies test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_cookies_expiry() {
+ function background() {
+ let expectedEvents = [];
+
+ browser.cookies.onChanged.addListener(event => {
+ expectedEvents.push(`${event.removed}:${event.cause}`);
+ if (expectedEvents.length === 1) {
+ browser.test.assertEq("true:expired", expectedEvents[0], "expired cookie removed");
+ browser.test.assertEq("first", event.cookie.name, "expired cookie has the expected name");
+ browser.test.assertEq("one", event.cookie.value, "expired cookie has the expected value");
+ } else {
+ browser.test.assertEq("false:explicit", expectedEvents[1], "new cookie added");
+ browser.test.assertEq("first", event.cookie.name, "new cookie has the expected name");
+ browser.test.assertEq("one-again", event.cookie.value, "new cookie has the expected value");
+ browser.test.notifyPass("cookie-expiry");
+ }
+ });
+
+ setTimeout(() => {
+ browser.test.sendMessage("change-cookies");
+ }, 1000);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["http://example.com/", "cookies"],
+ },
+ background,
+ });
+
+ let chromeScript = loadChromeScript(() => {
+ const {sendAsyncMessage} = this;
+ Services.cookies.add(".example.com", "/", "first", "one", false, false, false, Date.now() / 1000 + 1, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP);
+ sendAsyncMessage("done");
+ });
+ await chromeScript.promiseOneMessage("done");
+ chromeScript.destroy();
+
+ await extension.startup();
+ await extension.awaitMessage("change-cookies");
+
+ chromeScript = loadChromeScript(() => {
+ const {sendAsyncMessage} = this;
+ Services.cookies.add(".example.com", "/", "first", "one-again", false, false, false, Date.now() / 1000 + 10, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP);
+ sendAsyncMessage("done");
+ });
+ await chromeScript.promiseOneMessage("done");
+ chromeScript.destroy();
+
+ await extension.awaitFinish("cookie-expiry");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+<script src="head.js"></script>
+<script>
+"use strict";
+
+async function background() {
+ const url = "http://ext-cookie-first-party.mochi.test/";
+ const firstPartyDomain = "ext-cookie-first-party.mochi.test";
+ // A first party domain with invalid characters for the file system, which just happens to be a IPv6 address.
+ const firstPartyDomainInvalidChars = "[2606:4700:4700::1111]";
+ const expectedError = "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set.";
+
+ const assertExpectedCookies = (expected, cookies, message) => {
+ let matches = (cookie, expected) => {
+ if (!cookie || !expected) {
+ return cookie === expected; // true if both are null.
+ }
+ for (let key of Object.keys(expected)) {
+ if (cookie[key] !== expected[key]) {
+ return false;
+ }
+ }
+ return true;
+ };
+ browser.test.assertEq(expected.length, cookies.length, `Got expected number of cookies - ${message}`);
+ if (cookies.length !== expected.length) {
+ return;
+ }
+ for (let expect of expected) {
+ let foundCookies = cookies.filter(cookie => matches(cookie, expect));
+ browser.test.assertEq(1, foundCookies.length,
+ `Expected cookie ${JSON.stringify(expect)} found - ${message}`);
+ }
+ };
+
+ // Test when FPI is disabled.
+ const test_fpi_disabled = async () => {
+ let cookie, cookies;
+
+ // set
+ cookie = await browser.cookies.set({url, name: "foo1", value: "bar1"});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "set: FPI off, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], [cookie], "set: FPI off, w/ firstPartyDomain, FP cookie");
+
+ // get
+ // When FPI is disabled, missing key/null/undefined is equivalent to "".
+ cookie = await browser.cookies.get({url, name: "foo1"});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/o firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: null});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/ null firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: undefined});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI off, w/ undefined firstPartyDomain, non-FP cookie");
+
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie");
+ // There is no match for non-FP cookies with name "foo2".
+ cookie = await browser.cookies.get({url, name: "foo2"});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/o firstPartyDomain, no cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: ""});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/ empty firstPartyDomain, no cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: null});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/ null firstPartyDomain, no cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: undefined});
+ assertExpectedCookies([null], [cookie], "get: FPI off, w/ undefined firstPartyDomain, no cookie");
+
+ // getAll
+ for (let extra of [{}, {url}, {domain: firstPartyDomain}]) {
+ const prefix = `getAll(${JSON.stringify(extra)})`;
+ cookies = await browser.cookies.getAll({...extra});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], cookies, `${prefix}: FPI off, w/o firstPartyDomain, non-FP cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], cookies, `${prefix}: FPI off, w/ empty firstPartyDomain, non-FP cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], cookies, `${prefix}: FPI off, w/ null firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], cookies, `${prefix}: FPI off, w/ undefined firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], cookies, `${prefix}: FPI off, w/ firstPartyDomain, FP cookies`);
+ }
+
+ // remove
+ cookie = await browser.cookies.remove({url, name: "foo1"});
+ assertExpectedCookies([
+ {url, name: "foo1", firstPartyDomain: ""},
+ ], [cookie], "remove: FPI off, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo2", firstPartyDomain},
+ ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie");
+
+ // Test if FP cookies set when FPI off can be accessed when FPI on.
+ await browser.cookies.set({url, name: "foo1", value: "bar1"});
+ await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain});
+
+ browser.test.sendMessage("test_fpi_disabled");
+ };
+
+ // Test when FPI is enabled.
+ const test_fpi_enabled = async () => {
+ let cookie, cookies;
+
+ // set
+ await browser.test.assertRejects(
+ browser.cookies.set({url, name: "foo3", value: "bar3"}),
+ expectedError,
+ "set: FPI on, w/o firstPartyDomain, rejection");
+ cookie = await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], [cookie], "set: FPI on, w/ firstPartyDomain, FP cookie");
+
+ // get
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "foo3"}),
+ expectedError,
+ "get: FPI on, w/o firstPartyDomain, rejection");
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "foo3", firstPartyDomain: null}),
+ expectedError,
+ "get: FPI on, w/ null firstPartyDomain, rejection");
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "foo3", firstPartyDomain: undefined}),
+ expectedError,
+ "get: FPI on, w/ undefined firstPartyDomain, rejection");
+ cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], [cookie], "get: FPI on, w/ empty firstPartyDomain, non-FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie");
+ cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)");
+
+ // getAll
+ for (let extra of [{}, {url}, {domain: firstPartyDomain}]) {
+ const prefix = `getAll(${JSON.stringify(extra)})`;
+ await browser.test.assertRejects(
+ browser.cookies.getAll({...extra}),
+ expectedError,
+ `${prefix}: FPI on, w/o firstPartyDomain, rejection`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ ], cookies, `${prefix}: FPI on, w/ empty firstPartyDomain, non-FP cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], cookies, `${prefix}: FPI on, w/ null firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined});
+ assertExpectedCookies([
+ {name: "foo1", value: "bar1", firstPartyDomain: ""},
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], cookies, `${prefix}: FPI on, w/ undefined firstPartyDomain, all cookies`);
+ cookies = await browser.cookies.getAll({...extra, firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo2", value: "bar2", firstPartyDomain},
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], cookies, `${prefix}: FPI on, w/ firstPartyDomain, FP cookies`);
+ }
+
+ // remove
+ await browser.test.assertRejects(
+ browser.cookies.remove({url, name: "foo3"}),
+ expectedError,
+ "remove: FPI on, w/o firstPartyDomain, rejection");
+ cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo4", firstPartyDomain},
+ ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie");
+ cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo2", firstPartyDomain},
+ ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)");
+
+ // Test if FP cookies set when FPI on can be accessed when FPI off.
+ await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain});
+
+ browser.test.sendMessage("test_fpi_enabled");
+ };
+
+ // Test FPI with a first party domain with invalid characters for
+ // the file system.
+ const test_fpi_with_invalid_characters = async () => {
+ let cookie;
+
+ // Test setting a cookie with a first party domain with invalid characters
+ // for the file system.
+ cookie = await browser.cookies.set({url, name: "foo5", value: "bar5",
+ firstPartyDomain: firstPartyDomainInvalidChars});
+ assertExpectedCookies([
+ {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars},
+ ], [cookie], "set: FPI on, w/ firstPartyDomain with invalid characters, FP cookie");
+
+ // Test getting a cookie with a first party domain with invalid characters
+ // for the file system.
+ cookie = await browser.cookies.get({url, name: "foo5",
+ firstPartyDomain: firstPartyDomainInvalidChars});
+ assertExpectedCookies([
+ {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars},
+ ], [cookie], "get: FPI on, w/ firstPartyDomain with invalid characters, FP cookie");
+
+ // Test removing a cookie with a first party domain with invalid characters
+ // for the file system.
+ cookie = await browser.cookies.remove({url, name: "foo5",
+ firstPartyDomain: firstPartyDomainInvalidChars});
+ assertExpectedCookies([
+ {url, name: "foo5", firstPartyDomain: firstPartyDomainInvalidChars},
+ ], [cookie], "remove: FPI on, w/ firstPartyDomain with invalid characters, FP cookie");
+
+ browser.test.sendMessage("test_fpi_with_invalid_characters");
+ };
+
+ // Test when FPI is disabled again, accessing FP cookies set when FPI is enabled.
+ const test_fpd_cookies_on_fpi_disabled = async () => {
+ let cookie, cookies;
+ cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {name: "foo4", value: "bar4", firstPartyDomain},
+ ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)");
+ cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain});
+ assertExpectedCookies([
+ {url, name: "foo4", firstPartyDomain},
+ ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)");
+
+ // Clean up.
+ await browser.cookies.remove({url, name: "foo1"});
+
+ cookies = await browser.cookies.getAll({firstPartyDomain: null});
+ assertExpectedCookies([], cookies, "Test is finishing, all cookies removed");
+
+ browser.test.sendMessage("test_fpd_cookies_on_fpi_disabled");
+ };
+
+ browser.test.onMessage.addListener((message) => {
+ switch (message) {
+ case "test_fpi_disabled": return test_fpi_disabled();
+ case "test_fpi_enabled": return test_fpi_enabled();
+ case "test_fpi_with_invalid_characters": return test_fpi_with_invalid_characters();
+ case "test_fpd_cookies_on_fpi_disabled": return test_fpd_cookies_on_fpi_disabled();
+ default: return browser.test.notifyFail("unknown-message");
+ }
+ });
+}
+
+function enableFirstPartyIsolation() {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.firstparty.isolate", true],
+ ],
+ });
+}
+
+function disableFirstPartyIsolation() {
+ return SpecialPowers.popPrefEnv();
+}
+
+add_task(async () => {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://ext-cookie-first-party.mochi.test/"],
+ },
+ });
+ await extension.startup();
+ extension.sendMessage("test_fpi_disabled");
+ await extension.awaitMessage("test_fpi_disabled");
+ await enableFirstPartyIsolation();
+ extension.sendMessage("test_fpi_enabled");
+ await extension.awaitMessage("test_fpi_enabled");
+ extension.sendMessage("test_fpi_with_invalid_characters");
+ await extension.awaitMessage("test_fpi_with_invalid_characters");
+ await disableFirstPartyIsolation();
+ extension.sendMessage("test_fpd_cookies_on_fpi_disabled");
+ await extension.awaitMessage("test_fpd_cookies_on_fpi_disabled");
+ await extension.unload();
+});
+</script>
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..a7c6931c06
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_cookies_incognito_not_allowed() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.allowPrivateBrowsingByDefault", false]],
+ });
+
+ let privateExtension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ async background() {
+ let window = await browser.windows.create({incognito: true});
+ browser.test.onMessage.addListener(async () => {
+ await browser.windows.remove(window.id);
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("ready");
+ },
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ },
+ });
+ await privateExtension.startup();
+ await privateExtension.awaitMessage("ready");
+
+ async function background() {
+ const storeId = "firefox-private";
+ const url = "http://example.org/";
+
+ // Getting the wrong storeId will fail, otherwise we should finish the test fine.
+ browser.cookies.onChanged.addListener(changeInfo => {
+ let {cookie} = changeInfo;
+ browser.test.assertTrue(cookie.storeId != storeId, "cookie store is correct");
+ });
+
+ browser.test.onMessage.addListener(async () => {
+ let stores = await browser.cookies.getAllCookieStores();
+ let store = stores.find(s => s.incognito);
+ browser.test.assertTrue(!store, "incognito cookie store should not be available");
+ browser.test.notifyPass("cookies");
+ });
+
+ await browser.test.assertRejects(
+ browser.cookies.set({url, name: "test", storeId}),
+ /Extension disallowed access/,
+ "API should reject setting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.get({url, name: "test", storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.getAll({url, storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.remove({url, name: "test", storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+ await browser.test.assertRejects(
+ browser.cookies.getAll({url, storeId}),
+ /Extension disallowed access/,
+ "API should reject getting cookie");
+
+ browser.test.sendMessage("set-cookies");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("set-cookies");
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ Services.cookies.add("example.org", "/", "public", `foo${Math.random()}`,
+ false, false, false, Number.MAX_SAFE_INTEGER, {},
+ Ci.nsICookie.SAMESITE_NONE);
+ Services.cookies.add("example.org", "/", "private", `foo${Math.random()}`,
+ false, false, false, Number.MAX_SAFE_INTEGER, {privateBrowsingId: 1},
+ Ci.nsICookie.SAMESITE_NONE);
+ });
+ extension.sendMessage("test-cookie-store");
+ await extension.awaitFinish("cookies");
+
+ await extension.unload();
+ privateExtension.sendMessage("close");
+ await privateExtension.awaitMessage("done");
+ await privateExtension.unload();
+ chromeScript.destroy();
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_cookies.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function init() {
+ // We need to trigger a cookie eviction in order to test our batch delete
+ // observer.
+
+ // Set quotaPerHost to maxPerHost - 1, so there is only one cookie
+ // will be evicted everytime.
+ SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2);
+ SpecialPowers.setIntPref("network.cookie.maxPerHost", 3);
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("network.cookie.quotaPerHost");
+ SpecialPowers.clearUserPref("network.cookie.maxPerHost");
+ });
+});
+
+add_task(async function test_bad_cookie_permissions() {
+ info("Test non-matching, non-secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.net/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test non-matching, secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.net/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test non-matching, secure domain with secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.net/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test matching subdomain with superdomain privileges, secure cookie (http)");
+ await testCookies({
+ permissions: ["http://foo.bar.example.com/", "cookies"],
+ url: "http://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: true,
+ shouldPass: false,
+ shouldWrite: true,
+ });
+
+ info("Test matching, non-secure domain with secure cookie");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.com/",
+ domain: "example.com",
+ secure: true,
+ shouldPass: false,
+ shouldWrite: true,
+ });
+
+ info("Test matching, non-secure host, secure URL");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "https://example.com/",
+ domain: "example.com",
+ secure: true,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test non-matching domain");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.com/",
+ domain: "example.net",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+
+ info("Test invalid scheme");
+ await testCookies({
+ permissions: ["ftp://example.com/", "cookies"],
+ url: "ftp://example.com/",
+ domain: "example.com",
+ secure: false,
+ shouldPass: false,
+ shouldWrite: false,
+ });
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_cookies.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function init() {
+ // We need to trigger a cookie eviction in order to test our batch delete
+ // observer.
+
+ // Set quotaPerHost to maxPerHost - 1, so there is only one cookie
+ // will be evicted everytime.
+ SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2);
+ SpecialPowers.setIntPref("network.cookie.maxPerHost", 3);
+ SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("network.cookie.quotaPerHost");
+ SpecialPowers.clearUserPref("network.cookie.maxPerHost");
+ });
+});
+
+add_task(async function test_good_cookie_permissions() {
+ info("Test matching, non-secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["http://example.com/", "cookies"],
+ url: "http://example.com/",
+ domain: "example.com",
+ secure: false,
+ shouldPass: true,
+ });
+
+ info("Test matching, secure domain with non-secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.com/",
+ domain: "example.com",
+ secure: false,
+ shouldPass: true,
+ });
+
+ info("Test matching, secure domain with secure cookie");
+ await testCookies({
+ permissions: ["https://example.com/", "cookies"],
+ url: "https://example.com/",
+ domain: "example.com",
+ secure: true,
+ shouldPass: true,
+ });
+
+ info("Test matching subdomain with superdomain privileges, secure cookie (https)");
+ await testCookies({
+ permissions: ["https://foo.bar.example.com/", "cookies"],
+ url: "https://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: true,
+ shouldPass: true,
+ });
+
+ info("Test matching subdomain with superdomain privileges, non-secure cookie (https)");
+ await testCookies({
+ permissions: ["https://foo.bar.example.com/", "cookies"],
+ url: "https://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: false,
+ shouldPass: true,
+ });
+
+ info("Test matching subdomain with superdomain privileges, non-secure cookie (http)");
+ await testCookies({
+ permissions: ["http://foo.bar.example.com/", "cookies"],
+ url: "http://foo.bar.example.com/",
+ domain: ".example.com",
+ secure: false,
+ shouldPass: true,
+ });
+});
+
+</script>
+
+</body>
+</html>
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..ea163db0de
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html
@@ -0,0 +1,90 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Downloads Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function background() {
+ const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html";
+
+ browser.test.assertThrows(
+ () => browser.downloads.download(),
+ /Incorrect argument types for downloads.download/,
+ "Should fail without options"
+ );
+
+ browser.test.assertThrows(
+ () => browser.downloads.download({url: "invalid url"}),
+ /invalid url is not a valid URL/,
+ "Should fail on invalid URL"
+ );
+
+ browser.test.assertThrows(
+ () => browser.downloads.download({}),
+ /Property "url" is required/,
+ "Should fail with no URL"
+ );
+
+ browser.test.assertThrows(
+ () => browser.downloads.download({url, method: "DELETE"}),
+ /Invalid enumeration value "DELETE"/,
+ "Should fail with invalid method"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, headers: [{name: "Host", value: "Banana"}]}),
+ /Forbidden request header name/,
+ "Should fail with a forbidden header"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: "/tmp/file.gif"}),
+ /filename must not be an absolute path/,
+ "Should fail with an absolute file path"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: ""}),
+ /filename must not be empty/,
+ "Should fail with an empty file path"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: "file."}),
+ /filename must not contain illegal characters/,
+ "Should fail with a dot in the filename"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.download({url, filename: "../file.gif"}),
+ /filename must not contain back-references/,
+ "Should fail with a file path that contains back-references"
+ );
+
+ browser.test.notifyPass("download.done");
+}
+
+add_task(async function test_invalid_download_parameters() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {permissions: ["downloads"]},
+ background,
+ });
+ await extension.startup();
+
+ await extension.awaitFinish("download.done");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test checking webRequest.onBeforeRequest details object</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let expected = {
+ "file_contains_iframe.html": {
+ type: "main_frame",
+ frameAncestor_length: 0,
+ },
+ "file_contains_img.html": {
+ type: "sub_frame",
+ frameAncestor_length: 1,
+ },
+ "file_image_good.png": {
+ type: "image",
+ frameAncestor_length: 1,
+ }
+};
+
+function checkDetails(details) {
+ let url = new URL(details.url);
+ let filename = url.pathname.split("/").pop();
+ ok(expected.hasOwnProperty(filename), `Should be expecting a request for ${filename}`);
+ let expect = expected[filename];
+ is(expect.type, details.type, `${details.type} type matches`);
+ is(expect.frameAncestor_length, details.frameAncestors.length, "incorrect frameAncestors length");
+ if (filename == "file_contains_img.html") {
+ is(details.frameAncestors[0].frameId, details.parentFrameId,
+ "frameAncestors[0] should match parentFrameId");
+ expected["file_image_good.png"].frameId = details.frameId;
+ } else if (filename == "file_image_good.png") {
+ is(details.frameAncestors[0].frameId, details.parentFrameId,
+ "frameAncestors[0] should match parentFrameId");
+ is(details.frameId, expect.frameId,
+ "frameId for image and iframe should match");
+ }
+}
+
+add_task(async () => {
+ // Clear the image cache, since it gets in the way otherwise.
+ let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ let cache = imgTools.getImgCacheForDocument(document);
+ cache.clearCache(false);
+ await SpecialPowers.spawnChrome([], async () => {
+ Services.cache2.clear();
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("onBeforeRequest", details);
+ },
+ {
+ urls: [
+ "http://example.org/*/file_contains_img.html",
+ "http://mochi.test/*/file_contains_iframe.html",
+ "*://*/*.png",
+ ],
+ }
+ );
+ },
+ });
+
+ await extension.startup();
+ const FILE_URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html";
+ let win = window.open(FILE_URL);
+ await new Promise(resolve => win.addEventListener("load", () => resolve(), {once: true}));
+
+ for (let i = 0; i < Object.keys(expected).length; i++) {
+ checkDetails(await extension.awaitMessage("onBeforeRequest"));
+ }
+
+ win.close();
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..bdf300ec50
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html
@@ -0,0 +1,91 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_contentscript() {
+ function background() {
+ browser.runtime.onMessage.addListener(([script], sender) => {
+ browser.test.sendMessage("run", {script});
+ browser.test.sendMessage("run-" + script);
+ });
+ browser.test.sendMessage("running");
+ }
+
+ function contentScriptAll() {
+ browser.runtime.sendMessage(["all"]);
+ }
+ function contentScriptIncludesTest1() {
+ browser.runtime.sendMessage(["includes-test1"]);
+ }
+ function contentScriptExcludesTest1() {
+ browser.runtime.sendMessage(["excludes-test1"]);
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://example.org/", "http://*.example.org/"],
+ "exclude_globs": [],
+ "include_globs": ["*"],
+ "js": ["content_script_all.js"],
+ },
+ {
+ "matches": ["http://example.org/", "http://*.example.org/"],
+ "include_globs": ["*test1*"],
+ "js": ["content_script_includes_test1.js"],
+ },
+ {
+ "matches": ["http://example.org/", "http://*.example.org/"],
+ "exclude_globs": ["*test1*"],
+ "js": ["content_script_excludes_test1.js"],
+ },
+ ],
+ },
+ background,
+
+ files: {
+ "content_script_all.js": contentScriptAll,
+ "content_script_includes_test1.js": contentScriptIncludesTest1,
+ "content_script_excludes_test1.js": contentScriptExcludesTest1,
+ },
+
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let ran = 0;
+ extension.onMessage("run", ({script}) => {
+ ran++;
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("running")]);
+ info("extension loaded");
+
+ let win = window.open("http://example.org/");
+ await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-excludes-test1")]);
+ win.close();
+ is(ran, 2);
+
+ win = window.open("http://test1.example.org/");
+ await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-includes-test1")]);
+ win.close();
+ is(ran, 4);
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
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..ba91989d9c
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension external messaging</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function backgroundScript(id, otherId) {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.fail(`Got unexpected message: ${uneval(msg)} ${uneval(sender)}`);
+ });
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.fail(`Got unexpected connection: ${uneval(port.sender)}`);
+ });
+
+ browser.runtime.onMessageExternal.addListener((msg, sender) => {
+ browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`);
+ browser.test.assertEq(`helo-${id}`, msg, "Got expected message");
+
+ browser.test.sendMessage("onMessage-done");
+
+ return Promise.resolve(`ehlo-${otherId}`);
+ });
+
+ browser.runtime.onConnectExternal.addListener(port => {
+ browser.test.assertEq(otherId, port.sender.id, `${id}: Got expected external connecter ID`);
+
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(`helo-${id}`, msg, "Got expected port message");
+
+ port.postMessage(`ehlo-${otherId}`);
+
+ browser.test.sendMessage("onConnect-done");
+ });
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "go") {
+ browser.runtime.sendMessage(otherId, `helo-${otherId}`).then(result => {
+ browser.test.assertEq(`ehlo-${id}`, result, "Got expected reply");
+ browser.test.sendMessage("sendMessage-done");
+ });
+
+ let port = browser.runtime.connect(otherId);
+ port.postMessage(`helo-${otherId}`);
+
+ port.onMessage.addListener(msg => {
+ port.disconnect();
+
+ browser.test.assertEq(msg, `ehlo-${id}`, "Got expected port reply");
+ browser.test.sendMessage("connect-done");
+ });
+ }
+ });
+}
+
+function makeExtension(id, otherId) {
+ let args = `${JSON.stringify(id)}, ${JSON.stringify(otherId)}`;
+
+ let extensionData = {
+ background: `(${backgroundScript})(${args})`,
+ manifest: {
+ "applications": {"gecko": {id}},
+ },
+ };
+
+ return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+add_task(async function test_contentscript() {
+ const ID1 = "foo-message@mochitest.mozilla.org";
+ const ID2 = "bar-message@mochitest.mozilla.org";
+
+ let extension1 = makeExtension(ID1, ID2);
+ let extension2 = makeExtension(ID2, ID1);
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ extension1.sendMessage("go");
+ extension2.sendMessage("go");
+
+ await Promise.all([
+ extension1.awaitMessage("sendMessage-done"),
+ extension2.awaitMessage("sendMessage-done"),
+
+ extension1.awaitMessage("onMessage-done"),
+ extension2.awaitMessage("onMessage-done"),
+
+ extension1.awaitMessage("connect-done"),
+ extension2.awaitMessage("connect-done"),
+
+ extension1.awaitMessage("onConnect-done"),
+ extension2.awaitMessage("onConnect-done"),
+ ]);
+
+ await extension1.unload();
+ await extension2.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for generating WebExtensions</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.test.log("running background script");
+
+ browser.test.onMessage.addListener((x, y) => {
+ browser.test.assertEq(x, 10, "x is 10");
+ browser.test.assertEq(y, 20, "y is 20");
+
+ browser.test.notifyPass("background test passed");
+ });
+
+ browser.test.sendMessage("running", 1);
+}
+
+let extensionData = {
+ background,
+};
+
+add_task(async function test_background() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ info("load complete");
+ let [, x] = await Promise.all([extension.startup(), extension.awaitMessage("running")]);
+ is(x, 1, "got correct value from extension");
+ info("startup complete");
+ extension.sendMessage(10, 20);
+ await extension.awaitFinish();
+ info("test complete");
+ await extension.unload();
+ info("extension unloaded successfully");
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+add_task(async function test_geolocation_nopermission() {
+ let GEO_URL = "http://mochi.test:8888/tests/dom/tests/mochitest/geolocation/network_geolocation.sjs";
+ await SpecialPowers.pushPrefEnv({"set": [["geo.provider.network.url", GEO_URL]]});
+});
+
+add_task(async function test_geolocation() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "geolocation",
+ ],
+ },
+ background() {
+ navigator.geolocation.getCurrentPosition(() => {
+ browser.test.notifyPass("success geolocation call");
+ }, (error) => {
+ browser.test.notifyFail(`geolocation call ${error}`);
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_geolocation_nopermission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ navigator.geolocation.getCurrentPosition(() => {
+ browser.test.notifyFail("success geolocation call");
+ }, (error) => {
+ browser.test.notifyPass(`geolocation call ${error}`);
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_geolocation_prompt() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.tabs.create({url: "tab.html"});
+ },
+ files: {
+ "tab.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head></html>`,
+ "tab.js": () => {
+ navigator.geolocation.getCurrentPosition(() => {
+ browser.test.notifyPass("success geolocation call");
+ }, (error) => {
+ browser.test.notifyFail(`geolocation call ${error}`);
+ });
+ },
+ },
+ });
+
+ // Bypass the actual prompt, but the prompt result is to allow access.
+ await SpecialPowers.pushPrefEnv({"set": [["geo.prompt.testing", true], ["geo.prompt.testing.allow", true]]});
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+
+</body>
+</html>
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..c40578cd40
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_identity.html
@@ -0,0 +1,390 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for WebExtension Identity</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webextensions.identity.redirectDomain", "example.com"],
+ // Disable the network cache first-party partition during this
+ // test (TODO: look more closely to how that is affecting the intermittency
+ // of this test on MacOS, see Bug 1626482).
+ ["privacy.partition.network_state", false],
+ ],
+ });
+});
+
+add_task(async function test_noPermission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.identity,
+ "No identity api without permission"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_getRedirectURL() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ let redirect_base =
+ "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/";
+ await browser.test.assertEq(
+ redirect_base,
+ browser.identity.getRedirectURL(),
+ "redirect url ok"
+ );
+ await browser.test.assertEq(
+ redirect_base,
+ browser.identity.getRedirectURL(""),
+ "redirect url ok"
+ );
+ await browser.test.assertEq(
+ redirect_base + "foobar",
+ browser.identity.getRedirectURL("foobar"),
+ "redirect url ok"
+ );
+ await browser.test.assertEq(
+ redirect_base + "callback",
+ browser.identity.getRedirectURL("/callback"),
+ "redirect url ok"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_badAuthURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ for (let url of [
+ "foobar",
+ "about:addons",
+ "about:blank",
+ "ftp://example.com/test",
+ ]) {
+ await browser.test.assertThrows(
+ () => {
+ browser.identity.launchWebAuthFlow({ interactive: true, url });
+ },
+ /Type error for parameter details/,
+ "details.url is invalid"
+ );
+ }
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_badRequestURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ let base_uri =
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/";
+ let url = `${base_uri}?redirect_uri=badrobot}`;
+ await browser.test.assertRejects(
+ browser.identity.launchWebAuthFlow({ interactive: true, url }),
+ "redirect_uri is invalid",
+ "invalid redirect url"
+ );
+ url = `${base_uri}?redirect_uri=https://somesite.com`;
+ await browser.test.assertRejects(
+ browser.identity.launchWebAuthFlow({ interactive: true, url }),
+ "redirect_uri not allowed",
+ "invalid redirect url"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function background_launchWebAuthFlow_requires_interaction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["identity", "https://example.com/"],
+ },
+ async background() {
+ let base_uri =
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/";
+ let url = `${base_uri}?redirect_uri=${browser.identity.getRedirectURL(
+ "redirect"
+ )}`;
+ await browser.test.assertRejects(
+ browser.identity.launchWebAuthFlow({ interactive: false, url }),
+ "Requires user interaction",
+ "Rejects on required user interaction"
+ );
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+function background_launchWebAuthFlow({
+ interactive = false,
+ path = "redirect_auto.sjs",
+ params = {},
+ redirect = true,
+ useRedirectUri = true,
+} = {}) {
+ let uri_path = useRedirectUri ? "identity_cb" : "";
+ let expected_redirect = `https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/${uri_path}`;
+ let base_uri =
+ "https://example.com/tests/toolkit/components/extensions/test/mochitest/";
+ let redirect_uri = browser.identity.getRedirectURL(
+ useRedirectUri ? uri_path : undefined
+ );
+ browser.test.assertEq(
+ expected_redirect,
+ redirect_uri,
+ "expected redirect uri matches hash"
+ );
+ let url = `${base_uri}${path}`;
+ if (useRedirectUri) {
+ params.redirect_uri = redirect_uri;
+ } else {
+ // We kind of fake it with the redirect url that would normally be configured
+ // in the oauth service. This does still test that the identity service falls back
+ // to the extensions redirect url.
+ params.default_redirect = expected_redirect;
+ }
+ if (!redirect) {
+ params.no_redirect = 1;
+ }
+ let query = [];
+ for (let [param, value] of Object.entries(params)) {
+ query.push(`${param}=${encodeURIComponent(value)}`);
+ }
+ url = `${url}?${query.join("&")}`;
+
+ // Ensure we do not start the actual request for the redirect url. In the case
+ // of a 303 POST redirect we are getting a request started.
+ let watchRedirectRequest = () => {};
+ if (params.post !== 303) {
+ watchRedirectRequest = details => {
+ if (details.url.startsWith(expected_redirect)) {
+ browser.test.fail(`onBeforeRequest called for redirect url: ${JSON.stringify(details)}`);
+ }
+ };
+
+ browser.webRequest.onBeforeRequest.addListener(
+ watchRedirectRequest,
+ {
+ urls: [
+ "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/*",
+ ],
+ }
+ );
+ }
+
+ browser.identity
+ .launchWebAuthFlow({ interactive, url })
+ .then(redirectURL => {
+ browser.test.assertTrue(
+ redirectURL.startsWith(redirect_uri),
+ `correct redirect url ${redirectURL}`
+ );
+ if (redirect) {
+ let url = new URL(redirectURL);
+ browser.test.assertEq(
+ "here ya go",
+ url.searchParams.get("access_token"),
+ "Handled auto redirection"
+ );
+ }
+ })
+ .catch(error => {
+ if (redirect) {
+ browser.test.fail(error.message);
+ } else {
+ browser.test.assertEq(
+ "Requires user interaction",
+ error.message,
+ "Auth page loaded, interaction required."
+ );
+ }
+ }).then(() => {
+ browser.webRequest.onBeforeRequest.removeListener(watchRedirectRequest);
+ browser.test.sendMessage("done");
+ });
+}
+
+// Tests the situation where the oauth provider has already granted access and
+// simply redirects the oauth client to provide the access key or code.
+add_task(async function test_autoRedirect() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})()`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_autoRedirect_noRedirectURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({useRedirectUri: false})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Tests the situation where the oauth provider has not granted access and interactive=false
+add_task(async function test_noRedirect() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({redirect: false})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Tests the situation where the oauth provider must show a window where
+// presumably the user interacts, then the redirect occurs and access key or
+// code is provided. We bypass any real interaction, but want the window to
+// open and result in a redirect.
+add_task(async function test_interaction() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html"})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Tests the situation where the oauth provider redirects with a 303.
+add_task(async function test_auto303Redirect() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["webRequest", "identity", "https://*.example.com/*"],
+ },
+ background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html", params: {post: 303, server_uri: "redirect_auto.sjs"}})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_loopbackRedirectURI() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "identity@mozilla.org",
+ },
+ },
+ permissions: ["identity"],
+ },
+ async background() {
+ let redirectURL = "http://127.0.0.1/mozoauth2/35b64b676900f491c00e7f618d43f7040e88422e";
+ let actualRedirect = await browser.identity.launchWebAuthFlow({
+ interactive: true,
+ url: `https://example.com/tests/toolkit/components/extensions/test/mochitest/oauth.html?redirect_uri=${encodeURIComponent(redirectURL)}`
+ }).catch(error => {
+ browser.test.fail(error.message)
+ });
+ browser.test.assertTrue(
+ actualRedirect.startsWith(redirectURL),
+ "Expected redirect url to be loopback address"
+ )
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function testWithRealIdleService() {
+ function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let detectionInterval = args[0];
+ if (msg == "addListener") {
+ let status = await browser.idle.queryState(detectionInterval);
+ browser.test.assertEq("active", status, "Idle status is active");
+ browser.idle.setDetectionInterval(detectionInterval);
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq("idle", newState, "listener fired with the expected state");
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.test.sendMessage("listenerAdded");
+ } else if (msg == "checkState") {
+ let status = await browser.idle.queryState(detectionInterval);
+ browser.test.assertEq("idle", status, "Idle status is idle");
+ browser.test.notifyPass("idle");
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ await extension.startup();
+
+ let chromeScript = loadChromeScript(() => {
+ const {sendAsyncMessage} = this;
+ const idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(Ci.nsIUserIdleService);
+ let idleTime = idleService.idleTime;
+ sendAsyncMessage("detectionInterval", Math.max(Math.ceil(idleTime / 1000) + 10, 15));
+ });
+ let detectionInterval = await chromeScript.promiseOneMessage("detectionInterval");
+ chromeScript.destroy();
+
+ info(`Setting interval to ${detectionInterval}`);
+ extension.sendMessage("addListener", detectionInterval);
+ await extension.awaitMessage("listenerAdded");
+ info("Listener added");
+ await extension.awaitMessage("listenerFired");
+ info("Listener fired");
+ extension.sendMessage("checkState", detectionInterval);
+ await extension.awaitFinish("idle");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_in_incognito_context_true() {
+ function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq(true, msg, "inIncognitoContext is true");
+ browser.test.notifyPass("inIncognitoContext");
+ });
+
+ browser.windows.create({url: browser.runtime.getURL("/tab.html"), incognito: true});
+ }
+
+ function tabScript() {
+ browser.runtime.sendMessage(browser.extension.inIncognitoContext);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "tab.js": tabScript,
+ "tab.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head></html>`,
+ },
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("inIncognitoContext");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_listener_proxies() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+
+ manifest: {
+ "permissions": ["storage"],
+ },
+
+ async background() {
+ // Test that adding multiple listeners for the same event works as
+ // expected.
+
+ let awaitChanged = () => new Promise(resolve => {
+ browser.storage.onChanged.addListener(function listener() {
+ browser.storage.onChanged.removeListener(listener);
+ resolve();
+ });
+ });
+
+ let promises = [
+ awaitChanged(),
+ awaitChanged(),
+ ];
+
+ function removedListener() {}
+ browser.storage.onChanged.addListener(removedListener);
+ browser.storage.onChanged.removeListener(removedListener);
+
+ promises.push(awaitChanged(), awaitChanged());
+
+ browser.storage.local.set({foo: "bar"});
+
+ await Promise.all(promises);
+
+ browser.test.notifyPass("onchanged-listeners");
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("onchanged-listeners");
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..4561ef1a28
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html
@@ -0,0 +1,152 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for opening links in new tabs from extension frames</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function promiseObserved(topic, check) {
+ return new Promise(resolve => {
+ let obs = SpecialPowers.Services.obs;
+
+ function observer(subject, topic, data) {
+ subject = SpecialPowers.wrap(subject);
+ if (check(subject, data)) {
+ obs.removeObserver(observer, topic);
+ resolve({subject, data});
+ }
+ }
+ obs.addObserver(observer, topic);
+ });
+}
+
+add_task(async function test_target_blank_link_no_opener_from_privileged() {
+ const linkURL = "http://example.com/";
+
+ function extension_tab() {
+ document.getElementById("link").click();
+ }
+
+ function content_script() {
+ browser.runtime.sendMessage("content_page_loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "target_blank_link@tests.mozilla.org" } },
+ content_scripts: [{
+ js: ["content_script.js"],
+ matches: ["http://example.com/*"],
+ run_at: "document_idle",
+ }],
+ permissions: ["tabs"],
+ },
+ files: {
+ "page.html": `<!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></html>
+ <body>
+ <a href="${linkURL}" target="_blank" id="link">link</a>
+ <script src="extension_tab.js"><\/script>
+ </body>
+ </html>`,
+ "extension_tab.js": extension_tab,
+ "content_script.js": content_script,
+ },
+ background() {
+ let pageTab;
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ if (sender.tab) {
+ browser.test.sendMessage(msg, sender.tab.url);
+ browser.tabs.remove(sender.tab.id);
+ browser.tabs.remove(pageTab.id);
+ }
+ });
+ pageTab = browser.tabs.create({ url: browser.runtime.getURL("page.html") });
+ },
+ });
+
+ await extension.startup();
+
+ // Make sure page is loaded correctly
+ const url = await extension.awaitMessage("content_page_loaded");
+ is(url, linkURL, "Page URL should match");
+
+ await extension.unload();
+});
+
+add_task(async function test_target_blank_link() {
+ const linkURL = "http://mochi.test:8888/tests/toolkit/";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_security_policy: "script-src 'self' 'unsafe-eval'; object-src 'self';",
+
+ web_accessible_resources: ["iframe.html"],
+ },
+ files: {
+ "iframe.html": `<!DOCTYPE html>
+ <html>
+ <head><meta charset="utf-8"></html>
+ <body>
+ <a href="${linkURL}" target="_blank" id="link" rel="opener">link</a>
+ </body>
+ </html>`,
+ },
+ background() {
+ browser.test.sendMessage("frame_url", browser.runtime.getURL("iframe.html"));
+ },
+ });
+
+ await extension.startup();
+
+ let url = await extension.awaitMessage("frame_url");
+
+ let iframe = document.createElement("iframe");
+ iframe.src = url;
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.addEventListener("load", () => setTimeout(resolve, 0), {once: true}));
+
+ let win = SpecialPowers.wrap(iframe).contentWindow;
+
+ {
+ // Flush layout so that synthesizeMouseAtCenter on a cross-origin iframe
+ // works as expected.
+ document.body.getBoundingClientRect();
+
+ let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL);
+
+ await SpecialPowers.spawn(iframe, [], async () => {
+ this.content.document.getElementById("link").click();
+ });
+
+ let {subject: doc} = await promise;
+ info("Link opened");
+ doc.defaultView.close();
+ info("Window closed");
+ }
+
+ {
+ let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL);
+
+ let res = win.eval(`window.open("${linkURL}")`);
+ let {subject: doc} = await promise;
+ is(SpecialPowers.unwrap(res), SpecialPowers.unwrap(doc.defaultView), "window.open worked as expected");
+
+ doc.defaultView.close();
+ }
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for notifications</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_notifications.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// A 1x1 PNG image.
+// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain)
+let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=");
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer;
+
+add_task(async function setup_mock_alert_service() {
+ await MockAlertsService.register();
+});
+
+add_task(async function test_notification() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let id = await browser.notifications.create(opts);
+
+ browser.test.sendMessage("running", id);
+ browser.test.notifyPass("background test passed");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+ await extension.startup();
+ let x = await extension.awaitMessage("running");
+ is(x, "0", "got correct id from notifications.create");
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_notification_events() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let createdId = "98";
+
+ // Test an ignored listener.
+ browser.notifications.onButtonClicked.addListener(function() {});
+
+ // We cannot test onClicked listener without a mock
+ // but we can attempt to add a listener.
+ browser.notifications.onClicked.addListener(async function(id) {
+ browser.test.assertEq(createdId, id, "onClicked has the expected ID");
+ browser.test.sendMessage("notification-event", "clicked");
+ });
+
+ browser.notifications.onShown.addListener(async function listener(id) {
+ browser.test.assertEq(createdId, id, "onShown has the expected ID");
+ browser.test.sendMessage("notification-event", "shown");
+ });
+
+ browser.test.onMessage.addListener(async function(msg, expectedCount) {
+ if (msg === "create-again") {
+ let newId = await browser.notifications.create(createdId, opts);
+ browser.test.assertEq(createdId, newId, "create returned the expected id.");
+ browser.test.sendMessage("notification-created-twice");
+ } else if (msg === "check-count") {
+ let notifications = await browser.notifications.getAll();
+ let ids = Object.keys(notifications);
+ browser.test.assertEq(expectedCount, ids.length, `getAll() = ${ids}`);
+ browser.test.sendMessage("check-count-result");
+ }
+ });
+
+ // Test onClosed listener.
+ browser.notifications.onClosed.addListener(function listener(id) {
+ browser.test.assertEq(createdId, id, "onClosed received the expected id.");
+ browser.test.sendMessage("notification-event", "closed");
+ });
+
+ await browser.notifications.create(createdId, opts);
+
+ browser.test.sendMessage("notification-created-once");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ async function waitForNotificationEvent(name) {
+ info(`Waiting for notification event: ${name}`);
+ is(name, await extension.awaitMessage("notification-event"),
+ "Expected notification event");
+ }
+ async function checkNotificationCount(expectedCount) {
+ extension.sendMessage("check-count", expectedCount);
+ await extension.awaitMessage("check-count-result");
+ }
+
+ await extension.awaitMessage("notification-created-once");
+ await waitForNotificationEvent("shown");
+ await checkNotificationCount(1);
+
+ // On most platforms, clicking the notification closes it.
+ // But on macOS, the notification can repeatedly be clicked without closing.
+ await MockAlertsService.clickNotificationsWithoutClose();
+ await waitForNotificationEvent("clicked");
+ await checkNotificationCount(1);
+ await MockAlertsService.clickNotificationsWithoutClose();
+ await waitForNotificationEvent("clicked");
+ await checkNotificationCount(1);
+ await MockAlertsService.clickNotifications();
+ await waitForNotificationEvent("clicked");
+ await waitForNotificationEvent("closed");
+ await checkNotificationCount(0);
+
+ extension.sendMessage("create-again");
+ await extension.awaitMessage("notification-created-twice");
+ await waitForNotificationEvent("shown");
+ await checkNotificationCount(1);
+
+ await MockAlertsService.closeNotifications();
+ await waitForNotificationEvent("closed");
+ await checkNotificationCount(0);
+
+ await extension.unload();
+});
+
+add_task(async function test_notification_clear() {
+ function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let createdId = "99";
+
+ browser.notifications.onShown.addListener(async id => {
+ browser.test.assertEq(createdId, id, "onShown received the expected id.");
+ let wasCleared = await browser.notifications.clear(id);
+ browser.test.assertTrue(wasCleared, "notifications.clear returned true.");
+ });
+
+ browser.notifications.onClosed.addListener(id => {
+ browser.test.assertEq(createdId, id, "onClosed received the expected id.");
+ browser.test.notifyPass("background test passed");
+ });
+
+ browser.notifications.create(createdId, opts);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_notifications_empty_getAll() {
+ async function background() {
+ let notifications = await browser.notifications.getAll();
+
+ browser.test.assertEq("object", typeof notifications, "getAll() returned an object");
+ browser.test.assertEq(0, Object.keys(notifications).length, "the object has no properties");
+ browser.test.notifyPass("getAll empty");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitFinish("getAll empty");
+ await extension.unload();
+});
+
+add_task(async function test_notifications_populated_getAll() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ iconUrl: "a.png",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ await browser.notifications.create("p1", opts);
+ await browser.notifications.create("p2", opts);
+ let notifications = await browser.notifications.getAll();
+
+ browser.test.assertEq("object", typeof notifications, "getAll() returned an object");
+ browser.test.assertEq(2, Object.keys(notifications).length, "the object has 2 properties");
+
+ for (let notificationId of ["p1", "p2"]) {
+ for (let key of Object.keys(opts)) {
+ browser.test.assertEq(
+ opts[key],
+ notifications[notificationId][key],
+ `the notification has the expected value for option: ${key}`
+ );
+ }
+ }
+
+ browser.test.notifyPass("getAll populated");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ files: {
+ "a.png": IMAGE_ARRAYBUFFER,
+ },
+ });
+ await extension.startup();
+ await extension.awaitFinish("getAll populated");
+ await extension.unload();
+});
+
+add_task(async function test_buttons_unsupported() {
+ function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ buttons: [{title: "Button title"}],
+ };
+
+ let exception = {};
+ try {
+ browser.notifications.create(opts);
+ } catch (e) {
+ exception = e;
+ }
+
+ browser.test.assertTrue(
+ String(exception).includes('Property "buttons" is unsupported by Firefox'),
+ "notifications.create with buttons option threw an expected exception"
+ );
+ browser.test.notifyPass("buttons-unsupported");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitFinish("buttons-unsupported");
+ await extension.unload();
+});
+
+add_task(async function test_notifications_different_contexts() {
+ async function background() {
+ let opts = {
+ type: "basic",
+ title: "Testing Notification",
+ message: "Carry on",
+ };
+
+ let id = await browser.notifications.create(opts);
+
+ browser.runtime.onMessage.addListener(async (message, sender) => {
+ await browser.tabs.remove(sender.tab.id);
+
+ // We should be able to clear the notification after creating and
+ // destroying the tab.html page.
+ let wasCleared = await browser.notifications.clear(id);
+ browser.test.assertTrue(wasCleared, "The notification was cleared.");
+ browser.test.notifyPass("notifications");
+ });
+
+ browser.tabs.create({url: browser.runtime.getURL("/tab.html")});
+ }
+
+ async function tabScript() {
+ // We should be able to see the notification created in the background page
+ // in this page.
+ let notifications = await browser.notifications.getAll();
+ browser.test.assertEq(1, Object.keys(notifications).length,
+ "One notification found.");
+ browser.runtime.sendMessage("continue-test");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ background,
+ files: {
+ "tab.js": tabScript,
+ "tab.html": `<!DOCTYPE html><html><head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head></html>`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("notifications");
+ await extension.unload();
+});
+
+add_task(async function teardown_mock_alert_service() {
+ await MockAlertsService.unregister();
+});
+
+</script>
+
+</body>
+</html>
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..10305c8ac0
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html
@@ -0,0 +1,394 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for protocol handlers</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+/* global addMessageListener, sendAsyncMessage */
+
+function protocolChromeScript() {
+ addMessageListener("setup", () => {
+ let data = {};
+ const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+ data.preferredAction = protoInfo.preferredAction === protoInfo.useHelperApp;
+
+ let handlers = protoInfo.possibleApplicationHandlers;
+ data.handlers = handlers.length;
+
+ let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp);
+ data.isWebHandler = handler instanceof Ci.nsIWebHandlerApp;
+ data.uriTemplate = handler.uriTemplate;
+
+ // ext+ protocols should be set as default when there is only one
+ data.preferredApplicationHandler = protoInfo.preferredApplicationHandler == handler;
+ data.alwaysAskBeforeHandling = protoInfo.alwaysAskBeforeHandling;
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"]
+ .getService(Ci.nsIHandlerService);
+ handlerSvc.store(protoInfo);
+
+ sendAsyncMessage("handlerData", data);
+ });
+}
+
+add_task(async function test_protocolHandler() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["extensions.allowPrivateBrowsingByDefault", false],
+ // Disabling the external protocol permission prompt. We don't need it
+ // for this test.
+ ["security.external_protocol_requires_permission", false],
+ ]});
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "ext+foo",
+ "name": "a foo protocol handler",
+ "uriTemplate": "foo.html?val=%s",
+ },
+ ],
+ },
+
+ background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "open") {
+ let tab = await browser.tabs.create({url: arg});
+ browser.test.sendMessage("opened", tab.id);
+ } else if (msg == "close") {
+ await browser.tabs.remove(arg);
+ browser.test.sendMessage("closed");
+ }
+ });
+ browser.test.sendMessage("test-url", browser.runtime.getURL("foo.html"));
+ },
+
+ files: {
+ "foo.js": function() {
+ browser.test.sendMessage("test-query", location.search);
+ },
+ "foo.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="foo.js"><\/script>
+ </head>
+ </html>`,
+ },
+ };
+
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "open") {
+ let win = await browser.windows.create({ url: arg, incognito: true });
+ browser.test.sendMessage("opened", { windowId: win.id, tabId: win.tabs[0].id });
+ } else if(msg == "nav") {
+ await browser.tabs.update(arg.tabId, { url: arg.url })
+ browser.test.sendMessage("navigated");
+ } else if (msg == "close") {
+ await browser.windows.remove(arg);
+ browser.test.sendMessage("closed");
+ }
+ });
+ },
+ incognitoOverride: "spanning",
+ });
+ await pb_extension.startup();
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let handlerUrl = await extension.awaitMessage("test-url");
+
+ // Ensure that the protocol handler is configured, and set it as default to
+ // bypass the dialog.
+ let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
+
+ let msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup");
+ let data = await msg;
+ ok(data.preferredAction, "using a helper application is the preferred action");
+ ok(data.preferredApplicationHandler, "handler was set as default handler");
+ is(data.handlers, 1, "one handler is set");
+ ok(!data.alwaysAskBeforeHandling, "will not show dialog");
+ ok(data.isWebHandler, "the handler is a web handler");
+ is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template");
+ chromeScript.destroy();
+
+ extension.sendMessage("open", "ext+foo:test");
+ let id = await extension.awaitMessage("opened");
+
+ let query = await extension.awaitMessage("test-query");
+ is(query, "?val=ext%2Bfoo%3Atest", "test query ok");
+
+ extension.sendMessage("close", id);
+ await extension.awaitMessage("closed");
+
+ // Test the protocol in a private window, watch for the
+ // console error.
+ consoleMonitor.start([{message: /NS_ERROR_FILE_NOT_FOUND/}]);
+
+ // Expect the chooser window to be open, close it.
+ chromeScript = SpecialPowers.loadChromeScript(async () => {
+ const CONTENT_HANDLING_URL = "chrome://mozapps/content/handling/appChooser.xhtml";
+ const {BrowserTestUtils} = ChromeUtils.import("resource://testing-common/BrowserTestUtils.jsm");
+
+ let windowOpen = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ sendAsyncMessage("listenWindow");
+
+ let window = await windowOpen;
+ let gBrowser = window.gBrowser
+ let tabDialogBox = gBrowser.getTabDialogBox(gBrowser.selectedBrowser);
+ let dialogStack = tabDialogBox.getTabDialogManager()._dialogStack;
+
+ let checkFn = dialogEvent =>
+ dialogEvent.detail.dialog?._openedURL == CONTENT_HANDLING_URL;
+
+ let eventPromise = BrowserTestUtils.waitForEvent(
+ dialogStack,
+ "dialogopen",
+ true,
+ checkFn
+ );
+
+ sendAsyncMessage("listenDialog");
+
+ let event = await eventPromise;
+
+ let { dialog } = event.detail;
+
+ let entry = dialog._frame.contentDocument.getElementById("items").firstChild;
+ sendAsyncMessage("handling", {name: entry.getAttribute("name"), disabled: entry.disabled});
+
+ dialog.close();
+ });
+
+ // Wait for the chrome script to attach window listener
+ await chromeScript.promiseOneMessage("listenWindow");
+
+ let listenDialog = chromeScript.promiseOneMessage("listenDialog");
+ let windowOpen = pb_extension.awaitMessage("opened");
+
+ pb_extension.sendMessage("open", "ext+foo:test");
+
+ // Wait for chrome script to attach dialog listener
+ await listenDialog;
+ let {tabId, windowId} = await windowOpen;
+
+ let testData = chromeScript.promiseOneMessage("handling");
+ let navPromise = pb_extension.awaitMessage("navigated");
+ pb_extension.sendMessage("nav", {url: "ext+foo:test", tabId});
+ await navPromise;
+ await consoleMonitor.finished();
+ let entry = await testData;
+
+ is(entry.name, "a foo protocol handler", "entry is correct");
+ ok(entry.disabled, "handler is disabled");
+
+ let promiseClosed = pb_extension.awaitMessage("closed");
+ pb_extension.sendMessage("close", windowId);
+ await promiseClosed;
+ await pb_extension.unload();
+
+ // Shutdown the addon, then ensure the protocol was removed.
+ await extension.unload();
+ chromeScript = SpecialPowers.loadChromeScript(() => {
+ addMessageListener("setup", () => {
+ const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+ sendAsyncMessage("preferredApplicationHandler", !protoInfo.preferredApplicationHandler);
+ let handlers = protoInfo.possibleApplicationHandlers;
+
+ sendAsyncMessage("handlerData", {
+ preferredApplicationHandler: !protoInfo.preferredApplicationHandler,
+ handlers: handlers.length,
+ });
+ });
+ });
+
+ msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup");
+ data = await msg;
+ ok(data.preferredApplicationHandler, "no preferred handler is set");
+ is(data.handlers, 0, "no handler is set");
+ chromeScript.destroy();
+});
+
+add_task(async function test_protocolHandler_two() {
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "ext+foo",
+ "name": "a foo protocol handler",
+ "uriTemplate": "foo.html?val=%s",
+ },
+ {
+ "protocol": "ext+foo",
+ "name": "another foo protocol handler",
+ "uriTemplate": "foo2.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ // Ensure that the protocol handler is configured, and set it as default,
+ // but because there are two handlers, the dialog is not bypassed. We
+ // don't test the actual dialog ui, it's been here forever and works based
+ // on the alwaysAskBeforeHandling value.
+ let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript);
+
+ let msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup");
+ let data = await msg;
+ ok(data.preferredAction, "using a helper application is the preferred action");
+ ok(data.preferredApplicationHandler, "preferred handler is set");
+ is(data.handlers, 2, "two handlers are set");
+ ok(data.alwaysAskBeforeHandling, "will show dialog");
+ ok(data.isWebHandler, "the handler is a web handler");
+ chromeScript.destroy();
+ await extension.unload();
+});
+
+add_task(async function test_protocolHandler_https_target() {
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "ext+foo",
+ "name": "http target",
+ "uriTemplate": "https://example.com/foo.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ ok(true, "https uriTemplate target works");
+ await extension.unload();
+});
+
+add_task(async function test_protocolHandler_http_target() {
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "ext+foo",
+ "name": "http target",
+ "uriTemplate": "http://example.com/foo.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ ok(true, "http uriTemplate target works");
+ await extension.unload();
+});
+
+add_task(async function test_protocolHandler_restricted_protocol() {
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "http",
+ "name": "take over the http protocol",
+ "uriTemplate": "http.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ consoleMonitor.start([{message: /processing protocol_handlers\.0\.protocol/}]);
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(extension.startup(),
+ /startup failed/,
+ "unable to register restricted handler protocol");
+
+ await consoleMonitor.finished();
+});
+
+add_task(async function test_protocolHandler_restricted_uriTemplate() {
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "ext+foo",
+ "name": "take over the http protocol",
+ "uriTemplate": "ftp://example.com/file.txt",
+ },
+ ],
+ },
+ };
+
+ consoleMonitor.start([{message: /processing protocol_handlers\.0\.uriTemplate/}]);
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(extension.startup(),
+ /startup failed/,
+ "unable to register restricted handler uriTemplate");
+
+ await consoleMonitor.finished();
+});
+
+add_task(async function test_protocolHandler_duplicate() {
+ let extensionData = {
+ manifest: {
+ "protocol_handlers": [
+ {
+ "protocol": "ext+foo",
+ "name": "foo protocol",
+ "uriTemplate": "foo.html?val=%s",
+ },
+ {
+ "protocol": "ext+foo",
+ "name": "foo protocol",
+ "uriTemplate": "foo.html?val=%s",
+ },
+ ],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ // Get the count of handlers installed.
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ addMessageListener("setup", () => {
+ const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService);
+ let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo");
+ let handlers = protoInfo.possibleApplicationHandlers;
+ sendAsyncMessage("handlerData", handlers.length);
+ });
+ });
+
+ let msg = chromeScript.promiseOneMessage("handlerData");
+ chromeScript.sendAsyncMessage("setup");
+ let data = await msg;
+ is(data, 1, "cannot re-register the same handler config");
+ chromeScript.destroy();
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..24dc737982
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html
@@ -0,0 +1,92 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script>
+"use strict";
+
+function getExtension() {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ "applications": {
+ "gecko": {
+ "id": "redirect-to-jar@mochi.test",
+ },
+ },
+ "permissions": [
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ "web_accessible_resources": [
+ "finished.html",
+ ],
+ },
+ useAddonManager: "temporary",
+ files: {
+ "finished.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>redirected!</h1>
+ </body>
+ </html>
+ `,
+ },
+ background: async () => {
+ let redirectUrl = browser.extension.getURL("finished.html");
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ return {redirectUrl};
+ }, {urls: ["*://*/intercept*"]}, ["blocking"]);
+
+ let code = `new Promise(resolve => {
+ var s = document.createElement('iframe');
+ s.src = "/intercept?r=" + Math.random();
+ s.onload = async () => {
+ let url = await window.wrappedJSObject.SpecialPowers.spawn(s, [], () => content.location.href );
+ resolve(['loaded', url]);
+ }
+ s.onerror = () => resolve(['error']);
+ document.documentElement.appendChild(s);
+ });`;
+
+ async function testSubFrameResource(tabId, code) {
+ let [result] = await browser.tabs.executeScript(tabId, { code });
+ return result;
+ }
+
+ let tab = await browser.tabs.create({url: "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html"});
+ let result = await testSubFrameResource(tab.id, code);
+ browser.test.assertEq("loaded", result[0], "frame 1 loaded");
+ browser.test.assertEq(redirectUrl, result[1], "frame 1 redirected");
+ // If jar caching breaks redirects, this next test will fail (See Bug 1390346).
+ result = await testSubFrameResource(tab.id, code);
+ browser.test.assertEq("loaded", result[0], "frame 2 loaded");
+ browser.test.assertEq(redirectUrl, result[1], "frame 2 redirected");
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("requestsCompleted");
+ },
+ });
+}
+
+add_task(async function test_redirect_to_jar() {
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("requestsCompleted");
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
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..d9a85ad8e4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html
@@ -0,0 +1,129 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for WebRequest urlClassification</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.trackingprotection.enabled", true]],
+ });
+
+ let chromeScript = SpecialPowers.loadChromeScript(async _ => {
+ const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+ await UrlClassifierTestUtils.addTestTrackers();
+ sendAsyncMessage("trackersLoaded");
+ });
+ await chromeScript.promiseOneMessage("trackersLoaded");
+ chromeScript.destroy();
+});
+
+add_task(async function test_urlClassification() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {gecko: {id: "classification@mochi.test"}},
+ permissions: ["webRequest", "webRequestBlocking", "proxy", "<all_urls>"],
+ },
+ background() {
+ let expected = {
+ "http://tracking.example.org/": {first: "tracking", thirdParty: false, },
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org": { thirdParty: false, },
+ "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": {third: "tracking", thirdParty: true, },
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net": { thirdParty: false, },
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": { thirdParty: true, },
+ };
+ function testRequest(details) {
+ let expect = expected[details.url];
+ if (expect) {
+ if (expect.first) {
+ browser.test.assertTrue(details.urlClassification.firstParty.includes("tracking"), "tracking firstParty");
+ } else {
+ browser.test.assertEq(details.urlClassification.firstParty.length, 0, "not tracking firstParty");
+ }
+ if (expect.third) {
+ browser.test.assertTrue(details.urlClassification.thirdParty.includes("tracking"), "tracking thirdParty");
+ } else {
+ browser.test.assertEq(details.urlClassification.thirdParty.length, 0, "not tracking thirdParty");
+ }
+
+ browser.test.assertEq(details.thirdParty, expect.thirdParty, "3rd party flag matches");
+ return true;
+ }
+ return false;
+ }
+
+ browser.proxy.onRequest.addListener(details => {
+ browser.test.log(`proxy.onRequest ${JSON.stringify(details)}`);
+ testRequest(details);
+ }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]});
+ browser.webRequest.onBeforeRequest.addListener(async (details) => {
+ browser.test.log(`webRequest.onBeforeRequest ${JSON.stringify(details)}`);
+ testRequest(details);
+ }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}, ["blocking"]);
+ browser.webRequest.onCompleted.addListener(async (details) => {
+ browser.test.log(`webRequest.onCompleted ${JSON.stringify(details)}`);
+ if (testRequest(details)) {
+ browser.test.sendMessage("classification", details.url);
+ }
+ }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]});
+ },
+ });
+ await extension.startup();
+
+ // Test first party tracking classification.
+ let url = "http://tracking.example.org/";
+ let win = window.open(url);
+ is(await extension.awaitMessage("classification"), url, "request completed");
+ win.close();
+
+ // Test third party tracking classification, expecting two results.
+ url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org";
+ win = window.open(url);
+ is(await extension.awaitMessage("classification"), url);
+ is(await extension.awaitMessage("classification"),
+ "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png",
+ "request completed");
+ win.close();
+
+ // Test third party tracking classification, expecting two results.
+ url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net";
+ win = window.open(url);
+ is(await extension.awaitMessage("classification"), url);
+ is(await extension.awaitMessage("classification"),
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png",
+ "request completed");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ let chromeScript = SpecialPowers.loadChromeScript(async _ => {
+ // Cleanup cache
+ await new Promise(resolve => {
+ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, value => resolve());
+ });
+
+ /* global sendAsyncMessage */
+ const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+ await UrlClassifierTestUtils.cleanupTestTrackers();
+ sendAsyncMessage("trackersUnloaded");
+ });
+ await chromeScript.promiseOneMessage("trackersUnloaded");
+ chromeScript.destroy();
+});
+
+</script>
+
+</body>
+</html>
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..c4726092ec
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html
@@ -0,0 +1,82 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq(port.name, "ernie", "port name correct");
+ browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "URL correct");
+ browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "tab URL correct");
+
+ let expected = "message 1";
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, expected, "message is expected");
+ if (expected == "message 1") {
+ port.postMessage("message 2");
+ expected = "message 3";
+ } else if (expected == "message 3") {
+ expected = "disconnect";
+ browser.test.notifyPass("runtime.connect");
+ }
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(null, port.error, "No error because port is closed by disconnect() at other end");
+ browser.test.assertEq(expected, "disconnect", "got disconnection at right time");
+ });
+ });
+}
+
+function contentScript() {
+ let port = browser.runtime.connect({name: "ernie"});
+ port.postMessage("message 1");
+ port.onMessage.addListener(msg => {
+ if (msg == "message 2") {
+ port.postMessage("message 3");
+ port.disconnect();
+ }
+ });
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win), extension.awaitFinish("runtime.connect")]);
+
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function backgroundScript(token) {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "done");
+ browser.test.notifyPass("sendmessage_reply");
+ });
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "sender url correct");
+ browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ let tabId = port.sender.tab.id;
+ browser.tabs.connect(tabId, {name: token});
+
+ browser.test.assertEq(port.name, token, "token matches");
+ port.postMessage(token + "-done");
+ });
+
+ browser.test.sendMessage("background-ready");
+}
+
+function contentScript(token) {
+ let gotTabMessage = false;
+ let badTabMessage = false;
+ browser.runtime.onConnect.addListener(port => {
+ if (port.name == token) {
+ gotTabMessage = true;
+ } else {
+ badTabMessage = true;
+ }
+ port.disconnect();
+ });
+
+ let port = browser.runtime.connect(null, {name: token});
+ port.onMessage.addListener(function(msg) {
+ if (msg != token + "-done" || !gotTabMessage || badTabMessage) {
+ return; // test failed
+ }
+
+ // FIXME: Removing this line causes the test to fail:
+ // resource://gre/modules/ExtensionUtils.jsm, line 651: NS_ERROR_NOT_INITIALIZED
+ port.disconnect();
+ browser.runtime.sendMessage("done");
+ });
+}
+
+function makeExtension() {
+ let token = Math.random();
+ let extensionData = {
+ background: `(${backgroundScript})("${token}")`,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ }],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})("${token}")`,
+ },
+ };
+ return extensionData;
+}
+
+add_task(async function test_contentscript() {
+ let extension1 = ExtensionTestUtils.loadExtension(makeExtension());
+ let extension2 = ExtensionTestUtils.loadExtension(makeExtension());
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ await extension1.awaitMessage("background-ready");
+ await extension2.awaitMessage("background-ready");
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win),
+ extension1.awaitFinish("sendmessage_reply"),
+ extension2.awaitFinish("sendmessage_reply")]);
+
+ win.close();
+
+ await extension1.unload();
+ await extension2.unload();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+add_task(async function test_connect_bidirectionally_and_postMessage() {
+ function background() {
+ let onConnectCount = 0;
+ browser.runtime.onConnect.addListener(port => {
+ // 3. onConnect by connect() from CS.
+ browser.test.assertEq("from-cs", port.name);
+ browser.test.assertEq(1, ++onConnectCount,
+ "BG onConnect should be called once");
+
+ let tabId = port.sender.tab.id;
+ browser.test.assertTrue(tabId, "content script must have a tab ID");
+
+ let port2;
+ let postMessageCount1 = 0;
+ port.onMessage.addListener(msg => {
+ // 11. port.onMessage by port.postMessage in CS.
+ browser.test.assertEq("from CS to port", msg);
+ browser.test.assertEq(1, ++postMessageCount1,
+ "BG port.onMessage should be called once");
+
+ // 12. should trigger port2.onMessage in CS.
+ port2.postMessage("from BG to port2");
+ });
+
+ // 4. Should trigger onConnect in CS.
+ port2 = browser.tabs.connect(tabId, {name: "from-bg"});
+ let postMessageCount2 = 0;
+ port2.onMessage.addListener(msg => {
+ // 7. onMessage by port2.postMessage in CS.
+ browser.test.assertEq("from CS to port2", msg);
+ browser.test.assertEq(1, ++postMessageCount2,
+ "BG port2.onMessage should be called once");
+
+ // 8. Should trigger port.onMessage in CS.
+ port.postMessage("from BG to port");
+ });
+ });
+
+ // 1. Notify test runner to create a new tab.
+ browser.test.sendMessage("ready");
+ }
+
+ function contentScript() {
+ let onConnectCount = 0;
+ let port;
+ browser.runtime.onConnect.addListener(port2 => {
+ // 5. onConnect by connect() from BG.
+ browser.test.assertEq("from-bg", port2.name);
+ browser.test.assertEq(1, ++onConnectCount,
+ "CS onConnect should be called once");
+
+ let postMessageCount2 = 0;
+ port2.onMessage.addListener(msg => {
+ // 12. port2.onMessage by port2.postMessage in BG.
+ browser.test.assertEq("from BG to port2", msg);
+ browser.test.assertEq(1, ++postMessageCount2,
+ "CS port2.onMessage should be called once");
+
+ // TODO(robwu): Do not explicitly disconnect, it should not be a problem
+ // if we keep the ports open. However, not closing the ports causes the
+ // test to fail with NS_ERROR_NOT_INITIALIZED in ExtensionUtils.jsm, in
+ // Port.prototype.disconnect (nsIMessageSender.sendAsyncMessage).
+ port.disconnect();
+ port2.disconnect();
+ browser.test.notifyPass("ping pong done");
+ });
+ // 6. should trigger port2.onMessage in BG.
+ port2.postMessage("from CS to port2");
+ });
+
+ // 2. should trigger onConnect in BG.
+ port = browser.runtime.connect({name: "from-cs"});
+ let postMessageCount1 = 0;
+ port.onMessage.addListener(msg => {
+ // 9. onMessage by port.postMessage in BG.
+ browser.test.assertEq("from BG to port", msg);
+ browser.test.assertEq(1, ++postMessageCount1,
+ "CS port.onMessage should be called once");
+
+ // 10. should trigger port.onMessage in BG.
+ port.postMessage("from CS to port");
+ });
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ content_scripts: [{
+ js: ["contentscript.js"],
+ matches: ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ info("extension loaded");
+
+ await extension.awaitMessage("ready");
+
+ let win = window.open("file_sample.html");
+ await extension.awaitFinish("ping pong done");
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+</body>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq(port.name, "ernie", "port name correct");
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(null, port.error, "The port is implicitly closed without errors when the other context unloads");
+ // Closing an already-disconnected port is a no-op.
+ port.disconnect();
+ port.disconnect();
+ browser.test.sendMessage("disconnected");
+ });
+ browser.test.sendMessage("connected");
+ });
+}
+
+function contentScript() {
+ browser.runtime.connect({name: "ernie"});
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]);
+ win.close();
+ await extension.awaitMessage("disconnected");
+
+ info("win.close() succeeded");
+
+ win = window.open("file_sample.html");
+ await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]);
+
+ // Add an "unload" listener so that we don't put the window in the
+ // bfcache. This way it gets destroyed immediately upon navigation.
+ win.addEventListener("unload", function() {}); // eslint-disable-line mozilla/balanced-listeners
+
+ win.location = "http://example.com";
+ await extension.awaitMessage("disconnected");
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ // Add two listeners that both send replies. We're supposed to ignore all but one
+ // of them. Which one is chosen is non-deterministic.
+
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == "getreply") {
+ sendReply("reply1");
+ }
+ });
+
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == "getreply") {
+ sendReply("reply2");
+ }
+ });
+
+ function sleep(callback, n = 10) {
+ if (n == 0) {
+ callback();
+ } else {
+ setTimeout(function() { sleep(callback, n - 1); }, 0);
+ }
+ }
+
+ let done_count = 0;
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == "done") {
+ done_count++;
+ browser.test.assertEq(done_count, 1, "got exactly one reply");
+
+ // Go through the event loop a few times to make sure we don't get multiple replies.
+ sleep(function() {
+ browser.test.notifyPass("sendmessage_doublereply");
+ });
+ }
+ });
+}
+
+function contentScript() {
+ browser.runtime.sendMessage("getreply", function(resp) {
+ if (resp != "reply1" && resp != "reply2") {
+ return; // test failed
+ }
+ browser.runtime.sendMessage("done");
+ });
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_doublereply")]);
+
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
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..ca151b0216
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<head>
+ <title>Test sendMessage frameId</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<script>
+"use strict";
+
+add_task(async function test_sendMessage_frameId() {
+ const html = `<!doctype html><meta charset="utf-8"><script src="script.js"><\/script>`;
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "send_message_frame_id@tests.mozilla.org" } },
+ },
+ background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.sendMessage(msg, sender);
+ });
+ browser.tabs.create({url: "tab.html"});
+ },
+ files: {
+ "iframe.html": html,
+ "tab.html": `${html}<iframe src="iframe.html"></iframe>`,
+ "script.js": () => {
+ browser.runtime.sendMessage(window.top === window ? "tab" : "iframe");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const tab = await extension.awaitMessage("tab");
+ ok(tab.url.endsWith("tab.html"), "Got the message from the tab");
+ is(tab.frameId, 0, "And sender.frameId is zero");
+
+ const iframe = await extension.awaitMessage("iframe");
+ ok(iframe.url.endsWith("iframe.html"), "Got the message from the iframe");
+ is(typeof iframe.frameId, "number", "With sender.frameId of type number");
+ ok(iframe.frameId > 0, "And sender.frameId greater than zero");
+
+ await extension.unload();
+});
+
+</script>
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..970de26528
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+function loadContentScriptExtension(contentScript) {
+ let extensionData = {
+ manifest: {
+ "content_scripts": [{
+ "js": ["contentscript.js"],
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ }],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ };
+ return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+add_task(async function test_content_script_sendMessage_without_listener() {
+ async function contentScript() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("msg"),
+ "Could not establish connection. Receiving end does not exist.");
+
+ browser.test.notifyPass("sendMessage callback was invoked");
+ }
+
+ let extension = loadContentScriptExtension(contentScript);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitFinish("sendMessage callback was invoked");
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_content_script_chrome_sendMessage_without_listener() {
+ function contentScript() {
+ /* globals chrome */
+ browser.test.assertEq(null, chrome.runtime.lastError, "no lastError before call");
+ let retval = chrome.runtime.sendMessage("msg");
+ browser.test.assertEq(null, chrome.runtime.lastError, "no lastError after call");
+ // TODO(robwu): Fix the implementation and uncomment the next expectation.
+ // When content script APIs are schema-based (bugzil.la/1287007) this bug will be fixed for free.
+ // browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage without callback");
+ browser.test.assertTrue(retval instanceof Promise, "TODO: chrome.runtime.sendMessage should return undefined, not a promise");
+
+ let isAsyncCall = false;
+ retval = chrome.runtime.sendMessage("msg", reply => {
+ browser.test.assertEq(undefined, reply, "no reply");
+ browser.test.assertTrue(isAsyncCall, "chrome.runtime.sendMessage's callback must be called asynchronously");
+ browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback");
+ browser.test.assertEq("Could not establish connection. Receiving end does not exist.", chrome.runtime.lastError.message);
+ browser.test.notifyPass("finished chrome.runtime.sendMessage");
+ });
+ isAsyncCall = true;
+ }
+
+ let extension = loadContentScriptExtension(contentScript);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+ await extension.awaitFinish("finished chrome.runtime.sendMessage");
+ win.close();
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function background() {
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct");
+
+ if (msg == 0) {
+ sendReply("reply1");
+ } else if (msg == 1) {
+ window.setTimeout(function() {
+ sendReply("reply2");
+ }, 0);
+ return true;
+ } else if (msg == 2) {
+ browser.test.notifyPass("sendmessage_reply");
+ }
+ });
+}
+
+function contentScript() {
+ browser.runtime.sendMessage(0, function(resp1) {
+ if (resp1 != "reply1") {
+ return; // test failed
+ }
+ browser.runtime.sendMessage(1, function(resp2) {
+ if (resp2 != "reply2") {
+ return; // test failed
+ }
+ browser.runtime.sendMessage(2);
+ });
+ });
+}
+
+let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["tabs"],
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ }],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let win = window.open("file_sample.html");
+
+ await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_reply")]);
+
+ win.close();
+
+ await extension.unload();
+ info("extension unloaded");
+});
+</script>
+
+</body>
+</html>
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..d3227dbcaf
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html
@@ -0,0 +1,204 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+function backgroundScript(token, id, otherId) {
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`);
+
+ if (msg === `content-${token}`) {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"),
+ `${id}: sender url correct`);
+
+ let tabId = sender.tab.id;
+ browser.tabs.sendMessage(tabId, `${token}-contentMessage`);
+
+ sendReply(`${token}-done`);
+ } else if (msg === `tab-${token}`) {
+ browser.runtime.sendMessage(otherId, `${otherId}-tabMessage`);
+ browser.runtime.sendMessage(`${token}-tabMessage`);
+
+ sendReply(`${token}-done`);
+ } else {
+ browser.test.fail(`${id}: Unexpected runtime message received: ${msg} ${uneval(sender)}`);
+ }
+ });
+
+ browser.runtime.onMessageExternal.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`);
+
+ if (msg === `content-${id}`) {
+ browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"),
+ `${id}: external sender url correct`);
+
+ sendReply(`${otherId}-done`);
+ } else if (msg === `tab-${id}`) {
+ sendReply(`${otherId}-done`);
+ } else if (msg !== `${id}-tabMessage`) {
+ browser.test.fail(`${id}: Unexpected runtime external message received: ${msg} ${uneval(sender)}`);
+ }
+ });
+
+ browser.tabs.create({url: "tab.html"});
+}
+
+function contentScript(token, id, otherId) {
+ let gotContentMessage = false;
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`);
+
+ browser.test.assertEq(`${token}-contentMessage`, msg,
+ `${id}: Correct content script message`);
+ if (msg === `${token}-contentMessage`) {
+ gotContentMessage = true;
+ }
+ });
+
+ Promise.all([
+ browser.runtime.sendMessage(otherId, `content-${otherId}`).then(resp => {
+ browser.test.assertEq(`${id}-done`, resp, `${id}: Correct content script external response token`);
+ }),
+
+ browser.runtime.sendMessage(`content-${token}`).then(resp => {
+ browser.test.assertEq(`${token}-done`, resp, `${id}: Correct content script response token`);
+ }).catch(e => {
+ browser.test.fail(`content-${token} rejected with ${e.message}`);
+ }),
+ ]).then(() => {
+ browser.test.assertTrue(gotContentMessage, `${id}: Got content script message`);
+
+ browser.test.sendMessage("content-script-done");
+ });
+}
+
+async function tabScript(token, id, otherId) {
+ let gotTabMessage = false;
+ browser.runtime.onMessage.addListener((msg, sender, sendReply) => {
+ browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`);
+
+ if (String(msg).startsWith("content-")) {
+ return;
+ }
+
+ browser.test.assertEq(`${token}-tabMessage`, msg,
+ `${id}: Correct tab script message`);
+ if (msg === `${token}-tabMessage`) {
+ gotTabMessage = true;
+ }
+ });
+
+ browser.test.sendMessage("tab-script-loaded");
+
+ await new Promise(resolve => {
+ const listener = (msg) => {
+ if (msg !== "run-tab-script") {
+ return;
+ }
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ };
+ browser.test.onMessage.addListener(listener);
+ });
+
+ Promise.all([
+ browser.runtime.sendMessage(otherId, `tab-${otherId}`).then(resp => {
+ browser.test.assertEq(`${id}-done`, resp, `${id}: Correct tab script external response token`);
+ }),
+
+ browser.runtime.sendMessage(`tab-${token}`).then(resp => {
+ browser.test.assertEq(`${token}-done`, resp, `${id}: Correct tab script response token`);
+ }),
+ ]).then(() => {
+ browser.test.assertTrue(gotTabMessage, `${id}: Got tab script message`);
+
+ window.close();
+
+ browser.test.sendMessage("tab-script-done");
+ });
+}
+
+function makeExtension(id, otherId) {
+ let token = Math.random();
+
+ let args = `${token}, ${JSON.stringify(id)}, ${JSON.stringify(otherId)}`;
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ background: `(${backgroundScript})(${args})`,
+ manifest: {
+ "applications": {"gecko": {id}},
+
+ "permissions": ["tabs"],
+
+
+ "content_scripts": [{
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ }],
+ },
+
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab.js"><\/script>
+ </head>
+ </html>`,
+
+ "tab.js": `(${tabScript})(${args})`,
+
+ "content_script.js": `(${contentScript})(${args})`,
+ },
+ };
+ return extensionData;
+}
+
+add_task(async function test_contentscript() {
+ const ID1 = "sendmessage1@mochitest.mozilla.org";
+ const ID2 = "sendmessage2@mochitest.mozilla.org";
+
+ let extension1 = ExtensionTestUtils.loadExtension(makeExtension(ID1, ID2));
+ let extension2 = ExtensionTestUtils.loadExtension(makeExtension(ID2, ID1));
+
+ await Promise.all([
+ extension1.startup(),
+ extension2.startup(),
+ extension1.awaitMessage("tab-script-loaded"),
+ extension2.awaitMessage("tab-script-loaded"),
+ ]);
+
+ extension1.sendMessage("run-tab-script");
+ extension2.sendMessage("run-tab-script");
+
+ let win = window.open("file_sample.html");
+
+ await waitForLoad(win);
+
+ await Promise.all([
+ extension1.awaitMessage("content-script-done"),
+ extension2.awaitMessage("content-script-done"),
+ extension1.awaitMessage("tab-script-done"),
+ extension2.awaitMessage("tab-script-done"),
+ ]);
+
+ win.close();
+
+ await extension1.unload();
+ await extension2.unload();
+});
+</script>
+
+</body>
+</html>
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..e02b016419
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html
@@ -0,0 +1,235 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {
+ ExtensionStorageIDB,
+} = SpecialPowers.Cu.import("resource://gre/modules/ExtensionStorageIDB.jsm");
+
+const storageTestHelpers = {
+ storageLocal: {
+ async writeData() {
+ await browser.storage.local.set({hello: "world"});
+ browser.test.sendMessage("finished");
+ },
+
+ async readData() {
+ const matchBrowserStorage = await browser.storage.local.get("hello").then(result => {
+ return (Object.keys(result).length == 1 && result.hello == "world");
+ });
+
+ browser.test.sendMessage("results", {matchBrowserStorage});
+ },
+
+ assertResults({results, keepOnUninstall}) {
+ if (keepOnUninstall) {
+ is(results.matchBrowserStorage, true, "browser.storage.local data is still present");
+ } else {
+ is(results.matchBrowserStorage, false, "browser.storage.local data was cleared");
+ }
+ },
+ },
+ webAPIs: {
+ async readData() {
+ let matchLocalStorage = (localStorage.getItem("hello") == "world");
+
+ let idbPromise = new Promise((resolve, reject) => {
+ let req = indexedDB.open("test");
+ req.onerror = e => {
+ reject(new Error(`indexedDB open failed with ${e.errorCode}`));
+ };
+
+ req.onupgradeneeded = e => {
+ // no database, data is not present
+ resolve(false);
+ };
+
+ req.onsuccess = e => {
+ let db = e.target.result;
+ let transaction = db.transaction("store", "readwrite");
+ let addreq = transaction.objectStore("store").get("hello");
+ addreq.onerror = addreqError => {
+ reject(new Error(`read from indexedDB failed with ${addreqError.errorCode}`));
+ };
+ addreq.onsuccess = () => {
+ let match = (addreq.result.value == "world");
+ resolve(match);
+ };
+ };
+ });
+
+ await idbPromise.then(matchIDB => {
+ let result = {matchLocalStorage, matchIDB};
+ browser.test.sendMessage("results", result);
+ });
+ },
+
+ async writeData() {
+ localStorage.setItem("hello", "world");
+
+ let idbPromise = new Promise((resolve, reject) => {
+ let req = indexedDB.open("test");
+ req.onerror = e => {
+ reject(new Error(`indexedDB open failed with ${e.errorCode}`));
+ };
+
+ req.onupgradeneeded = e => {
+ let db = e.target.result;
+ db.createObjectStore("store", {keyPath: "name"});
+ };
+
+ req.onsuccess = e => {
+ let db = e.target.result;
+ let transaction = db.transaction("store", "readwrite");
+ let addreq = transaction.objectStore("store")
+ .add({name: "hello", value: "world"});
+ addreq.onerror = addreqError => {
+ reject(new Error(`add to indexedDB failed with ${addreqError.errorCode}`));
+ };
+ addreq.onsuccess = () => {
+ resolve();
+ };
+ };
+ });
+
+ await idbPromise.then(() => {
+ browser.test.sendMessage("finished");
+ });
+ },
+
+ assertResults({results, keepOnUninstall}) {
+ if (keepOnUninstall) {
+ is(results.matchLocalStorage, true, "localStorage data is still present");
+ is(results.matchIDB, true, "indexedDB data is still present");
+ } else {
+ is(results.matchLocalStorage, false, "localStorage data was cleared");
+ is(results.matchIDB, false, "indexedDB data was cleared");
+ }
+ },
+ },
+};
+
+async function test_uninstall({extensionId, writeData, readData, assertResults}) {
+ // Set the pref to prevent cleaning up storage on uninstall in a separate prefEnv
+ // so we can pop it below, leaving flags set in the previous prefEnvs unmodified.
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextensions.keepStorageOnUninstall", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: writeData,
+ manifest: {
+ applications: {gecko: {id: extensionId}},
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+ await extension.unload();
+
+ // Check that we can still see data we wrote to storage but clear the
+ // "leave storage" flag so our storaged gets cleared on the next uninstall.
+ // This effectively tests the keepUuidOnUninstall logic, which ensures
+ // that when we read storage again and check that it is cleared, that
+ // it is actually a meaningful test!
+ await SpecialPowers.popPrefEnv();
+
+ extension = ExtensionTestUtils.loadExtension({
+ background: readData,
+ manifest: {
+ applications: {gecko: {id: extensionId}},
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let results = await extension.awaitMessage("results");
+
+ assertResults({results, keepOnUninstall: true});
+
+ await extension.unload();
+
+ // Read again. This time, our data should be gone.
+ extension = ExtensionTestUtils.loadExtension({
+ background: readData,
+ manifest: {
+ applications: {gecko: {id: extensionId}},
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ results = await extension.awaitMessage("results");
+
+ assertResults({results, keepOnUninstall: false});
+
+ await extension.unload();
+}
+
+
+add_task(async function test_setup_keep_uuid_on_uninstall() {
+ // Use a test-only pref to leave the addonid->uuid mapping around after
+ // uninstall so that we can re-attach to the same storage (this prefEnv
+ // is kept for this entire file and cleared automatically once all the
+ // tests in this file have been executed).
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webextensions.keepUuidOnUninstall", true]],
+ });
+});
+
+// Test extension indexedDB and localStorage storages get cleaned up when the
+// extension is uninstalled.
+add_task(async function test_uninstall_with_webapi_storages() {
+ await test_uninstall({
+ extensionId: "storage.cleanup-WebAPIStorages@tests.mozilla.org",
+ ...(storageTestHelpers.webAPIs),
+ });
+});
+
+// Test browser.storage.local with JSONFile backend gets cleaned up when the
+// extension is uninstalled.
+add_task(async function test_uninistall_with_storage_local_file_backend() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ });
+
+ await test_uninstall({
+ extensionId: "storage.cleanup-JSONFileBackend@tests.mozilla.org",
+ ...(storageTestHelpers.storageLocal),
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// Repeat the cleanup test when the storage.local IndexedDB backend is enabled.
+add_task(async function test_uninistall_with_storage_local_idb_backend() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
+ });
+
+ await test_uninstall({
+ extensionId: "storage.cleanup-IDBBackend@tests.mozilla.org",
+ ...(storageTestHelpers.storageLocal),
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+</script>
+
+</body>
+</html>
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..3a02f3fb63
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html
@@ -0,0 +1,126 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test Storage API </title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["dom.storageManager.enabled", true],
+ ["dom.storageManager.prompt.testing", true],
+ ["dom.storageManager.prompt.testing.allow", true],
+ ],
+ });
+});
+
+add_task(async function test_backgroundScript() {
+ function background() {
+ browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface");
+
+ // Test estimate.
+ browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function");
+ browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function");
+ browser.test.assertTrue(navigator.storage.estimate() instanceof Promise, "estimate returns a promise");
+
+ return browser.test.notifyPass("navigation_storage_api.done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("navigation_storage_api.done");
+ await extension.unload();
+});
+
+add_task(async function test_contentScript() {
+ function contentScript() {
+ // Should not access storage api in non-secure context.
+ browser.test.assertEq(undefined, navigator.storage,
+ "A page from the unsecure http protocol " +
+ "doesn't have access to the navigator.storage API");
+
+ return browser.test.notifyPass("navigation_storage_api.done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ "matches": ["http://example.com/*/file_sample.html"],
+ "js": ["content_script.js"],
+ }],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})()`,
+ },
+ });
+
+ await extension.startup();
+
+ // Open an explicit URL for testing Storage API in an insecure context.
+ let win = window.open("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html");
+
+ await extension.awaitFinish("navigation_storage_api.done");
+
+ await extension.unload();
+ win.close();
+});
+
+add_task(async function test_contentScriptSecure() {
+ function contentScript() {
+ browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface");
+
+ // Test estimate.
+ browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function");
+ browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function");
+
+ // The promise that estimate function returns belongs to the content page,
+ // but the Promise constructor belongs to the content script sandbox.
+ // Check window.Promise here.
+ browser.test.assertTrue(navigator.storage.estimate() instanceof window.Promise, "estimate returns a promise");
+
+ return browser.test.notifyPass("navigation_storage_api.done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [{
+ "matches": ["https://example.com/*/file_sample.html"],
+ "js": ["content_script.js"],
+ }],
+ },
+
+ files: {
+ "content_script.js": `(${contentScript})()`,
+ },
+ });
+
+ await extension.startup();
+
+ // Open an explicit URL for testing Storage API in a secure context.
+ let win = window.open("file_sample.html");
+
+ await extension.awaitFinish("navigation_storage_api.done");
+
+ await extension.unload();
+ win.close();
+});
+
+add_task(async function cleanup() {
+ await SpecialPowers.popPrefEnv();
+});
+</script>
+
+</body>
+</html>
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..b0c7425383
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>WebExtension test</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// The purpose of this test is making sure that the implementation enabled by
+// default for the storage.local and storage.sync APIs does work across all
+// platforms/builds/apps
+add_task(async function test_storage_smoke_test() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ for (let storageArea of ["sync", "local"]) {
+ let storage = browser.storage[storageArea];
+
+ browser.test.assertTrue(!!storage, `StorageArea ${storageArea} is present.`)
+
+ let data = await storage.get();
+ browser.test.assertEq(0, Object.keys(data).length,
+ `Storage starts out empty for ${storageArea}`);
+
+ data = await storage.get("test");
+ browser.test.assertEq(0, Object.keys(data).length,
+ `Can read non-existent keys for ${storageArea}`);
+
+ await storage.set({
+ "test1": "test-value1",
+ "test2": "test-value2",
+ "test3": "test-value3"
+ });
+
+ browser.test.assertEq(
+ "test-value1",
+ (await storage.get("test1")).test1,
+ `Can set and read back single values for ${storageArea}`);
+
+ browser.test.assertEq(
+ "test-value2",
+ (await storage.get("test2")).test2,
+ `Can set and read back single values for ${storageArea}`);
+
+ data = await storage.get();
+ browser.test.assertEq(3, Object.keys(data).length,
+ `Can set and read back all values for ${storageArea}`);
+ browser.test.assertEq("test-value1", data.test1,
+ `Can set and read back all values for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Can set and read back all values for ${storageArea}`);
+ browser.test.assertEq("test-value3", data.test3,
+ `Can set and read back all values for ${storageArea}`);
+
+ data = await storage.get(["test1", "test2"]);
+ browser.test.assertEq(2, Object.keys(data).length,
+ `Can set and read back array of values for ${storageArea}`);
+ browser.test.assertEq("test-value1", data.test1,
+ `Can set and read back array of values for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Can set and read back array of values for ${storageArea}`);
+
+ await storage.remove("test1");
+ data = await storage.get(["test1", "test2"]);
+ browser.test.assertEq(1, Object.keys(data).length,
+ `Data can be removed for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Data can be removed for ${storageArea}`);
+
+ data = await storage.get({
+ test1: 1,
+ test2: 2,
+ });
+ browser.test.assertEq(2, Object.keys(data).length,
+ `Expected a key-value pair for every property for ${storageArea}`);
+ browser.test.assertEq(1, data.test1,
+ `Use default value if key was deleted for ${storageArea}`);
+ browser.test.assertEq("test-value2", data.test2,
+ `Use stored value if found for ${storageArea}`);
+
+ await storage.clear();
+ data = await storage.get();
+ browser.test.assertEq(0, Object.keys(data).length,
+ `Data is empty after clear for ${storageArea}`);
+ }
+
+ browser.test.sendMessage("done");
+ },
+ // Note: when Android supports sync on the java layer we will need to add
+ // useAddonManager: "permanent" here. Bug 1625257
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for multiple extensions trying to filterResponseData on the same request</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const TEST_URL =
+ "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt";
+
+add_task(async () => {
+ const firstExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ requestId }) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ filter.ondata = event => {
+ filter.write(new TextEncoder().encode("Start "));
+ filter.write(event.data);
+ filter.disconnect();
+ };
+ },
+ {
+ urls: [
+ "http://example.org/*/file_streamfilter.txt",
+ ],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ const secondExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ requestId }) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+ filter.onstop = event => {
+ filter.write(new TextEncoder().encode(" End"));
+ filter.close();
+ };
+ },
+ {
+ urls: [
+ "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt",
+ ],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ await firstExtension.startup();
+ await secondExtension.startup();
+
+ let iframe = document.createElement("iframe");
+ iframe.src = TEST_URL;
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true}));
+
+ let content = await SpecialPowers.spawn(iframe, [], async () => {
+ return this.content.document.body.textContent;
+ });
+ SimpleTest.is(content, "Start Middle\n End", "Correctly intercepted page content");
+
+ await firstExtension.unload();
+ await secondExtension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..2cf15db4e2
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Test for using filterResponseData to intercept a cross-origin navigation that will involve a process switch with fission</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const TEST_HOST = "http://example.com/";
+const CROSS_ORIGIN_HOST = "http://example.org/";
+const TEST_PATH =
+ "tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt";
+
+const TEST_URL = TEST_HOST + TEST_PATH;
+const CROSS_ORIGIN_URL = CROSS_ORIGIN_HOST + TEST_PATH;
+
+add_task(async () => {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ requestId }) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+ filter.onstop = event => {
+ filter.write(new TextEncoder().encode(" End"));
+ filter.close();
+ };
+ },
+ {
+ urls: [
+ "http://example.org/*/file_streamfilter.txt",
+ ],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ let iframe = document.createElement("iframe");
+ iframe.src = TEST_URL;
+ document.body.appendChild(iframe);
+ await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true}));
+
+
+ iframe.src = CROSS_ORIGIN_URL;
+ await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true}));
+
+ let content = await SpecialPowers.spawn(iframe, [], async () => {
+ return this.content.document.body.textContent;
+ });
+ SimpleTest.is(content, "Middle\n End", "Correctly intercepted page content");
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..f7389236ab
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html
@@ -0,0 +1,340 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>WebExtension test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_webext_tab_subframe_privileges() {
+ function background() {
+ browser.runtime.onMessage.addListener(async ({msg, success, tabId, error}) => {
+ if (msg == "webext-tab-subframe-privileges") {
+ if (success) {
+ await browser.tabs.remove(tabId);
+
+ browser.test.notifyPass(msg);
+ } else {
+ browser.test.log(`Got an unexpected error: ${error}`);
+
+ let tabs = await browser.tabs.query({active: true});
+ await browser.tabs.remove(tabs[0].id);
+
+ browser.test.notifyFail(msg);
+ }
+ }
+ });
+ browser.tabs.create({url: browser.runtime.getURL("/tab.html")});
+ }
+
+ async function tabSubframeScript() {
+ browser.test.assertTrue(browser.tabs != undefined,
+ "Subframe of a privileged page has access to privileged APIs");
+ if (browser.tabs) {
+ try {
+ let tab = await browser.tabs.getCurrent();
+ browser.runtime.sendMessage({
+ msg: "webext-tab-subframe-privileges",
+ success: true,
+ tabId: tab.id,
+ });
+ } catch (e) {
+ browser.runtime.sendMessage({msg: "webext-tab-subframe-privileges", success: false, error: `${e}`});
+ }
+ } else {
+ browser.runtime.sendMessage({
+ msg: "webext-tab-subframe-privileges",
+ success: false,
+ error: `Privileged APIs missing in WebExtension tab sub-frame`,
+ });
+ }
+ }
+
+ let extensionData = {
+ background,
+ files: {
+ "tab.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe src="tab-subframe.html"></iframe>
+ </body>
+ </html>`,
+ "tab-subframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "tab-subframe.js": tabSubframeScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-tab-subframe-privileges");
+ await extension.unload();
+});
+
+add_task(async function test_webext_background_subframe_privileges() {
+ function backgroundSubframeScript() {
+ browser.test.assertTrue(browser.tabs != undefined,
+ "Subframe of a background page has access to privileged APIs");
+ browser.test.notifyPass("webext-background-subframe-privileges");
+ }
+
+ let extensionData = {
+ manifest: {
+ background: {
+ page: "background.html",
+ },
+ },
+ files: {
+ "background.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe src="background-subframe.html"></iframe>
+ </body>
+ </html>`,
+ "background-subframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="background-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "background-subframe.js": backgroundSubframeScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-background-subframe-privileges");
+ await extension.unload();
+});
+
+add_task(async function test_webext_contentscript_iframe_subframe_privileges() {
+ function background() {
+ browser.runtime.onMessage.addListener(({name, hasTabsAPI, hasStorageAPI}) => {
+ if (name == "contentscript-iframe-loaded") {
+ browser.test.assertFalse(hasTabsAPI,
+ "Subframe of a content script privileged iframes has no access to privileged APIs");
+ browser.test.assertTrue(hasStorageAPI,
+ "Subframe of a content script privileged iframes has access to content script APIs");
+
+ browser.test.notifyPass("webext-contentscript-subframe-privileges");
+ }
+ });
+ }
+
+ function subframeScript() {
+ browser.runtime.sendMessage({
+ name: "contentscript-iframe-loaded",
+ hasTabsAPI: browser.tabs != undefined,
+ hasStorageAPI: browser.storage != undefined,
+ });
+ }
+
+ function contentScript() {
+ let iframe = document.createElement("iframe");
+ iframe.setAttribute("src", browser.runtime.getURL("/contentscript-iframe.html"));
+ document.body.appendChild(iframe);
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ "permissions": ["storage"],
+ "content_scripts": [{
+ "matches": ["http://example.com/*"],
+ "js": ["contentscript.js"],
+ }],
+ web_accessible_resources: [
+ "contentscript-iframe.html",
+ ],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ "contentscript-iframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe src="contentscript-iframe-subframe.html"></iframe>
+ </body>
+ </html>`,
+ "contentscript-iframe-subframe.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="contentscript-iframe-subframe.js"><\/script>
+ </head>
+ </html>`,
+ "contentscript-iframe-subframe.js": subframeScript,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let win = window.open("http://example.com");
+
+ await extension.awaitFinish("webext-contentscript-subframe-privileges");
+
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_webext_background_remote_subframe_privileges() {
+ function backgroundSubframeScript() {
+ window.addEventListener("message", evt => {
+ browser.test.assertEq("http://mochi.test:8888", evt.origin, "postmessage origin ok");
+ browser.test.assertFalse(evt.data.tabs, "remote frame cannot access webextension APIs");
+ browser.test.assertEq("cookie=monster", evt.data.cookie, "Expected cookie value");
+ browser.test.notifyPass("webext-background-subframe-privileges");
+ }, {once: true});
+ browser.cookies.set({url: "http://mochi.test:8888", name: "cookie", "value": "monster"});
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["cookies", "*://mochi.test/*", "tabs"],
+ background: {
+ page: "background.html",
+ },
+ },
+ files: {
+ "background.html": `<!DOCTYPE>
+ <head>
+ <meta charset="utf-8">
+ <script src="background-subframe.js"><\/script>
+ </head>
+ <body>
+ <iframe src='${SimpleTest.getTestFileURL("file_remote_frame.html")}'></iframe>
+ </body>
+ </html>`,
+ "background-subframe.js": backgroundSubframeScript,
+ },
+ };
+ // Need remote webextensions to be able to load remote content from a background page.
+ if (!SpecialPowers.getBoolPref("extensions.webextensions.remote", true)) {
+ return;
+ }
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitFinish("webext-background-subframe-privileges");
+ await extension.unload();
+});
+
+// Test a moz-extension:// iframe inside a content iframe in an extension page.
+add_task(async function test_sub_subframe_conduit_verified_env() {
+ let manifest = {
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ all_frames: true,
+ js: ["cs.js"],
+ }],
+ background: {
+ page: "background.html",
+ },
+ web_accessible_resources: ["iframe.html"],
+ };
+
+ let files = {
+ "iframe.html": `<!DOCTYPE html><meta charset=utf-8> iframe`,
+ "cs.js"() {
+ // A compromised content sandbox shouldn't be able to trick the parent
+ // process into giving it extension privileges by sending false metadata.
+ async function faker(extensionId, envType) {
+ try {
+ let id = envType + "-xyz1234";
+ let wgc = this.content.windowGlobalChild;
+
+ let conduit = wgc.getActor("Conduits").openConduit({}, {
+ id,
+ envType,
+ extensionId,
+ query: ["CreateProxyContext"],
+ });
+
+ return await conduit.queryCreateProxyContext({
+ childId: id,
+ extensionId,
+ envType: "addon_parent",
+ url: this.content.location.href,
+ viewType: "tab",
+ });
+ } catch (e) {
+ return e.message;
+ }
+ }
+
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("iframe.html");
+
+ iframe.onload = async () => {
+ for (let envType of ["content_child", "addon_child"]) {
+ let msg = await this.wrappedJSObject.SpecialPowers.spawn(
+ iframe, [browser.runtime.id, envType], faker);
+ browser.test.sendMessage(envType, msg);
+ }
+ };
+ document.body.appendChild(iframe);
+ },
+ "background.html": `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <iframe src="${SimpleTest.getTestFileURL("file_sample.html")}">
+ </iframe>
+ page
+ `,
+ };
+
+ async function expectErrors(ext, log) {
+ let err = await ext.awaitMessage("content_child");
+ is(err, "Bad sender context envType: content_child");
+
+ err = await ext.awaitMessage("addon_child");
+ is(err, "Unknown sender or wrong actor for recvCreateProxyContext");
+ }
+
+ let remote = SpecialPowers.getBoolPref("extensions.webextensions.remote");
+
+ let badProcess = { message: /Bad {[\w-]+} process: web/ };
+ let badPrincipal = { message: /Bad {[\w-]+} principal: http/ };
+ consoleMonitor.start(remote ? [badPrincipal, badProcess] : [badProcess]);
+
+ let extension = ExtensionTestUtils.loadExtension({ manifest, files });
+ await extension.startup();
+
+ if (remote) {
+ info("Need OOP to spoof from a web iframe inside background page.");
+ await expectErrors(extension);
+ }
+
+ info("Try spoofing from the web process.");
+ let win = window.open("./file_sample.html");
+ await expectErrors(extension);
+ win.close();
+
+ await extension.unload();
+ await consoleMonitor.finished();
+ info("Conduit creation logged correct exception(s).");
+});
+
+</script>
+</body>
+</html>
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..7feb1064ba
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html
@@ -0,0 +1,301 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Tests tabs.captureTab and tabs.captureVisibleTab</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+async function runTest({ html, fullZoom, coords, rect, scale }) {
+ let url = `data:text/html,${encodeURIComponent(html)}#scroll`;
+
+ async function background({ coords, rect, scale, method, fullZoom }) {
+ try {
+ // Wait for the page to load
+ await new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(
+ () => resolve(),
+ {url: [{schemes: ["data"]}]});
+ });
+
+ let [tab] = await browser.tabs.query({
+ currentWindow: true,
+ active: true,
+ });
+
+ // TODO: Bug 1665429 - on mobile we ignore zoom for now
+ if (browser.tabs.setZoom) {
+ await browser.tabs.setZoom(tab.id, fullZoom ?? 1);
+ }
+
+ let id = method === "captureVisibleTab" ? tab.windowId : tab.id;
+
+ let [jpeg, png, ...pngs] = await Promise.all([
+ browser.tabs[method](id, { format: "jpeg", quality: 95, rect, scale }),
+ browser.tabs[method](id, { format: "png", quality: 95, rect, scale }),
+ browser.tabs[method](id, { quality: 95, rect, scale }),
+ browser.tabs[method](id, { rect, scale }),
+ ]);
+
+ browser.test.assertTrue(
+ pngs.every(url => url == png),
+ "All PNGs are identical"
+ );
+
+ browser.test.assertTrue(
+ jpeg.startsWith("data:image/jpeg;base64,"),
+ "jpeg is JPEG"
+ );
+ browser.test.assertTrue(
+ png.startsWith("data:image/png;base64,"),
+ "png is PNG"
+ );
+
+ let promises = [jpeg, png].map(
+ url =>
+ new Promise(resolve => {
+ let img = new Image();
+ img.src = url;
+ img.onload = () => resolve(img);
+ })
+ );
+
+ let width = (rect?.width ?? tab.width) * (scale ?? devicePixelRatio);
+ let height = (rect?.height ?? tab.height) * (scale ?? devicePixelRatio);
+
+ [jpeg, png] = await Promise.all(promises);
+ let images = { jpeg, png };
+ for (let format of Object.keys(images)) {
+ let img = images[format];
+
+ // WGP.drawSnapshot() deals in int coordinates, and rounds down.
+ browser.test.assertTrue(
+ Math.abs(width - img.width) <= 1,
+ `${format} ok image width: ${img.width}, expected: ${width}`
+ );
+ browser.test.assertTrue(
+ Math.abs(height - img.height) <= 1,
+ `${format} ok image height ${img.height}, expected: ${height}`
+ );
+
+ let canvas = document.createElement("canvas");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.mozOpaque = true;
+
+ let ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0);
+
+ for (let { x, y, color } of coords) {
+ x = (x + img.width) % img.width;
+ y = (y + img.height) % img.height;
+ let imageData = ctx.getImageData(x, y, 1, 1).data;
+
+ if (format == "png") {
+ browser.test.assertEq(
+ `rgba(${color},255)`,
+ `rgba(${[...imageData]})`,
+ `${format} image color is correct at (${x}, ${y})`
+ );
+ } else {
+ // Allow for some deviation in JPEG version due to lossy compression.
+ const SLOP = 3;
+
+ browser.test.log(
+ `Testing ${format} image color at (${x}, ${y}), have rgba(${[
+ ...imageData,
+ ]}), expecting approx. rgba(${color},255)`
+ );
+
+ browser.test.assertTrue(
+ Math.abs(color[0] - imageData[0]) <= SLOP,
+ `${format} image color.red is correct at (${x}, ${y})`
+ );
+ browser.test.assertTrue(
+ Math.abs(color[1] - imageData[1]) <= SLOP,
+ `${format} image color.green is correct at (${x}, ${y})`
+ );
+ browser.test.assertTrue(
+ Math.abs(color[2] - imageData[2]) <= SLOP,
+ `${format} image color.blue is correct at (${x}, ${y})`
+ );
+ browser.test.assertEq(
+ 255,
+ imageData[3],
+ `${format} image color.alpha is correct at (${x}, ${y})`
+ );
+ }
+ }
+ }
+
+ browser.test.notifyPass("captureTab");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("captureTab");
+ }
+ }
+
+ for (let method of ["captureTab", "captureVisibleTab"]) {
+ let options = { coords, rect, scale, method, fullZoom };
+ info(`Testing configuration: ${JSON.stringify(options)}`);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["<all_urls>", "webNavigation"],
+ },
+
+ background: `(${background})(${JSON.stringify(options)})`,
+ });
+
+ await extension.startup();
+
+ let testWindow = window.open(url);
+ await extension.awaitFinish("captureTab");
+
+ testWindow.close();
+ await extension.unload();
+ }
+}
+
+async function testEdgeToEdge({ color, fullZoom }) {
+ let neutral = [0xaa, 0xaa, 0xaa];
+
+ let html = `
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ </head>
+ <body style="background-color: rgb(${color})">
+ <!-- Fill most of the image with a neutral color to test edge-to-edge scaling. -->
+ <div style="position: absolute;
+ left: 2px;
+ right: 2px;
+ top: 2px;
+ bottom: 2px;
+ background: rgb(${neutral});"></div>
+ </body>
+ </html>
+ `;
+
+ // Check the colors of the first and last pixels of the image, to make
+ // sure we capture the entire frame, and scale it correctly.
+ let coords = [
+ { x: 0, y: 0, color },
+ { x: -1, y: -1, color },
+ { x: 300, y: 200, color: neutral },
+ ];
+
+ info(`Test edge to edge color ${color} at fullZoom=${fullZoom}`);
+ await runTest({ html, fullZoom, coords });
+}
+
+add_task(async function testCaptureEdgeToEdge() {
+ await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 1 });
+ await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 2 });
+ await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 0.5 });
+ await testEdgeToEdge({ color: [255, 255, 255], fullZoom: 1 });
+});
+
+const tallDoc = `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <div style="background: yellow; width: 50%; height: 500px;"></div>
+ <div id=scroll style="background: red; width: 25%; height: 5000px;"></div>
+ Opened with the #scroll fragment, scrolls the div ^ into view.
+`;
+
+// Test currently visible viewport is captured if scrolling is involved.
+add_task(async function testScrolledViewport() {
+ await runTest({
+ html: tallDoc,
+ coords: [
+ { x: 50, y: 50, color: [255, 0, 0] },
+ { x: 50, y: -50, color: [255, 0, 0] },
+ { x: -50, y: -50, color: [255, 255, 255] },
+ ],
+ });
+});
+
+// Test rect and scale options.
+add_task(async function testRectAndScale() {
+ await runTest({
+ html: tallDoc,
+ rect: { x: 50, y: 50, width: 10, height: 1000 },
+ scale: 4,
+ coords: [
+ { x: 0, y: 0, color: [255, 255, 0] },
+ { x: -1, y: 0, color: [255, 255, 0] },
+ { x: 0, y: -1, color: [255, 0, 0] },
+ { x: -1, y: -1, color: [255, 0, 0] },
+ ],
+ });
+});
+
+// Test OOP iframes are captured, for Fission compatibility.
+add_task(async function testOOPiframe() {
+ await runTest({
+ html: `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <iframe src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_green.html"></iframe>
+ `,
+ coords: [
+ { x: 50, y: 50, color: [0, 255, 0] },
+ { x: 50, y: -50, color: [255, 255, 255] },
+ { x: -50, y: 50, color: [255, 255, 255] },
+ ],
+ });
+});
+
+add_task(async function testCaptureTabPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.tabs.captureTab,
+ 'Extension without "<all_urls>" permission should not have access to captureTab'
+ );
+ browser.test.notifyPass("captureTabPermissions");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("captureTabPermissions");
+ await extension.unload();
+});
+
+add_task(async function testCaptureVisibleTabPermissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["tabs"],
+ },
+
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.tabs.captureVisibleTab,
+ 'Extension without "<all_urls>" permission should not have access to captureVisibleTab'
+ );
+ browser.test.notifyPass("captureVisibleTabPermissions");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("captureVisibleTabPermissions");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..99d8b77f16
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html
@@ -0,0 +1,780 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Tabs permissions test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const URL1 =
+ "http://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html";
+const URL2 =
+ "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html";
+
+const helperExtensionDef = {
+ manifest: {
+ applications: {
+ gecko: {
+ id: "helper@tests.mozilla.org",
+ },
+ },
+ permissions: ["webNavigation", "<all_urls>"],
+ },
+
+ useAddonManager: "permanent",
+
+ async background() {
+ browser.test.onMessage.addListener(async message => {
+ switch (message.subject) {
+ case "createTab": {
+ const tabLoaded = new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(function listener(
+ details
+ ) {
+ if (details.url === message.data.url) {
+ browser.webNavigation.onCompleted.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ const tab = await browser.tabs.create({ url: message.data.url });
+ await tabLoaded;
+ browser.test.sendMessage("tabCreated", tab.id);
+ break;
+ }
+
+ case "changeTabURL": {
+ const tabLoaded = new Promise(resolve => {
+ browser.webNavigation.onCompleted.addListener(function listener(
+ details
+ ) {
+ if (details.url === message.data.url) {
+ browser.webNavigation.onCompleted.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await browser.tabs.update(message.data.tabId, {
+ url: message.data.url,
+ });
+ await tabLoaded;
+ browser.test.sendMessage("tabURLChanged", message.data.tabId);
+ break;
+ }
+
+ case "changeTabHashAndTitle": {
+ const tabChanged = new Promise(resolve => {
+ let hasURLChangeInfo = false,
+ hasTitleChangeInfo = false;
+ browser.tabs.onUpdated.addListener(function listener(
+ tabId,
+ changeInfo,
+ tab
+ ) {
+ if (changeInfo.url?.endsWith(message.data.urlHash)) {
+ hasURLChangeInfo = true;
+ }
+ if (changeInfo.title === message.data.title) {
+ hasTitleChangeInfo = true;
+ }
+ if (hasURLChangeInfo && hasTitleChangeInfo) {
+ browser.tabs.onUpdated.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await browser.tabs.executeScript(message.data.tabId, {
+ code: `
+ document.location.hash = ${JSON.stringify(message.data.urlHash)};
+ document.title = ${JSON.stringify(message.data.title)};
+ `,
+ });
+ await tabChanged;
+ browser.test.sendMessage("tabHashAndTitleChanged");
+ break;
+ }
+
+ case "removeTab": {
+ await browser.tabs.remove(message.data.tabId);
+ browser.test.sendMessage("tabRemoved");
+ break;
+ }
+
+ default:
+ browser.test.fail(`Received unexpected message: ${message}`);
+ }
+ });
+ },
+};
+
+/*
+ * Test tabs.query function
+ * Check if the correct tabs are queried by url or title based on the granted permissions
+ */
+async function test_query(testCases, permissions) {
+ const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "permissions@tests.mozilla.org",
+ },
+ },
+ permissions,
+ },
+
+ useAddonManager: "permanent",
+
+ async background() {
+ // wait for start message
+ const [testCases, tabIdFromURL1, tabIdFromURL2] = await new Promise(
+ resolve => {
+ browser.test.onMessage.addListener(message => resolve(message));
+ }
+ );
+
+ for (const testCase of testCases) {
+ const query = testCase.query;
+ const matchingTabs = testCase.matchingTabs;
+
+ let tabQuery = await browser.tabs.query(query);
+ // ignore other tabs in the window
+ tabQuery = tabQuery.filter(tab => {
+ return tab.id === tabIdFromURL1 || tab.id === tabIdFromURL2;
+ });
+
+ browser.test.assertEq(matchingTabs, tabQuery.length, `Tabs queried`);
+ }
+ // send end message
+ browser.test.notifyPass("tabs.query");
+ },
+ });
+
+ await helperExtension.startup();
+ await extension.startup();
+
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL1 },
+ });
+ const tabIdFromURL1 = await helperExtension.awaitMessage("tabCreated");
+
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL2 },
+ });
+ const tabIdFromURL2 = await helperExtension.awaitMessage("tabCreated");
+
+ if (permissions.includes("activeTab")) {
+ extension.grantActiveTab(tabIdFromURL2);
+ }
+
+ extension.sendMessage([testCases, tabIdFromURL1, tabIdFromURL2]);
+ await extension.awaitFinish("tabs.query");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId: tabIdFromURL1 },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId: tabIdFromURL2 },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+
+ await extension.unload();
+ await helperExtension.unload();
+}
+
+// http://www.example.com host permission
+add_task(function query_with_host_permission_url1() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["*://www.example.com/*"]
+ );
+});
+
+// http://example.net host permission
+add_task(function query_with_host_permission_url2() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["*://example.net/*"]
+ );
+});
+
+// <all_urls> permission
+add_task(function query_with_host_permission_all_urls() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 2,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 2,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["<all_urls>"]
+ );
+});
+
+// tabs permission
+add_task(function query_with_tabs_permission() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 2,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 2,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["tabs"]
+ );
+});
+
+// activeTab permission
+add_task(function query_with_activeTab_permission() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 1,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 1,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 1,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ ["activeTab"]
+ );
+});
+// no permission
+add_task(function query_without_permission() {
+ return test_query(
+ [
+ {
+ query: { url: "*://www.example.com/*" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: "<all_urls>" },
+ matchingTabs: 0,
+ },
+ {
+ query: { url: ["*://www.example.com/*", "*://example.net/*"] },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "The Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: { title: "Another Title" },
+ matchingTabs: 0,
+ },
+ {
+ query: {},
+ matchingTabs: 2,
+ },
+ ],
+ []
+ );
+});
+
+/*
+ * Test tabs.onUpdate and tabs.get function
+ * Check if the changeInfo or tab object contains the restricted properties
+ * url and title only when the right permissions are granted
+ * The tab is updated without causing navigation in order to also test activeTab permission
+ */
+async function test_restricted_properties(
+ permissions,
+ hasRestrictedProperties
+) {
+ const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "permissions@tests.mozilla.org",
+ },
+ },
+ permissions,
+ },
+
+ useAddonManager: "permanent",
+
+ async background() {
+ // wait for test start signal and data
+ const [
+ hasRestrictedProperties,
+ tabId,
+ urlHash,
+ title,
+ ] = await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ resolve(message);
+ });
+ });
+
+ let hasURLChangeInfo = false,
+ hasTitleChangeInfo = false;
+ function onUpdateListener(tabId, changeInfo, tab) {
+ if (changeInfo.url?.endsWith(urlHash)) {
+ hasURLChangeInfo = true;
+ }
+ if (changeInfo.title === title) {
+ hasTitleChangeInfo = true;
+ }
+ }
+ browser.tabs.onUpdated.addListener(onUpdateListener);
+
+ // wait for test evaluation signal and data
+ await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ if (message === "collectTestResults") {
+ resolve(message);
+ }
+ });
+ browser.test.sendMessage("waitingForTabPropertyChanges");
+ });
+
+ // check onUpdate changeInfo
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ hasURLChangeInfo,
+ `Has changeInfo property "url"`
+ );
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ hasTitleChangeInfo,
+ `Has changeInfo property "title"`
+ );
+ // check tab properties
+ const tabGet = await browser.tabs.get(tabId);
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ !!tabGet.url?.endsWith(urlHash),
+ `Has tab property "url"`
+ );
+ browser.test.assertEq(
+ hasRestrictedProperties,
+ tabGet.title === title,
+ `Has tab property "title"`
+ );
+ // send end message
+ browser.test.notifyPass("tabs.restricted_properties");
+ },
+ });
+
+ const urlHash = "#ChangedURL";
+ const title = "Changed Title";
+
+ await helperExtension.startup();
+ await extension.startup();
+
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL1 },
+ });
+ const tabId = await helperExtension.awaitMessage("tabCreated");
+
+ if (permissions.includes("activeTab")) {
+ extension.grantActiveTab(tabId);
+ }
+ // send test start signal and data
+ extension.sendMessage([hasRestrictedProperties, tabId, urlHash, title]);
+ await extension.awaitMessage("waitingForTabPropertyChanges");
+
+ helperExtension.sendMessage({
+ subject: "changeTabHashAndTitle",
+ data: {
+ tabId,
+ urlHash,
+ title,
+ },
+ });
+ await helperExtension.awaitMessage("tabHashAndTitleChanged");
+
+ // send end signal and evaluate results
+ extension.sendMessage("collectTestResults");
+ await extension.awaitFinish("tabs.restricted_properties");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+
+ await extension.unload();
+ await helperExtension.unload();
+}
+
+// http://www.example.com host permission
+add_task(function has_restricted_properties_with_host_permission_url1() {
+ return test_restricted_properties(["*://www.example.com/*"], true);
+});
+// http://example.net host permission
+add_task(function has_restricted_properties_with_host_permission_url2() {
+ return test_restricted_properties(["*://example.net/*"], false);
+});
+// <all_urls> permission
+add_task(function has_restricted_properties_with_host_permission_all_urls() {
+ return test_restricted_properties(["<all_urls>"], true);
+});
+// tabs permission
+add_task(function has_restricted_properties_with_tabs_permission() {
+ return test_restricted_properties(["tabs"], true);
+});
+// activeTab permission
+add_task(function has_restricted_properties_with_activeTab_permission() {
+ return test_restricted_properties(["activeTab"], true);
+}).skip(); // TODO bug 1686080: support changeInfo.url with activeTab
+// no permission
+add_task(function has_restricted_properties_without_permission() {
+ return test_restricted_properties([], false);
+});
+
+
+/*
+ * Test tabs.onUpdate filter functionality
+ * Check if the restricted filter properties only work if the
+ * right permissions are granted
+ */
+async function test_onUpdateFilter(testCases, permissions) {
+ // Filters for onUpdated are not supported on Android.
+ if (AppConstants.platform === "android") {
+ return;
+ }
+
+ const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef);
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "permissions@tests.mozilla.org",
+ },
+ },
+ permissions,
+ },
+
+ useAddonManager: "permanent",
+
+ async background() {
+ let listenerGotCalled = false;
+ function onUpdateListener(tabId, changeInfo, tab) {
+ listenerGotCalled = true;
+ }
+
+ browser.test.onMessage.addListener(async message => {
+ switch (message.subject) {
+ case "setup": {
+ browser.tabs.onUpdated.addListener(
+ onUpdateListener,
+ message.data.filter
+ );
+ browser.test.sendMessage("done");
+ break;
+ }
+
+ case "collectTestResults": {
+ browser.test.assertEq(
+ message.data.expectEvent,
+ listenerGotCalled,
+ `Update listener called`
+ );
+ browser.tabs.onUpdated.removeListener(onUpdateListener);
+ listenerGotCalled = false;
+ browser.test.sendMessage("done");
+ break;
+ }
+
+ default:
+ browser.test.fail(`Received unexpected message: ${message}`);
+ }
+ });
+ },
+ });
+
+ await helperExtension.startup();
+ await extension.startup();
+
+ for (const testCase of testCases) {
+ helperExtension.sendMessage({
+ subject: "createTab",
+ data: { url: URL1 },
+ });
+ const tabId = await helperExtension.awaitMessage("tabCreated");
+
+ extension.sendMessage({
+ subject: "setup",
+ data: {
+ filter: testCase.filter,
+ },
+ });
+ await extension.awaitMessage("done");
+
+ helperExtension.sendMessage({
+ subject: "changeTabURL",
+ data: {
+ tabId,
+ url: URL2,
+ },
+ });
+ await helperExtension.awaitMessage("tabURLChanged");
+
+ extension.sendMessage({
+ subject: "collectTestResults",
+ data: {
+ expectEvent: testCase.expectEvent,
+ },
+ });
+ await extension.awaitMessage("done");
+
+ helperExtension.sendMessage({
+ subject: "removeTab",
+ data: { tabId },
+ });
+ await helperExtension.awaitMessage("tabRemoved");
+ }
+
+ await extension.unload();
+ await helperExtension.unload();
+}
+
+// http://mozilla.org host permission
+add_task(function onUpdateFilter_with_host_permission_url3() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: false,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["*://mozilla.org/*"]
+ );
+});
+
+// http://example.net host permission
+add_task(function onUpdateFilter_with_host_permission_url2() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: true,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: true,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: true,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["*://example.net/*"]
+ );
+});
+
+// <all_urls> permission
+add_task(function onUpdateFilter_with_host_permission_all_urls() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: true,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: true,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: true,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["<all_urls>"]
+ );
+});
+
+// tabs permission
+add_task(function onUpdateFilter_with_tabs_permission() {
+ return test_onUpdateFilter(
+ [
+ {
+ filter: { urls: ["*://mozilla.org/*"] },
+ expectEvent: false,
+ },
+ {
+ filter: { urls: ["<all_urls>"] },
+ expectEvent: true,
+ },
+ {
+ filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] },
+ expectEvent: true,
+ },
+ {
+ filter: { properties: ["title"] },
+ expectEvent: true,
+ },
+ {
+ filter: {},
+ expectEvent: true,
+ },
+ ],
+ ["tabs"]
+ );
+});
+
+</script>
+
+</body>
+</html>
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..6393114c5f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html
@@ -0,0 +1,95 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Tabs create Test</title>
+ <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+async function test_query(query) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "current-window@tests.mozilla.org",
+ }
+ },
+ permissions: ["tabs"],
+ browser_action: {
+ default_popup: "popup.html",
+ },
+ },
+
+ useAddonManager: "permanent",
+
+ background: async function() {
+ let query = await new Promise(resolve => {
+ browser.test.onMessage.addListener(message => {
+ resolve(message);
+ });
+ });
+ let tab = await browser.tabs.create({ url: "http://www.example.com", active: true });
+ browser.runtime.onMessage.addListener(message => {
+ if (message === "popup-loaded") {
+ browser.runtime.sendMessage({ tab, query });
+ }
+ });
+ browser.test.withHandlingUserInput(() =>
+ browser.browserAction.openPopup()
+ );
+ },
+
+ files: {
+ "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`,
+ "popup.js"() {
+ browser.runtime.onMessage.addListener(async function({ tab, query }) {
+ let tabs = await browser.tabs.query(query);
+ browser.test.assertEq(tabs.length, 1, `Got one tab`);
+ browser.test.assertEq(tabs[0].id, tab.id, "The tab is the right one");
+
+ // Create a new tab and verify that we still see the right result
+ let newTab = await browser.tabs.create({ url: "http://www.example.com", active: true });
+ tabs = await browser.tabs.query(query);
+ browser.test.assertEq(tabs.length, 1, `Got one tab`);
+ browser.test.assertEq(tabs[0].id, newTab.id, "Got the newly-created tab");
+
+ await browser.tabs.remove(newTab.id);
+
+ // Remove the tab and verify that we see the old tab
+ tabs = await browser.tabs.query(query);
+ browser.test.assertEq(tabs.length, 1, `Got one tab`);
+ browser.test.assertEq(tabs[0].id, tab.id, "Got the tab that was active before");
+
+ // Cleanup
+ await browser.tabs.remove(tab.id);
+
+ browser.test.notifyPass("tabs.query");
+ });
+ browser.runtime.sendMessage("popup-loaded");
+ },
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage(query);
+ await extension.awaitFinish("tabs.query");
+ await extension.unload();
+}
+
+add_task(function test_query_currentWindow_from_popup() {
+ return test_query({ currentWindow: true, active: true });
+});
+
+add_task(function test_query_lastActiveWindow_from_popup() {
+ return test_query({ lastFocusedWindow: true, active: true });
+});
+</script>
+
+</body>
+</html>
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..293914fe5d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html
@@ -0,0 +1,95 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test tabs.sendMessage</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script>
+"use strict";
+
+add_task(async function test_tabs_sendMessage_to_extension_page_frame() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: {
+ gecko: { id: "blah@android" },
+ },
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html?tabs.sendMessage"],
+ js: ["cs.js"],
+ }],
+ web_accessible_resources: ["page.html", "page.js"],
+ },
+
+ async background() {
+ let tab;
+
+ browser.runtime.onMessage.addListener(async (msg, sender) => {
+ browser.test.assertEq(msg, "page-script-ready");
+ browser.test.assertEq(sender.url, browser.runtime.getURL("page.html"));
+
+ let tabId = sender.tab.id;
+ let response = await browser.tabs.sendMessage(tabId, "tab-sendMessage");
+
+ switch (response) {
+ case "extension-tab":
+ browser.test.assertEq(tab.id, tabId, "Extension tab responded");
+ browser.test.assertEq(sender.frameId, 0, "Response from top level");
+ await browser.tabs.remove(tab.id);
+ browser.test.sendMessage("extension-tab-responded");
+ break;
+
+ case "extension-frame":
+ browser.test.assertTrue(sender.frameId > 0, "Response from iframe");
+ browser.test.sendMessage("extension-frame-responded");
+ break;
+
+ default:
+ browser.test.fail("Unexpected response: " + response);
+ }
+ });
+
+ tab = await browser.tabs.create({ url: "page.html" });
+ },
+
+ files: {
+ "cs.js"() {
+ let iframe = document.createElement("iframe");
+ iframe.src = browser.runtime.getURL("page.html");
+ document.body.append(iframe);
+ browser.test.sendMessage("content-script-done");
+ },
+
+ "page.html": `<!DOCTYPE html>
+ <meta charset=utf-8>
+ <script src=page.js><\/script>
+ Extension page`,
+
+ "page.js"() {
+ browser.runtime.onMessage.addListener(async msg => {
+ browser.test.assertEq(msg, "tab-sendMessage");
+ return window.parent === window ? "extension-tab" : "extension-frame";
+ });
+ browser.runtime.sendMessage("page-script-ready");
+ },
+ }
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("extension-tab-responded");
+
+ let win = window.open("file_sample.html?tabs.sendMessage");
+ await extension.awaitMessage("content-script-done");
+ await extension.awaitMessage("extension-frame-responded");
+ win.close();
+
+ await extension.unload();
+});
+
+</script>
+</body>
+</html>
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..9fef13d8d4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html
@@ -0,0 +1,196 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Testing test</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+function loadExtensionAndInterceptTest(extensionData) {
+ let results = [];
+ let testResolve;
+ let testDone = new Promise(resolve => { testResolve = resolve; });
+ let handler = {
+ testResult(...result) {
+ result.pop();
+ results.push(result);
+ SimpleTest.info(`Received test result: ${JSON.stringify(result)}`);
+ },
+
+ testMessage(msg, ...args) {
+ results.push(["test-message", msg, ...args]);
+ SimpleTest.info(`Received message: ${msg} ${JSON.stringify(args)}`);
+ if (msg === "This is the last browser.test call") {
+ testResolve();
+ }
+ },
+ };
+ let extension = SpecialPowers.loadExtension(extensionData, handler);
+ SimpleTest.registerCleanupFunction(() => {
+ if (extension.state == "pending" || extension.state == "running") {
+ SimpleTest.ok(false, "Extension left running at test shutdown");
+ return extension.unload();
+ } else if (extension.state == "unloading") {
+ SimpleTest.ok(false, "Extension not fully unloaded at test shutdown");
+ }
+ });
+ extension.awaitResults = () => testDone.then(() => results);
+ return extension;
+}
+
+function testScript() {
+ // Note: The result of these browser.test calls are intercepted by the test.
+ // See verifyTestResults for the expectations of each browser.test call.
+ browser.test.notifyPass("dot notifyPass");
+ browser.test.notifyFail("dot notifyFail");
+ browser.test.log("dot log");
+ browser.test.fail("dot fail");
+ browser.test.succeed("dot succeed");
+ browser.test.assertTrue(true);
+ browser.test.assertFalse(false);
+ browser.test.assertEq("", "");
+
+ let obj = {};
+ let arr = [];
+ let dom = document.createElement("body");
+ browser.test.assertTrue(obj, "Object truthy");
+ browser.test.assertTrue(arr, "Array truthy");
+ browser.test.assertTrue(dom, "Element truthy");
+ browser.test.assertTrue(true, "True truthy");
+ browser.test.assertTrue(false, "False truthy");
+ browser.test.assertTrue(null, "Null truthy");
+ browser.test.assertTrue(undefined, "Void truthy");
+ browser.test.assertTrue(false, document.createElement("html"));
+
+ browser.test.assertFalse(obj, "Object falsey");
+ browser.test.assertFalse(arr, "Array falsey");
+ browser.test.assertFalse(dom, "Element falsey");
+ browser.test.assertFalse(true, "True falsey");
+ browser.test.assertFalse(false, "False falsey");
+ browser.test.assertFalse(null, "Null falsey");
+ browser.test.assertFalse(undefined, "Void falsey");
+ browser.test.assertFalse(true, document.createElement("head"));
+
+ browser.test.assertEq(obj, obj, "Object equality");
+ browser.test.assertEq(arr, arr, "Array equality");
+ browser.test.assertEq(dom, dom, "Element equality");
+ browser.test.assertEq(null, null, "Null equality");
+ browser.test.assertEq(undefined, undefined, "Void equality");
+
+ browser.test.assertEq({}, {}, "Object reference ineqality");
+ browser.test.assertEq([], [], "Array reference ineqality");
+ browser.test.assertEq(dom, document.createElement("body"), "Element ineqality");
+ browser.test.assertEq(null, undefined, "Null and void ineqality");
+ browser.test.assertEq(true, false, document.createElement("div"));
+
+ obj = {
+ toString() {
+ return "Dynamic toString forbidden";
+ },
+ };
+ browser.test.assertEq(obj, obj, "obj with dynamic toString()");
+ browser.test.assertThrows(
+ () => { throw new Error("dummy"); },
+ /dummy2/,
+ "intentional failure"
+ );
+ browser.test.sendMessage("Ran test at", location.protocol);
+ browser.test.sendMessage("This is the last browser.test call");
+}
+
+function verifyTestResults(results, shortName, expectedProtocol) {
+ let expectations = [
+ ["test-done", true, "dot notifyPass"],
+ ["test-done", false, "dot notifyFail"],
+ ["test-log", true, "dot log"],
+ ["test-result", false, "dot fail"],
+ ["test-result", true, "dot succeed"],
+ ["test-result", true, "undefined"],
+ ["test-result", true, "undefined"],
+ ["test-eq", true, "undefined", "", ""],
+
+ ["test-result", true, "Object truthy"],
+ ["test-result", true, "Array truthy"],
+ ["test-result", true, "Element truthy"],
+ ["test-result", true, "True truthy"],
+ ["test-result", false, "False truthy"],
+ ["test-result", false, "Null truthy"],
+ ["test-result", false, "Void truthy"],
+ ["test-result", false, "[object HTMLHtmlElement]"],
+
+ ["test-result", false, "Object falsey"],
+ ["test-result", false, "Array falsey"],
+ ["test-result", false, "Element falsey"],
+ ["test-result", false, "True falsey"],
+ ["test-result", true, "False falsey"],
+ ["test-result", true, "Null falsey"],
+ ["test-result", true, "Void falsey"],
+ ["test-result", false, "[object HTMLHeadElement]"],
+
+ ["test-eq", true, "Object equality", "[object Object]", "[object Object]"],
+ ["test-eq", true, "Array equality", "", ""],
+ ["test-eq", true, "Element equality", "[object HTMLBodyElement]", "[object HTMLBodyElement]"],
+ ["test-eq", true, "Null equality", "null", "null"],
+ ["test-eq", true, "Void equality", "undefined", "undefined"],
+
+ ["test-eq", false, "Object reference ineqality", "[object Object]", "[object Object] (different)"],
+ ["test-eq", false, "Array reference ineqality", "", " (different)"],
+ ["test-eq", false, "Element ineqality", "[object HTMLBodyElement]", "[object HTMLBodyElement] (different)"],
+ ["test-eq", false, "Null and void ineqality", "null", "undefined"],
+ ["test-eq", false, "[object HTMLDivElement]", "true", "false"],
+
+ ["test-eq", true, "obj with dynamic toString()", "[object Object]", "[object Object]"],
+ ["test-result", false, "Function threw, expecting error to match /dummy2/, got \"dummy\": intentional failure"],
+
+ ["test-message", "Ran test at", expectedProtocol],
+ ["test-message", "This is the last browser.test call"],
+ ];
+
+ expectations.forEach((expectation, i) => {
+ let msg = expectation.slice(2).join(" - ");
+ isDeeply(results[i], expectation, `${shortName} (${msg})`);
+ });
+ is(results[expectations.length], undefined, "No more results");
+}
+
+add_task(async function test_test_in_background() {
+ let extensionData = {
+ background: `(${testScript})()`,
+ };
+
+ let extension = loadExtensionAndInterceptTest(extensionData);
+ await extension.startup();
+ let results = await extension.awaitResults();
+ verifyTestResults(results, "background page", "moz-extension:");
+ await extension.unload();
+});
+
+add_task(async function test_test_in_content_script() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [{
+ matches: ["http://mochi.test/*/file_sample.html"],
+ js: ["contentscript.js"],
+ }],
+ },
+ files: {
+ "contentscript.js": `(${testScript})()`,
+ },
+ };
+
+ let extension = loadExtensionAndInterceptTest(extensionData);
+ await extension.startup();
+ let win = window.open("file_sample.html");
+ let results = await extension.awaitResults();
+ win.close();
+ verifyTestResults(results, "content script", "http:");
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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..b92de8ab4f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html
@@ -0,0 +1,139 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_unlimitedStorage.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+async function test_background_storagePersist(EXTENSION_ID) {
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["dom.storageManager.enabled", true],
+ ["dom.storageManager.prompt.testing", false],
+ ["dom.storageManager.prompt.testing.allow", false],
+ ],
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ manifest: {
+ permissions: ["storage", "unlimitedStorage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+
+ background: async function() {
+ const PROMISE_RACE_TIMEOUT = 8000;
+
+ browser.test.sendMessage("extension-uuid", window.location.host);
+
+ await browser.storage.local.set({testkey: "testvalue"});
+ await browser.test.sendMessage("storage-local-called");
+
+ const requestStoragePersist = async () => {
+ const persistAllowed = await navigator.storage.persist();
+ if (!persistAllowed) {
+ throw new Error("navigator.storage.persist() has been denied");
+ }
+ };
+
+ await Promise.race([
+ requestStoragePersist(),
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error("Timeout opening persistent db from background page"));
+ }, PROMISE_RACE_TIMEOUT);
+ }),
+ ]).then(
+ () => {
+ browser.test.notifyPass("indexeddb-storagePersistent-unlimitedStorage-done");
+ },
+ (error) => {
+ browser.test.fail(`error while testing persistent IndexedDB storage: ${error}`);
+ browser.test.notifyFail("indexeddb-storagePersistent-unlimitedStorage-done");
+ }
+ );
+ },
+ });
+
+ await extension.startup();
+
+ const uuid = await extension.awaitMessage("extension-uuid");
+
+ await extension.awaitMessage("storage-local-called");
+
+ let chromeScript = SpecialPowers.loadChromeScript(function test_country_data() {
+ const {addMessageListener, sendAsyncMessage} = this;
+
+ addMessageListener("getPersistedStatus", (uuid) => {
+ const {
+ ExtensionStorageIDB,
+ } = ChromeUtils.import("resource://gre/modules/ExtensionStorageIDB.jsm");
+
+ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ const {WebExtensionPolicy} = Cu.getGlobalForObject(ExtensionStorageIDB);
+ const policy = WebExtensionPolicy.getByHostname(uuid);
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(policy.extension);
+ const request = Services.qms.persisted(storagePrincipal);
+ request.callback = () => {
+ // request.result will be undeinfed if the request failed (request.resultCode !== Cr.NS_OK).
+ sendAsyncMessage("gotPersistedStatus", request.result);
+ };
+ });
+ });
+
+ const persistedPromise = chromeScript.promiseOneMessage("gotPersistedStatus");
+ chromeScript.sendAsyncMessage("getPersistedStatus", uuid);
+ is(await persistedPromise, true, "Got the expected persist status for the storagePrincipal");
+
+ await extension.awaitFinish("indexeddb-storagePersistent-unlimitedStorage-done");
+ await extension.unload();
+
+ checkSitePermissions(uuid, Services.perms.UNKNOWN_ACTION, "has been cleared");
+}
+
+add_task(async function test_unlimitedStorage() {
+ const EXTENSION_ID = "test-storagePersist@mozilla";
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ ["extensions.webextensions.ExtensionStorageIDB.enabled", true],
+ ],
+ });
+
+ // Verify persist mode enabled when the storage.local IDB database is opened from
+ // the main process (from parent/ext-storage.js).
+ info("Test unlimitedStorage on an extension migrating to the IndexedDB storage.local backend)");
+ await test_background_storagePersist(EXTENSION_ID);
+
+ await SpecialPowers.pushPrefEnv({
+ "set": [
+ [`extensions.webextensions.ExtensionStorageIDB.migrated.` + EXTENSION_ID, true],
+ ],
+ });
+
+ // Verify persist mode enabled when the storage.local IDB database is opened from
+ // the child process (from child/ext-storage.js).
+ info("Test unlimitedStorage on an extension migrated to the IndexedDB storage.local backend");
+ await test_background_storagePersist(EXTENSION_ID);
+});
+
+</script>
+
+</body>
+</html>
diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html
new file mode 100644
index 0000000000..fe06d22e8d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html
@@ -0,0 +1,81 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_unlimitedStorage.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+
+"use strict";
+
+add_task(async function test_legacy_indexedDB_storagePersistent_unlimitedStorage() {
+ const EXTENSION_ID = "test-idbStoragePersistent@mozilla";
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ manifest: {
+ permissions: ["unlimitedStorage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+
+ background: async function() {
+ const PROMISE_RACE_TIMEOUT = 8000;
+
+ browser.test.sendMessage("extension-uuid", window.location.host);
+
+ try {
+ await Promise.race([
+ new Promise((resolve, reject) => {
+ const dbReq = indexedDB.open("test-persistent-idb", {version: 1.0, storage: "persistent"});
+
+ dbReq.onerror = evt => {
+ reject(evt.target.error);
+ };
+
+ dbReq.onsuccess = () => {
+ resolve();
+ };
+ }),
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ reject(new Error("Timeout opening persistent db from background page"));
+ }, PROMISE_RACE_TIMEOUT);
+ }),
+ ]);
+
+ browser.test.notifyPass("indexeddb-storagePersistent-unlimitedStorage-done");
+ } catch (error) {
+ const loggedError = error instanceof DOMException ? error.message : error;
+ browser.test.fail(`error while testing persistent IndexedDB storage: ${loggedError}`);
+ browser.test.notifyFail("indexeddb-storagePersistent-unlimitedStorage-done");
+ }
+ },
+ });
+
+ await extension.startup();
+
+ const uuid = await extension.awaitMessage("extension-uuid");
+
+ await extension.awaitFinish("indexeddb-storagePersistent-unlimitedStorage-done");
+
+ await extension.unload();
+
+ checkSitePermissions(uuid, Services.perms.UNKNOWN_ACTION, "has been cleared");
+});
+
+</script>
+
+</body>
+</html>
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..5c9de814e4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html
@@ -0,0 +1,174 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test the web_accessible_resources incognito</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=");
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer;
+
+async function testImageLoading(src, expectedAction) {
+ let imageLoadingPromise = new Promise((resolve, reject) => {
+ let cleanupListeners;
+ let testImage = new window.Image();
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testImage.wrappedJSObject.setAttribute("src", src);
+
+ let loadListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "loaded");
+ };
+
+ let errorListener = (event) => {
+ cleanupListeners();
+ resolve(expectedAction === "blocked");
+ browser.test.log(`+++ image loading ${event.error}`);
+ };
+
+ cleanupListeners = () => {
+ testImage.removeEventListener("load", loadListener);
+ testImage.removeEventListener("error", errorListener);
+ };
+
+ testImage.addEventListener("load", loadListener);
+ testImage.addEventListener("error", errorListener);
+ });
+
+ let success = await imageLoadingPromise;
+ browser.runtime.sendMessage({name: "image-loading", expectedAction, success});
+}
+
+function testScript() {
+ window.postMessage("test-script-loaded", "*");
+}
+
+add_task(async function test_web_accessible_resources_incognito() {
+ await SpecialPowers.pushPrefEnv({set: [
+ ["extensions.allowPrivateBrowsingByDefault", false],
+ ]});
+
+ // This extension will not have access to private browsing so its
+ // accessible resources should not be able to load in them.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "web_accessible_resources": [
+ "image.png",
+ "test_script.js",
+ "accessible.html",
+ ],
+ },
+ background() {
+ browser.test.sendMessage("url", browser.extension.getURL(""));
+ },
+ files: {
+ "image.png": IMAGE_ARRAYBUFFER,
+ "test_script.js": testScript,
+ "accessible.html": `<html><head>
+ <meta charset="utf-8">
+ </head></html>`,
+ },
+ });
+
+ await extension.startup();
+ let baseUrl = await extension.awaitMessage("url");
+
+ async function content() {
+ let baseUrl = await browser.runtime.sendMessage({name: "get-url"});
+ testImageLoading(`${baseUrl}image.png`, "loaded");
+
+ let testScriptElement = document.createElement("script");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testScriptElement.wrappedJSObject.setAttribute("src", `${baseUrl}test_script.js`);
+ document.head.appendChild(testScriptElement);
+
+ let iframe = document.createElement("iframe");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ iframe.wrappedJSObject.setAttribute("src", `${baseUrl}accessible.html`);
+ document.body.appendChild(iframe);
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ window.addEventListener("message", event => {
+ browser.runtime.sendMessage({"name": event.data});
+ });
+ }
+
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["tabs"],
+ content_scripts: [{
+ "matches": ["*://example.com/*/file_sample.html"],
+ "run_at": "document_end",
+ "js": ["content_script_helper.js", "content_script.js"],
+ }],
+ },
+ files: {
+ "content_script_helper.js": `${testImageLoading}`,
+ "content_script.js": content,
+ },
+ background() {
+ let url = "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+ let baseUrl;
+ let window;
+
+ browser.runtime.onMessage.addListener(async msg => {
+ switch (msg.name) {
+ case "image-loading":
+ browser.test.assertFalse(msg.success, `Image was ${msg.expectedAction}`);
+ browser.test.sendMessage(`image-${msg.expectedAction}`);
+ break;
+ case "get-url":
+ return baseUrl;
+ default:
+ browser.test.fail(`unexepected message ${msg.name}`);
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "start") {
+ baseUrl = data;
+ window = await browser.windows.create({url, incognito: true});
+ }
+ if (msg == "close") {
+ browser.windows.remove(window.id);
+ }
+ });
+ },
+ });
+ await pb_extension.startup();
+
+ consoleMonitor.start([
+ {message: /may not load or link to.*image.png/},
+ {message: /may not load or link to.*test_script.js/},
+ {message: /\<script\> source URI is not allowed in this document/},
+ {message: /may not load or link to.*accessible.html/},
+ ]);
+
+ pb_extension.sendMessage("start", baseUrl);
+
+ await pb_extension.awaitMessage("image-loaded");
+
+ pb_extension.sendMessage("close");
+
+ await extension.unload();
+ await pb_extension.unload();
+
+ await consoleMonitor.finished();
+});
+
+</script>
+
+</body>
+</html>
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..d6ae4358d4
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html
@@ -0,0 +1,265 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test the web_accessible_resources manifest directive</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+
+SimpleTest.registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref("security.mixed_content.block_display_content");
+});
+
+let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=");
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer;
+
+async function testImageLoading(src, expectedAction) {
+ let imageLoadingPromise = new Promise((resolve, reject) => {
+ let cleanupListeners;
+ let testImage = document.createElement("img");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testImage.wrappedJSObject.setAttribute("src", src);
+
+ let loadListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "loaded");
+ };
+
+ let errorListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "blocked");
+ };
+
+ cleanupListeners = () => {
+ testImage.removeEventListener("load", loadListener);
+ testImage.removeEventListener("error", errorListener);
+ };
+
+ testImage.addEventListener("load", loadListener);
+ testImage.addEventListener("error", errorListener);
+
+ document.body.appendChild(testImage);
+ });
+
+ let success = await imageLoadingPromise;
+ browser.runtime.sendMessage({name: "image-loading", expectedAction, success});
+}
+
+add_task(async function test_web_accessible_resources() {
+ function background() {
+ let gotURL;
+ let tabId;
+
+ function loadFrame(url) {
+ return new Promise(resolve => {
+ browser.tabs.sendMessage(tabId, ["load-iframe", url], reply => {
+ resolve(reply);
+ });
+ });
+ }
+
+ let urls = [
+ [browser.extension.getURL("accessible.html"), true],
+ [browser.extension.getURL("accessible.html") + "?foo=bar", true],
+ [browser.extension.getURL("accessible.html") + "#!foo=bar", true],
+ [browser.extension.getURL("forbidden.html"), false],
+ [browser.extension.getURL("wild1.html"), true],
+ [browser.extension.getURL("wild2.htm"), false],
+ ];
+
+ async function runTests() {
+ for (let [url, shouldLoad] of urls) {
+ let success = await loadFrame(url);
+
+ browser.test.assertEq(shouldLoad, success, "Load was successful");
+ if (shouldLoad) {
+ browser.test.assertEq(url, gotURL, "Got expected url");
+ } else {
+ browser.test.assertEq(undefined, gotURL, "Got no url");
+ }
+ gotURL = undefined;
+ }
+
+ browser.test.notifyPass("web-accessible-resources");
+ }
+
+ browser.runtime.onMessage.addListener(([msg, url], sender) => {
+ if (msg == "content-script-ready") {
+ tabId = sender.tab.id;
+ runTests();
+ } else if (msg == "page-script") {
+ browser.test.assertEq(undefined, gotURL, "Should have gotten only one message");
+ browser.test.assertEq("string", typeof(url), "URL should be a string");
+ gotURL = url;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ function contentScript() {
+ browser.runtime.onMessage.addListener(([msg, url], sender, respond) => {
+ if (msg == "load-iframe") {
+ let iframe = document.createElement("iframe");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ iframe.wrappedJSObject.setAttribute("src", url);
+ iframe.addEventListener("load", () => { respond(true); });
+ iframe.addEventListener("error", () => { respond(false); });
+ document.body.appendChild(iframe);
+ return true;
+ }
+ });
+ browser.runtime.sendMessage(["content-script-ready"]);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://example.com/"],
+ "js": ["content_script.js"],
+ "run_at": "document_idle",
+ },
+ ],
+
+ "web_accessible_resources": [
+ "/accessible.html",
+ "wild*.html",
+ ],
+ },
+
+ background,
+
+ files: {
+ "content_script.js": contentScript,
+
+ "accessible.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="accessible.js"><\/script>
+ </head></html>`,
+
+ "accessible.js": 'browser.runtime.sendMessage(["page-script", location.href]);',
+
+ "inaccessible.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="inaccessible.js"><\/script>
+ </head></html>`,
+
+ "inaccessible.js": 'browser.runtime.sendMessage(["page-script", location.href]);',
+
+ "wild1.html": `<html><head>
+ <meta charset="utf-8">
+ <script src="wild.js"><\/script>
+ </head></html>`,
+
+ "wild2.htm": `<html><head>
+ <meta charset="utf-8">
+ <script src="wild.js"><\/script>
+ </head></html>`,
+
+ "wild.js": 'browser.runtime.sendMessage(["page-script", location.href]);',
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("ready");
+
+ let win = window.open("http://example.com/");
+
+ await extension.awaitFinish("web-accessible-resources");
+
+ win.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_web_accessible_resources_mixed_content() {
+ function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg.name === "image-loading") {
+ browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`);
+ browser.test.sendMessage(`image-${msg.expectedAction}`);
+ } else {
+ browser.test.sendMessage(msg);
+ if (msg === "accessible-script-loaded") {
+ browser.test.notifyPass("mixed-test");
+ }
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function content() {
+ testImageLoading("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", "blocked");
+ testImageLoading(browser.extension.getURL("image.png"), "loaded");
+
+ let testScriptElement = document.createElement("script");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testScriptElement.wrappedJSObject.setAttribute("src", browser.extension.getURL("test_script.js"));
+ document.head.appendChild(testScriptElement);
+
+ window.addEventListener("message", event => {
+ browser.runtime.sendMessage(event.data);
+ });
+ }
+
+ function testScript() {
+ window.postMessage("accessible-script-loaded", "*");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "content_scripts": [{
+ "matches": ["https://example.com/*/file_mixed.html"],
+ "run_at": "document_end",
+ "js": ["content_script_helper.js", "content_script.js"],
+ }],
+ "web_accessible_resources": [
+ "image.png",
+ "test_script.js",
+ ],
+ },
+ background,
+ files: {
+ "content_script_helper.js": `${testImageLoading}`,
+ "content_script.js": content,
+ "test_script.js": testScript,
+ "image.png": IMAGE_ARRAYBUFFER,
+ },
+ });
+
+ SpecialPowers.setBoolPref("security.mixed_content.block_display_content", true);
+
+ await Promise.all([extension.startup(), extension.awaitMessage("background-ready")]);
+
+ let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_mixed.html");
+
+ await Promise.all([
+ extension.awaitMessage("image-blocked"),
+ extension.awaitMessage("image-loaded"),
+ extension.awaitMessage("accessible-script-loaded"),
+ ]);
+ await extension.awaitFinish("mixed-test");
+ win.close();
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..f471ef6a2f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html
@@ -0,0 +1,611 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+if (AppConstants.platform === "android") {
+ SimpleTest.requestLongerTimeout(3);
+}
+
+/* globals sendMouseEvent */
+
+function backgroundScript() {
+ const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+ const URL = BASE + "/file_WebNavigation_page1.html";
+
+ const EVENTS = [
+ "onTabReplaced",
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ ];
+
+ let expectedTabId = -1;
+
+ function gotEvent(event, details) {
+ if (!details.url.startsWith(BASE)) {
+ return;
+ }
+ browser.test.log(`Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`);
+
+ if (expectedTabId == -1) {
+ browser.test.assertTrue(details.tabId !== undefined, "tab ID defined");
+ expectedTabId = details.tabId;
+ }
+
+ browser.test.assertEq(details.tabId, expectedTabId, "correct tab");
+
+ browser.test.sendMessage("received", {url: details.url, event});
+
+ if (details.url == URL) {
+ browser.test.assertEq(0, details.frameId, "root frame ID correct");
+ browser.test.assertEq(-1, details.parentFrameId, "root parent frame ID correct");
+ } else {
+ browser.test.assertEq(0, details.parentFrameId, "parent frame ID correct");
+ browser.test.assertTrue(details.frameId != 0, "frame ID probably okay");
+ }
+
+ browser.test.assertTrue(details.frameId !== undefined, "frameId != undefined");
+ browser.test.assertTrue(details.parentFrameId !== undefined, "parentFrameId != undefined");
+ }
+
+ let listeners = {};
+ for (let event of EVENTS) {
+ listeners[event] = gotEvent.bind(null, event);
+ browser.webNavigation[event].addListener(listeners[event]);
+ }
+
+ browser.test.sendMessage("ready");
+}
+
+const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+const URL = BASE + "/file_WebNavigation_page1.html";
+const FORM_URL = URL + "?";
+const FRAME = BASE + "/file_WebNavigation_page2.html";
+const FRAME2 = BASE + "/file_WebNavigation_page3.html";
+const FRAME_PUSHSTATE = BASE + "/file_WebNavigation_page3_pushState.html";
+const REDIRECT = BASE + "/redirection.sjs";
+const REDIRECTED = BASE + "/dummy_page.html";
+const CLIENT_REDIRECT = BASE + "/file_webNavigation_clientRedirect.html";
+const CLIENT_REDIRECT_HTTPHEADER = BASE + "/file_webNavigation_clientRedirect_httpHeaders.html";
+const FRAME_CLIENT_REDIRECT = BASE + "/file_webNavigation_frameClientRedirect.html";
+const FRAME_REDIRECT = BASE + "/file_webNavigation_frameRedirect.html";
+const FRAME_MANUAL = BASE + "/file_webNavigation_manualSubframe.html";
+const FRAME_MANUAL_PAGE1 = BASE + "/file_webNavigation_manualSubframe_page1.html";
+const FRAME_MANUAL_PAGE2 = BASE + "/file_webNavigation_manualSubframe_page2.html";
+const INVALID_PAGE = "https://invalid.localhost/";
+
+const REQUIRED = [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+];
+
+var received = [];
+var completedResolve;
+var waitingURL, waitingEvent;
+
+function loadAndWait(win, event, url, script) {
+ received = [];
+ waitingEvent = event;
+ waitingURL = url;
+ dump(`RUN ${script}\n`);
+ script();
+ return new Promise(resolve => { completedResolve = resolve; });
+}
+
+add_task(async function webnav_transitions_props() {
+ function backgroundScriptTransitions() {
+ const EVENTS = [
+ "onCommitted",
+ "onHistoryStateUpdated",
+ "onReferenceFragmentUpdated",
+ "onCompleted",
+ ];
+
+ function gotEvent(event, details) {
+ browser.test.log(`Got ${event} ${details.url} ${details.transitionType} ${details.transitionQualifiers && JSON.stringify(details.transitionQualifiers)}`);
+
+ browser.test.sendMessage("received", {url: details.url, details, event});
+ }
+
+ let listeners = {};
+ for (let event of EVENTS) {
+ listeners[event] = gotEvent.bind(null, event);
+ browser.webNavigation[event].addListener(listeners[event]);
+ }
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background: backgroundScriptTransitions,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ extension.onMessage("received", ({url, event, details}) => {
+ received.push({url, event, details});
+
+ if (event == waitingEvent && url == waitingURL) {
+ completedResolve();
+ }
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+ info("webnavigation extension loaded");
+
+ let win = window.open();
+
+ await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; });
+
+ // transitionType: reload
+ received = [];
+ await loadAndWait(win, "onCompleted", URL, () => { win.location.reload(); });
+
+ let found = received.find((data) => (data.event == "onCommitted" && data.url == URL));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "reload",
+ "Got the expected 'reload' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "transitionQualifiers found in the OnCommitted events");
+ }
+
+ // transitionType: auto_subframe
+ found = received.find((data) => (data.event == "onCommitted" && data.url == FRAME));
+
+ ok(found, "Got the sub-frame onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "transitionQualifiers found in the OnCommitted events");
+ }
+
+ // transitionType: form_submit
+ received = [];
+ await loadAndWait(win, "onCompleted", FORM_URL, () => {
+ win.document.querySelector("form").submit();
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "form_submit",
+ "Got the expected 'form_submit' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "transitionQualifiers found in the OnCommitted events");
+ }
+
+ // transitionQualifier: server_redirect
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = REDIRECT; });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "server_redirect"),
+ "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: forward_back
+ received = [];
+ await loadAndWait(win, "onCompleted", FORM_URL, () => { win.history.back(); });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "forward_back"),
+ "Got the expected 'forward_back' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: client_redirect
+ // (from meta http-equiv tag)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => {
+ win.location = CLIENT_REDIRECT;
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "client_redirect"),
+ "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: client_redirect
+ // (from http headers)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => {
+ win.location = CLIENT_REDIRECT_HTTPHEADER;
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" &&
+ data.url == CLIENT_REDIRECT_HTTPHEADER));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "link",
+ "Got the expected 'link' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "client_redirect"),
+ "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: client_redirect (sub-frame)
+ // (from meta http-equiv tag)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => {
+ win.location = FRAME_CLIENT_REDIRECT;
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ ok(Array.isArray(found.details.transitionQualifiers) &&
+ found.details.transitionQualifiers.find((q) => q == "client_redirect"),
+ "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionQualifier: server_redirect (sub-frame)
+ received = [];
+ await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = FRAME_REDIRECT; });
+
+ found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECT));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ // TODO BUG 1264936: currently the server_redirect is not detected in sub-frames
+ // once we fix it we can test it here:
+ //
+ // ok(Array.isArray(found.details.transitionQualifiers) &&
+ // found.details.transitionQualifiers.find((q) => q == "server_redirect"),
+ // "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events");
+ }
+
+ // transitionType: manual_subframe
+ received = [];
+ await loadAndWait(win, "onCompleted", FRAME_MANUAL, () => { win.location = FRAME_MANUAL; });
+ found = received.find((data) => (data.event == "onCommitted" &&
+ data.url == FRAME_MANUAL_PAGE1));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'auto_subframe' transitionType in the OnCommitted event");
+ }
+
+ received = [];
+ await loadAndWait(win, "onCompleted", FRAME_MANUAL_PAGE2, () => {
+ let el = win.document.querySelector("iframe")
+ .contentDocument.querySelector("a");
+ sendMouseEvent({type: "click"}, el, win);
+ });
+
+ found = received.find((data) => (data.event == "onCommitted" &&
+ data.url == FRAME_MANUAL_PAGE2));
+
+ ok(found, "Got the onCommitted event");
+
+ if (found) {
+ if (AppConstants.MOZ_BUILD_APP === "browser") {
+ is(found.details.transitionType, "manual_subframe",
+ "Got the expected 'manual_subframe' transitionType in the OnCommitted event");
+ } else {
+ is(found.details.transitionType, "auto_subframe",
+ "Got the expected 'manual_subframe' transitionType in the OnCommitted event");
+ }
+ }
+
+ // Test transitions properties on onHistoryStateUpdated events.
+
+ received = [];
+ await loadAndWait(win, "onCompleted", FRAME2, () => { win.location = FRAME2; });
+
+ received = [];
+ await loadAndWait(win, "onHistoryStateUpdated", `${FRAME2}/pushState`, () => {
+ win.history.pushState({}, "History PushState", `${FRAME2}/pushState`);
+ });
+
+ found = received.find((data) => (data.event == "onHistoryStateUpdated" &&
+ data.url == `${FRAME2}/pushState`));
+
+ ok(found, "Got the onHistoryStateUpdated event");
+
+ if (found) {
+ is(typeof found.details.transitionType, "string",
+ "Got transitionType in the onHistoryStateUpdated event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "Got transitionQualifiers in the onHistoryStateUpdated event");
+ }
+
+ // Test transitions properties on onReferenceFragmentUpdated events.
+
+ received = [];
+ await loadAndWait(win, "onReferenceFragmentUpdated", `${FRAME2}/pushState#ref2`, () => {
+ win.history.pushState({}, "ReferenceFragment Update", `${FRAME2}/pushState#ref2`);
+ });
+
+ found = received.find((data) => (data.event == "onReferenceFragmentUpdated" &&
+ data.url == `${FRAME2}/pushState#ref2`));
+
+ ok(found, "Got the onReferenceFragmentUpdated event");
+
+ if (found) {
+ is(typeof found.details.transitionType, "string",
+ "Got transitionType in the onReferenceFragmentUpdated event");
+ ok(Array.isArray(found.details.transitionQualifiers),
+ "Got transitionQualifiers in the onReferenceFragmentUpdated event");
+ }
+
+ // cleanup phase
+ win.close();
+
+ await extension.unload();
+ info("webnavigation extension unloaded");
+});
+
+add_task(async function webnav_ordering() {
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background: backgroundScript,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ extension.onMessage("received", ({url, event}) => {
+ received.push({url, event});
+
+ if (event == waitingEvent && url == waitingURL) {
+ completedResolve();
+ }
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ info("webnavigation extension loaded");
+
+ let win = window.open();
+
+ await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; });
+
+ function checkRequired(url) {
+ for (let event of REQUIRED) {
+ let found = false;
+ for (let r of received) {
+ if (r.url == url && r.event == event) {
+ found = true;
+ }
+ }
+ ok(found, `Received event ${event} from ${url}`);
+ }
+ }
+
+ checkRequired(URL);
+ checkRequired(FRAME);
+
+ function checkBefore(action1, action2) {
+ function find(action) {
+ for (let i = 0; i < received.length; i++) {
+ if (received[i].url == action.url && received[i].event == action.event) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ let index1 = find(action1);
+ let index2 = find(action2);
+ ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`);
+ ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`);
+ ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`);
+ }
+
+ // As required in the webNavigation API documentation:
+ // If a navigating frame contains subframes, its onCommitted is fired before any
+ // of its children's onBeforeNavigate; while onCompleted is fired after
+ // all of its children's onCompleted.
+ checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"});
+ checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"});
+
+ // As required in the webNAvigation API documentation, check the event sequence:
+ // onBeforeNavigate -> onCommitted -> onDOMContentLoaded -> onCompleted
+ let expectedEventSequence = [
+ "onBeforeNavigate", "onCommitted", "onDOMContentLoaded", "onCompleted",
+ ];
+
+ for (let i = 1; i < expectedEventSequence.length; i++) {
+ let after = expectedEventSequence[i];
+ let before = expectedEventSequence[i - 1];
+ checkBefore({url: URL, event: before}, {url: URL, event: after});
+ checkBefore({url: FRAME, event: before}, {url: FRAME, event: after});
+ }
+
+ await loadAndWait(win, "onCompleted", FRAME2, () => { win.frames[0].location = FRAME2; });
+
+ checkRequired(FRAME2);
+
+ let navigationSequence = [
+ {
+ action: () => { win.frames[0].document.getElementById("elt").click(); },
+ waitURL: `${FRAME2}#ref`,
+ expectedEvent: "onReferenceFragmentUpdated",
+ description: "clicked an anchor link",
+ },
+ {
+ action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); },
+ waitURL: `${FRAME2}#ref2`,
+ expectedEvent: "onReferenceFragmentUpdated",
+ description: "history.pushState, same pathname, different hash",
+ },
+ {
+ action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); },
+ waitURL: `${FRAME2}#ref2`,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, same pathname, same hash",
+ },
+ {
+ action: () => {
+ win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param1=value#ref2`);
+ },
+ waitURL: `${FRAME2}?query_param1=value#ref2`,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, same pathname, same hash, different query params",
+ },
+ {
+ action: () => {
+ win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param2=value#ref3`);
+ },
+ waitURL: `${FRAME2}?query_param2=value#ref3`,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, same pathname, different hash, different query params",
+ },
+ {
+ action: () => { win.frames[0].history.pushState(null, "History PushState", FRAME_PUSHSTATE); },
+ waitURL: FRAME_PUSHSTATE,
+ expectedEvent: "onHistoryStateUpdated",
+ description: "history.pushState, different pathname",
+ },
+ ];
+
+ for (let navigation of navigationSequence) {
+ let {expectedEvent, waitURL, action, description} = navigation;
+ info(`Waiting ${expectedEvent} from ${waitURL} - ${description}`);
+ await loadAndWait(win, expectedEvent, waitURL, action);
+ info(`Received ${expectedEvent} from ${waitURL} - ${description}`);
+ }
+
+ for (let i = navigationSequence.length - 1; i > 0; i--) {
+ let {waitURL: fromURL, expectedEvent} = navigationSequence[i];
+ let {waitURL} = navigationSequence[i - 1];
+ info(`Waiting ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`);
+ await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.back(); });
+ info(`Received ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`);
+ }
+
+ for (let i = 0; i < navigationSequence.length - 1; i++) {
+ let {waitURL: fromURL} = navigationSequence[i];
+ let {waitURL, expectedEvent} = navigationSequence[i + 1];
+ info(`Waiting ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`);
+ await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.forward(); });
+ info(`Received ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`);
+ }
+
+ win.close();
+
+ await extension.unload();
+ info("webnavigation extension unloaded");
+});
+
+add_task(async function webnav_error_event() {
+ function backgroundScriptErrorEvent() {
+ browser.webNavigation.onErrorOccurred.addListener((details) => {
+ browser.test.log(`Got onErrorOccurred ${details.url} ${details.error}`);
+
+ browser.test.sendMessage("received", {url: details.url, details, event: "onErrorOccurred"});
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background: backgroundScriptErrorEvent,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ extension.onMessage("received", ({url, event, details}) => {
+ received.push({url, event, details});
+
+ if (event == waitingEvent && url == waitingURL) {
+ completedResolve();
+ }
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+ info("webnavigation extension loaded");
+
+ let win = window.open();
+
+ received = [];
+ await loadAndWait(win, "onErrorOccurred", INVALID_PAGE, () => { win.location = INVALID_PAGE; });
+
+ let found = received.find((data) => (data.event == "onErrorOccurred" &&
+ data.url == INVALID_PAGE));
+
+ ok(found, "Got the onErrorOccurred event");
+
+ if (found) {
+ ok(found.details.error.match(/Error code [0-9]+/),
+ "Got the expected error string in the onErrorOccurred event");
+ }
+
+ // cleanup phase
+ win.close();
+
+ await extension.unload();
+ info("webnavigation extension unloaded");
+});
+</script>
+
+</body>
+</html>
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..60720e9663
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html
@@ -0,0 +1,299 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webnav_unresolved_uri_on_expected_URI_scheme() {
+ function background() {
+ let listeners = [];
+
+ function cleanupTestListeners() {
+ browser.test.log(`Cleanup previous test event listeners`);
+ for (let {event, listener} of listeners.splice(0)) {
+ browser.webNavigation[event].removeListener(listener);
+ }
+ }
+
+ function createTestListener(event, fail, urlFilter) {
+ return new Promise(resolve => {
+ function listener(details) {
+ let log = JSON.stringify({url: details.url, urlFilter});
+ if (fail) {
+ browser.test.fail(`Got an unexpected ${event} on the failure listener: ${log}`);
+ } else {
+ browser.test.succeed(`Got the expected ${event} on the success listener: ${log}`);
+ }
+
+ resolve();
+ }
+
+ browser.webNavigation[event].addListener(listener, {url: urlFilter});
+ listeners.push({event, listener});
+ });
+ }
+
+ browser.test.onMessage.addListener((msg, events, data) => {
+ if (msg !== "test-filters") {
+ return;
+ }
+
+ let promises = [];
+
+ for (let {okFilter, failFilter} of data.filters) {
+ for (let event of events) {
+ promises.push(
+ Promise.race([
+ createTestListener(event, false, okFilter),
+ createTestListener(event, true, failFilter),
+ ]));
+ }
+ }
+
+ Promise.all(promises).catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ }).then(() => {
+ cleanupTestListeners();
+ browser.test.sendMessage("test-filter-next");
+ });
+
+ browser.test.sendMessage("test-filter-ready");
+ });
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let win = window.open();
+
+ let testFilterScenarios = [
+ {
+ url: "http://example.net/browser",
+ filters: [
+ // schemes
+ {
+ okFilter: [{schemes: ["http"]}],
+ failFilter: [{schemes: ["https"]}],
+ },
+ // ports
+ {
+ okFilter: [{ports: [80, 22, 443]}],
+ failFilter: [{ports: [81, 82, 83]}],
+ },
+ {
+ okFilter: [{ports: [22, 443, [10, 80]]}],
+ failFilter: [{ports: [22, 23, [81, 100]]}],
+ },
+ // multiple criteria in a single filter:
+ // if one of the criteria is not verified, the event should not be received.
+ {
+ okFilter: [{schemes: ["http"], ports: [80, 22, 443]}],
+ failFilter: [{schemes: ["http"], ports: [81, 82, 83]}],
+ },
+ // multiple urlFilters on the same listener
+ // if at least one of the criteria is verified, the event should be received.
+ {
+ okFilter: [{schemes: ["https"]}, {ports: [80, 22, 443]}],
+ failFilter: [{schemes: ["https"]}, {ports: [81, 82, 83]}],
+ },
+ ],
+ },
+ {
+ url: "http://example.net/browser?param=1#ref",
+ filters: [
+ // host: Equals, Contains, Prefix, Suffix
+ {
+ okFilter: [{hostEquals: "example.net"}],
+ failFilter: [{hostEquals: "example.com"}],
+ },
+ {
+ okFilter: [{hostContains: ".example"}],
+ failFilter: [{hostContains: ".www"}],
+ },
+ {
+ okFilter: [{hostPrefix: "example"}],
+ failFilter: [{hostPrefix: "www"}],
+ },
+ {
+ okFilter: [{hostSuffix: "net"}],
+ failFilter: [{hostSuffix: "com"}],
+ },
+ // path: Equals, Contains, Prefix, Suffix
+ {
+ okFilter: [{pathEquals: "/browser"}],
+ failFilter: [{pathEquals: "/"}],
+ },
+ {
+ okFilter: [{pathContains: "brow"}],
+ failFilter: [{pathContains: "tool"}],
+ },
+ {
+ okFilter: [{pathPrefix: "/bro"}],
+ failFilter: [{pathPrefix: "/tool"}],
+ },
+ {
+ okFilter: [{pathSuffix: "wser"}],
+ failFilter: [{pathSuffix: "kit"}],
+ },
+ // query: Equals, Contains, Prefix, Suffix
+ {
+ okFilter: [{queryEquals: "param=1"}],
+ failFilter: [{queryEquals: "wrongparam=2"}],
+ },
+ {
+ okFilter: [{queryContains: "param"}],
+ failFilter: [{queryContains: "wrongparam"}],
+ },
+ {
+ okFilter: [{queryPrefix: "param="}],
+ failFilter: [{queryPrefix: "wrong"}],
+ },
+ {
+ okFilter: [{querySuffix: "=1"}],
+ failFilter: [{querySuffix: "=2"}],
+ },
+ // urlMatches, originAndPathMatches
+ {
+ okFilter: [{urlMatches: "example.net/.*\?param=1"}],
+ failFilter: [{urlMatches: "example.net/.*\?wrongparam=2"}],
+ },
+ {
+ okFilter: [{originAndPathMatches: "example.net\/browser"}],
+ failFilter: [{originAndPathMatches: "example.net/.*\?param=1"}],
+ },
+ ],
+ },
+ ];
+
+ info("WebNavigation event filters test scenarios starting...");
+
+ const EVENTS = [
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ ];
+
+ for (let data of testFilterScenarios) {
+ info(`Prepare the new test scenario: ${JSON.stringify(data)}`);
+
+ // Bug 1589102: using plain "about:blank" crashes here in fission+debug.
+ win.location = "about:blank?2";
+
+ extension.sendMessage("test-filters", EVENTS, data);
+ await extension.awaitMessage("test-filter-ready");
+
+ info(`Loading the test url: ${data.url}`);
+ win.location = data.url;
+
+ await extension.awaitMessage("test-filter-next");
+
+ info("Test scenario completed. Moving to the next test scenario.");
+ }
+
+ info("WebNavigation event filters test onReferenceFragmentUpdated scenario starting...");
+
+ const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+ let url = BASE + "/file_WebNavigation_page3.html";
+
+ let okFilter = [{urlContains: "_page3.html"}];
+ let failFilter = [{ports: [444]}];
+ let data = {filters: [{okFilter, failFilter}]};
+ let event = "onCompleted";
+
+ info(`Loading the initial test url: ${url}`);
+ extension.sendMessage("test-filters", [event], data);
+
+ await extension.awaitMessage("test-filter-ready");
+ win.location = url;
+ await extension.awaitMessage("test-filter-next");
+
+ event = "onReferenceFragmentUpdated";
+ extension.sendMessage("test-filters", [event], data);
+
+ await extension.awaitMessage("test-filter-ready");
+ win.location = url + "#ref1";
+ await extension.awaitMessage("test-filter-next");
+
+ info("WebNavigation event filters test onHistoryStateUpdated scenario starting...");
+
+ event = "onHistoryStateUpdated";
+ extension.sendMessage("test-filters", [event], data);
+ await extension.awaitMessage("test-filter-ready");
+
+ win.history.pushState({}, "", BASE + "/pushState_page3.html");
+ await extension.awaitMessage("test-filter-next");
+
+ // TODO: add additional specific tests for the other webNavigation events:
+ // onErrorOccurred (and onCreatedNavigationTarget on supported)
+
+ info("WebNavigation event filters test scenarios completed.");
+
+ await extension.unload();
+
+ win.close();
+});
+
+add_task(async function test_webnav_empty_filter_validation_error() {
+ function background() {
+ let catchedException;
+
+ try {
+ browser.webNavigation.onCompleted.addListener(
+ // Empty callback (not really used)
+ () => {},
+ // Empty filter (which should raise a validation error exception).
+ {url: []}
+ );
+ } catch (e) {
+ catchedException = e;
+ browser.test.log(`Got an exception`);
+ }
+
+ if (catchedException &&
+ catchedException.message.includes("Type error for parameter filters") &&
+ catchedException.message.includes("Array requires at least 1 items; you have 0")) {
+ browser.test.notifyPass("webNav.emptyFilterValidationError");
+ } else {
+ browser.test.notifyFail("webNav.emptyFilterValidationError");
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webNavigation",
+ ],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("webNav.emptyFilterValidationError");
+
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..6b161d8247
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html
@@ -0,0 +1,109 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function webnav_test_incognito() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.allowPrivateBrowsingByDefault", false]],
+ });
+
+ // Monitor will fail if it gets any event.
+ let monitor = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": ["webNavigation", "*://mochi.test/*"],
+ },
+ background() {
+ const EVENTS = [
+ "onTabReplaced",
+ "onBeforeNavigate",
+ "onCommitted",
+ "onDOMContentLoaded",
+ "onCompleted",
+ "onErrorOccurred",
+ "onReferenceFragmentUpdated",
+ "onHistoryStateUpdated",
+ ];
+
+ function onEvent(event, details) {
+ browser.test.fail(`not_allowed - Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`);
+ }
+
+ let listeners = {};
+ for (let event of EVENTS) {
+ listeners[event] = onEvent.bind(null, event);
+ browser.webNavigation[event].addListener(listeners[event]);
+ }
+
+ browser.test.onMessage.addListener(async (message, tabId) => {
+ // try to access the private window
+ await browser.test.assertRejects(browser.webNavigation.getAllFrames({tabId}),
+ /Invalid tab ID/,
+ "should not be able to get incognito frames");
+ await browser.test.assertRejects(browser.webNavigation.getFrame({tabId, frameId: 0}),
+ /Invalid tab ID/,
+ "should not be able to get incognito frames");
+ browser.test.notifyPass("completed");
+ });
+ },
+ });
+
+ // extension loads a private window and waits for the onCompleted event.
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["tabs", "webNavigation", "*://mochi.test/*"],
+ },
+ async background() {
+ const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest";
+ const url = BASE + "/file_WebNavigation_page1.html";
+ let window;
+
+ browser.webNavigation.onCompleted.addListener(async (details) => {
+ if (details.url !== url) {
+ return;
+ }
+ browser.test.log(`spanning - Got onCompleted ${details.url} ${details.frameId} ${details.parentFrameId}`);
+ browser.test.sendMessage("completed");
+ });
+ browser.test.onMessage.addListener(async () => {
+ await browser.windows.remove(window.id);
+ browser.test.notifyPass("done");
+ });
+ window = await browser.windows.create({url, incognito: true});
+ let tabs = await browser.tabs.query({active: true, windowId: window.id});
+ browser.test.sendMessage("tabId", tabs[0].id);
+ },
+ });
+
+ await monitor.startup();
+ await extension.startup();
+
+ await extension.awaitMessage("completed");
+ let tabId = await extension.awaitMessage("tabId");
+
+ await monitor.sendMessage("tab", tabId);
+ await monitor.awaitFinish("completed");
+
+ await extension.sendMessage("close");
+ await extension.awaitFinish("done");
+
+ await extension.unload();
+ await monitor.unload();
+});
+
+</script>
+
+</body>
+</html>
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..ea99ec244a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html
@@ -0,0 +1,134 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css">
+<script>
+"use strict";
+
+// Check that the windowId and tabId filter work as expected in the webRequest
+// and proxy API:
+// - A non-matching windowId / tabId listener won't trigger events.
+// - A matching tabId from a tab triggers the event.
+// - A matching windowId from a tab triggers the event.
+// (unlike test_ext_webrequest_filter.html, this also works on Android)
+// - Requests from background pages can be matched with windowId and tabId -1.
+add_task(async function test_filter_tabId_and_windowId() {
+ async function tabScript() {
+ let pendingExpectations = new Set();
+ // Helper to detect completion of expected requests.
+ function watchExpected(filter, desc) {
+ desc += ` - ${JSON.stringify(filter)}`;
+ const DESC_PROXY = `${desc} (proxy)`;
+ const DESC_WEBREQUEST = `${desc} (webRequest)`;
+ pendingExpectations.add(DESC_PROXY);
+ pendingExpectations.add(DESC_WEBREQUEST);
+ browser.proxy.onRequest.addListener(() => {
+ pendingExpectations.delete(DESC_PROXY);
+ }, filter);
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ pendingExpectations.delete(DESC_WEBREQUEST);
+ },
+ filter,
+ ["blocking"]
+ );
+ }
+
+ // Helper to detect unexpected requests.
+ function watchUnexpected(filter, desc) {
+ desc += ` - ${JSON.stringify(filter)}`;
+ browser.proxy.onRequest.addListener(() => {
+ browser.test.fail(`${desc} - unexpected proxy event`);
+ }, filter);
+ browser.webRequest.onBeforeRequest.addListener(() => {
+ browser.test.fail(`${desc} - unexpected webRequest event`);
+ }, filter);
+ }
+
+ function registerExpectations(url, windowId, tabId) {
+ const urls = [url];
+ watchUnexpected({ urls, windowId: 0 }, "non-matching windowId");
+ watchUnexpected({ urls, tabId: 0 }, "non-matching tabId");
+
+ watchExpected({ urls, windowId }, "windowId matches");
+ watchExpected({ urls, tabId }, "tabId matches");
+ }
+
+ try {
+ let { windowId, tabId } = await browser.runtime.sendMessage("getIds");
+ browser.test.log(`Dummy tab has: tabId=${tabId} windowId=${windowId}`);
+ registerExpectations("http://example.com/?tab", windowId, tabId);
+ registerExpectations("http://example.com/?bg", -1, -1);
+
+ // Call an API method implemented in the parent process to ensure that
+ // the listeners have been registered (workaround for bug 1300234).
+ // There is a .catch() at the end because the call is rejected on Android.
+ await browser.proxy.settings.get({}).catch(() => {});
+
+ browser.test.log("Triggering request from background page.");
+ await browser.runtime.sendMessage("triggerBackgroundRequest");
+
+ browser.test.log("Triggering request from tab.");
+ await fetch("http://example.com/?tab");
+
+ browser.test.assertEq(0, pendingExpectations.size, "got all events");
+ for (let description of pendingExpectations) {
+ browser.test.fail(`Event not observed: ${description}`);
+ }
+ } catch (e) {
+ browser.test.fail(`Unexpected test failure: ${e} :: ${e.stack}`);
+ }
+ browser.test.sendMessage("testCompleted");
+ }
+
+ function background() {
+ browser.runtime.onMessage.addListener(async (msg, sender) => {
+ if (msg === "getIds") {
+ return { windowId: sender.tab.windowId, tabId: sender.tab.id };
+ }
+ if (msg === "triggerBackgroundRequest") {
+ await fetch("http://example.com/?bg");
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "proxy",
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/*",
+ ],
+ web_accessible_resources: ["tab.html"],
+ },
+ background,
+ files: {
+ "tab.html": `<!DOCTYPE html><script src="tab.js"><\/script>`,
+ "tab.js": tabScript,
+ },
+ });
+ await extension.startup();
+
+ // bug 1641735: tabs.create / tabs.remove does not work in GeckoView unless
+ // `useAddonManager: "permanent"` is used, so use window.open() instead.
+ //
+ // Note that somehow window.open() unexpectedly runs null when extensions
+ // run in-process, i.e. extensions.webextensions.remote=false. Fortunately,
+ // extension tabs are automatically closed as part of extension.unload()
+ // below (provided that extension APIs are used in the tab - bug 1399655).
+ window.open(`moz-extension://${extension.uuid}/tab.html`);
+
+ await extension.awaitMessage("testCompleted");
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+</body>
+</html>
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..f191e58b56
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html
@@ -0,0 +1,182 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+// This file defines content scripts.
+/* eslint-env mozilla/frame-script */
+
+let baseUrl = "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/authenticate.sjs";
+function testXHR(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = resolve;
+ xhr.onabort = reject;
+ xhr.onerror = reject;
+ xhr.send();
+ });
+}
+
+function getAuthHandler(result, blocking = true) {
+ function background(result) {
+ browser.webRequest.onAuthRequired.addListener((details) => {
+ browser.test.succeed(`authHandler.onAuthRequired called with ${details.requestId} ${details.url} result ${JSON.stringify(result)}`);
+ browser.test.sendMessage("onAuthRequired");
+ return result;
+ }, {urls: ["*://mochi.test/*"]}, ["blocking"]);
+ browser.webRequest.onCompleted.addListener((details) => {
+ browser.test.succeed(`authHandler.onCompleted called with ${details.requestId} ${details.url}`);
+ browser.test.sendMessage("onCompleted");
+ }, {urls: ["*://mochi.test/*"]});
+ browser.webRequest.onErrorOccurred.addListener((details) => {
+ browser.test.succeed(`authHandler.onErrorOccurred called with ${details.requestId} ${details.url}`);
+ browser.test.sendMessage("onErrorOccurred");
+ }, {urls: ["*://mochi.test/*"]});
+ }
+
+ let permissions = [
+ "webRequest",
+ "*://mochi.test/*",
+ ];
+ if (blocking) {
+ permissions.push("webRequestBlocking");
+ }
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+ background: `(${background})(${JSON.stringify(result)})`,
+ });
+}
+
+add_task(async function test_webRequest_auth_nonblocking_forwardAuthProvider() {
+ // The chrome script sets up a default auth handler on the channel, the
+ // extension does not return anything in the authRequred call. We should
+ // get the call in the extension first, then in the chrome code where we
+ // cancel the request to avoid dealing with the prompt dialog here. The test
+ // is to ensure that WebRequest calls the previous notificationCallbacks
+ // if the authorization is not handled by the onAuthRequired handler.
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ let observer = channel => {
+ if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test")) {
+ return;
+ }
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ channel.notificationCallbacks = {
+ QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor",
+ "nsIAuthPromptProvider",
+ "nsIAuthPrompt2"]),
+ getInterface: ChromeUtils.generateQI(["nsIAuthPromptProvider",
+ "nsIAuthPrompt2"]),
+ promptAuth(channel, level, authInfo) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ getAuthPrompt(reason, iid) {
+ return this;
+ },
+ asyncPromptAuth(channel, callback, context, level, authInfo) {
+ // We just cancel here, we're only ensuring that non-webrequest
+ // notificationcallbacks get called if webrequest doesn't handle it.
+ Promise.resolve().then(() => {
+ callback.onAuthCancelled(context, false);
+ channel.cancel(Cr.NS_BINDING_ABORTED);
+ sendAsyncMessage("callback-complete");
+ });
+ },
+ };
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ sendAsyncMessage("chrome-ready");
+ });
+ await chromeScript.promiseOneMessage("chrome-ready");
+ let callbackComplete = chromeScript.promiseOneMessage("callback-complete");
+
+ let handlingExt = getAuthHandler();
+ await handlingExt.startup();
+
+ await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuth&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`),
+ ProgressEvent,
+ "caught rejected xhr");
+
+ await callbackComplete;
+ await handlingExt.awaitMessage("onAuthRequired");
+ // We expect onErrorOccurred because the "default" authprompt above cancelled
+ // the auth request to avoid a dialog.
+ await handlingExt.awaitMessage("onErrorOccurred");
+ await handlingExt.unload();
+ chromeScript.destroy();
+});
+
+add_task(async function test_webRequest_auth_nonblocking_forwardAuthPrompt2() {
+ // The chrome script sets up a default auth handler on the channel, the
+ // extension does not return anything in the authRequred call. We should
+ // get the call in the extension first, then in the chrome code where we
+ // cancel the request to avoid dealing with the prompt dialog here. The test
+ // is to ensure that WebRequest calls the previous notificationCallbacks
+ // if the authorization is not handled by the onAuthRequired handler.
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ let observer = channel => {
+ if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test")) {
+ return;
+ }
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ channel.notificationCallbacks = {
+ QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor",
+ "nsIAuthPrompt2"]),
+ getInterface: ChromeUtils.generateQI(["nsIAuthPrompt2"]),
+ promptAuth(request, level, authInfo) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ asyncPromptAuth(request, callback, context, level, authInfo) {
+ // We just cancel here, we're only ensuring that non-webrequest
+ // notificationcallbacks get called if webrequest doesn't handle it.
+ Promise.resolve().then(() => {
+ request.cancel(Cr.NS_BINDING_ABORTED);
+ sendAsyncMessage("callback-complete");
+ });
+ },
+ };
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ sendAsyncMessage("chrome-ready");
+ });
+ await chromeScript.promiseOneMessage("chrome-ready");
+ let callbackComplete = chromeScript.promiseOneMessage("callback-complete");
+
+ let handlingExt = getAuthHandler();
+ await handlingExt.startup();
+
+ await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuthPromptProvider&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`),
+ ProgressEvent,
+ "caught rejected xhr");
+
+ await callbackComplete;
+ await handlingExt.awaitMessage("onAuthRequired");
+ // We expect onErrorOccurred because the "default" authprompt above cancelled
+ // the auth request to avoid a dialog.
+ await handlingExt.awaitMessage("onErrorOccurred");
+ await handlingExt.unload();
+ chromeScript.destroy();
+});
+</script>
+</head>
+<body>
+<div id="test">Authorization Test</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webRequest_serviceworker_events() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true]],
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ let eventNames = new Set([
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ "onErrorOccurred",
+ ]);
+
+ function listener(name, details) {
+ browser.test.assertTrue(eventNames.has(name), `received ${name}`);
+ eventNames.delete(name);
+ if (name == "onCompleted") {
+ eventNames.delete("onErrorOccurred");
+ } else if (name == "onErrorOccurred") {
+ eventNames.delete("onCompleted");
+ }
+ if (eventNames.size == 0) {
+ browser.test.sendMessage("done");
+ }
+ }
+
+ for (let name of eventNames) {
+ browser.webRequest[name].addListener(
+ listener.bind(null, name),
+ {urls: ["https://example.com/*"]}
+ );
+ }
+ },
+ });
+
+ await extension.startup();
+ let registration = await navigator.serviceWorker.register("webrequest_worker.js", {scope: "."});
+ await waitForState(registration.installing, "activated");
+ await extension.awaitMessage("done");
+ await registration.unregister();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_background_events() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ let eventNames = new Set([
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ ]);
+
+ function listener(name, details) {
+ browser.test.assertTrue(eventNames.has(name), `received ${name}`);
+ eventNames.delete(name);
+
+ if (eventNames.size === 0) {
+ browser.test.assertEq("xmlhttprequest", details.type, "correct type for fetch [see bug 1366710]");
+ browser.test.assertEq(0, eventNames.size, "messages received");
+ browser.test.sendMessage("done");
+ }
+ }
+
+ for (let name of eventNames) {
+ browser.webRequest[name].addListener(
+ listener.bind(null, name),
+ {urls: ["https://example.com/*"]}
+ );
+ }
+
+ fetch("https://example.com/example.txt").then(() => {
+ browser.test.succeed("Fetch succeeded.");
+ }, () => {
+ browser.test.fail("fetch received");
+ browser.test.sendMessage("done");
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..742f048a8a
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html
@@ -0,0 +1,446 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+function promiseWindowEvent(name, accept) {
+ return new Promise(resolve => {
+ window.addEventListener(name, function listener(event) {
+ if (event.data !== accept) {
+ return;
+ }
+ window.removeEventListener(name, listener);
+ resolve(event);
+ });
+ });
+}
+
+if (AppConstants.platform === "android") {
+ SimpleTest.requestLongerTimeout(3);
+}
+
+let extension;
+add_task(async function setup() {
+ // Clear the image cache, since it gets in the way otherwise.
+ let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ let cache = imgTools.getImgCacheForDocument(document);
+ cache.clearCache(false);
+ function clearCache() {
+ ChromeUtils.import("resource://gre/modules/Services.jsm", {}).Services.cache2.clear();
+ }
+ SpecialPowers.loadChromeScript(clearCache);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.http.rcwn.enabled", false]],
+ });
+
+ extension = makeExtension();
+ await extension.startup();
+});
+
+// expect is a set of test values used by the background script.
+//
+// type: type of request action
+// events: optional, If defined only the events listed are expected for the
+// request. If undefined, all events except onErrorOccurred
+// and onBeforeRedirect are expected. Must be in order received.
+// redirect: url to redirect to during onBeforeSendHeaders
+// status: number expected status during onHeadersReceived, 200 default
+// cancel: event in which we return cancel=true. cancelled message is sent.
+// cached: expected fromCache value, default is false, checked in onCompletion
+// headers: request or response headers to modify
+// origin: The expected originUrl, a default origin can be passed for all files
+
+add_task(async function test_webRequest_links() {
+ let expect = {
+ "file_style_bad.css": {
+ type: "stylesheet",
+ events: ["onBeforeRequest", "onErrorOccurred"],
+ cancel: "onBeforeRequest",
+ },
+ "file_style_redirect.css": {
+ type: "stylesheet",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"],
+ optional_events: ["onHeadersReceived"],
+ redirect: "file_style_good.css",
+ },
+ "file_style_good.css": {
+ type: "stylesheet",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addStylesheet("file_style_bad.css");
+ await extension.awaitMessage("cancelled");
+ // we redirect to style_good which completes the test
+ addStylesheet("file_style_redirect.css");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_images() {
+ let expect = {
+ "file_image_bad.png": {
+ type: "image",
+ events: ["onBeforeRequest", "onErrorOccurred"],
+ cancel: "onBeforeRequest",
+ },
+ "file_image_redirect.png": {
+ type: "image",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"],
+ optional_events: ["onHeadersReceived"],
+ redirect: "file_image_good.png",
+ },
+ "file_image_good.png": {
+ type: "image",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addImage("file_image_bad.png");
+ await extension.awaitMessage("cancelled");
+ // we redirect to image_good which completes the test
+ addImage("file_image_redirect.png");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_scripts() {
+ let expect = {
+ "file_script_bad.js": {
+ type: "script",
+ events: ["onBeforeRequest", "onErrorOccurred"],
+ cancel: "onBeforeRequest",
+ },
+ "file_script_redirect.js": {
+ type: "script",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"],
+ optional_events: ["onHeadersReceived"],
+ redirect: "file_script_good.js",
+ },
+ "file_script_good.js": {
+ type: "script",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ let message = promiseWindowEvent("message", "test1");
+ addScript("file_script_bad.js");
+ await extension.awaitMessage("cancelled");
+ // we redirect to script_good which completes the test
+ addScript("file_script_redirect.js?q=test1");
+ await extension.awaitMessage("done");
+
+ is((await message).data, "test1", "good script ran");
+});
+
+add_task(async function test_webRequest_xhr_get() {
+ let expect = {
+ "file_script_xhr.js": {
+ type: "script",
+ },
+ "xhr_resource": {
+ status: 404,
+ type: "xmlhttprequest",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addScript("file_script_xhr.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_nonexistent() {
+ let expect = {
+ "nonexistent_script_url.js": {
+ status: 404,
+ type: "script",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addScript("nonexistent_script_url.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_checkCached() {
+ let expect = {
+ "file_image_good.png": {
+ type: "image",
+ cached: true,
+ },
+ "file_script_good.js": {
+ type: "script",
+ cached: true,
+ },
+ "file_style_good.css": {
+ type: "stylesheet",
+ cached: true,
+ },
+ "nonexistent_script_url.js": {
+ status: 404,
+ type: "script",
+ cached: false,
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ let message = promiseWindowEvent("message", "test1");
+
+ addImage("file_image_good.png");
+ addScript("file_script_good.js?q=test1");
+
+ is((await message).data, "test1", "good script ran");
+
+ addStylesheet("file_style_good.css");
+ addScript("nonexistent_script_url.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_headers() {
+ let expect = {
+ "file_script_nonexistent.js": {
+ type: "script",
+ status: 404,
+ headers: {
+ request: {
+ add: {
+ "X-WebRequest-request": "text",
+ "X-WebRequest-request-binary": "binary",
+ },
+ modify: {
+ "user-agent": "WebRequest",
+ },
+ remove: [
+ "referer",
+ ],
+ },
+ response: {
+ add: {
+ "X-WebRequest-response": "text",
+ "X-WebRequest-response-binary": "binary",
+ },
+ modify: {
+ "server": "WebRequest",
+ "content-type": "text/html; charset=utf-8",
+ },
+ remove: [
+ "connection",
+ ],
+ },
+ },
+ completion: "onCompleted",
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addScript("file_script_nonexistent.js");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function test_webRequest_tabId() {
+ function background() {
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ tab = newTab;
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "close-tab") {
+ browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+ }
+
+ let tabExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "web_request_tab_id@tests.mozilla.org" } },
+ permissions: [
+ "tabs",
+ ],
+ },
+ background,
+ });
+ await tabExt.startup();
+
+ let linkUrl = `file_WebRequest_page3.html?trigger=a&nocache=${Math.random()}`;
+ let expect = {
+ "file_WebRequest_page3.html": {
+ type: "main_frame",
+ },
+ };
+
+ if (AppConstants.platform != "android") {
+ expect["favicon.ico"] = {
+ type: "image",
+ origin: SimpleTest.getTestFileURL(linkUrl),
+ cached: false,
+ };
+ }
+
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ let a = addLink(linkUrl);
+ a.click();
+ await extension.awaitMessage("done");
+
+ let closed = tabExt.awaitMessage("tab-closed");
+ tabExt.sendMessage("close-tab");
+ await closed;
+
+ await tabExt.unload();
+});
+
+add_task(async function test_webRequest_tabId_browser() {
+ async function background(url) {
+ let tabId;
+ browser.test.onMessage.addListener(async (msg, expected) => {
+ if (msg == "create") {
+ let tab = await browser.tabs.create({url});
+ tabId = tab.id;
+ return;
+ }
+ if (msg == "done") {
+ await browser.tabs.remove(tabId);
+ browser.test.sendMessage("done");
+ }
+ });
+ browser.test.sendMessage("origin", browser.runtime.getURL("/"));
+ }
+
+ let pageUrl = `${SimpleTest.getTestFileURL("file_sample.html")}?nocache=${Math.random()}`;
+ let tabExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "tab_id_browser@tests.mozilla.org" } },
+ permissions: [
+ "tabs",
+ ],
+ },
+ background: `(${background})('${pageUrl}')`,
+ });
+
+ let expect = {
+ "file_sample.html": {
+ type: "main_frame",
+ },
+ };
+
+ if (AppConstants.platform != "android") {
+ expect["favicon.ico"] = {
+ type: "image",
+ origin: pageUrl,
+ cached: true,
+ };
+ }
+
+ await tabExt.startup();
+ let origin = await tabExt.awaitMessage("origin");
+
+ // expecting origin == extension baseUrl
+ extension.sendMessage("set-expected", {expect, origin});
+ await extension.awaitMessage("continue");
+
+ // open a tab from an extension principal
+ tabExt.sendMessage("create");
+ await extension.awaitMessage("done");
+ tabExt.sendMessage("done");
+ await tabExt.awaitMessage("done");
+ await tabExt.unload();
+});
+
+add_task(async function test_webRequest_frames() {
+ let expect = {
+ "redirection.sjs": {
+ status: 302,
+ type: "sub_frame",
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onBeforeRedirect"],
+ },
+ "dummy_page.html": {
+ type: "sub_frame",
+ status: 404,
+ },
+ "badrobot": {
+ type: "sub_frame",
+ status: 404,
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onErrorOccurred"],
+ // When an url's hostname fails to be resolved, an NS_ERROR_NET_ON_RESOLVED/RESOLVING
+ // onError event may be fired right before the NS_ERROR_UNKNOWN_HOST
+ // (See Bug 1516862 for a rationale).
+ optional_events: ["onErrorOccurred"],
+ error: ["NS_ERROR_UNKNOWN_HOST", "NS_ERROR_NET_ON_RESOLVED", "NS_ERROR_NET_ON_RESOLVING"],
+ },
+ };
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+ addFrame("redirection.sjs");
+ addFrame("https://nonresolvablehostname.invalid/badrobot");
+ await extension.awaitMessage("done");
+});
+
+add_task(async function teardown() {
+ await extension.unload();
+});
+
+add_task(async function test_case_preserving() {
+ const manifest = {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://mochi.test/",
+ ],
+ };
+
+ async function background() {
+ // This is testing if header names preserve case,
+ // so the case-sensitive comparison is on purpose.
+ function ua({url, requestHeaders}) {
+ if (url.endsWith("?blind-add")) {
+ requestHeaders.push({name: "user-agent", value: "Blind/Add"});
+ return {requestHeaders};
+ }
+ for (const header of requestHeaders) {
+ if (header.name === "User-Agent") {
+ header.value = "Case/Sensitive";
+ }
+ }
+ return {requestHeaders};
+ }
+
+ await browser.webRequest.onBeforeSendHeaders.addListener(ua, {urls: ["<all_urls>"]}, ["blocking", "requestHeaders"]);
+ browser.test.sendMessage("ready");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({manifest, background});
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ const response1 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs"));
+ const headers1 = JSON.parse(await response1.text());
+
+ is(headers1["user-agent"], "Case/Sensitive", "User-Agent header matched and changed.");
+
+ const response2 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs?blind-add"));
+ const headers2 = JSON.parse(await response2.text());
+
+ is(headers2["user-agent"], "Blind/Add", "User-Agent header blindly added.");
+
+ await extension.unload();
+});
+
+</script>
+</head>
+<body>
+<div id="test">Sample text</div>
+
+</body>
+</html>
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..89ef9f4809
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for WebRequest errors</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+<script type="text/javascript">
+"use strict";
+
+async function test_connection_refused(url, expectedError) {
+ async function background(url, expectedError) {
+ browser.test.log(`background url is ${url}`);
+ browser.webRequest.onErrorOccurred.addListener(details => {
+ if (details.url != url) {
+ return;
+ }
+ browser.test.assertTrue(details.error.startsWith(expectedError), "error correct");
+ browser.test.sendMessage("onErrorOccurred");
+ }, {urls: ["<all_urls>"]});
+
+ let tabId;
+ browser.test.onMessage.addListener(async (msg, expected) => {
+ await browser.tabs.remove(tabId);
+ browser.test.sendMessage("done");
+ });
+
+ let tab = await browser.tabs.create({url});
+ tabId = tab.id;
+ }
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: "connection_refused@tests.mozilla.org" } },
+ permissions: ["webRequest", "tabs", "*://badchain.include-subdomains.pinning.example.com/*"],
+ },
+ background: `(${background})("${url}", "${expectedError}")`,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("onErrorOccurred");
+ extension.sendMessage("close-tab");
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+}
+
+add_task(function test_bad_cert() {
+ return test_connection_refused("https://badchain.include-subdomains.pinning.example.com/", "Unable to communicate securely with peer");
+});
+
+</script>
+
+</body>
+</html>
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..62539b54bc
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html
@@ -0,0 +1,227 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+if (AppConstants.platform === "android") {
+ SimpleTest.requestLongerTimeout(6);
+}
+
+let windowData, testWindow;
+
+add_task(async function setup() {
+ let chromeScript = SpecialPowers.loadChromeScript(function() {
+ ChromeUtils.import("resource://gre/modules/Services.jsm", {}).Services.cache2.clear();
+ });
+ chromeScript.destroy();
+
+ testWindow = window.open("about:blank", "_blank", "width=100,height=100");
+ await waitForLoad(testWindow);
+
+ // Fetch the windowId and tabId we need to filter with WebRequest.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "tabs",
+ ],
+ },
+ background() {
+ browser.tabs.query({currentWindow: true}).then(tabs => {
+ let tab = tabs.find(tab => tab.active);
+ let {windowId} = tab;
+
+ browser.test.log(`current window ${windowId} tabs: ${JSON.stringify(tabs.map(tab => [tab.id, tab.url]))}`);
+ browser.test.sendMessage("windowData", {windowId, tabId: tab.id});
+ });
+ },
+ });
+ await extension.startup();
+ windowData = await extension.awaitMessage("windowData");
+ info(`window is ${JSON.stringify(windowData)}`);
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_filter_window() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // Android does not support multiple windows.
+ return;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true],
+ ["network.http.rcwn.enabled", false]],
+ });
+
+ let events = {
+ "onBeforeRequest": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ "onBeforeSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]],
+ "onSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]],
+ "onBeforeRedirect": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ "onHeadersReceived": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]],
+ "onResponseStarted": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ "onCompleted": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]],
+ "onErrorOccurred": [{urls: ["<all_urls>"], windowId: windowData.windowId}],
+ };
+ let expect = {
+ "file_image_bad.png": {
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"],
+ type: "main_frame",
+ },
+ };
+
+ if (AppConstants.platform != "android") {
+ expect["favicon.ico"] = {
+ // These events only happen in non-e10s. See bug 1472156.
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"],
+ type: "image",
+ origin: SimpleTest.getTestFileURL("file_image_bad.png"),
+ };
+ }
+
+ let extension = makeExtension(events);
+ await extension.startup();
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+
+ // We should not get events for a new window load.
+ let newWindow = window.open("file_image_good.png", "_blank", "width=100,height=100");
+ await waitForLoad(newWindow);
+ newWindow.close();
+
+ // We should not get background events.
+ let registration = await navigator.serviceWorker.register("webrequest_worker.js?test0", {scope: "."});
+ await waitForState(registration.installing, "activated");
+
+ // We should get events for the reload.
+ testWindow.location = "file_image_bad.png";
+ await extension.awaitMessage("done");
+
+ testWindow.location = "about:blank";
+ await registration.unregister();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_filter_tab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true]],
+ });
+
+ let img = `file_image_good.png?r=${Math.random()}`;
+
+ let events = {
+ "onBeforeRequest": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]],
+ "onSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]],
+ "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ "onHeadersReceived": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]],
+ "onResponseStarted": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ "onCompleted": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]],
+ "onErrorOccurred": [{urls: ["<all_urls>"], tabId: windowData.tabId}],
+ };
+ let expect = {
+ "file_image_good.png": {
+ // These events only happen in non-e10s. See bug 1472156.
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"],
+ type: "main_frame",
+ // cached: AppConstants.MOZ_BUILD_APP === "browser",
+ },
+ };
+
+ if (AppConstants.platform != "android") {
+ // A favicon request may be initiated, and complete or be aborted.
+ expect["favicon.ico"] = {
+ optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted", "onCompleted", "onErrorOccurred"],
+ type: "image",
+ origin: SimpleTest.getTestFileURL(img),
+ };
+ }
+
+ let extension = makeExtension(events);
+ await extension.startup();
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+
+ if (AppConstants.MOZ_BUILD_APP === "browser") {
+ // We should not get events for a new window load.
+ let newWindow = window.open(img, "_blank", "width=100,height=100");
+ await waitForLoad(newWindow);
+ newWindow.close();
+ }
+
+ // We should not get background events.
+ let registration = await navigator.serviceWorker.register("webrequest_worker.js?test1", {scope: "."});
+ await waitForState(registration.installing, "activated");
+
+ // We should get events for the reload.
+ testWindow.location = img;
+ await extension.awaitMessage("done");
+
+ testWindow.location = "about:blank";
+ await registration.unregister();
+ await extension.unload();
+});
+
+
+add_task(async function test_webRequest_filter_background() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.serviceWorkers.testing.enabled", true]],
+ });
+
+ let events = {
+ "onBeforeRequest": [{urls: ["<all_urls>"], tabId: -1}],
+ "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]],
+ "onSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]],
+ "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: -1}],
+ "onHeadersReceived": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]],
+ "onResponseStarted": [{urls: ["<all_urls>"], tabId: -1}],
+ "onCompleted": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]],
+ "onErrorOccurred": [{urls: ["<all_urls>"], tabId: -1}],
+ };
+ let expect = {
+ "webrequest_worker.js": {
+ type: "script",
+ },
+ "example.txt": {
+ status: 404,
+ events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted"],
+ optional_events: ["onCompleted", "onErrorOccurred"],
+ type: "xmlhttprequest",
+ origin: SimpleTest.getTestFileURL("webrequest_worker.js?test2"),
+ },
+ };
+
+ let extension = makeExtension(events);
+ await extension.startup();
+ extension.sendMessage("set-expected", {expect, origin: location.href});
+ await extension.awaitMessage("continue");
+
+ // We should not get events for a window.
+ testWindow.location = "file_image_bad.png";
+
+ // We should get events for the background page.
+ let registration = await navigator.serviceWorker.register(SimpleTest.getTestFileURL("webrequest_worker.js?test2"), {scope: "."});
+ await waitForState(registration.installing, "activated");
+ await extension.awaitMessage("done");
+ testWindow.location = "about:blank";
+ await registration.unregister();
+
+ await extension.unload();
+});
+
+add_task(async function teardown() {
+ testWindow.close();
+});
+</script>
+</head>
+<body>
+
+</body>
+</html>
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..1b26a77f2b
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html
@@ -0,0 +1,214 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>", "tabs"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.sendMessage("onBeforeRequest", details);
+ }, {urls: ["<all_urls>"]}, ["blocking"]);
+
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ browser.test.sendMessage("tab-created");
+ tab = newTab;
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "close-tab") {
+ browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+ },
+};
+
+let expected = {
+ "file_simple_xhr.html": {
+ type: "main_frame",
+ toplevel: true,
+ },
+ "file_image_good.png": {
+ type: "image",
+ toplevel: true,
+ origin: "file_simple_xhr.html",
+ },
+ "example.txt": {
+ type: "xmlhttprequest",
+ toplevel: true,
+ origin: "file_simple_xhr.html",
+ },
+ // sub frames will have the origin and first ancestor is the
+ // parent document
+ "file_simple_xhr_frame.html": {
+ type: "sub_frame",
+ toplevelParent: true,
+ origin: "file_simple_xhr.html",
+ parent: "file_simple_xhr.html",
+ },
+ // a resource in a sub frame will have origin of the subframe,
+ // but the ancestor chain starts with the parent document
+ "xhr_resource": {
+ type: "xmlhttprequest",
+ origin: "file_simple_xhr_frame.html",
+ parent: "file_simple_xhr.html",
+ },
+ "file_image_bad.png": {
+ type: "image",
+ depth: 2,
+ origin: "file_simple_xhr_frame.html",
+ parent: "file_simple_xhr.html",
+ },
+ "file_simple_xhr_frame2.html": {
+ type: "sub_frame",
+ depth: 2,
+ origin: "file_simple_xhr_frame.html",
+ parent: "file_simple_xhr_frame.html",
+ },
+ "file_image_redirect.png": {
+ type: "image",
+ depth: 2,
+ origin: "file_simple_xhr_frame2.html",
+ parent: "file_simple_xhr_frame.html",
+ },
+ "xhr_resource_2": {
+ type: "xmlhttprequest",
+ depth: 2,
+ origin: "file_simple_xhr_frame2.html",
+ parent: "file_simple_xhr_frame.html",
+ },
+ // This is loaded in a sandbox iframe. originUrl is not available for that,
+ // and requests within a sandboxed iframe will additionally have an empty
+ // url on their immediate parent/ancestor.
+ "file_simple_sandboxed_frame.html": {
+ type: "sub_frame",
+ depth: 3,
+ parent: "file_simple_xhr_frame2.html",
+ },
+ "xhr_sandboxed": {
+ type: "xmlhttprequest",
+ sandboxed: true,
+ depth: 3,
+ parent: "",
+ },
+ "file_image_great.png": {
+ type: "image",
+ sandboxed: true,
+ depth: 3,
+ parent: "",
+ },
+ "file_simple_sandboxed_subframe.html": {
+ type: "sub_frame",
+ depth: 4,
+ parent: "",
+ },
+};
+
+if (AppConstants.platform != "android") {
+ expected["favicon.ico"] = {
+ type: "image",
+ toplevel: true,
+ origin: "file_simple_xhr.html",
+ cached: false,
+ };
+}
+
+function checkDetails(details) {
+ // See bug 1471387
+ if (details.originUrl == "about:newtab") {
+ return;
+ }
+
+ let url = new URL(details.url);
+ let filename = url.pathname.split("/").pop();
+ ok(filename in expected, `Should be expecting a request for ${filename}`);
+ let expect = expected[filename];
+ is(expect.type, details.type, `${details.type} type matches`);
+ if (details.parentFrameId == -1) {
+ is(details.frameAncestors.length, 0, "no ancestors for main_frame requests");
+ } else if (details.parentFrameId == 0) {
+ is(details.frameAncestors.length, 1, "one ancestors for sub_frame requests");
+ } else {
+ ok(details.frameAncestors.length > 1, "have multiple ancestors for deep subframe requests");
+ is(details.frameAncestors.length, expect.depth, "have multiple ancestors for deep subframe requests");
+ }
+ if (details.parentFrameId > -1) {
+ ok(!expect.origin || details.originUrl.includes(expect.origin), "origin url is correct");
+ is(details.frameAncestors[0].frameId, details.parentFrameId, "first ancestor matches request.parentFrameId");
+ ok(details.frameAncestors[0].url.includes(expect.parent), "ancestor parent page correct");
+ is(details.frameAncestors[details.frameAncestors.length - 1].frameId, 0, "last ancestor is always zero");
+ // All our tests should be somewhere within the frame that we set topframe in the query string. That
+ // frame will always be the last ancestor.
+ ok(details.frameAncestors[details.frameAncestors.length - 1].url.includes("topframe=true"), "last ancestor is always topframe");
+ }
+ if (expect.toplevel) {
+ is(details.frameId, 0, "expect load at top level");
+ is(details.parentFrameId, -1, "expect top level frame to have no parent");
+ } else if (details.type == "sub_frame") {
+ ok(details.frameId > 0, "expect sub_frame to load into a new frame");
+ if (expect.toplevelParent) {
+ is(details.parentFrameId, 0, "expect sub_frame to have top level parent");
+ is(details.frameAncestors.length, 1, "one ancestor for top sub_frame request");
+ } else {
+ ok(details.parentFrameId > 0, "expect sub_frame to have parent");
+ ok(details.frameAncestors.length > 1, "sub_frame has ancestors");
+ }
+ expect.subframeId = details.frameId;
+ expect.parentId = details.parentFrameId;
+ } else if (expect.sandboxed) {
+ is(details.documentUrl, undefined, "null principal documentUrl for sandboxed request");
+ } else {
+ // get the parent frame.
+ let purl = new URL(details.documentUrl);
+ let pfilename = purl.pathname.split("/").pop();
+ let parent = expected[pfilename];
+ is(details.frameId, parent.subframeId, "expect load in subframe");
+ is(details.parentFrameId, parent.parentId, "expect subframe parent");
+ }
+}
+
+add_task(async function test_webRequest_main_frame() {
+ // Clear the image cache, since it gets in the way otherwise.
+ let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools);
+ let cache = imgTools.getImgCacheForDocument(document);
+ cache.clearCache(false);
+ function clearCache() {
+ ChromeUtils.import("resource://gre/modules/Services.jsm", {}).Services.cache2.clear();
+ }
+ SpecialPowers.loadChromeScript(clearCache);
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let a = addLink(`file_simple_xhr.html?topframe=true&nocache=${Math.random()}`);
+ a.click();
+
+ for (let i = 0; i < Object.keys(expected).length; i++) {
+ checkDetails(await extension.awaitMessage("onBeforeRequest"));
+ }
+
+ await extension.awaitMessage("tab-created");
+ extension.sendMessage("close-tab");
+ await extension.awaitMessage("tab-closed");
+
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+<div id="test">Sample text</div>
+
+</body>
+</html>
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..51ffc1e4f6
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html
@@ -0,0 +1,223 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <script type="text/javascript" src="head_webrequest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+<script>
+"use strict";
+
+function getExtension() {
+ async function background() {
+ let expect;
+ let urls = ["*://*.example.org/tests/*"];
+ browser.webRequest.onBeforeRequest.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onBeforeRequest");
+ }, {urls}, ["blocking"]);
+ browser.webRequest.onBeforeSendHeaders.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onBeforeSendHeaders");
+ }, {urls}, ["blocking", "requestHeaders"]);
+ browser.webRequest.onSendHeaders.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onSendHeaders");
+ }, {urls}, ["requestHeaders"]);
+
+ async function testSecurityInfo(details, options) {
+ let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, options);
+ browser.test.assertTrue(securityInfo && securityInfo.state == "secure",
+ "security info reflects https");
+
+ if (options.certificateChain) {
+ // Some of the tests here only produce a single cert in the chain.
+ browser.test.assertTrue(securityInfo.certificates.length >= 1, "have certificate chain");
+ } else {
+ browser.test.assertTrue(securityInfo.certificates.length == 1, "no certificate chain");
+ }
+ let cert = securityInfo.certificates[0];
+ let now = Date.now();
+ browser.test.assertTrue(Number.isInteger(cert.validity.start), "cert start is integer");
+ browser.test.assertTrue(Number.isInteger(cert.validity.end), "cert end is integer");
+ browser.test.assertTrue(cert.validity.start < now, "cert start validity is correct");
+ browser.test.assertTrue(now < cert.validity.end, "cert end validity is correct");
+ if (options.rawDER) {
+ for (let cert of securityInfo.certificates) {
+ browser.test.assertTrue(!!cert.rawDER.length, "have rawDER");
+ }
+ }
+ }
+
+ browser.webRequest.onHeadersReceived.addListener(async (details) => {
+ browser.test.assertEq(expect.shift(), "onHeadersReceived");
+
+ // We exepect all requests to have been upgraded at this point.
+ browser.test.assertTrue(details.url.startsWith("https"), "connection is https");
+ await testSecurityInfo(details, {});
+ await testSecurityInfo(details, {certificateChain: true});
+ await testSecurityInfo(details, {rawDER: true});
+ await testSecurityInfo(details, {certificateChain: true, rawDER: true});
+
+ let headers = details.responseHeaders || [];
+ for (let header of headers) {
+ if (header.name.toLowerCase() === "strict-transport-security") {
+ return;
+ }
+ }
+
+ headers.push({
+ name: "Strict-Transport-Security",
+ value: "max-age=31536000000",
+ });
+ return {responseHeaders: headers};
+ }, {urls}, ["blocking", "responseHeaders"]);
+ browser.webRequest.onBeforeRedirect.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onBeforeRedirect");
+ }, {urls});
+ browser.webRequest.onResponseStarted.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onResponseStarted");
+ }, {urls});
+ browser.webRequest.onCompleted.addListener(details => {
+ browser.test.assertEq(expect.shift(), "onCompleted");
+ browser.test.sendMessage("onCompleted", details.url);
+ }, {urls});
+ browser.webRequest.onErrorOccurred.addListener(details => {
+ browser.test.notifyFail(`onErrorOccurred ${JSON.stringify(details)}`);
+ }, {urls});
+
+ async function onUpdated(tabId, tabInfo, tab) {
+ if (tabInfo.status !== "complete" || tab.url === "about:blank") {
+ return;
+ }
+ browser.tabs.remove(tabId);
+ browser.tabs.onUpdated.removeListener(onUpdated);
+ browser.test.sendMessage("tabs-done", tab.url);
+ }
+ browser.test.onMessage.addListener((url, expected) => {
+ expect = expected;
+ browser.tabs.onUpdated.addListener(onUpdated);
+ browser.tabs.create({url});
+ });
+ }
+
+ let manifest = {
+ "permissions": [
+ "tabs",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ };
+ return ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ });
+}
+
+// This test makes a request against a server that redirects with a 302.
+add_task(async function test_hsts_request() {
+ const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest";
+
+ let extension = getExtension();
+ await extension.startup();
+
+ // simple redirect
+ let sample = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html";
+ extension.sendMessage(
+ `https://${testPath}/redirect_auto.sjs?redirect_uri=${sample}`,
+ ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders",
+ "onHeadersReceived", "onBeforeRedirect", "onBeforeRequest",
+ "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived",
+ "onResponseStarted", "onCompleted"]);
+ // redirect_auto adds a query string
+ ok((await extension.awaitMessage("tabs-done")).startsWith(sample), "redirection ok");
+ ok((await extension.awaitMessage("onCompleted")).startsWith(sample), "redirection ok");
+
+ // priming hsts
+ extension.sendMessage(
+ `https://${testPath}/hsts.sjs`,
+ ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders",
+ "onHeadersReceived", "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("tabs-done"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs",
+ "hsts primed");
+ is(await extension.awaitMessage("onCompleted"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs");
+
+ // test upgrade
+ extension.sendMessage(
+ `http://${testPath}/hsts.sjs`,
+ ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest",
+ "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived",
+ "onResponseStarted", "onCompleted"]);
+ is(await extension.awaitMessage("tabs-done"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs",
+ "hsts upgraded");
+ is(await extension.awaitMessage("onCompleted"),
+ "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs");
+
+ await extension.unload();
+});
+
+// This test makes a priming request and adds the STS header, then tests the upgrade.
+add_task(async function test_hsts_header() {
+ const testPath = "test1.example.org/tests/toolkit/components/extensions/test/mochitest";
+
+ let extension = getExtension();
+ await extension.startup();
+
+ // priming hsts, this time there is no STS header, onHeadersReceived adds it.
+ let completed = extension.awaitMessage("onCompleted");
+ let tabdone = extension.awaitMessage("tabs-done");
+ extension.sendMessage(
+ `https://${testPath}/file_sample.html`,
+ ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders",
+ "onHeadersReceived", "onResponseStarted", "onCompleted"]);
+ is(await tabdone, `https://${testPath}/file_sample.html`, "priming request done");
+ is(await completed, `https://${testPath}/file_sample.html`, "priming request done");
+
+ // test upgrade from http to https due to onHeadersReceived adding STS header
+ completed = extension.awaitMessage("onCompleted");
+ tabdone = extension.awaitMessage("tabs-done");
+ extension.sendMessage(
+ `http://${testPath}/file_sample.html`,
+ ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest",
+ "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived",
+ "onResponseStarted", "onCompleted"]);
+ is(await tabdone, `https://${testPath}/file_sample.html`, "hsts upgraded");
+ is(await completed, `https://${testPath}/file_sample.html`, "request upgraded");
+
+ await extension.unload();
+});
+
+add_task(async function test_nonBlocking_securityInfo() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ "permissions": [
+ "webRequest",
+ "<all_urls>",
+ ],
+ },
+ async background() {
+ let tab;
+ browser.webRequest.onHeadersReceived.addListener(async (details) => {
+ let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {});
+ browser.test.assertTrue(!securityInfo, "securityInfo undefined on http request");
+ browser.tabs.remove(tab.id);
+ browser.test.notifyPass("success");
+ }, {urls: ["<all_urls>"], types: ["main_frame"]});
+ tab = await browser.tabs.create({url: "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html"});
+ },
+ });
+ await extension.startup();
+
+ await extension.awaitFinish("success");
+ await extension.unload();
+});
+</script>
+</head>
+<body>
+
+</body>
+</html>
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..457d0508b7
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html
@@ -0,0 +1,70 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Bug 1450965: Skip Cors Check for Early WebExtention Redirects </title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* Description of the test:
+ * We try to Check if a WebExtention can redirect a request and bypass CORS
+ * We're redirecting a fetch request in onBeforeRequest
+ * which should not be blocked, even though we do not have
+ * the CORS information yet.
+ */
+
+const WIN_URL =
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html";
+
+
+add_task(async function test_webRequest_redirect_cors_bypass() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ if (details.url.includes("file_cors_blocked.txt")) {
+ // File_cors_blocked does not need to exist, because we're redirecting anyway.
+ const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest";
+ let redirectUrl = `https://${testPath}/file_sample.txt`;
+
+ // If the WebExtion cant bypass CORS, the fetch will throw a CORS-Exception
+ // because we do not have the CORS header yet for 'file-cors-blocked.txt'
+ return {redirectUrl};
+ }
+ }, {urls: ["<all_urls>"]}, ["blocking"]);
+ },
+
+ });
+
+ await extension.startup();
+ let win = window.open(WIN_URL);
+ // Creating a message channel to the new tab.
+ const channel = new BroadcastChannel("test_bus");
+ await new Promise((resolve, reject) => {
+ channel.onmessage = async function(fetch_result) {
+ // Fetch result data will either be the text content of file_sample.txt -> 'Sample'
+ // or a network-Error.
+ // In case it's 'Sample' the redirect did happen correctly.
+ ok(fetch_result.data == "Sample", "Cors was Bypassed");
+ win.close();
+ await extension.unload();
+ resolve();
+ };
+ });
+});
+
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* Description of the test:
+ * We load a *.js file which gets redirected to a data: URI.
+ * Since there is no good way to communicate loaded data: URI scripts
+ * we use updating a divContainer as a detour to verify the data: URI
+ * script has loaded.
+ */
+
+const WIN_URL =
+ "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html";
+
+add_task(async function test_webRequest_redirect_data_uri() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://mochi.test/tests/*",
+ ],
+ content_scripts: [{
+ matches: ["*://mochi.test/tests/*/file_redirect_data_uri.html"],
+ run_at: "document_end",
+ js: ["content_script.js"],
+ "all_frames": true,
+ }],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ if (details.url.includes("dummy_non_existend_file.js")) {
+ let redirectUrl =
+ "data:text/javascript,document.getElementById('testdiv').textContent='loaded'";
+ return {redirectUrl};
+ }
+ }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]);
+ },
+
+ files: {
+ "content_script.js": function() {
+ let scriptEl = document.createElement("script");
+ // please note that dummy_non_existend_file.js file does not really need
+ // to exist because we redirect the load within onBeforeRequest().
+ scriptEl.src = "dummy_non_existend_file.js";
+ document.body.appendChild(scriptEl);
+
+ scriptEl.onload = function() {
+ let divContent = document.getElementById("testdiv").textContent;
+ browser.test.assertEq(divContent, "loaded",
+ "redirect to data: URI allowed");
+ browser.test.sendMessage("finished");
+ };
+ scriptEl.onerror = function() {
+ browser.test.fail("script load failure");
+ browser.test.sendMessage("finished");
+ };
+ },
+ },
+ });
+
+ await extension.startup();
+ let win = window.open(WIN_URL);
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
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..3d24f5a64d
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html
@@ -0,0 +1,89 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for simple WebExtension</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+add_task(async function test_webRequest_upgrade() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://mochi.test/tests/*",
+ ],
+ },
+ background() {
+ browser.webRequest.onSendHeaders.addListener((details) => {
+ // At this point, the request should have been upgraded.
+ browser.test.assertTrue(details.url.startsWith("https:"), "request is upgraded");
+ browser.test.assertTrue(details.url.includes("file_sample"), "redirect after upgrade worked");
+ browser.test.sendMessage("finished");
+ }, {urls: ["*://mochi.test/tests/*"]});
+
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ browser.test.log(`onBeforeRequest ${details.requestId} ${details.url}`);
+ let url = new URL(details.url);
+ if (url.protocol == "http:") {
+ return {upgradeToSecure: true};
+ }
+ // After the channel is initially upgraded, we get another onBeforeRequest
+ // call. Here we can redirect again to a new url.
+ if (details.url.includes("file_mixed.html")) {
+ let redirectUrl = new URL("file_sample.html", details.url).href;
+ return {redirectUrl};
+ }
+ }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]);
+ },
+ });
+
+ await extension.startup();
+ let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html");
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_redirect_wins() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "*://mochi.test/tests/*",
+ ],
+ },
+ background() {
+ browser.webRequest.onSendHeaders.addListener((details) => {
+ // At this point, the request should have been redirected instead of upgraded.
+ browser.test.assertTrue(details.url.includes("file_sample"), "request was redirected");
+ browser.test.sendMessage("finished");
+ }, {urls: ["*://mochi.test/tests/*"]});
+
+ browser.webRequest.onBeforeRequest.addListener((details) => {
+ if (details.url.includes("file_mixed.html")) {
+ let redirectUrl = new URL("file_sample.html", details.url).href;
+ return {upgradeToSecure: true, redirectUrl};
+ }
+ }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]);
+ },
+ });
+
+ await extension.startup();
+ let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html");
+ await extension.awaitMessage("finished");
+ win.close();
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..f4f2e66955
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html
@@ -0,0 +1,212 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<form method="post"
+ action="file_WebRequest_page3.html?trigger=form"
+ target="_blank"
+ enctype="multipart/form-data"
+ >
+<input type="text" name="&quot;special&quot; ch�rs" value="sp�cial">
+<input type="file" name="testFile">
+<input type="file" name="emptyFile">
+<input type="text" name="textInput1" value="value1">
+</form>
+
+<form method="post"
+ action="file_WebRequest_page3.html?trigger=form"
+ target="_blank"
+ enctype="multipart/form-data"
+ >
+<input type="text" name="textInput2" value="value2">
+<input type="file" name="testFile">
+<input type="file" name="emptyFile">
+</form>
+
+</form>
+<form method="post"
+ action="file_WebRequest_page3.html?trigger=form"
+ target="_blank"
+ >
+<input type="text" name="textInput" value="value1">
+<input type="text" name="textInput" value="value2">
+</form>
+<script>
+"use strict";
+
+let files, testFile, blob, file, uploads;
+add_task(async function test_setup() {
+ files = await new Promise(resolve => {
+ SpecialPowers.createFiles([{name: "testFile.pdf", data: "Not really a PDF file :)", "type": "application/x-pdf"}], (result) => {
+ resolve(result);
+ });
+ });
+ testFile = files[0];
+ blob = {
+ name: "blobAsFile",
+ content: new Blob(["A blob sent as a file"], {type: "text/csv"}),
+ fileName: "blobAsFile.csv",
+ };
+ file = {
+ name: "testFile",
+ fileName: testFile.name,
+ };
+ uploads = {
+ [blob.name]: blob,
+ [file.name]: file,
+ };
+});
+
+function background() {
+ const FILTERS = {urls: ["<all_urls>"]};
+
+ function onUpload(details) {
+ let url = new URL(details.url);
+ let upload = url.searchParams.get("upload");
+ if (!upload) {
+ return;
+ }
+
+ let requestBody = details.requestBody;
+ browser.test.log(`onBeforeRequest upload: ${details.url} ${JSON.stringify(details.requestBody)}`);
+ browser.test.assertTrue(!!requestBody, `Intercepted upload ${details.url} #${details.requestId} ${upload} have a requestBody`);
+ if (!requestBody) {
+ return;
+ }
+ let byteLength = parseInt(upload, 10);
+ if (byteLength) {
+ browser.test.assertTrue(!!requestBody.raw, `Binary upload ${details.url} #${details.requestId} ${upload} have a raw attribute`);
+ browser.test.assertEq(byteLength, requestBody.raw && requestBody.raw.map(r => r.bytes ? r.bytes.byteLength : 0).reduce((a, b) => a + b), `Binary upload size matches`);
+ return;
+ }
+ if ("raw" in requestBody) {
+ browser.test.assertEq(upload, JSON.stringify(requestBody.raw).replace(/(\bfile: ")[^"]+/, "$1<file>"), `Upload ${details.url} #${details.requestId} matches raw data`);
+ } else {
+ browser.test.assertEq(upload, JSON.stringify(requestBody.formData), `Upload ${details.url} #${details.requestId} matches form data.`);
+ }
+ }
+
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${details.requestId} ${details.url}`);
+ // See bug 1471387
+ if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") {
+ return;
+ }
+
+ browser.test.sendMessage("done");
+ },
+ FILTERS);
+
+ let onBeforeRequest = details => {
+ browser.test.log(`${name} ${details.requestId} ${details.url}`);
+ // See bug 1471387
+ if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") {
+ return;
+ }
+
+ onUpload(details);
+ };
+
+ browser.webRequest.onBeforeRequest.addListener(
+ onBeforeRequest, FILTERS, ["requestBody"]);
+
+ let tab;
+ browser.tabs.onCreated.addListener(newTab => {
+ tab = newTab;
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "close-tab") {
+ browser.tabs.remove(tab.id);
+ browser.test.sendMessage("tab-closed");
+ }
+ });
+}
+
+add_task(async function test_xhr_forms() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "tabs",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ async function doneAndTabClosed() {
+ await extension.awaitMessage("done");
+ let closed = extension.awaitMessage("tab-closed");
+ extension.sendMessage("close-tab");
+ await closed;
+ }
+
+ for (let form of document.forms) {
+ if (file.name in form.elements) {
+ SpecialPowers.wrap(form.elements[file.name]).mozSetFileArray(files);
+ }
+ let action = new URL(form.action);
+ let formData = new FormData(form);
+ let webRequestFD = {};
+
+ let updateActionURL = () => {
+ for (let name of formData.keys()) {
+ webRequestFD[name] = name in uploads ? [uploads[name].fileName] : formData.getAll(name);
+ }
+ action.searchParams.set("upload", JSON.stringify(webRequestFD));
+ action.searchParams.set("enctype", form.enctype);
+ };
+
+ updateActionURL();
+
+ form.action = action;
+ form.submit();
+ await doneAndTabClosed();
+
+ if (form.enctype !== "multipart/form-data") {
+ continue;
+ }
+
+ let post = (data) => {
+ let xhr = new XMLHttpRequest();
+ action.searchParams.set("xhr", "1");
+ xhr.open("POST", action.href);
+ xhr.send(data);
+ action.searchParams.delete("xhr");
+ return doneAndTabClosed();
+ };
+
+ formData.append(blob.name, blob.content, blob.fileName);
+ formData.append("formDataField", "some value");
+ updateActionURL();
+ await post(formData);
+
+ action.searchParams.set("upload", JSON.stringify([{file: "<file>"}]));
+ await post(testFile);
+
+ action.searchParams.set("upload", `${blob.content.size} bytes`);
+ await post(blob.content);
+
+ let byteLength = 16;
+ action.searchParams.set("upload", `${byteLength} bytes`);
+ await post(new ArrayBuffer(byteLength));
+ }
+
+ await extension.unload();
+});
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for content script</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_postMessage() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ "matches": ["http://mochi.test/*/file_sample.html"],
+ "js": ["content_script.js"],
+ "run_at": "document_start",
+ "all_frames": true,
+ },
+ ],
+
+ web_accessible_resources: ["iframe.html"],
+ },
+
+ background() {
+ browser.test.sendMessage("iframe-url", browser.runtime.getURL("iframe.html"));
+ },
+
+ files: {
+ "content_script.js": function() {
+ window.addEventListener("message", event => {
+ if (event.data == "ping") {
+ event.source.postMessage({pong: location.href},
+ event.origin);
+ }
+ });
+ },
+
+ "iframe.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="content_script.js"><\/script>
+ </head>
+ </html>`,
+ },
+ };
+
+ let createIframe = url => {
+ let iframe = document.createElement("iframe");
+ return new Promise(resolve => {
+ iframe.src = url;
+ iframe.onload = resolve;
+ document.body.appendChild(iframe);
+ }).then(() => {
+ return iframe;
+ });
+ };
+
+ let awaitMessage = () => {
+ return new Promise(resolve => {
+ let listener = event => {
+ if (event.data.pong) {
+ window.removeEventListener("message", listener);
+ resolve(event.data);
+ }
+ };
+ window.addEventListener("message", listener);
+ });
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let iframeURL = await extension.awaitMessage("iframe-url");
+ let testURL = SimpleTest.getTestFileURL("file_sample.html");
+
+ for (let url of [iframeURL, testURL]) {
+ info(`Testing URL ${url}`);
+
+ let iframe = await createIframe(url);
+
+ iframe.contentWindow.postMessage(
+ "ping", url);
+
+ let pong = await awaitMessage();
+ is(pong.pong, url, "Got expected pong");
+
+ iframe.remove();
+ }
+
+ await extension.unload();
+});
+</script>
+
+</body>
+</html>
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..6f46fa8eea
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html
@@ -0,0 +1,31 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Verify non-remote mode</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+add_task(async function verify_extensions_in_parent_process() {
+ // This test ensures we are running with the proper settings.
+ const { WebExtensionPolicy } = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services);
+ SimpleTest.ok(!WebExtensionPolicy.useRemoteWebExtensions, "extensions running in-process");
+
+ let chromeScript = SpecialPowers.loadChromeScript(() => {
+ const { WebExtensionPolicy } = Cu.getGlobalForObject(Services);
+ Assert.ok(WebExtensionPolicy.isExtensionProcess, "parent is extension process");
+ this.sendAsyncMessage("checks_done");
+ });
+ await chromeScript.promiseOneMessage("checks_done");
+ chromeScript.destroy();
+});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Verify remote mode</title>
+ <meta charset="utf-8">
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <script type="text/javascript" src="head.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+ "use strict";
+ // This test ensures we are running with the proper settings.
+ const {WebExtensionPolicy} = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services);
+ SimpleTest.ok(WebExtensionPolicy.useRemoteWebExtensions, "extensions running remote");
+ SimpleTest.ok(!WebExtensionPolicy.isExtensionProcess, "testing from remote process");
+</script>
+
+</body>
+</html>
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..6a44fcac2e
--- /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.jsm b/toolkit/components/extensions/test/mochitest/webrequest_test.jsm
new file mode 100644
index 0000000000..6fc2fe3d7f
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/webrequest_test.jsm
@@ -0,0 +1,22 @@
+"use strict";
+
+var EXPORTED_SYMBOLS = ["webrequest_test"];
+
+Cu.importGlobalProperties(["fetch"]);
+
+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..3622fff4f6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,9 @@
+"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,
+ },
+};
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 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<p>Page</p>
+</body>
+</html>
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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<link rel="stylesheet" href="file_style_good.css">
+<link rel="stylesheet" href="file_style_bad.css">
+<link rel="stylesheet" href="file_style_redirect.css">
+</head>
+<body>
+
+<div class="test">Sample text</div>
+
+<img id="img_good" src="file_image_good.png">
+<img id="img_bad" src="file_image_bad.png">
+<img id="img_redirect" src="file_image_redirect.png">
+
+<script src="file_script_good.js"></script>
+<script src="file_script_bad.js"></script>
+<script src="file_script_redirect.js"></script>
+
+<script src="nonexistent_script_url.js"></script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script src="http://example.org/data/file_WebRequest_permission_original.js"></script>
+<script>
+"use strict";
+
+window.parent.postMessage({
+ page: "original",
+ script: window.testScript,
+}, "*");
+</script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script src="http://example.org/data/file_WebRequest_permission_original.js"></script>
+<script>
+"use strict";
+
+window.parent.postMessage({
+ page: "redirected",
+ script: window.testScript,
+}, "*");
+</script>
+</body>
+</html>
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_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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+<img id="bad-image" src="http://example.org/data/file_image_bad.png">
+<script id="bad-script" src="http://example.org/data/file_script_bad.js"></script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<script src="http://example.net/intercept_by_webRequest.js"></script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+ <iframe id="iframe"></iframe>
+
+ <script type="text/javascript">
+ "use strict";
+ addEventListener("load", () => {
+ let iframe = document.getElementById("iframe");
+ let doc = iframe.contentDocument;
+ doc.open("text/html");
+ doc.write("Hello.");
+ doc.close();
+ }, {once: true});
+ </script>
+</body>
+</html>
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..fbae3d6d76
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html
@@ -0,0 +1,35 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+ <iframe id="iframe"></iframe>
+
+ <script type="text/javascript">
+ "use strict";
+ addEventListener("load", () => {
+ // Send a heap-minimize observer notification so our script cache is
+ // cleared, and our content script isn't available for synchronous
+ // insertion.
+ window.dispatchEvent(new CustomEvent("MozHeapMinimize"));
+
+ let iframe = document.getElementById("iframe");
+ let doc = iframe.contentDocument;
+ doc.open("text/html");
+ // We need to do two writes here. The first creates the document element,
+ // which normally triggers parser blocking. The second triggers the
+ // creation of the element we're about to query for, which would normally
+ // happen asynchronously if the parser were blocked.
+ doc.write("<div id=meh>");
+ doc.write("<div id=beer></div>");
+
+ let elem = doc.getElementById("beer");
+ top.postMessage(elem instanceof HTMLDivElement ? "ok" : "fail",
+ "*");
+
+ doc.close();
+ }, {once: true});
+ </script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div>Download HTML File</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Iframe document</title>
+</head>
+<body>
+</body>
+</html>
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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png
Binary files 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 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+addEventListener("message", async function(event) {
+ const url = new URL("/return_headers.sjs", location).href;
+
+ const webpageFetchResult = await fetch(url).then(res => res.json());
+ const webpageXhrResult = await new Promise(resolve => {
+ const req = new XMLHttpRequest();
+ req.open("GET", url);
+ req.addEventListener("load", () => resolve(JSON.parse(req.responseText)),
+ {once: true});
+ req.addEventListener("error", () => resolve({error: "webpage xhr failed to complete"}),
+ {once: true});
+ req.send();
+ });
+
+ postMessage({
+ type: "testPageGlobals",
+ webpageFetchResult,
+ webpageXhrResult,
+ }, "*");
+}, {once: true});
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<script>
+"use strict";
+
+/* globals privilegedFetch, privilegedXHR */
+/* eslint-disable mozilla/balanced-listeners */
+
+addEventListener("message", function rcv(event) {
+ removeEventListener("message", rcv, false);
+
+ function assertTrue(condition, description) {
+ postMessage({msg: "assertTrue", condition, description}, "*");
+ }
+
+ function assertThrows(func, expectedError, msg) {
+ try {
+ func();
+ } catch (e) {
+ assertTrue(expectedError.test(e), msg + ": threw " + e);
+ return;
+ }
+
+ assertTrue(false, "Function did not throw, " +
+ "expected error should have matched " + expectedError);
+ }
+
+ function passListener() {
+ assertTrue(true, "Content XHR has no elevated privileges");
+ postMessage({"msg": "finish"}, "*");
+ }
+
+ function failListener() {
+ assertTrue(false, "Content XHR has no elevated privileges");
+ postMessage({"msg": "finish"}, "*");
+ }
+
+ assertThrows(function() { new privilegedXHR(); },
+ /Permission denied to access object/,
+ "Content should not be allowed to construct a privileged XHR constructor");
+
+ assertThrows(function() { new privilegedFetch(); },
+ / is not a constructor/,
+ "Content should not be allowed to construct a privileged fetch() constructor");
+
+ let req = new XMLHttpRequest();
+ req.addEventListener("load", failListener);
+ req.addEventListener("error", passListener);
+ req.open("GET", "http://example.org/example.txt");
+ req.send();
+}, false);
+</script>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+ <script type="text/javascript">
+ "use strict";
+ throw new Error(`WebExt Privilege Escalation: typeof(browser) = ${typeof(browser)}`);
+ </script>
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="test">Sample text</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+
+<div id="registered-extension-url-style">Registered Extension URL style</div>
+<div id="registered-extension-text-style">Registered Extension Text style</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+
+<html>
+<head>
+<meta charset="utf-8">
+<script type="application/javascript" src="file_script_good.js"></script>
+<script type="application/javascript" src="file_script_bad.js"></script>
+</head>
+<body>
+
+<div id="test">Sample text</div>
+
+</body>
+</html>
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 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<div id="host">host</div>
+<script>
+ "use strict";
+ document.getElementById("host").attachShadow({mode: "closed"});
+</script>
+</body>
+</html>
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 @@
+<!doctype html>
+<meta charset=utf-8>
+<link rel=stylesheet href=file_stylesheet_cache.css>
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 @@
+<!doctype html>
+<meta charset=utf-8>
+<!-- The first one should hit the cache, the second one should not. -->
+<link rel=stylesheet href=file_stylesheet_cache.css>
+<script>
+ "use strict";
+ // This script guarantees that the load of the above stylesheet has happened
+ // by now.
+ //
+ // Now we can go ahead and load the other one programmatically. It's
+ // important that we don't just throw a <link> in the markup below to
+ // guarantee
+ // that the load happens afterwards (that is, to cheat the parser's speculative
+ // load mechanism).
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "file_stylesheet_cache.css?2";
+ document.head.appendChild(link);
+</script>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Top-level frame document</title>
+</head>
+<body>
+ <iframe src="file_iframe.html"></iframe>
+ <iframe src="about:blank"></iframe>
+ <iframe srcdoc="Iframe srcdoc"></iframe>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Document with example.org frame</title>
+</head>
+<body>
+ <iframe src="http://example.org/data/file_iframe.html"></iframe>
+</body>
+</html>
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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif
Binary files 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
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif
Binary files 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..4608f77bd6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head.js
@@ -0,0 +1,277 @@
+"use strict";
+
+/* exported createHttpServer, cleanupDir, clearCache, promiseConsoleOutput,
+ promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear,
+ runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput */
+
+var { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+var { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+var {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+} = ChromeUtils.import("resource://gre/modules/Timer.jsm");
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.import(
+ "resource://testing-common/AddonTestUtils.jsm"
+);
+
+// eslint-disable-next-line no-unused-vars
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ContentTask: "resource://testing-common/ContentTask.jsm",
+ Extension: "resource://gre/modules/Extension.jsm",
+ ExtensionData: "resource://gre/modules/Extension.jsm",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
+ ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm",
+ FileUtils: "resource://gre/modules/FileUtils.jsm",
+ MessageChannel: "resource://gre/modules/MessageChannel.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+ PromiseTestUtils: "resource://testing-common/PromiseTestUtils.jsm",
+ Schemas: "resource://gre/modules/Schemas.jsm",
+});
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+// These values may be changed in later head files and tested in check_remote
+// below.
+Services.prefs.setBoolPref("browser.tabs.remote.autostart", false);
+Services.prefs.setBoolPref("extensions.webextensions.remote", false);
+const testEnv = {
+ expectRemote: false,
+};
+
+add_task(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);
+};
+
+if (AppConstants.platform === "android") {
+ Services.io.offline = true;
+}
+
+/**
+ * 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.import(
+ "resource://gre/modules/MessageChannel.jsm"
+ );
+
+ 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;
+ });
+}
diff --git a/toolkit/components/extensions/test/xpcshell/head_e10s.js b/toolkit/components/extensions/test/xpcshell/head_e10s.js
new file mode 100644
index 0000000000..196afae7c9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_e10s.js
@@ -0,0 +1,8 @@
+"use strict";
+
+/* globals ExtensionTestUtils */
+
+// xpcshell disables e10s by default. Turn it on.
+Services.prefs.setBoolPref("browser.tabs.remote.autostart", true);
+
+ExtensionTestUtils.remoteContentScripts = true;
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..01f16ec54c
--- /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.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+ );
+
+ 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..e0b977c22c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js
@@ -0,0 +1,153 @@
+/* -*- 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.defineModuleGetter(
+ this,
+ "MockRegistry",
+ "resource://testing-common/MockRegistry.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+let { Subprocess, SubprocessImpl } = ChromeUtils.import(
+ "resource://gre/modules/Subprocess.jsm",
+ null
+);
+
+// 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";
+OS.File.makeDir(OS.Path.join(tmpDir.path, TYPE_SLUG));
+
+registerCleanupFunction(() => {
+ tmpDir.remove(true);
+});
+
+function getPath(filename) {
+ return OS.Path.join(tmpDir.path, TYPE_SLUG, filename);
+}
+
+const ID = "native@tests.mozilla.org";
+
+async function setupHosts(scripts) {
+ const PERMS = { unixMode: 0o755 };
+
+ const env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ const pythonPath = await Subprocess.pathSearch(env.get("PYTHON"));
+
+ async function writeManifest(script, scriptPath, path) {
+ let body = `#!${pythonPath} -u\n${script.script}`;
+
+ await OS.File.writeAtomic(scriptPath, body);
+ await OS.File.setPermissions(scriptPath, PERMS);
+
+ let manifest = {
+ name: script.name,
+ description: script.description,
+ path,
+ type: "stdio",
+ allowed_extensions: [ID],
+ };
+
+ let manifestPath = getPath(`${script.name}.json`);
+ await OS.File.writeAtomic(manifestPath, JSON.stringify(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 OS.File.writeAtomic(batPath, batBody);
+
+ // Create absolute and relative path versions of the entry.
+ for (let [name, path] of [
+ [script.name, batPath],
+ [`relative.${script.name}`, OS.Path.basename(batPath)],
+ ]) {
+ script.name = name;
+ let manifestPath = await writeManifest(script, scriptPath, path);
+
+ 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_storage.js b/toolkit/components/extensions/test/xpcshell/head_storage.js
new file mode 100644
index 0000000000..09a5b45b0e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_storage.js
@@ -0,0 +1,1227 @@
+/* -*- 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);
+}
+
+function test_sync_reloading_extensions_works() {
+ async function testFn() {
+ // Just some random extension ID that we can re-use
+ const extensionId = "my-extension-id@1";
+
+ function loadExtension() {
+ function background() {
+ browser.storage.sync.set({ a: "b" }).then(() => {
+ browser.test.notifyPass("set-works");
+ });
+ }
+
+ return ExtensionTestUtils.loadExtension(
+ {
+ manifest: {
+ permissions: ["storage"],
+ },
+ background: `(${background})()`,
+ },
+ extensionId
+ );
+ }
+
+ ok(
+ Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false),
+ "The `${STORAGE_SYNC_PREF}` should be set to true"
+ );
+
+ let extension1 = loadExtension();
+
+ await extension1.startup();
+ await extension1.awaitFinish("set-works");
+ await extension1.unload();
+
+ let extension2 = loadExtension();
+
+ await extension2.startup();
+ await extension2.awaitFinish("set-works");
+ await extension2.unload();
+ }
+
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn);
+}
+
+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") {
+ browser.test.assertEq(
+ String(date),
+ String(obj.date),
+ "date part correct"
+ );
+ browser.test.assertEq(
+ "/regexp/",
+ obj.regexp.toString(),
+ "regexp part correct"
+ );
+ // storage.local doesn't call 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");
+ }
+ 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 is not available 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"],
+ applications: { 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 {
+ 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"],
+ applications: { 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");
+ }
+ 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();
+}
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..691743c696
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_sync.js
@@ -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/. */
+
+"use strict";
+
+/* exported withSyncContext */
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm", this);
+
+class KintoExtContext extends ExtensionCommon.BaseContext {
+ constructor(principal) {
+ super();
+ Object.defineProperty(this, "principal", {
+ value: principal,
+ configurable: true,
+ });
+ this.sandbox = Cu.Sandbox(principal, { wantXrays: false });
+ this.extension = { id: "test@web.extension" };
+ }
+
+ 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..6492d8f995
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.js
@@ -0,0 +1,110 @@
+/* -*- 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 */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ContentTaskUtils",
+ "resource://testing-common/ContentTaskUtils.jsm"
+);
+
+const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote");
+
+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 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}.`
+ );
+}
diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.ini b/toolkit/components/extensions/test/xpcshell/native_messaging.ini
new file mode 100644
index 0000000000..b64cda83c8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/native_messaging.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+head = head.js head_e10s.js head_native_messaging.js
+tail =
+firefox-appdir = browser
+skip-if = appname == "thunderbird" || os == "android"
+subprocess = true
+support-files =
+ data/**
+tags = webextensions
+
+[test_ext_native_messaging.js]
+skip-if = (os == "win" && processor == "aarch64") # bug 1530841
+[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_ExtensionStorageSync_migration_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js
new file mode 100644
index 0000000000..ad763cb321
--- /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.import(
+ "resource://gre/modules/ExtensionStorageSync.jsm"
+);
+const { extensionStorageSync: kintoImpl } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageSyncKinto.jsm"
+);
+
+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..c0aa4254ad
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js
@@ -0,0 +1,552 @@
+/* -*- 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_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: "<all_urls>" });
+ pass({ url: "https://mozilla.org/a", pattern: "<all_urls>" });
+ pass({ url: "ftp://mozilla.org/a", pattern: "<all_urls>" });
+ pass({ url: "file:///a", pattern: "<all_urls>" });
+ fail({ url: "gopher://wuarchive.wustl.edu/a", pattern: "<all_urls>" });
+
+ // 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 },
+ });
+ 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 },
+ });
+
+ // 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: "<all_urls>", filter: "ftp://ab.cd/" });
+ fail({ hosts: "<all_urls>" });
+
+ // 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_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: ["<all_urls>"], newPat: "*://*/*" });
+ pass({ oldPat: ["<all_urls>"], newPat: "http://*/*" });
+ pass({ oldPat: ["<all_urls>"], 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: "<all_urls>" });
+ 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/*" });
+});
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..8b627a0ee9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js
@@ -0,0 +1,286 @@
+/* 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,
+ cleartext: JSON.stringify({
+ id: "guidAAA",
+ extId: "ext-2",
+ data: JSON.stringify({
+ c: 1234,
+ }),
+ }),
+ },
+ {
+ id: "guidBBB",
+ modified: 0.1,
+ cleartext: JSON.stringify({
+ id: "guidBBB",
+ 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.
+ let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply);
+ let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json));
+ let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.cleartext));
+ 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");
+ equal(
+ ext1Guid,
+ parsedCleartexts[ext1Index].id,
+ "ext-1 ID in envelope should match cleartext"
+ );
+ deepEqual(
+ parsedData[ext1Index],
+ {
+ a: "abc",
+ },
+ "Should upload new data for ext-1"
+ );
+ equal(
+ outgoingEnvelopes[ext2Index].id,
+ parsedCleartexts[ext2Index].id,
+ "ext-2 ID in envelope should match cleartext"
+ );
+ 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..78d61d4b29
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js
@@ -0,0 +1,209 @@
+/* -*- 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"));
+
+let policy = new WebExtensionPolicy({
+ id: "foo@bar.baz",
+ mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2",
+ baseURL: "file:///foo",
+
+ allowedOrigins: new MatchPatternSet([]),
+ localizeCallback() {},
+});
+
+add_task(async function test_WebExtensinonContentScript_url_matching() {
+ let contentScript = new WebExtensionContentScript(policy, {
+ 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)),
+ });
+
+ ok(
+ contentScript.matchesURI(newURI("http://foo.com/bar")),
+ "Simple matches include should match"
+ );
+
+ ok(
+ contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")),
+ "Simple matches include should 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"
+ );
+});
+
+async function loadURL(url) {
+ let requests = new Map();
+
+ function requestObserver(request) {
+ request.QueryInterface(Ci.nsIChannel);
+ if (request.isDocument) {
+ requests.set(request.name, request);
+ }
+ }
+
+ Services.obs.addObserver(requestObserver, "http-on-examine-response");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ Services.obs.removeObserver(requestObserver, "http-on-examine-response");
+
+ return { contentPage, requests };
+}
+
+add_task(async function test_WebExtensinonContentScript_frame_matching() {
+ 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, requests } = await loadURL(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 }, args => {
+ 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);
+ }
+ this.policy = new WebExtensionPolicy({
+ id: "foo@bar.baz",
+ mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2",
+ baseURL: "file:///foo",
+
+ allowedOrigins: new MatchPatternSet([]),
+ 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],
+ `Script ${i} ${should} match the ${frame} frame`
+ );
+ }
+ }
+ });
+
+ // Parent tests against loadInfo
+ tests = tests.map(t => {
+ t.contentScript.matches = new MatchPatternSet(t.matches);
+ t.script = new WebExtensionContentScript(policy, t.contentScript);
+ return t;
+ });
+
+ for (let [i, test] of tests.entries()) {
+ for (let [frame, url] of Object.entries(urls)) {
+ let should = test[frame] ? "should" : "should not";
+
+ if (url.startsWith("http")) {
+ let request = requests.get(url);
+
+ equal(
+ test.script.matchesLoadInfo(request.URI, request.loadInfo),
+ test[frame],
+ `Script ${i} ${should} match the request LoadInfo for ${frame} frame`
+ );
+ }
+ }
+ }
+
+ await contentPage.close();
+});
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..75c6edd9c4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js
@@ -0,0 +1,376 @@
+/* -*- 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: ["<all_urls>"],
+ webAccessibleResources: ["/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,
+ ["<all_urls>"],
+ "Initial permissions should be correct"
+ );
+
+ ok(
+ policy.hasPermission("<all_urls>"),
+ "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("<all_urls>"),
+ "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.isPathWebAccessible("/foo/bar"),
+ "Web-accessible glob should be web-accessible"
+ );
+ ok(
+ policy.isPathWebAccessible("/bar.baz"),
+ "Web-accessible path should be web-accessible"
+ );
+ ok(
+ !policy.isPathWebAccessible("/bar.baz/quux"),
+ "Non-web-accessible path should not be web-accessible"
+ );
+
+ // Localization
+
+ equal(
+ policy.localize("foo"),
+ "<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;
+ }
+});
+
+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: ["<all_urls>"],
+ });
+
+ let policy2 = new WebExtensionPolicy({
+ id: id2,
+ mozExtensionHostname: uuid2,
+ baseURL,
+ localizeCallback() {},
+ allowedOrigins: new MatchPatternSet([]),
+ permissions: ["<all_urls>"],
+ });
+
+ 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"
+ );
+});
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..0b24cc4c50
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js
@@ -0,0 +1,278 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+
+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; object-src: 'none'";
+
+ 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.defaultCSP,
+ },
+ {
+ 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'; object-src 'none'; 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: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://*; object-src 'self'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v2 allows unsafe-eval",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' 'unsafe-eval'; object-src 'self'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v3 invalid csp results in default csp used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'none'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v3 forbidden protocol results in default csp used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://*; object-src 'self'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v3 forbidden eval results in default csp used",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' 'unsafe-eval'; object-src 'self'`,
+ },
+ },
+ expectedPolicy: aps.defaultCSP,
+ },
+ {
+ name: "manifest_v3 allows localhost",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://localhost; object-src 'self'`,
+ },
+ },
+ expectedPolicy: `script-src 'self' https://localhost; object-src 'self'`,
+ },
+ {
+ name: "manifest_v3 allows 127.0.0.1",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: `script-src 'self' https://127.0.0.1; object-src 'self'`,
+ },
+ },
+ expectedPolicy: `script-src 'self' https://127.0.0.1; object-src 'self'`,
+ },
+ {
+ 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.defaultCSP,
+ },
+ {
+ name: "manifest_v3 used with v2 syntax",
+ manifest: {
+ manifest_version: 3,
+ content_security_policy: extension_pages,
+ },
+ expectedPolicy: extension_pages,
+ },
+ {
+ 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..fb494f3da2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js
@@ -0,0 +1,298 @@
+/* -*- 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' 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'; object-src 'self';", 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'",
+ null,
+ "A valid default-src should count as a valid script-src or object-src"
+ );
+
+ checkPolicy(
+ "default-src 'self'; script-src 'self'",
+ null,
+ "A valid default-src should count as a valid script-src or object-src"
+ );
+
+ checkPolicy(
+ "default-src 'self'; object-src 'self'",
+ null,
+ "A valid default-src should count as a valid script-src or object-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(
+ "default-src 'self'; object-src http://example.com",
+ "\u2018object-src\u2019 directive contains a forbidden http: protocol source",
+ "A valid default-src should not allow an invalid object-src directive"
+ );
+
+ checkPolicy(
+ "script-src 'self';",
+ "Policy is missing a required \u2018object-src\u2019 directive"
+ );
+
+ checkPolicy(
+ "script-src 'none'; object-src 'none'",
+ "\u2018script-src\u2019 must include the source 'self'"
+ );
+
+ checkPolicy("script-src 'self'; object-src 'none';", null);
+
+ checkPolicy(
+ "script-src 'self' 'unsafe-inline'; object-src 'self';",
+ "\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}; object-src 'none';`, null);
+ }
+
+ let directives = ["script-src", "object-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}`);
+
+ let result = cps.validateAddonCSP(
+ policy,
+ Ci.nsIAddonContentPolicy.CSP_ALLOW_LOCALHOST
+ );
+ equal(result, expectedResult);
+ };
+
+ checkPolicy("script-src 'self'; object-src 'self';", null);
+ checkPolicy("script-src 'self'; object-src 'self'; worker-src 'none'", null);
+ checkPolicy("script-src 'self'; object-src 'none'; worker-src 'self'", null);
+
+ let hash =
+ "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='";
+
+ checkPolicy(
+ `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; ` +
+ `object-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}`,
+ null
+ );
+
+ for (let policy of ["", "object-src 'none';", "worker-src 'none';"]) {
+ checkPolicy(
+ policy,
+ "Policy is missing a required \u2018script-src\u2019 directive"
+ );
+ }
+
+ checkPolicy(
+ "default-src 'self'",
+ null,
+ "A valid default-src should count as a valid script-src or object-src"
+ );
+
+ for (let directive of ["script-src", "object-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 'self';",
+ "Policy is missing a required \u2018object-src\u2019 directive"
+ );
+
+ checkPolicy(
+ "script-src 'none'; object-src 'none'",
+ "\u2018script-src\u2019 must include the source 'self'"
+ );
+
+ checkPolicy("script-src 'self'; object-src 'none';", null);
+
+ checkPolicy(
+ "script-src 'self' 'unsafe-inline'; object-src 'self';",
+ "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword"
+ );
+
+ checkPolicy(
+ "script-src 'self' 'unsafe-eval'; object-src 'self';",
+ "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' 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}; object-src 'none';`, null);
+ }
+
+ let directives = ["script-src", "object-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..20ffb71d18
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js
@@ -0,0 +1,80 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { MessageManagerProxy } = ChromeUtils.import(
+ "resource://gre/modules/MessageManagerProxy.jsm"
+);
+const { PromiseUtils } = ChromeUtils.import(
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+
+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 = PromiseUtils.defer();
+ 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..4a23b65264
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js
@@ -0,0 +1,21 @@
+"use strict";
+
+add_task(async function test_api_restricted() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: { id: "activityLog-permission@tests.mozilla.org" },
+ },
+ permissions: ["activityLog"],
+ },
+ async background() {
+ browser.test.assertEq(
+ undefined,
+ browser.activityLog,
+ "activityLog is privileged"
+ );
+ },
+ });
+ await extension.startup();
+ await extension.unload();
+});
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..bc4e0409cb
--- /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),
+ /Trying to read undeclared field/,
+ "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),
+ /Trying to read undeclared field/,
+ "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),
+ /Trying to read undeclared field/,
+ "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),
+ /Trying to read undeclared field/,
+ "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),
+ /Trying to read undeclared field/,
+ "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),
+ /Trying to read undeclared field/,
+ "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..0751f7d573
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js
@@ -0,0 +1,219 @@
+/* -*- 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_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();
+});
+
+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();
+});
+
+async function test_alarm_fires_with_options(alarmCreateOptions) {
+ 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.notifyPass("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.notifyFail("alarms-create-with-options");
+ }, 10000);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ // Pass the alarms.create options to the background page.
+ background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`,
+ manifest: {
+ permissions: ["alarms"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("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"
+ );
+});
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_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
new file mode 100644
index 0000000000..4be29dc848
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js
@@ -0,0 +1,76 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+let { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+);
+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_background_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js
new file mode 100644
index 0000000000..a603b03a29
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js
@@ -0,0 +1,35 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function testBackgroundWindow() {
+ 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();
+});
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..ec9d9a6c43
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js
@@ -0,0 +1,195 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { BrowserTestUtils } = ChromeUtils.import(
+ "resource://testing-common/BrowserTestUtils.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let {
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+);
+
+let { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+);
+
+// Crashes a <browser>'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("<browser> must be remote");
+ }
+
+ // Trigger crash by sending a message to BrowserTestUtils actor.
+ BrowserTestUtils.sendAsyncMessage(
+ browser.browsingContext,
+ "BrowserTestUtils:CrashFrame",
+ {}
+ );
+}
+
+// Verifies that a delayed background page is not loaded when an extension is
+// shut down during startup.
+add_task(async function test_unload_extension_before_background_page_startup() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ browser.test.sendMessage("background_startup_observed");
+ },
+ });
+
+ // Delayed startup are only enabled for browser (re)starts, so we need to
+ // install the extension first, and then unload it.
+
+ await extension.startup();
+ await extension.awaitMessage("background_startup_observed");
+
+ // Now the actual test: Unloading an extension before the startup has
+ // finished should interrupt the start-up and abort pending delayed loads.
+ info("Starting extension whose startup will be interrupted");
+ ExtensionParent._resetStartupPromises();
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let extensionBrowserInsertions = 0;
+ let onExtensionBrowserInserted = () => ++extensionBrowserInsertions;
+ Management.on("extension-browser-inserted", onExtensionBrowserInserted);
+
+ info("Unloading extension before the delayed background page starts loading");
+ await extension.addon.disable();
+
+ // Re-enable the add-on to let enough time pass to load a whole background
+ // page. If at the end of this the original background page hasn't loaded,
+ // we can consider the test successful.
+ await extension.addon.enable();
+
+ // Trigger the notification that would load a background page.
+ info("Forcing pending delayed background page to load");
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+
+ // This is the expected message from the re-enabled add-on.
+ await extension.awaitMessage("background_startup_observed");
+ await extension.unload();
+
+ await promiseShutdownManager();
+ ExtensionParent._resetStartupPromises();
+
+ Management.off("extension-browser-inserted", onExtensionBrowserInserted);
+ Assert.equal(
+ extensionBrowserInsertions,
+ 1,
+ "Extension browser should have been inserted only once"
+ );
+});
+
+// Verifies that the "build" method of BackgroundPage in ext-backgroundPage.js
+// does not deadlock when startup is interrupted by extension shutdown.
+add_task(async function test_unload_extension_during_background_page_startup() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ browser.test.sendMessage("background_starting");
+ },
+ });
+
+ // Delayed startup are only enabled for browser (re)starts, so we need to
+ // install the extension first, and then reload it.
+ await extension.startup();
+ await extension.awaitMessage("background_starting");
+
+ ExtensionParent._resetStartupPromises();
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let bgStartupPromise = new Promise(resolve => {
+ function onBackgroundPageDone(eventName) {
+ extension.extension.off("background-page-started", onBackgroundPageDone);
+ extension.extension.off("background-page-aborted", onBackgroundPageDone);
+
+ if (eventName === "background-page-aborted") {
+ info("Background page startup was interrupted");
+ resolve("bg_aborted");
+ } else {
+ info("Background page startup finished normally");
+ resolve("bg_fully_loaded");
+ }
+ }
+ extension.extension.on("background-page-started", onBackgroundPageDone);
+ extension.extension.on("background-page-aborted", onBackgroundPageDone);
+ });
+
+ let bgStartingPromise = new Promise(resolve => {
+ let backgroundLoadCount = 0;
+ let backgroundPageUrl = extension.extension.baseURI.resolve(
+ "_generated_background_page.html"
+ );
+
+ // Prevent the background page from actually loading.
+ Management.once("extension-browser-inserted", (eventName, browser) => {
+ // Intercept background page load.
+ let browserLoadURI = browser.loadURI;
+ browser.loadURI = function() {
+ Assert.equal(++backgroundLoadCount, 1, "loadURI should be called once");
+ Assert.equal(
+ arguments[0],
+ backgroundPageUrl,
+ "Expected background page"
+ );
+ // Reset to "about:blank" to not load the actual background page.
+ arguments[0] = "about:blank";
+ browserLoadURI.apply(this, arguments);
+
+ // And force the extension process to crash.
+ if (browser.isRemote) {
+ crashFrame(browser);
+ } 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 <browser> instead, to simulate the immediate disconnection
+ // of the message manager (that would happen if the process crashed).
+ browser.remove();
+ }
+ resolve();
+ };
+ });
+ });
+
+ // Force background page to initialize.
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ await bgStartingPromise;
+
+ await extension.unload();
+ await promiseShutdownManager();
+
+ // This part is the regression test for bug 1501375. It verifies that the
+ // background building completes eventually.
+ // If it does not, then the next line will cause a timeout.
+ info("Waiting for background builder to finish");
+ let bgLoadState = await bgStartupPromise;
+ Assert.equal(bgLoadState, "bg_aborted", "Startup should be interrupted");
+
+ ExtensionParent._resetStartupPromises();
+});
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..eaf20827e5
--- /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.import(
+ "resource://testing-common/PlacesTestUtils.jsm"
+);
+
+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_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js
new file mode 100644
index 0000000000..5075e643be
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.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 test_background_incognito() {
+ info(
+ "Test background page incognito value with permanent private browsing enabled"
+ );
+
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+ });
+
+ 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": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`,
+ },
+};
+
+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_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..013a68726c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js
@@ -0,0 +1,99 @@
+"use strict";
+
+add_task(async function test_background_reload_and_unload() {
+ let events = [];
+ {
+ let { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+ );
+ 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..8ca76ea3c2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js
@@ -0,0 +1,104 @@
+/* -*- 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() {
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("loaded");
+ },
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("loaded");
+ },
+ });
+
+ clearHistograms();
+
+ assertHistogramEmpty(HISTOGRAM);
+ assertKeyedHistogramEmpty(HISTOGRAM_KEYED);
+
+ 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}`
+ );
+
+ 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}`
+ );
+
+ await extension1.unload();
+ await extension2.unload();
+});
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": `<html><head>
+ <script src="missing.js"></script>
+ <script src="page.js"></script>
+ </head></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..ed4eb8a664
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js
@@ -0,0 +1,454 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+
+// 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,
+ "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[apiName];
+ // 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,
+ });
+ }
+
+ await testSetting("ftpProtocolEnabled", false, {
+ "network.ftp.enabled": false,
+ });
+ await testSetting("ftpProtocolEnabled", true, {
+ "network.ftp.enabled": true,
+ });
+
+ 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("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 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."
+ );
+
+ 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",
+ applications: {
+ 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",
+ applications: {
+ 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_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..612f2dd0f3
--- /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.import(
+ "resource://testing-common/SiteDataTestUtils.jsm"
+);
+
+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_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js
new file mode 100644
index 0000000000..45c6a122fd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js
@@ -0,0 +1,109 @@
+"use strict";
+
+/**
+ * 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 = "success\n";
+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(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);
+});
+
+add_task(async function test_captivePortal_basic() {
+ let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService(
+ Ci.nsICaptivePortalService
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["captivePortal"],
+ },
+ 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.test.onMessage.addListener(async msg => {
+ if (msg == "getstate") {
+ browser.test.sendMessage(
+ "getstate",
+ await browser.captivePortal.getState()
+ );
+ }
+ });
+ browser.test.assertEq(
+ "unknown",
+ await browser.captivePortal.getState(),
+ "initial state unknown"
+ );
+ },
+ });
+ await extension.startup();
+
+ // 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);
+
+ let 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");
+
+ 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_contentScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
new file mode 100644
index 0000000000..71174716fd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js
@@ -0,0 +1,591 @@
+"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",
+ "<all_urls>",
+ ],
+ 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": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="background-frame.js"></script>
+ </head>
+ <body>
+ </body>
+ </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",
+ },
+ // 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.
+ },
+ ];
+
+ 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++;
+ 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 contentScript.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 } = script;
+
+ deepEqual(
+ {
+ allFrames,
+ cssPaths,
+ jsPaths,
+ matchAboutBlank,
+ runAt,
+ },
+ {
+ allFrames: true,
+ cssPaths: [`${baseExtURL}/content_style.css`],
+ jsPaths: [`${baseExtURL}/content_script.js`],
+ matchAboutBlank: true,
+ runAt: "document_start",
+ },
+ "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();
+});
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..47de723f0f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js
@@ -0,0 +1,251 @@
+"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("<!DOCTYPE html><html></html>");
+});
+
+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 = [];
+baseCSP[2] = {
+ "object-src": ["blob:", "filesystem:", "moz-extension:", "'self'"],
+ "script-src": [
+ "'unsafe-eval'",
+ "'unsafe-inline'",
+ "blob:",
+ "filesystem:",
+ "http://localhost:*",
+ "http://127.0.0.1:*",
+ "https://*",
+ "moz-extension:",
+ "'self'",
+ ],
+};
+baseCSP[3] = {
+ "object-src": ["'self'"],
+ "script-src": ["http://localhost:*", "http://127.0.0.1:*", "'self'"],
+ "worker-src": ["http://localhost:*", "http://127.0.0.1:*", "'self'"],
+};
+
+/**
+ * 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 {number} [manifest_version]
+ * @param {object} [customCSP]
+ */
+async function testPolicy(manifest_version = 2, customCSP = null) {
+ let baseURL;
+
+ let addonCSP = {
+ "object-src": ["'self'"],
+ "script-src": ["'self'"],
+ };
+
+ 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.extension.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 = () => {
+ try {
+ // eslint-disable-next-line no-undef
+ importScripts(`http://127.0.0.1:${port}/worker.js`);
+ postMessage({ loaded: true });
+ } catch (e) {
+ postMessage({ loaded: false });
+ }
+ };
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+
+ files: {
+ "tab.html": `<html><head><meta charset="utf-8">
+ <script src="tab.js"></${"script"}></head></html>`,
+
+ "tab.js": tabScript,
+
+ "content.html": `<html><head><meta charset="utf-8"></head></html>`,
+ "worker.js": `(${testWorker})(${server.identity.primaryPort})`,
+ },
+
+ manifest: {
+ manifest_version,
+ content_security_policy,
+
+ web_accessible_resources: ["content.html", "tab.html"],
+ },
+ });
+
+ function frameScript() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ addEventListener(
+ "DOMWindowCreated",
+ event => {
+ let win = event.target.ownerGlobal;
+ function getCSP() {
+ let { cspJSON } = win.document;
+ return win.wrappedJSObject.JSON.parse(cspJSON);
+ }
+ Cu.exportFunction(getCSP, win, { defineAs: "getCSP" });
+ },
+ true
+ );
+ }
+ let frameScriptURL = `data:,(${encodeURI(frameScript)}).call(this)`;
+ Services.mm.loadFrameScript(frameScriptURL, true, true);
+
+ info(`Testing CSP for policy: ${content_security_policy}`);
+
+ await extension.startup();
+
+ baseURL = await extension.awaitMessage("base-url");
+
+ let tabPage = await ExtensionTestUtils.loadContentPage(
+ `${baseURL}/tab.html`,
+ { extension }
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ let contentCSP = await contentPage.spawn(
+ `${baseURL}/content.html`,
+ async src => {
+ let doc = this.content.document;
+
+ let frame = doc.createElement("iframe");
+ frame.src = src;
+ doc.body.appendChild(frame);
+
+ await new Promise(resolve => {
+ frame.onload = resolve;
+ });
+
+ return frame.contentWindow.wrappedJSObject.getCSP();
+ }
+ );
+
+ let backgroundCSP = await extension.awaitMessage("background-csp");
+ checkCSP(backgroundCSP, "background page");
+
+ let tabCSP = await extension.awaitMessage("tab-csp");
+ checkCSP(tabCSP, "tab page");
+
+ checkCSP(contentCSP, "content frame");
+
+ let workerCSP = await extension.awaitMessage("worker-csp");
+ // TODO BUG 1685627: This test should fail if localhost is not in the csp.
+ ok(workerCSP.loaded, "worker loaded");
+
+ await contentPage.close();
+ await tabPage.close();
+
+ await extension.unload();
+
+ Services.mm.removeDelayedFrameScript(frameScriptURL);
+}
+
+add_task(async function testCSP() {
+ await testPolicy(2, null);
+
+ let hash =
+ "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='";
+
+ await testPolicy(2, {
+ "object-src": "'self' https://*.example.com",
+ "script-src": `'self' https://*.example.com 'unsafe-eval' ${hash}`,
+ });
+
+ await testPolicy(2, {
+ "object-src": "'none'",
+ "script-src": `'self'`,
+ });
+
+ await testPolicy(3, {
+ "object-src": "'self' http://localhost",
+ "script-src": `'self' http://localhost:123 ${hash}`,
+ "worker-src": `'self' http://127.0.0.1:*`,
+ });
+
+ await testPolicy(3, {
+ "object-src": "'none'",
+ "script-src": `'self'`,
+ "worker-src": `'self'`,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js
new file mode 100644
index 0000000000..1d130798f6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js
@@ -0,0 +1,266 @@
+"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();
+
+add_task(async function test_contentscript_runAt() {
+ 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]);
+ }
+ );
+ }
+
+ 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.applications.gecko.id;
+ browser.runtime.sendMessage(["chrome-namespace-ok"]);
+ }
+
+ let extensionData = {
+ manifest: {
+ applications: { gecko: { id: "contentscript@tests.mozilla.org" } },
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_start.js"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_end.js"],
+ run_at: "document_end",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_idle.js"],
+ run_at: "document_idle",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_idle.js"],
+ // Test default `run_at`.
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+ 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 > 1) {
+ resolve();
+ }
+ });
+ });
+
+ let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok");
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await Promise.all([completePromise, chromeNamespacePromise]);
+
+ await contentPage.close();
+
+ equal(loadingCount, 1, "document_start script ran exactly once");
+ equal(interactiveCount, 1, "document_end script ran exactly once");
+ equal(completeCount, 2, "document_idle script ran exactly twice");
+
+ await extension.unload();
+});
+
+add_task(async function test_contentscript_window_open() {
+ if (AppConstants.DEBUG && ExtensionTestUtils.remoteContentScripts) {
+ return;
+ }
+
+ let script = async () => {
+ /* globals x */
+ browser.test.assertEq(1, x, "Should only run once");
+
+ if (top !== window) {
+ // Wait for our parent page to load, then set a timeout to wait for the
+ // document.open call, so we make sure to not tear down the extension
+ // until after we've done the document.open.
+ await new Promise(resolve => {
+ top.addEventListener("load", () => setTimeout(resolve, 0), {
+ once: true,
+ });
+ });
+ }
+
+ browser.test.sendMessage("content-script", [location.href, top === window]);
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "contentscript@tests.mozilla.org" } },
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ match_about_blank: true,
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": `
+ var x = (x || 0) + 1;
+ (${script})();
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/file_document_open.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ let [pageURL, pageIsTop] = await extension.awaitMessage("content-script");
+
+ // Sometimes we get a content script load for the initial about:blank
+ // top level frame here, sometimes we don't. Either way is fine, as long as we
+ // don't get two loads into the same document.open() document.
+ if (pageURL === "about:blank") {
+ equal(pageIsTop, true);
+ [pageURL, pageIsTop] = await extension.awaitMessage("content-script");
+ }
+
+ Assert.deepEqual([pageURL, pageIsTop], [url, true]);
+
+ let [frameURL, isTop] = await extension.awaitMessage("content-script");
+ Assert.deepEqual([frameURL, isTop], [url, false]);
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test verify that a cached script is still able to catch the document
+// while it is still loading (when we do not block the document parsing as
+// we do for a non cached script).
+add_task(async function test_cached_contentscript_on_document_start() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_document_open.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": `
+ browser.test.sendMessage("content-script-loaded", {
+ url: window.location.href,
+ documentReadyState: document.readyState,
+ });
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/file_document_open.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ let msg = await extension.awaitMessage("content-script-loaded");
+ Assert.deepEqual(
+ msg,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a non cached script"
+ );
+
+ // Reload the page and check that the cached content script is still able to
+ // run on document_start.
+ await contentPage.loadURL(url);
+
+ let msgFromCached = await extension.awaitMessage("content-script-loaded");
+ Assert.deepEqual(
+ msgFromCached,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a cached script"
+ );
+
+ await extension.unload();
+
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js
new file mode 100644
index 0000000000..023cc3d2a4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js
@@ -0,0 +1,78 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/blank-iframe.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<iframe></iframe>");
+});
+
+add_task(async function content_script_at_document_start() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ js: ["start.js"],
+ run_at: "document_start",
+ match_about_blank: true,
+ },
+ ],
+ },
+
+ files: {
+ "start.js": function() {
+ browser.test.sendMessage("content-script-done");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+ await extension.awaitMessage("content-script-done");
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function content_style_at_document_start() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ css: ["start.css"],
+ run_at: "document_start",
+ match_about_blank: true,
+ },
+ {
+ matches: ["<all_urls>"],
+ js: ["end.js"],
+ run_at: "document_end",
+ match_about_blank: true,
+ },
+ ],
+ },
+
+ files: {
+ "start.css": "body { background: red; }",
+ "end.js": function() {
+ let style = window.getComputedStyle(document.body);
+ browser.test.assertEq(
+ "rgb(255, 0, 0)",
+ style.backgroundColor,
+ "document_start style should have been applied"
+ );
+ browser.test.sendMessage("content-script-done");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+ await extension.awaitMessage("content-script-done");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js
new file mode 100644
index 0000000000..4e42181e71
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js
@@ -0,0 +1,65 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript_api_injection() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["content_script_iframe.html"],
+ },
+
+ 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.js"() {
+ window.location = `http://example.com/data/file_privilege_escalation.html`;
+ },
+ "content_script_iframe.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script type="text/javascript" src="content_script_iframe.js"></script>
+ </head>
+ </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("<!DOCTYPE html><html></html>");
+});
+
+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(`<!DOCTYPE html>
+ <script>
+ function readByWeb() {
+ let ctx = document.querySelector("canvas").getContext("2d");
+ let {data} = ctx.getImageData(0, 0, 1, 1);
+ return data.slice(0, 3).join();
+ }
+ </script>
+ `);
+});
+
+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..2bb30f3c90
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js
@@ -0,0 +1,348 @@
+"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("<!DOCTYPE html><html></html>");
+});
+
+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.spawn(extension.id, async extensionId => {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ this.context = DocumentManager.getContext(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.spawn(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.spawn(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();
+});
+
+async function contentscript_context_incognito_not_allowed_test() {
+ 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");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { privateBrowsing: true }
+ );
+
+ await contentPage.spawn(extension.id, async extensionId => {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ let context = DocumentManager.getContext(extensionId, this.content);
+ Assert.equal(
+ context,
+ null,
+ "Extension unable to use content_script in private browsing window"
+ );
+ });
+
+ await contentPage.close();
+ await extension.unload();
+}
+
+add_task(async function test_contentscript_context_incognito_not_allowed() {
+ return runWithPrefs(
+ [["extensions.allowPrivateBrowsingByDefault", false]],
+ contentscript_context_incognito_not_allowed_test
+ );
+});
+
+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.spawn(extension.id, async extensionId => {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ // Save context so we can verify that contentWindow is nulled after unload.
+ this.context = DocumentManager.getContext(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.import(
+ "resource://gre/modules/Timer.jsm"
+ );
+ 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.spawn(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.spawn(extension.id, async extensionId => {
+ let context;
+ let checkContextIsValid = description => {
+ if (!context) {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ context = DocumentManager.getContext(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.spawn(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.spawn(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..1b705e0a53
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js
@@ -0,0 +1,160 @@
+"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("<!DOCTYPE html><html></html>");
+});
+
+server.registerPathHandler("/bfcachetestpage", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html;charset=utf-8", false);
+ response.write(`<!DOCTYPE html>
+<script>
+ window.addEventListener("pageshow", (event) => {
+ event.stopImmediatePropagation();
+ if (window.browserTestSendMessage) {
+ browserTestSendMessage("content-script-show");
+ }
+ });
+ window.addEventListener("pagehide", (event) => {
+ event.stopImmediatePropagation();
+ if (window.browserTestSendMessage) {
+ if (event.persisted) {
+ browserTestSendMessage("content-script-hide");
+ } else {
+ browserTestSendMessage("content-script-unload");
+ }
+ }
+ }, true);
+</script>`);
+});
+
+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.spawn(extension.id, async extensionId => {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ this.context = DocumentManager.getContext(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.spawn(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");
+
+ await contentPage.spawn(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 extension.awaitMessage("content-script-unload");
+
+ await contentPage.spawn(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..ff2622e4fb
--- /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 {
+ applications: {
+ gecko: { id: expectedManifestGeckoId },
+ },
+ } = chrome.runtime.getManifest();
+ let {
+ applications: {
+ gecko: { id: actualManifestGeckoId },
+ },
+ } = manifest;
+
+ browser.test.assertEq(
+ actualManifestGeckoId,
+ expectedManifestGeckoId,
+ "the add-on manifest should be accessible from the created iframe"
+ );
+
+ let {
+ applications: {
+ 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: {
+ applications: { 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": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script type="text/javascript" src="content_script_iframe.js"></script>
+ </head>
+ </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.spawn(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.spawn(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..cf770d91b4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js
@@ -0,0 +1,355 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+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 = `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>
+ <img id="testimg">
+ </body>
+ </html>`;
+
+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);
+ });
+}
+
+// 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",
+ },
+ },
+ {
+ description: "Fetch url from content script uses page csp.",
+ pageCSP: `${gDefaultCSP} connect-src *;`,
+ script: testFetch,
+ version: 3,
+ data: {
+ content: true,
+ url: `${BASE_URL}/data/file_image_good.png`,
+ },
+ expect: true,
+ },
+
+ // 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,
+ },
+];
+
+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: ["<all_urls>"],
+ },
+
+ files: {
+ "content_script.js": `
+ (${contentScript})(${JSON.stringify(test.report)}).then(() => {
+ browser.test.sendMessage("violationEvent");
+ });
+ (${test.script})(${JSON.stringify(test.data)}).then(result => {
+ browser.test.sendMessage("result", result);
+ });
+ `,
+ },
+ };
+
+ 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..d94023387f
--- /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("<!DOCTYPE html><html></html>");
+});
+
+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(null, task);
+ equal(maxWidth, "42px", "Stylesheet correctly applied");
+
+ await extension.unload();
+
+ maxWidth = await contentPage.spawn(null, task);
+ equal(maxWidth, "none", "Stylesheet correctly removed");
+
+ 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..f485a012c9
--- /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_in_background.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js
new file mode 100644
index 0000000000..1a8aa6d706
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js
@@ -0,0 +1,61 @@
+/* -*- 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 connect_from_background_frame() {
+ async function background() {
+ const FRAME_URL = "http://example.com:8888/dummyFrame";
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab");
+ browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL");
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq("pong", msg, "Reply from content script");
+ port.disconnect();
+ });
+ port.postMessage("ping");
+ });
+
+ 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}`);
+
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq("ping", msg, "Expected message to content script");
+ port.postMessage("pong");
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.sendMessage("disconnected_in_content_script");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*"],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ background,
+ });
+ await extension.startup();
+ await extension.awaitMessage("disconnected_in_content_script");
+ await extension.unload();
+});
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..484c41ad3f
--- /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/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/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/file_download.txt";
+ document.head.appendChild(c);
+ },
+ },
+ });
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ "http://a.example.com/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");
+ ok(b.time > 0, "connectionEnd available from b.example.com");
+ equal(b.size, 428, "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_restrictSchemes.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js
new file mode 100644
index 0000000000..e9b1dbe57c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js
@@ -0,0 +1,70 @@
+"use strict";
+
+function makeExtension(id, isPrivileged) {
+ return ExtensionTestUtils.loadExtension({
+ isPrivileged,
+
+ manifest: {
+ applications: { gecko: { id } },
+
+ permissions: isPrivileged ? ["mozillaAddons"] : [],
+
+ content_scripts: [
+ {
+ matches: ["resource://foo/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+
+ 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}`);
+ },
+ },
+ });
+}
+
+add_task(async function test_contentscript_restrictSchemes() {
+ 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("unprivileged@tests.mozilla.org", false);
+ let privileged = makeExtension("privileged@tests.mozilla.org", true);
+
+ await unprivileged.startup();
+ await privileged.startup();
+
+ 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();
+});
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..e0ed263065
--- /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..2bf7981657
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js
@@ -0,0 +1,102 @@
+"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 = [];
+ {
+ let { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+ );
+ 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(null, () => {
+ 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..f5df8e61d2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js
@@ -0,0 +1,1373 @@
+/* -*- 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.
+ */
+
+const env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+);
+
+// Make sure media pre-loading is enabled on Android so that our <audio> and
+// <video> elements trigger the expected requests.
+Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.ALLOWED);
+Services.prefs.setIntPref("media.preload.default", 3);
+
+// Increase the length of the code samples included in CSP reports so that we
+// can correctly validate them.
+Services.prefs.setIntPref(
+ "security.csp.reporting.script-sample.max-length",
+ 4096
+);
+
+// Do not trunacate the blocked-uri in CSP reports for frame navigations.
+Services.prefs.setBoolPref(
+ "security.csp.truncate_blocked_uri_for_frame_navigations",
+ false
+);
+
+// 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({
+ hosts: ["example.com", "csplog.example.net"],
+});
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+var gContentSecurityPolicy = null;
+
+const BASE_URL = `http://example.com`;
+const CSP_REPORT_PATH = "/csp-report.sjs";
+
+/**
+ * Registers a static HTML document with the given content at the given
+ * path in our test HTTP server.
+ *
+ * @param {string} path
+ * @param {string} content
+ */
+function registerStaticPage(path, content) {
+ server.registerPathHandler(path, (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ if (gContentSecurityPolicy) {
+ response.setHeader("Content-Security-Policy", gContentSecurityPolicy);
+ }
+ response.write(content);
+ });
+}
+
+/**
+ * A set of tags which are automatically closed in HTML documents, and
+ * do not require an explicit closing tag.
+ */
+const AUTOCLOSE_TAGS = new Set(["img", "input", "link", "source"]);
+
+/**
+ * An object describing the elements to create for a specific test.
+ *
+ * @typedef {object} ElementTestCase
+ * @property {Array} element
+ * A recursive array, describing the element to create, in the
+ * following format:
+ *
+ * ["tagname", {attr: "attrValue"},
+ * ["child-tagname", {attr: "value"}],
+ * ...]
+ *
+ * For each test, a DOM tree will be created with this structure.
+ * A source attribute, with the name `test.srcAttr` and a value
+ * based on the values of `test.src` and `opts`, will be added to
+ * the first leaf node encountered.
+ * @property {string} src
+ * The relative URL to use as the source of the element. Each
+ * load of this URL will have a separate set of query parameters
+ * appended to it, based on the values in `opts`.
+ * @property {string} [srcAttr = "src"]
+ * The attribute in which to store the element's source URL.
+ * @property {string} [srcAttr = "src"]
+ * The attribute in which to store the element's source URL.
+ * @property {boolean} [liveSrc = false]
+ * If true, changing the source attribute after the element has
+ * been inserted into the document is expected to trigger a new
+ * load, and that configuration will be tested.
+ */
+
+/**
+ * Options for this specific configuration of an element test.
+ *
+ * @typedef {object} ElementTestOptions
+ * @property {string} origin
+ * The origin with which the content is expected to load. This
+ * may be one of "page", "contentScript", or "extension". The actual load
+ * of the URL will be tested against the computed origin strings for
+ * those two contexts.
+ * @property {string} source
+ * An arbitrary string which uniquely identifies the source of
+ * the load. For instance, each of these should have separate
+ * origin strings:
+ *
+ * - An element present in the initial page HTML.
+ * - An element injected by a page script belonging to web
+ * content.
+ * - An element injected by an extension content script.
+ */
+
+/**
+ * Data describing a test element, which can be used to create a
+ * corresponding DOM tree.
+ *
+ * @typedef {object} ElementData
+ * @property {string} tagName
+ * The tag name for the element.
+ * @property {object} attrs
+ * A property containing key-value pairs for each of the
+ * attribute's elements.
+ * @property {Array<ElementData>} children
+ * A possibly empty array of element data for child elements.
+ */
+
+/**
+ * Returns data necessary to create test elements for the given test,
+ * with the given options.
+ *
+ * @param {ElementTestCase} test
+ * An object describing the elements to create for a specific
+ * test. This element will be created under various
+ * circumstances, as described by `opts`.
+ * @param {ElementTestOptions} opts
+ * Options for this specific configuration of the test.
+ * @returns {ElementData}
+ */
+function getElementData(test, opts) {
+ let baseURL = typeof BASE_URL !== "undefined" ? BASE_URL : location.href;
+
+ let { srcAttr, src } = test;
+
+ // Absolutify the URL, so it passes sanity checks that ignore
+ // triggering principals for relative URLs.
+ src = new URL(
+ src +
+ `?origin=${encodeURIComponent(opts.origin)}&source=${encodeURIComponent(
+ opts.source
+ )}`,
+ baseURL
+ ).href;
+
+ let haveSrc = false;
+ function rec(element) {
+ let [tagName, attrs, ...children] = element;
+
+ if (children.length) {
+ children = children.map(rec);
+ } else if (!haveSrc) {
+ attrs = Object.assign({ [srcAttr]: src }, attrs);
+ haveSrc = true;
+ }
+
+ return { tagName, attrs, children };
+ }
+ return rec(test.element);
+}
+
+/**
+ * The result type of the {@see createElement} function.
+ *
+ * @typedef {object} CreateElementResult
+ * @property {Element} elem
+ * The root element of the created DOM tree.
+ * @property {Element} srcElem
+ * The element in the tree to which the source attribute must be
+ * added.
+ * @property {string} src
+ * The value of the source element.
+ */
+
+/**
+ * Creates a DOM tree for a given test, in a given configuration, as
+ * understood by {@see getElementData}, but without the `test.srcAttr`
+ * attribute having been set. The caller must set the value of that
+ * attribute to the returned `src` value.
+ *
+ * There are many different ways most source values can be set
+ * (DOM attribute, DOM property, ...) and many different contexts
+ * (content script verses page script). Each test should be run with as
+ * many variants of these as possible.
+ *
+ * @param {ElementTestCase} test
+ * A test object, as passed to {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ * An options object, as passed to {@see getElementData}.
+ * @returns {CreateElementResult}
+ */
+function createElement(test, opts) {
+ let srcElem;
+ let src;
+
+ function rec({ tagName, attrs, children }) {
+ let elem = document.createElement(tagName);
+
+ for (let [key, val] of Object.entries(attrs)) {
+ if (key === test.srcAttr) {
+ srcElem = elem;
+ src = val;
+ } else {
+ elem.setAttribute(key, val);
+ }
+ }
+ for (let child of children) {
+ elem.appendChild(rec(child));
+ }
+ return elem;
+ }
+ let elem = rec(getElementData(test, opts));
+
+ return { elem, srcElem, src };
+}
+
+/**
+ * Escapes any occurrences of &, ", < or > with XML entities.
+ *
+ * @param {string} str
+ * The string to escape.
+ * @returns {string} The escaped string.
+ */
+function escapeXML(str) {
+ let replacements = {
+ "&": "&amp;",
+ '"': "&quot;",
+ "'": "&apos;",
+ "<": "&lt;",
+ ">": "&gt;",
+ };
+ return String(str).replace(/[&"''<>]/g, m => replacements[m]);
+}
+
+/**
+ * A tagged template function which escapes any XML metacharacters in
+ * interpolated values.
+ *
+ * @param {Array<string>} strings
+ * An array of literal strings extracted from the templates.
+ * @param {Array} values
+ * An array of interpolated values extracted from the template.
+ * @returns {string}
+ * The result of the escaped values interpolated with the literal
+ * strings.
+ */
+function escaped(strings, ...values) {
+ let result = [];
+
+ for (let [i, string] of strings.entries()) {
+ result.push(string);
+ if (i < values.length) {
+ result.push(escapeXML(values[i]));
+ }
+ }
+
+ return result.join("");
+}
+
+/**
+ * Converts the given test data, as accepted by {@see getElementData},
+ * to an HTML representation.
+ *
+ * @param {ElementTestCase} test
+ * A test object, as passed to {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ * An options object, as passed to {@see getElementData}.
+ * @returns {string}
+ */
+function toHTML(test, opts) {
+ function rec({ tagName, attrs, children }) {
+ let html = [`<${tagName}`];
+ for (let [key, val] of Object.entries(attrs)) {
+ html.push(escaped` ${key}="${val}"`);
+ }
+
+ html.push(">");
+ if (!AUTOCLOSE_TAGS.has(tagName)) {
+ for (let child of children) {
+ html.push(rec(child));
+ }
+
+ html.push(`</${tagName}>`);
+ }
+ return html.join("");
+ }
+ return rec(getElementData(test, opts));
+}
+
+/**
+ * Injects various permutations of inline CSS into a content page, from both
+ * extension content script and content page contexts, and sends a "css-sources"
+ * message to the test harness describing the injected content for verification.
+ */
+function testInlineCSS() {
+ let urls = [];
+ let sources = [];
+
+ /**
+ * Constructs the URL of an image to be loaded by the given origin, and
+ * returns a CSS url() expression for it.
+ *
+ * The `name` parameter is an arbitrary name which should describe how the URL
+ * is loaded. The `opts` object may contain arbitrary properties which
+ * describe the load. Currently, only `inline` is recognized, and indicates
+ * that the URL is being used in an inline stylesheet which may be blocked by
+ * CSP.
+ *
+ * The URL and its parameters are recorded, and sent to the parent process for
+ * verification.
+ *
+ * @param {string} origin
+ * @param {string} name
+ * @param {object} [opts]
+ * @returns {string}
+ */
+ let i = 0;
+ let url = (origin, name, opts = {}) => {
+ let source = `${origin}-${name}`;
+
+ let { href } = new URL(
+ `css-${i++}.png?origin=${encodeURIComponent(
+ origin
+ )}&source=${encodeURIComponent(source)}`,
+ location.href
+ );
+
+ urls.push(Object.assign({}, opts, { href, origin, source }));
+ return `url("${href}")`;
+ };
+
+ /**
+ * Registers the given inline CSS source as being loaded by the given origin,
+ * and returns that CSS text.
+ *
+ * @param {string} origin
+ * @param {string} css
+ * @returns {string}
+ */
+ let source = (origin, css) => {
+ sources.push({ origin, css });
+ return css;
+ };
+
+ /**
+ * Saves the given function to be run after a short delay, just before sending
+ * the list of loaded sources to the parent process.
+ */
+ let laters = [];
+ let later = fn => {
+ laters.push(fn);
+ };
+
+ // Note: When accessing an element through `wrappedJSObject`, the operations
+ // occur in the content page context, using the content subject principal.
+ // When accessing it through X-ray wrappers, they happen in the content script
+ // context, using its subject principal.
+
+ {
+ let li = document.createElement("li");
+ li.setAttribute(
+ "style",
+ source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style-first")}`
+ )
+ );
+ li.style.wrappedJSObject.listStyleImage = url(
+ "page",
+ "li.style.listStyleImage-second"
+ );
+ document.body.appendChild(li);
+ }
+
+ {
+ let li = document.createElement("li");
+ li.wrappedJSObject.setAttribute(
+ "style",
+ source(
+ "page",
+ `background: ${url("page", "li.style-first", { inline: true })}`
+ )
+ );
+ li.style.listStyleImage = url(
+ "contentScript",
+ "li.style.listStyleImage-second"
+ );
+ document.body.appendChild(li);
+ }
+
+ {
+ let li = document.createElement("li");
+ document.body.appendChild(li);
+ li.setAttribute(
+ "style",
+ source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style-first")}`
+ )
+ );
+ later(() =>
+ li.wrappedJSObject.setAttribute(
+ "style",
+ source(
+ "page",
+ `background: ${url("page", "li.style-second", { inline: true })}`
+ )
+ )
+ );
+ }
+
+ {
+ let li = document.createElement("li");
+ document.body.appendChild(li);
+ li.wrappedJSObject.setAttribute(
+ "style",
+ source(
+ "page",
+ `background: ${url("page", "li.style-first", { inline: true })}`
+ )
+ );
+ later(() =>
+ li.setAttribute(
+ "style",
+ source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style-second")}`
+ )
+ )
+ );
+ }
+
+ {
+ let li = document.createElement("li");
+ document.body.appendChild(li);
+ li.style.cssText = source(
+ "contentScript",
+ `background: ${url("contentScript", "li.style.cssText-first")}`
+ );
+
+ // TODO: This inline style should be blocked, since our style-src does not
+ // include 'unsafe-eval', but that is currently unimplemented.
+ later(() => {
+ li.style.wrappedJSObject.cssText = `background: ${url(
+ "page",
+ "li.style.cssText-second"
+ )}`;
+ });
+ }
+
+ // Creates a new element, inserts it into the page, and returns its CSS selector.
+ let divNum = 0;
+ function getSelector() {
+ let div = document.createElement("div");
+ div.id = `generated-div-${divNum++}`;
+ document.body.appendChild(div);
+ return `#${div.id}`;
+ }
+
+ for (let prop of ["textContent", "innerHTML"]) {
+ // Test creating <style> element from the extension side and then replacing
+ // its contents from the content side.
+ {
+ let sel = getSelector();
+ let style = document.createElement("style");
+ style[prop] = source(
+ "extension",
+ `${sel} { background: ${url("extension", `style-${prop}-first`)}; }`
+ );
+ document.head.appendChild(style);
+
+ later(() => {
+ style.wrappedJSObject[prop] = source(
+ "page",
+ `${sel} { background: ${url("page", `style-${prop}-second`, {
+ inline: true,
+ })}; }`
+ );
+ });
+ }
+
+ // Test creating <style> element from the extension side and then appending
+ // a text node to it. Regardless of whether the append happens from the
+ // content or extension side, this should cause the principal to be
+ // forgotten.
+ let testModifyAfterInject = (name, modifyFunc) => {
+ let sel = getSelector();
+ let style = document.createElement("style");
+ style[prop] = source(
+ "extension",
+ `${sel} { background: ${url(
+ "extension",
+ `style-${name}-${prop}-first`
+ )}; }`
+ );
+ document.head.appendChild(style);
+
+ later(() => {
+ modifyFunc(
+ style,
+ `${sel} { background: ${url("page", `style-${name}-${prop}-second`, {
+ inline: true,
+ })}; }`
+ );
+ source("page", style.textContent);
+ });
+ };
+
+ testModifyAfterInject("appendChild", (style, css) => {
+ style.appendChild(document.createTextNode(css));
+ });
+
+ // Test creating <style> element from the extension side and then appending
+ // to it using insertAdjacentHTML, with the same rules as above.
+ testModifyAfterInject("insertAdjacentHTML", (style, css) => {
+ // eslint-disable-next-line no-unsanitized/method
+ style.insertAdjacentHTML("beforeend", css);
+ });
+
+ // And again using insertAdjacentText.
+ testModifyAfterInject("insertAdjacentText", (style, css) => {
+ style.insertAdjacentText("beforeend", css);
+ });
+
+ // Test creating a style element and then accessing its CSSStyleSheet object.
+ {
+ let sel = getSelector();
+ let style = document.createElement("style");
+ style[prop] = source(
+ "extension",
+ `${sel} { background: ${url("extension", `style-${prop}-sheet`)}; }`
+ );
+ document.head.appendChild(style);
+
+ browser.test.assertThrows(
+ () => style.sheet.wrappedJSObject.cssRules,
+ /Not allowed to access cross-origin stylesheet/,
+ "Page content should not be able to access extension-generated CSS rules"
+ );
+
+ style.sheet.insertRule(
+ source(
+ "extension",
+ `${sel} { border-image: ${url(
+ "extension",
+ `style-${prop}-sheet-insertRule`
+ )}; }`
+ )
+ );
+ }
+ }
+
+ setTimeout(() => {
+ for (let fn of laters) {
+ fn();
+ }
+ browser.test.sendMessage("css-sources", { urls, sources });
+ });
+}
+
+/**
+ * A function which will be stringified, and run both as a page script
+ * and an extension content script, to test element injection under
+ * various configurations.
+ *
+ * @param {Array<ElementTestCase>} tests
+ * A list of test objects, as understood by {@see getElementData}.
+ * @param {ElementTestOptions} baseOpts
+ * A base options object, as understood by {@see getElementData},
+ * which represents the default values for injections under this
+ * context.
+ */
+function injectElements(tests, baseOpts) {
+ window.addEventListener(
+ "load",
+ () => {
+ if (typeof browser === "object") {
+ try {
+ testInlineCSS();
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ }
+ }
+
+ // Basic smoke test to check that SVG images do not try to create a document
+ // with an expanded principal, which would cause a crash.
+ let img = document.createElement("img");
+ img.src = "data:image/svg+xml,%3Csvg%2F%3E";
+ document.body.appendChild(img);
+
+ let rand = Math.random();
+
+ // Basic smoke test to check that we don't try to create stylesheets with an
+ // expanded principal, which would cause a crash when loading font sets.
+ let cssText = `
+ @font-face {
+ font-family: "DoesNotExist${rand}";
+ src: url("fonts/DoesNotExist.${rand}.woff") format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }`;
+
+ let link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = "data:text/css;base64," + btoa(cssText);
+ document.head.appendChild(link);
+
+ let style = document.createElement("style");
+ style.textContent = cssText;
+ document.head.appendChild(style);
+
+ let overrideOpts = opts => Object.assign({}, baseOpts, opts);
+ let opts = baseOpts;
+
+ // Build the full element with setAttr, then inject.
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem.setAttribute(test.srcAttr, src);
+ document.body.appendChild(elem);
+ }
+
+ // Build the full element with a property setter.
+ opts = overrideOpts({ source: `${baseOpts.source}-prop` });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem[test.srcAttr] = src;
+ document.body.appendChild(elem);
+ }
+
+ // Build the element without the source attribute, inject, then set
+ // it.
+ opts = overrideOpts({ source: `${baseOpts.source}-attr-after-inject` });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ document.body.appendChild(elem);
+ srcElem.setAttribute(test.srcAttr, src);
+ }
+
+ // Build the element without the source attribute, inject, then set
+ // the corresponding property.
+ opts = overrideOpts({ source: `${baseOpts.source}-prop-after-inject` });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ document.body.appendChild(elem);
+ srcElem[test.srcAttr] = src;
+ }
+
+ // Build the element with a relative, rather than absolute, URL, and
+ // make sure it always has the page origin.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-relative-url`,
+ origin: "page",
+ });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ // Note: This assumes that the content page and the src URL are
+ // always at the server root. If that changes, the test will
+ // timeout waiting for matching requests.
+ src = src.replace(/.*\//, "");
+ srcElem.setAttribute(test.srcAttr, src);
+ document.body.appendChild(elem);
+ }
+
+ // If we're in an extension content script, do some additional checks.
+ if (typeof browser !== "undefined") {
+ // Build the element without the source attribute, inject, then
+ // have content set it.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-content-attr-after-inject`,
+ origin: "page",
+ });
+
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+
+ document.body.appendChild(elem);
+ window.wrappedJSObject.elem = srcElem;
+ window.wrappedJSObject.eval(
+ `elem.setAttribute(${JSON.stringify(
+ test.srcAttr
+ )}, ${JSON.stringify(src)})`
+ );
+ }
+
+ // Build the full element, then let content inject.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-content-inject-after-attr`,
+ });
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem.setAttribute(test.srcAttr, src);
+ window.wrappedJSObject.elem = elem;
+ window.wrappedJSObject.eval(`document.body.appendChild(elem)`);
+ }
+
+ // Build the element without the source attribute, let content set
+ // it, then inject.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-inject-after-content-attr`,
+ origin: "page",
+ });
+
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ window.wrappedJSObject.elem = srcElem;
+ window.wrappedJSObject.eval(
+ `elem.setAttribute(${JSON.stringify(
+ test.srcAttr
+ )}, ${JSON.stringify(src)})`
+ );
+ document.body.appendChild(elem);
+ }
+
+ // Build the element with a dummy source attribute, inject, then
+ // let content change it.
+ opts = overrideOpts({
+ source: `${baseOpts.source}-content-change-after-inject`,
+ origin: "page",
+ });
+
+ for (let test of tests) {
+ let { elem, srcElem, src } = createElement(test, opts);
+ srcElem.setAttribute(test.srcAttr, "meh.txt");
+ document.body.appendChild(elem);
+ window.wrappedJSObject.elem = srcElem;
+ window.wrappedJSObject.eval(
+ `elem.setAttribute(${JSON.stringify(
+ test.srcAttr
+ )}, ${JSON.stringify(src)})`
+ );
+ }
+ }
+ },
+ { once: true }
+ );
+}
+
+/**
+ * Stringifies the {@see injectElements} function for use as a page or
+ * content script.
+ *
+ * @param {Array<ElementTestCase>} tests
+ * A list of test objects, as understood by {@see getElementData}.
+ * @param {ElementTestOptions} opts
+ * A base options object, as understood by {@see getElementData},
+ * which represents the default values for injections under this
+ * context.
+ * @returns {string}
+ */
+function getInjectionScript(tests, opts) {
+ return `
+ ${getElementData}
+ ${createElement}
+ ${testInlineCSS}
+ (${injectElements})(${JSON.stringify(tests)},
+ ${JSON.stringify(opts)});
+ `;
+}
+
+/**
+ * Extracts the "origin" query parameter from the given URL, and returns it,
+ * along with the URL sans origin parameter.
+ *
+ * @param {string} origURL
+ * @returns {object}
+ * An object with `origin` and `baseURL` properties, containing the value
+ * or the URL's "origin" query parameter and the URL with that parameter
+ * removed, respectively.
+ */
+function getOriginBase(origURL) {
+ let url = new URL(origURL);
+ let origin = url.searchParams.get("origin");
+ url.searchParams.delete("origin");
+
+ return { origin, baseURL: url.href };
+}
+
+/**
+ * An object containing sets of base URLs and CSS sources which are present in
+ * the test page, sorted based on how they should be treated by CSP.
+ *
+ * @typedef {object} RequestedURLs
+ * @property {Set<string>} expectedURLs
+ * A set of URLs which should be successfully requested by the content
+ * page.
+ * @property {Set<string>} forbiddenURLs
+ * A set of URLs which are present in the content page, but should never
+ * generate requests.
+ * @property {Set<string>} blockedURLs
+ * A set of URLs which are present in the content page, and should be
+ * blocked by CSP, and reported in a CSP report.
+ * @property {Set<string>} blockedSources
+ * A set of inline CSS sources which should be blocked by CSP, and
+ * reported in a CSP report.
+ */
+
+/**
+ * Computes a list of expected and forbidden base URLs for the given
+ * sets of tests and sources. The base URL is the complete request URL
+ * with the `origin` query parameter removed.
+ *
+ * @param {Array<ElementTestCase>} tests
+ * A list of tests, as understood by {@see getElementData}.
+ * @param {Object<string, object>} expectedSources
+ * A set of sources for which each of the above tests is expected
+ * to generate one request, if each of the properties in the
+ * value object matches the value of the same property in the
+ * test object.
+ * @param {Object<string, object>} [forbiddenSources = {}]
+ * A set of sources for which requests should never be sent. Any
+ * matching requests from these sources will cause the test to
+ * fail.
+ * @returns {RequestedURLs}
+ */
+function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) {
+ let expectedURLs = new Set();
+ let forbiddenURLs = new Set();
+
+ function* iterSources(test, sources) {
+ for (let [source, attrs] of Object.entries(sources)) {
+ // if a source defines attributes (e.g. liveSrc in PAGE_SOURCES etc.) then all
+ // attributes in the source must be matched by the test (see const TEST).
+ if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) {
+ yield `${BASE_URL}/${test.src}?source=${source}`;
+ }
+ }
+ }
+
+ for (let test of tests) {
+ for (let urlPrefix of iterSources(test, expectedSources)) {
+ expectedURLs.add(urlPrefix);
+ }
+ for (let urlPrefix of iterSources(test, forbiddenSources)) {
+ forbiddenURLs.add(urlPrefix);
+ }
+ }
+
+ return { expectedURLs, forbiddenURLs, blockedURLs: forbiddenURLs };
+}
+
+/**
+ * Generates a set of expected and forbidden URLs and sources based on the CSS
+ * injected by our content script.
+ *
+ * @param {object} message
+ * The "css-sources" message sent by the content script, containing lists
+ * of CSS sources injected into the page.
+ * @param {Array<object>} message.urls
+ * A list of URLs present in styles injected by the content script.
+ * @param {string} message.urls.*.origin
+ * The origin of the URL, one of "page", "contentScript", or "extension".
+ * @param {string} message.urls.*.href
+ * The URL string.
+ * @param {boolean} message.urls.*.inline
+ * If true, the URL is present in an inline stylesheet, which may be
+ * blocked by CSP prior to parsing, depending on its origin.
+ * @param {Array<object>} message.sources
+ * A list of inline CSS sources injected by the content script.
+ * @param {string} message.sources.*.origin
+ * The origin of the CSS, one of "page", "contentScript", or "extension".
+ * @param {string} message.sources.*.css
+ * The CSS source text.
+ * @param {boolean} [cspEnabled = false]
+ * If true, a strict CSP is enabled for this page, and inline page
+ * sources should be blocked. URLs present in these sources will not be
+ * expected to generate a CSP report, the inline sources themselves will.
+ * @param {boolean} [contentCspEnabled = false]
+ * @returns {RequestedURLs}
+ */
+function computeExpectedForbiddenURLs(
+ { urls, sources },
+ cspEnabled = false,
+ contentCspEnabled = false
+) {
+ let expectedURLs = new Set();
+ let forbiddenURLs = new Set();
+ let blockedURLs = new Set();
+ let blockedSources = new Set();
+
+ for (let { href, origin, inline } of urls) {
+ let { baseURL } = getOriginBase(href);
+ if (cspEnabled && origin === "page") {
+ if (inline) {
+ forbiddenURLs.add(baseURL);
+ } else {
+ blockedURLs.add(baseURL);
+ }
+ } else if (contentCspEnabled && origin === "contentScript") {
+ if (inline) {
+ forbiddenURLs.add(baseURL);
+ }
+ } else {
+ expectedURLs.add(baseURL);
+ }
+ }
+
+ if (cspEnabled) {
+ for (let { origin, css } of sources) {
+ if (origin === "page") {
+ blockedSources.add(css);
+ }
+ }
+ }
+
+ return { expectedURLs, forbiddenURLs, blockedURLs, blockedSources };
+}
+
+/**
+ * Awaits the content loads for each of the given expected base URLs,
+ * and checks that their origin strings are as expected. Triggers a test
+ * failure if any of the given forbidden URLs is requested.
+ *
+ * @param {Promise<object>} urlsPromise
+ * A promise which resolves to an object containing expected and
+ * forbidden URL sets, as returned by {@see computeBaseURLs}.
+ * @param {object<string, string>} origins
+ * A mapping of origin parameters as they appear in URL query
+ * strings to the origin strings returned by corresponding
+ * principals. These values are used to test requests against
+ * their expected origins.
+ * @returns {Promise}
+ * A promise which resolves when all requests have been
+ * processed.
+ */
+function awaitLoads(urlsPromise, origins) {
+ return new Promise(resolve => {
+ let expectedURLs, forbiddenURLs;
+ let queuedChannels = [];
+
+ let observer;
+
+ function checkChannel(channel) {
+ let origURL = channel.URI.spec;
+ let { baseURL, origin } = getOriginBase(origURL);
+
+ if (forbiddenURLs.has(baseURL)) {
+ ok(false, `Got unexpected request for forbidden URL ${origURL}`);
+ }
+
+ if (expectedURLs.has(baseURL)) {
+ expectedURLs.delete(baseURL);
+
+ equal(
+ channel.loadInfo.triggeringPrincipal.origin,
+ origins[origin],
+ `Got expected origin for URL ${origURL}`
+ );
+
+ if (!expectedURLs.size) {
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ info("Got all expected requests");
+ resolve();
+ }
+ }
+ }
+
+ urlsPromise.then(urls => {
+ expectedURLs = new Set(urls.expectedURLs);
+ forbiddenURLs = new Set([...urls.forbiddenURLs, ...urls.blockedURLs]);
+
+ for (let channel of queuedChannels.splice(0)) {
+ checkChannel(channel.QueryInterface(Ci.nsIChannel));
+ }
+ });
+
+ observer = (channel, topic, data) => {
+ if (expectedURLs) {
+ checkChannel(channel.QueryInterface(Ci.nsIChannel));
+ } else {
+ queuedChannels.push(channel);
+ }
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ });
+}
+
+function readUTF8InputStream(stream) {
+ let buffer = NetUtil.readInputStream(stream, stream.available());
+ return new TextDecoder().decode(buffer);
+}
+
+/**
+ * Awaits CSP reports for each of the given forbidden base URLs.
+ * Triggers a test failure if any of the given expected URLs triggers a
+ * report.
+ *
+ * @param {Promise<object>} urlsPromise
+ * A promise which resolves to an object containing expected and
+ * forbidden URL sets, as returned by {@see computeBaseURLs}.
+ * @returns {Promise}
+ * A promise which resolves when all requests have been
+ * processed.
+ */
+function awaitCSP(urlsPromise) {
+ return new Promise(resolve => {
+ let expectedURLs, blockedURLs, blockedSources;
+ let queuedRequests = [];
+
+ function checkRequest(request) {
+ let body = JSON.parse(readUTF8InputStream(request.bodyInputStream));
+ let report = body["csp-report"];
+
+ let origURL = report["blocked-uri"];
+ if (origURL !== "inline" && origURL !== "") {
+ let { baseURL } = getOriginBase(origURL);
+
+ if (expectedURLs.has(baseURL)) {
+ ok(false, `Got unexpected CSP report for allowed URL ${origURL}`);
+ }
+
+ if (blockedURLs.has(baseURL)) {
+ blockedURLs.delete(baseURL);
+
+ ok(true, `Got CSP report for forbidden URL ${origURL}`);
+ }
+ }
+
+ let source = report["script-sample"];
+ if (source) {
+ if (blockedSources.has(source)) {
+ blockedSources.delete(source);
+
+ ok(
+ true,
+ `Got CSP report for forbidden inline source ${JSON.stringify(
+ source
+ )}`
+ );
+ }
+ }
+
+ if (!blockedURLs.size && !blockedSources.size) {
+ ok(true, "Got all expected CSP reports");
+ resolve();
+ }
+ }
+
+ urlsPromise.then(urls => {
+ blockedURLs = new Set(urls.blockedURLs);
+ blockedSources = new Set(urls.blockedSources);
+ ({ expectedURLs } = urls);
+
+ for (let request of queuedRequests.splice(0)) {
+ checkRequest(request);
+ }
+ });
+
+ server.registerPathHandler(CSP_REPORT_PATH, (request, response) => {
+ response.setStatusLine(request.httpVersion, 204, "No Content");
+
+ if (expectedURLs) {
+ checkRequest(request);
+ } else {
+ queuedRequests.push(request);
+ }
+ });
+ });
+}
+
+/**
+ * A list of tests to run in each context, as understood by
+ * {@see getElementData}.
+ */
+const TESTS = [
+ {
+ element: ["audio", {}],
+ src: "audio.webm",
+ },
+ {
+ element: ["audio", {}, ["source", {}]],
+ src: "audio-source.webm",
+ },
+ // TODO: <frame> element, which requires a frameset document.
+ {
+ // the blocked-uri for frame-navigations is the pre-path URI. For the
+ // purpose of this test we do not strip the blocked-uri by setting the
+ // preference 'truncate_blocked_uri_for_frame_navigations'
+ element: ["iframe", {}],
+ src: "iframe.html",
+ },
+ {
+ element: ["img", {}],
+ src: "img.png",
+ },
+ {
+ element: ["img", {}],
+ src: "imgset.png",
+ srcAttr: "srcset",
+ },
+ {
+ element: ["input", { type: "image" }],
+ src: "input.png",
+ },
+ {
+ element: ["link", { rel: "stylesheet" }],
+ src: "link.css",
+ srcAttr: "href",
+ },
+ {
+ element: ["picture", {}, ["source", {}], ["img", {}]],
+ src: "picture.png",
+ srcAttr: "srcset",
+ },
+ {
+ element: ["script", {}],
+ src: "script.js",
+ liveSrc: false,
+ },
+ {
+ element: ["video", {}],
+ src: "video.webm",
+ },
+ {
+ element: ["video", {}, ["source", {}]],
+ src: "video-source.webm",
+ },
+];
+
+for (let test of TESTS) {
+ if (!test.srcAttr) {
+ test.srcAttr = "src";
+ }
+ if (!("liveSrc" in test)) {
+ test.liveSrc = true;
+ }
+}
+
+/**
+ * A set of sources for which each of the above tests is expected to
+ * generate one request, if each of the properties in the value object
+ * matches the value of the same property in the test object.
+ */
+// Sources which load with the page context.
+const PAGE_SOURCES = {
+ "contentScript-content-attr-after-inject": { liveSrc: true },
+ "contentScript-content-change-after-inject": { liveSrc: true },
+ "contentScript-inject-after-content-attr": {},
+ "contentScript-relative-url": {},
+ pageHTML: {},
+ pageScript: {},
+ "pageScript-attr-after-inject": {},
+ "pageScript-prop": {},
+ "pageScript-prop-after-inject": {},
+ "pageScript-relative-url": {},
+};
+// Sources which load with the extension context.
+const EXTENSION_SOURCES = {
+ contentScript: {},
+ "contentScript-attr-after-inject": { liveSrc: true },
+ "contentScript-content-inject-after-attr": {},
+ "contentScript-prop": {},
+ "contentScript-prop-after-inject": {},
+};
+// When our default content script CSP is applied, only
+// liveSrc: true are loading. IOW, the "script" test above
+// will fail.
+const EXTENSION_SOURCES_CONTENT_CSP = {
+ contentScript: { liveSrc: true },
+ "contentScript-attr-after-inject": { liveSrc: true },
+ "contentScript-content-inject-after-attr": { liveSrc: true },
+ "contentScript-prop": { liveSrc: true },
+ "contentScript-prop-after-inject": { liveSrc: true },
+};
+// All sources.
+const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES);
+
+registerStaticPage(
+ "/page.html",
+ `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ <script nonce="deadbeef">
+ ${getInjectionScript(TESTS, { source: "pageScript", origin: "page" })}
+ </script>
+ </head>
+ <body>
+ ${TESTS.map(test =>
+ toHTML(test, { source: "pageHTML", origin: "page" })
+ ).join("\n ")}
+ </body>
+ </html>`
+);
+
+function catchViolation() {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ document.addEventListener("securitypolicyviolation", e => {
+ browser.test.assertTrue(
+ e.documentURI !== "moz-extension",
+ `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}`
+ );
+ });
+}
+
+const EXTENSION_DATA = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/page.html"],
+ run_at: "document_start",
+ js: ["violation.js", "content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "violation.js": catchViolation,
+ "content_script.js": getInjectionScript(TESTS, {
+ source: "contentScript",
+ origin: "contentScript",
+ }),
+ },
+};
+
+const pageURL = `${BASE_URL}/page.html`;
+const pageURI = Services.io.newURI(pageURL);
+
+// Merges the sets of expected URL and source data returned by separate
+// computedExpectedForbiddenURLs and computedBaseURLs calls.
+function mergeSources(a, b) {
+ return {
+ expectedURLs: new Set([...a.expectedURLs, ...b.expectedURLs]),
+ forbiddenURLs: new Set([...a.forbiddenURLs, ...b.forbiddenURLs]),
+ blockedURLs: new Set([...a.blockedURLs, ...b.blockedURLs]),
+ blockedSources: a.blockedSources || b.blockedSources,
+ };
+}
+
+// Returns a set of origin strings for the given extension and content page, for
+// use in verifying request triggering principals.
+function getOrigins(extension) {
+ return {
+ page: Services.scriptSecurityManager.createContentPrincipal(pageURI, {})
+ .origin,
+ contentScript: Cu.getObjectPrincipal(
+ Cu.Sandbox([extension.principal, pageURL])
+ ).origin,
+ extension: extension.principal.origin,
+ };
+}
+
+/**
+ * Tests that various types of inline content elements initiate requests
+ * with the triggering pringipal of the caller that requested the load.
+ */
+add_task(async function test_contentscript_triggeringPrincipals() {
+ let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await extension.startup();
+
+ let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+ return mergeSources(
+ computeExpectedForbiddenURLs(msg),
+ computeBaseURLs(TESTS, SOURCES)
+ );
+ });
+
+ let origins = getOrigins(extension.extension);
+ let finished = awaitLoads(urlsPromise, origins);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ await finished;
+
+ await extension.unload();
+ await contentPage.close();
+
+ clearCache();
+});
+
+/**
+ * Tests that the correct CSP is applied to loads of inline content
+ * depending on whether the load was initiated by an extension or the
+ * content page.
+ */
+add_task(async function test_contentscript_csp() {
+ // TODO bug 1408193: We currently don't get the full set of CSP reports when
+ // running in network scheduling chaos mode. It's not entirely clear why.
+ let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16);
+ let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02);
+
+ gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`;
+
+ let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await extension.startup();
+
+ let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+ return mergeSources(
+ computeExpectedForbiddenURLs(msg, true),
+ computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES)
+ );
+ });
+
+ let origins = getOrigins(extension.extension);
+
+ let finished = Promise.all([
+ awaitLoads(urlsPromise, origins),
+ checkCSPReports && awaitCSP(urlsPromise),
+ ]);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ await finished;
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+/**
+ * Tests that the correct CSP is applied to loads of inline content
+ * depending on whether the load was initiated by an extension or the
+ * content page.
+ */
+add_task(async function test_extension_contentscript_csp() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+ // TODO bug 1408193: We currently don't get the full set of CSP reports when
+ // running in network scheduling chaos mode. It's not entirely clear why.
+ let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16);
+ let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02);
+
+ gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`;
+
+ let data = {
+ ...EXTENSION_DATA,
+ manifest: {
+ ...EXTENSION_DATA.manifest,
+ manifest_version: 3,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(data);
+ await extension.startup();
+
+ let urlsPromise = extension.awaitMessage("css-sources").then(msg => {
+ return mergeSources(
+ computeExpectedForbiddenURLs(msg, true, true),
+ computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES)
+ );
+ });
+
+ let origins = getOrigins(extension.extension);
+
+ let finished = Promise.all([
+ awaitLoads(urlsPromise, origins),
+ checkCSPReports && awaitCSP(urlsPromise),
+ ]);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(pageURL);
+
+ await finished;
+
+ await extension.unload();
+ await contentPage.close();
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js
new file mode 100644
index 0000000000..dd3ab7846d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function content_script_unregistered_during_loadContentScript() {
+ let content_scripts = [];
+
+ for (let i = 0; i < 10; i++) {
+ content_scripts.push({
+ matches: ["<all_urls>"],
+ js: ["dummy.js"],
+ run_at: "document_start",
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts,
+ },
+ files: {
+ "dummy.js": function() {
+ browser.test.sendMessage("content-script-executed");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ info("Wait for all the content scripts to be executed");
+ await Promise.all(
+ content_scripts.map(() => extension.awaitMessage("content-script-executed"))
+ );
+
+ const promiseDone = contentPage.spawn([extension.id], extensionId => {
+ const { ExtensionProcessScript } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionProcessScript.jsm"
+ );
+
+ return new Promise(resolve => {
+ // This recreates a scenario similar to Bug 1593240 and ensures that the
+ // related fix doesn't regress. Replacing loadContentScript with a
+ // function that unregisters all the content scripts make us sure that
+ // mutating the policy contentScripts doesn't trigger a crash due to
+ // the invalidation of the contentScripts iterator being used by the
+ // caller (ExtensionPolicyService::CheckContentScripts).
+ const { loadContentScript } = ExtensionProcessScript;
+ ExtensionProcessScript.loadContentScript = async (...args) => {
+ const policy = WebExtensionPolicy.getByID(extensionId);
+ let initial = policy.contentScripts.length;
+ let i = initial;
+ while (i) {
+ policy.unregisterContentScript(policy.contentScripts[--i]);
+ }
+ Services.tm.dispatchToMainThread(() =>
+ resolve({
+ initial,
+ final: policy.contentScripts.length,
+ })
+ );
+ // Call the real loadContentScript method.
+ return loadContentScript(...args);
+ };
+ });
+ });
+
+ info("Reload the webpage");
+ await contentPage.loadURL(`${BASE_URL}/file_sample.html`);
+ info("Wait for all the content scripts to be executed again");
+ await Promise.all(
+ content_scripts.map(() => extension.awaitMessage("content-script-executed"))
+ );
+ info("No crash triggered as expected");
+
+ Assert.deepEqual(
+ await promiseDone,
+ { initial: content_scripts.length, final: 0 },
+ "All content scripts unregistered as expected"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js
new file mode 100644
index 0000000000..83cb2f86e9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("layout.xml.prettyprint", true);
+
+const BASE_XML = '<?xml version="1.0" encoding="UTF-8"?>';
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/test.xml", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/xml; charset=utf-8", false);
+ response.write(`${BASE_XML}\n<note></note>`);
+});
+
+// Make sure that XML pretty printer runs after content scripts
+// that runs at document_start (See Bug 1605657).
+add_task(async function content_script_on_xml_prettyprinted_document() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["<all_urls>"],
+ js: ["start.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "start.js": async function() {
+ const el = document.createElement("ext-el");
+ document.documentElement.append(el);
+ if (document.readyState !== "complete") {
+ await new Promise(resolve => {
+ document.addEventListener("DOMContentLoaded", resolve, {
+ once: true,
+ });
+ });
+ }
+ browser.test.sendMessage("content-script-done");
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/test.xml"
+ );
+
+ info("Wait content script and xml document to be fully loaded");
+ await extension.awaitMessage("content-script-done");
+
+ info("Verify the xml file is still pretty printed");
+ const res = await contentPage.spawn([], () => {
+ const doc = this.content.document;
+ const shadowRoot = doc.documentElement.openOrClosedShadowRoot;
+ const prettyPrintLink =
+ shadowRoot &&
+ shadowRoot.querySelector("link[href*='XMLPrettyPrint.css']");
+ return {
+ hasShadowRoot: !!shadowRoot,
+ hasPrettyPrintLink: !!prettyPrintLink,
+ };
+ });
+
+ Assert.deepEqual(
+ res,
+ { hasShadowRoot: true, hasPrettyPrintLink: true },
+ "The XML file has the pretty print shadowRoot"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js
new file mode 100644
index 0000000000..cab508b040
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js
@@ -0,0 +1,85 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["example.net", "example.org"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_process_switch_cross_origin_frame() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.org/*/file_iframe.html"],
+ all_frames: true,
+ js: ["cs.js"],
+ },
+ ],
+ },
+
+ background() {
+ browser.runtime.onConnect.addListener(port => {
+ port.onMessage.addListener(async () => {
+ let { url, frameId } = port.sender;
+
+ browser.test.assertTrue(frameId > 0, "sender frameId is ok");
+ browser.test.assertTrue(
+ url.endsWith("file_iframe.html"),
+ "url is ok"
+ );
+
+ port.postMessage(frameId);
+ port.disconnect();
+ });
+ });
+ },
+
+ files: {
+ "cs.js"() {
+ browser.test.assertEq(
+ location.href,
+ "http://example.org/data/file_iframe.html",
+ "url is ok"
+ );
+
+ let frameId;
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(response => {
+ frameId = response;
+ });
+ port.onDisconnect.addListener(() => {
+ browser.test.sendMessage("content-script-loaded", frameId);
+ });
+ port.postMessage("hello");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ const contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.net/data/file_with_xorigin_frame.html"
+ );
+
+ const browserProcessId =
+ contentPage.browser.browsingContext.currentWindowGlobal.domProcess.childID;
+
+ const scriptFrameId = await extension.awaitMessage("content-script-loaded");
+
+ const children = contentPage.browser.browsingContext.children.map(bc => ({
+ browsingContextId: bc.id,
+ processId: bc.currentWindowGlobal.domProcess.childID,
+ }));
+
+ Assert.equal(children.length, 1);
+ Assert.equal(scriptFrameId, children[0].browsingContextId);
+
+ if (contentPage.remoteSubframes) {
+ Assert.notEqual(browserProcessId, children[0].processId);
+ } else {
+ Assert.equal(browserProcessId, children[0].processId);
+ }
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js
new file mode 100644
index 0000000000..7b92d5c4b7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js
@@ -0,0 +1,59 @@
+"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 unwrapped = window.wrappedJSObject;
+
+ browser.test.assertEq(
+ "undefined",
+ typeof test,
+ "Should not have named X-ray property access"
+ );
+ browser.test.assertEq(
+ undefined,
+ window.test,
+ "Should not have named X-ray property access"
+ );
+ browser.test.assertEq(
+ "object",
+ typeof unwrapped.test,
+ "Should always have non-X-ray named property access"
+ );
+
+ browser.test.notifyPass("contentScriptXrays");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitFinish("contentScriptXrays");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js
new file mode 100644
index 0000000000..7c06fe33a5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js
@@ -0,0 +1,198 @@
+"use strict";
+
+const global = this;
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+var { BaseContext, EventManager } = ExtensionCommon;
+
+class StubContext extends BaseContext {
+ constructor() {
+ let fakeExtension = { id: "test@web.extension" };
+ super("testEnv", fakeExtension);
+ this.sandbox = Cu.Sandbox(global);
+ }
+
+ logActivity(type, name, data) {
+ // no-op required by subclass
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+
+ get principal() {
+ return Cu.getObjectPrincipal(this.sandbox);
+ }
+}
+
+add_task(async function test_post_unload_promises() {
+ let context = new StubContext();
+
+ let fail = result => {
+ ok(false, `Unexpected callback: ${result}`);
+ };
+
+ // Make sure promises resolve normally prior to unload.
+ let promises = [
+ context.wrapPromise(Promise.resolve()),
+ context.wrapPromise(Promise.reject({ message: "" })).catch(() => {}),
+ ];
+
+ await Promise.all(promises);
+
+ // Make sure promises that resolve after unload do not trigger
+ // resolution handlers.
+
+ context.wrapPromise(Promise.resolve("resolved")).then(fail);
+
+ context.wrapPromise(Promise.reject({ message: "rejected" })).then(fail, fail);
+
+ context.unload();
+
+ // The `setTimeout` ensures that we return to the event loop after
+ // promise resolution, which means we're guaranteed to return after
+ // any micro-tasks that get enqueued by the resolution handlers above.
+ await new Promise(resolve => setTimeout(resolve, 0));
+});
+
+add_task(async function test_post_unload_listeners() {
+ let context = new StubContext();
+
+ let fire;
+ let manager = new EventManager({
+ context,
+ name: "EventManager",
+ register: _fire => {
+ fire = () => {
+ _fire.async();
+ };
+ return () => {};
+ },
+ });
+
+ let fail = event => {
+ ok(false, `Unexpected event: ${event}`);
+ };
+
+ // Check that event listeners isn't called after it has been removed.
+ manager.addListener(fail);
+
+ let promise = new Promise(resolve => manager.addListener(resolve));
+
+ fire();
+
+ // The `fireSingleton` call ia dispatched asynchronously, so it won't
+ // have fired by this point. The `fail` listener that we remove now
+ // should not be called, even though the event has already been
+ // enqueued.
+ manager.removeListener(fail);
+
+ // Wait for the remaining listener to be called, which should always
+ // happen after the `fail` listener would normally be called.
+ await promise;
+
+ // Check that the event listener isn't called after the context has
+ // unloaded.
+ manager.addListener(fail);
+
+ // The `fire` callback always dispatches events
+ // asynchronously, so we need to test that any pending event callbacks
+ // aren't fired after the context unloads. We also need to test that
+ // any `fire` calls that happen *after* the context is unloaded also
+ // do not trigger callbacks.
+ fire();
+ Promise.resolve().then(fire);
+
+ context.unload();
+
+ // The `setTimeout` ensures that we return to the event loop after
+ // promise resolution, which means we're guaranteed to return after
+ // any micro-tasks that get enqueued by the resolution handlers above.
+ await new Promise(resolve => setTimeout(resolve, 0));
+});
+
+class Context extends BaseContext {
+ constructor(principal) {
+ let fakeExtension = { id: "test@web.extension" };
+ super("testEnv", fakeExtension);
+ Object.defineProperty(this, "principal", {
+ value: principal,
+ configurable: true,
+ });
+ this.sandbox = Cu.Sandbox(principal, { wantXrays: false });
+ }
+
+ logActivity(type, name, data) {
+ // no-op required by subclass
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+}
+
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin(
+ "http://www.example.org"
+);
+const PRINCIPAL2 = ssm.createContentPrincipalFromOrigin(
+ "http://www.somethingelse.org"
+);
+
+// Test that toJSON() works in the json sandbox
+add_task(async function test_stringify_toJSON() {
+ let context = new Context(PRINCIPAL1);
+ let obj = Cu.evalInSandbox(
+ "({hidden: true, toJSON() { return {visible: true}; } })",
+ context.sandbox
+ );
+
+ let stringified = context.jsonStringify(obj);
+ let expected = JSON.stringify({ visible: true });
+ equal(
+ stringified,
+ expected,
+ "Stringified object with toJSON() method is as expected"
+ );
+});
+
+// Test that stringifying in inaccessible property throws
+add_task(async function test_stringify_inaccessible() {
+ let context = new Context(PRINCIPAL1);
+ let sandbox = context.sandbox;
+ let sandbox2 = Cu.Sandbox(PRINCIPAL2);
+
+ Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox(
+ "({ subobject: true })",
+ sandbox2
+ );
+ let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox);
+ Assert.throws(() => {
+ context.jsonStringify(obj);
+ }, /Permission denied to access property "toJSON"/);
+});
+
+add_task(async function test_stringify_accessible() {
+ // Test that an accessible property from another global is included
+ let principal = Cu.getObjectPrincipal(Cu.Sandbox([PRINCIPAL1, PRINCIPAL2]));
+ let context = new Context(principal);
+ let sandbox = context.sandbox;
+ let sandbox2 = Cu.Sandbox(PRINCIPAL2);
+
+ Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox(
+ "({ subobject: true })",
+ sandbox2
+ );
+ let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox);
+ let stringified = context.jsonStringify(obj);
+
+ let expected = JSON.stringify({ local: true, nested: { subobject: true } });
+ equal(
+ stringified,
+ expected,
+ "Stringified object with accessible property is as expected"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js
new file mode 100644
index 0000000000..521b7db4e9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js
@@ -0,0 +1,273 @@
+"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();
+
+// Each of these tests do the following:
+// 1. Load document to create an extension context (instance of BaseContext).
+// 2. Get weak reference to that context.
+// 3. Unload the document.
+// 4. Force GC and check that the weak reference has been invalidated.
+
+async function reloadTopContext(contentPage) {
+ await contentPage.spawn(null, async () => {
+ let { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+ );
+ let windowNukeObserved = TestUtils.topicObserved("inner-window-nuked");
+ info(`Reloading top-level document`);
+ this.content.location.reload();
+ await windowNukeObserved;
+ info(`Reloaded top-level document`);
+ });
+}
+
+async function assertContextReleased(contentPage, description) {
+ await contentPage.spawn(description, async assertionDescription => {
+ // Force GC, see https://searchfox.org/mozilla-central/rev/b0275bc977ad7fda615ef34b822bba938f2b16fd/testing/talos/talos/tests/devtools/addon/content/damp.js#84-98
+ // and https://searchfox.org/mozilla-central/rev/33c21c060b7f3a52477a73d06ebcb2bf313c4431/xpcom/base/nsMemoryReporterManager.cpp#2574-2585,2591-2594
+ let gcCount = 0;
+ while (gcCount < 30 && this.contextWeakRef.get() !== null) {
+ ++gcCount;
+ // The JS engine will sometimes hold IC stubs for function
+ // environments alive across multiple CCs, which can keep
+ // closed-over JS objects alive. A shrinking GC will throw those
+ // stubs away, and therefore side-step the problem.
+ Cu.forceShrinkingGC();
+ Cu.forceCC();
+ Cu.forceGC();
+ await new Promise(resolve => this.content.setTimeout(resolve, 0));
+ }
+
+ // The above loop needs to be repeated at most 3 times according to MinimizeMemoryUsage:
+ // https://searchfox.org/mozilla-central/rev/6f86cc3479f80ace97f62634e2c82a483d1ede40/xpcom/base/nsMemoryReporterManager.cpp#2644-2647
+ Assert.lessOrEqual(
+ gcCount,
+ 3,
+ `Context should have been GCd within a few GC attempts.`
+ );
+
+ // Each test will set this.contextWeakRef before unloading the document.
+ Assert.ok(!this.contextWeakRef.get(), assertionDescription);
+ });
+}
+
+add_task(async function test_ContentScriptContextChild_in_child_frame() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_iframe.html"],
+ js: ["content_script.js"],
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": "browser.test.sendMessage('contentScriptLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_toplevel.html`
+ );
+ await extension.awaitMessage("contentScriptLoaded");
+
+ await contentPage.spawn(extension.id, async extensionId => {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ let frame = this.content.document.querySelector(
+ "iframe[src*='file_iframe.html']"
+ );
+ let context = DocumentManager.getContext(extensionId, frame.contentWindow);
+
+ Assert.ok(context, "Got content script context");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ frame.remove();
+ });
+
+ await assertContextReleased(
+ contentPage,
+ "ContentScriptContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_ContentScriptContextChild_in_toplevel() {
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ all_frames: true,
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": "browser.test.sendMessage('contentScriptLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension.awaitMessage("contentScriptLoaded");
+
+ await contentPage.spawn(extension.id, async extensionId => {
+ let { DocumentManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionContent.jsm",
+ null
+ );
+ let context = DocumentManager.getContext(extensionId, this.content);
+
+ Assert.ok(context, "Got content script context");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ });
+
+ await reloadTopContext(contentPage);
+ await extension.awaitMessage("contentScriptLoaded");
+ await assertContextReleased(
+ contentPage,
+ "ContentScriptContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_ExtensionPageContextChild_in_child_frame() {
+ let extensionData = {
+ files: {
+ "iframe.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <script src="script.js"></script>
+ `,
+ "toplevel.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <iframe src="iframe.html"></iframe>
+ `,
+ "script.js": "browser.test.sendMessage('extensionPageLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/toplevel.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("extensionPageLoaded");
+
+ await contentPage.spawn(extension.id, async extensionId => {
+ let { ExtensionPageChild } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPageChild.jsm"
+ );
+
+ let frame = this.content.document.querySelector(
+ "iframe[src*='iframe.html']"
+ );
+ let innerWindowID =
+ frame.browsingContext.currentWindowContext.innerWindowId;
+ let context = ExtensionPageChild.extensionContexts.get(innerWindowID);
+
+ Assert.ok(context, "Got extension page context for child frame");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ frame.remove();
+ });
+
+ await assertContextReleased(
+ contentPage,
+ "ExtensionPageContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_ExtensionPageContextChild_in_toplevel() {
+ let extensionData = {
+ files: {
+ "toplevel.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <script src="script.js"></script>
+ `,
+ "script.js": "browser.test.sendMessage('extensionPageLoaded');",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/toplevel.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("extensionPageLoaded");
+
+ await contentPage.spawn(extension.id, async extensionId => {
+ let { ExtensionPageChild } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPageChild.jsm"
+ );
+
+ let innerWindowID = this.content.windowGlobalChild.innerWindowId;
+ let context = ExtensionPageChild.extensionContexts.get(innerWindowID);
+
+ Assert.ok(context, "Got extension page context for top-level document");
+
+ this.contextWeakRef = Cu.getWeakReference(context);
+ });
+
+ await reloadTopContext(contentPage);
+ await extension.awaitMessage("extensionPageLoaded");
+ // For some unknown reason, the context cannot forcidbly be released by the
+ // garbage collector unless we wait for a short while.
+ await contentPage.spawn(null, async () => {
+ let start = Date.now();
+ // The treshold was found after running this subtest only, 300 times
+ // in a release build (100 of xpcshell, xpcshell-e10s and xpcshell-remote).
+ // With treshold 8, almost half of the tests complete after a 17-18 ms delay.
+ // With treshold 7, over half of the tests complete after a 13-14 ms delay,
+ // with 12 failures in 300 tests runs.
+ // Let's double that number to have a safety margin.
+ for (let i = 0; i < 15; ++i) {
+ await new Promise(resolve => this.content.setTimeout(resolve, 0));
+ }
+ info(`Going to GC after waiting for ${Date.now() - start} ms.`);
+ });
+ await assertContextReleased(
+ contentPage,
+ "ExtensionPageContextChild should have been released"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js
new file mode 100644
index 0000000000..1c1827c64f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js
@@ -0,0 +1,513 @@
+"use strict";
+
+do_get_profile();
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionPreferencesManager",
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+
+const CONTAINERS_PREF = "privacy.userContext.enabled";
+
+AddonTestUtils.init(this);
+
+add_task(async function startup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contextualIdentities_without_permissions() {
+ function background() {
+ browser.test.assertTrue(
+ !browser.contextualIdentities,
+ "contextualIdentities API is not available when the contextualIdentities permission is not required"
+ );
+ browser.test.notifyPass("contextualIdentities_without_permission");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ applications: {
+ gecko: { id: "testing@thing.com" },
+ },
+ permissions: [],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities_without_permission");
+ await extension.unload();
+});
+
+add_task(async function test_contextualIdentity_events() {
+ async function background() {
+ function createOneTimeListener(type) {
+ return new Promise((resolve, reject) => {
+ try {
+ browser.test.assertTrue(
+ type in browser.contextualIdentities,
+ `Found API object browser.contextualIdentities.${type}`
+ );
+ const listener = change => {
+ browser.test.assertTrue(
+ "contextualIdentity" in change,
+ `Found identity in change`
+ );
+ browser.contextualIdentities[type].removeListener(listener);
+ resolve(change);
+ };
+ browser.contextualIdentities[type].addListener(listener);
+ } catch (e) {
+ reject(e);
+ }
+ });
+ }
+
+ function assertExpected(expected, container) {
+ // Number of keys that are added by the APIs
+ const createdCount = 2;
+ for (let key of Object.keys(container)) {
+ browser.test.assertTrue(key in expected, `found property ${key}`);
+ browser.test.assertEq(
+ expected[key],
+ container[key],
+ `property value for ${key} is correct`
+ );
+ }
+ const hexMatch = /^#[0-9a-f]{6}$/;
+ browser.test.assertTrue(
+ hexMatch.test(expected.colorCode),
+ "Color code property was expected Hex shape"
+ );
+ const iconMatch = /^resource:\/\/usercontext-content\/[a-z]+[.]svg$/;
+ browser.test.assertTrue(
+ iconMatch.test(expected.iconUrl),
+ "Icon url property was expected shape"
+ );
+ browser.test.assertEq(
+ Object.keys(expected).length,
+ Object.keys(container).length + createdCount,
+ "all expected properties found"
+ );
+ }
+
+ let onCreatePromise = createOneTimeListener("onCreated");
+
+ let containerObj = { name: "foobar", color: "red", icon: "circle" };
+ let ci = await browser.contextualIdentities.create(containerObj);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ const onCreateListenerResponse = await onCreatePromise;
+ const cookieStoreId = ci.cookieStoreId;
+ assertExpected(
+ onCreateListenerResponse.contextualIdentity,
+ Object.assign(containerObj, { cookieStoreId })
+ );
+
+ let onUpdatedPromise = createOneTimeListener("onUpdated");
+ let updateContainerObj = { name: "testing", color: "blue", icon: "dollar" };
+ ci = await browser.contextualIdentities.update(
+ cookieStoreId,
+ updateContainerObj
+ );
+ browser.test.assertTrue(!!ci, "We have an update identity");
+ const onUpdatedListenerResponse = await onUpdatedPromise;
+ assertExpected(
+ onUpdatedListenerResponse.contextualIdentity,
+ Object.assign(updateContainerObj, { cookieStoreId })
+ );
+
+ let onRemovePromise = createOneTimeListener("onRemoved");
+ ci = await browser.contextualIdentities.remove(
+ updateContainerObj.cookieStoreId
+ );
+ browser.test.assertTrue(!!ci, "We have an remove identity");
+ const onRemoveListenerResponse = await onRemovePromise;
+ assertExpected(
+ onRemoveListenerResponse.contextualIdentity,
+ Object.assign(updateContainerObj, { cookieStoreId })
+ );
+
+ browser.test.notifyPass("contextualIdentities_events");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ useAddonManager: "temporary",
+ manifest: {
+ applications: {
+ gecko: { id: "testing@thing.com" },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+
+ Services.prefs.setBoolPref(CONTAINERS_PREF, true);
+
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities_events");
+ await extension.unload();
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(async function test_contextualIdentity_with_permissions() {
+ const initial = Services.prefs.getBoolPref(CONTAINERS_PREF);
+
+ async function background() {
+ let ci;
+ await browser.test.assertRejects(
+ browser.contextualIdentities.get("foobar"),
+ "Invalid contextual identity: foobar",
+ "API should reject here"
+ );
+ await browser.test.assertRejects(
+ browser.contextualIdentities.update("foobar", { name: "testing" }),
+ "Invalid contextual identity: foobar",
+ "API should reject for unknown updates"
+ );
+ await browser.test.assertRejects(
+ browser.contextualIdentities.remove("foobar"),
+ "Invalid contextual identity: foobar",
+ "API should reject for removing unknown containers"
+ );
+
+ ci = await browser.contextualIdentities.get("firefox-container-1");
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertTrue("name" in ci, "We have an identity.name");
+ browser.test.assertTrue("color" in ci, "We have an identity.color");
+ browser.test.assertTrue("icon" in ci, "We have an identity.icon");
+ browser.test.assertEq("Personal", ci.name, "identity.name is correct");
+ browser.test.assertEq(
+ "firefox-container-1",
+ ci.cookieStoreId,
+ "identity.cookieStoreId is correct"
+ );
+
+ function listenForMessage(messageName, stateChangeBool) {
+ return new Promise(resolve => {
+ browser.test.onMessage.addListener(function listener(msg) {
+ browser.test.log(`Got message from background: ${msg}`);
+ if (msg === messageName + "-response") {
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ }
+ });
+ browser.test.log(
+ `Sending message to background: ${messageName} ${stateChangeBool}`
+ );
+ browser.test.sendMessage(messageName, stateChangeBool);
+ });
+ }
+
+ await listenForMessage("containers-state-change", false);
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.query({}),
+ "Contextual identities are currently disabled",
+ "Throws when containers are disabled"
+ );
+
+ await listenForMessage("containers-state-change", true);
+
+ let cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(
+ 4,
+ cis.length,
+ "by default we should have 4 containers"
+ );
+
+ cis = await browser.contextualIdentities.query({ name: "Personal" });
+ browser.test.assertEq(
+ 1,
+ cis.length,
+ "by default we should have 1 container called Personal"
+ );
+
+ cis = await browser.contextualIdentities.query({ name: "foobar" });
+ browser.test.assertEq(
+ 0,
+ cis.length,
+ "by default we should have 0 container called foobar"
+ );
+
+ ci = await browser.contextualIdentities.create({
+ name: "foobar",
+ color: "red",
+ icon: "gift",
+ });
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("foobar", ci.name, "identity.name is correct");
+ browser.test.assertEq("red", ci.color, "identity.color is correct");
+ browser.test.assertEq("gift", ci.icon, "identity.icon is correct");
+ browser.test.assertTrue(
+ !!ci.cookieStoreId,
+ "identity.cookieStoreId is correct"
+ );
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.create({
+ name: "foobar",
+ color: "red",
+ icon: "firefox",
+ }),
+ "Invalid icon firefox for container",
+ "Create container called with an invalid icon"
+ );
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.create({
+ name: "foobar",
+ color: "firefox-orange",
+ icon: "gift",
+ }),
+ "Invalid color name firefox-orange for container",
+ "Create container called with an invalid color"
+ );
+
+ cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(
+ 5,
+ cis.length,
+ "we should still have have 5 containers"
+ );
+
+ ci = await browser.contextualIdentities.get(ci.cookieStoreId);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("foobar", ci.name, "identity.name is correct");
+ browser.test.assertEq("red", ci.color, "identity.color is correct");
+ browser.test.assertEq("gift", ci.icon, "identity.icon is correct");
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.update(ci.cookieStoreId, {
+ name: "foobar",
+ color: "red",
+ icon: "firefox",
+ }),
+ "Invalid icon firefox for container",
+ "Create container called with an invalid icon"
+ );
+
+ browser.test.assertRejects(
+ browser.contextualIdentities.update(ci.cookieStoreId, {
+ name: "foobar",
+ color: "firefox-orange",
+ icon: "gift",
+ }),
+ "Invalid color name firefox-orange for container",
+ "Create container called with an invalid color"
+ );
+
+ cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(5, cis.length, "now we have 5 identities");
+
+ ci = await browser.contextualIdentities.update(ci.cookieStoreId, {
+ name: "barfoo",
+ color: "blue",
+ icon: "cart",
+ });
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("barfoo", ci.name, "identity.name is correct");
+ browser.test.assertEq("blue", ci.color, "identity.color is correct");
+ browser.test.assertEq("cart", ci.icon, "identity.icon is correct");
+
+ ci = await browser.contextualIdentities.get(ci.cookieStoreId);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("barfoo", ci.name, "identity.name is correct");
+ browser.test.assertEq("blue", ci.color, "identity.color is correct");
+ browser.test.assertEq("cart", ci.icon, "identity.icon is correct");
+
+ ci = await browser.contextualIdentities.remove(ci.cookieStoreId);
+ browser.test.assertTrue(!!ci, "We have an identity");
+ browser.test.assertEq("barfoo", ci.name, "identity.name is correct");
+ browser.test.assertEq("blue", ci.color, "identity.color is correct");
+ browser.test.assertEq("cart", ci.icon, "identity.icon is correct");
+
+ cis = await browser.contextualIdentities.query({});
+ browser.test.assertEq(4, cis.length, "we are back to 4 identities");
+
+ browser.test.notifyPass("contextualIdentities");
+ }
+
+ function makeExtension(id) {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ applications: {
+ gecko: { id },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+ }
+
+ let extension = makeExtension("containers-test@mozilla.org");
+
+ extension.onMessage("containers-state-change", stateBool => {
+ Cu.reportError(`Got message "containers-state-change", ${stateBool}`);
+ Services.prefs.setBoolPref(CONTAINERS_PREF, stateBool);
+ Cu.reportError("Changed pref");
+ extension.sendMessage("containers-state-change-response");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities");
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ true,
+ "Pref should now be enabled, whatever it's initial state"
+ );
+ await extension.unload();
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ initial,
+ "Pref should now be initial state"
+ );
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(async function test_contextualIdentity_extensions_enable_containers() {
+ const initial = Services.prefs.getBoolPref(CONTAINERS_PREF);
+ async function background() {
+ let ci = await browser.contextualIdentities.get("firefox-container-1");
+ browser.test.assertTrue(!!ci, "We have an identity");
+
+ browser.test.notifyPass("contextualIdentities");
+ }
+ function makeExtension(id) {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ applications: {
+ gecko: { id },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+ }
+ async function testSetting(expect, message) {
+ let setting = await ExtensionPreferencesManager.getSetting(
+ "privacy.containers"
+ );
+ if (expect === null) {
+ equal(setting, null, message);
+ } else {
+ equal(setting.value, expect, message);
+ }
+ }
+ function testPref(expect, message) {
+ equal(Services.prefs.getBoolPref(CONTAINERS_PREF), expect, message);
+ }
+
+ let extension = makeExtension("containers-test@mozilla.org");
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities");
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ true,
+ "Pref should now be enabled, whatever it's initial state"
+ );
+ await extension.unload();
+ await testSetting(null, "setting should be unset");
+ testPref(initial, "setting should be initial value");
+
+ // Lets set containers explicitly to be off and test we keep it that way after removal
+ Services.prefs.setBoolPref(CONTAINERS_PREF, false);
+
+ let extension1 = makeExtension("containers-test-1@mozilla.org");
+ await extension1.startup();
+ await extension1.awaitFinish("contextualIdentities");
+ await testSetting(extension1.id, "setting should be controlled");
+ testPref(true, "Pref should now be enabled, whatever it's initial state");
+
+ await extension1.unload();
+ await testSetting(null, "setting should be unset");
+ testPref(false, "Pref should be false");
+
+ // Lets set containers explicitly to be on and test we keep it that way after removal.
+ Services.prefs.setBoolPref(CONTAINERS_PREF, true);
+
+ let extension2 = makeExtension("containers-test-2@mozilla.org");
+ let extension3 = makeExtension("containers-test-3@mozilla.org");
+ await extension2.startup();
+ await extension2.awaitFinish("contextualIdentities");
+ await extension3.startup();
+ await extension3.awaitFinish("contextualIdentities");
+
+ // Flip the ordering to check it's still enabled
+ await testSetting(extension3.id, "setting should still be controlled by 3");
+ testPref(true, "Pref should now be enabled 1");
+ await extension3.unload();
+ await testSetting(extension2.id, "setting should still be controlled by 2");
+ testPref(true, "Pref should now be enabled 2");
+ await extension2.unload();
+ await testSetting(null, "setting should be unset");
+ testPref(true, "Pref should now be enabled 3");
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
+
+add_task(async function test_contextualIdentity_preference_change() {
+ async function background() {
+ let extensionInfo = await browser.management.getSelf();
+ if (extensionInfo.version == "1.0.0") {
+ const containers = await browser.contextualIdentities.query({});
+ browser.test.assertEq(
+ containers.length,
+ 4,
+ "We still have the original containers"
+ );
+ await browser.contextualIdentities.create({
+ name: "foobar",
+ color: "red",
+ icon: "circle",
+ });
+ }
+ const containers = await browser.contextualIdentities.query({});
+ browser.test.assertEq(containers.length, 5, "We have a new container");
+ if (extensionInfo.version == "1.1.0") {
+ await browser.contextualIdentities.remove(containers[4].cookieStoreId);
+ }
+ browser.test.notifyPass("contextualIdentities");
+ }
+ function makeExtension(id, version) {
+ return ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background,
+ manifest: {
+ version,
+ applications: {
+ gecko: { id },
+ },
+ permissions: ["contextualIdentities"],
+ },
+ });
+ }
+
+ Services.prefs.setBoolPref(CONTAINERS_PREF, false);
+ let extension = makeExtension("containers-pref-test@mozilla.org", "1.0.0");
+ await extension.startup();
+ await extension.awaitFinish("contextualIdentities");
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ true,
+ "Pref should now be enabled, whatever it's initial state"
+ );
+
+ let extension2 = makeExtension("containers-pref-test@mozilla.org", "1.1.0");
+ await extension2.startup();
+ await extension2.awaitFinish("contextualIdentities");
+
+ await extension.unload();
+ equal(
+ Services.prefs.getBoolPref(CONTAINERS_PREF),
+ false,
+ "Pref should now be the initial state we set it to."
+ );
+
+ Services.prefs.clearUserPref(CONTAINERS_PREF);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js
new file mode 100644
index 0000000000..8edd61fc63
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js
@@ -0,0 +1,675 @@
+"use strict";
+
+const { UrlClassifierTestUtils } = ChromeUtils.import(
+ "resource://testing-common/UrlClassifierTestUtils.jsm"
+);
+
+const {
+ // cookieBehavior constants.
+ BEHAVIOR_REJECT,
+ BEHAVIOR_REJECT_TRACKER,
+
+ // lifetimePolicy constants.
+ ACCEPT_SESSION,
+} = Ci.nsICookieService;
+
+function createPage({ script, body = "" } = {}) {
+ if (script) {
+ body += `<script src="${script}"></script>`;
+ }
+
+ return `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ ${body}
+ </body>
+ </html>`;
+}
+
+const server = createHttpServer({ hosts: ["example.com", "itisatracker.org"] });
+server.registerDirectory("/data/", do_get_file("data"));
+server.registerPathHandler("/test-cookies", (request, response) => {
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/json", false);
+ response.setHeader("Set-Cookie", "myKey=myCookie", true);
+ response.write('{"success": true}');
+});
+server.registerPathHandler("/subframe.html", (request, response) => {
+ response.write(createPage());
+});
+server.registerPathHandler("/page-with-tracker.html", (request, response) => {
+ response.write(
+ createPage({
+ body: `<iframe src="http://itisatracker.org/test-cookies"></iframe>`,
+ })
+ );
+});
+server.registerPathHandler("/sw.js", (request, response) => {
+ response.setHeader("Content-Type", "text/javascript", false);
+ response.write("");
+});
+
+function assertCookiesForHost(url, cookiesCount, message) {
+ const { host } = new URL(url);
+ const cookies = Services.cookies.cookies.filter(
+ cookie => cookie.host === host
+ );
+ equal(cookies.length, cookiesCount, message);
+ return cookies;
+}
+
+// Test that the indexedDB and localStorage are allowed in an extension page
+// and that the indexedDB is allowed in a extension worker.
+add_task(async function test_ext_page_allowed_storage() {
+ function testWebStorages() {
+ const url = window.location.href;
+
+ try {
+ // In a webpage accessing indexedDB throws on cookiesBehavior reject,
+ // here we verify that doesn't happen for an extension page.
+ browser.test.assertTrue(
+ indexedDB,
+ "IndexedDB global should be accessible"
+ );
+
+ // In a webpage localStorage is undefined on cookiesBehavior reject,
+ // here we verify that doesn't happen for an extension page.
+ browser.test.assertTrue(
+ localStorage,
+ "localStorage global should be defined"
+ );
+
+ const worker = new Worker("worker.js");
+ worker.onmessage = event => {
+ browser.test.assertTrue(
+ event.data.pass,
+ "extension page worker have access to indexedDB"
+ );
+
+ browser.test.sendMessage("test-storage:done", url);
+ };
+
+ worker.postMessage({});
+ } catch (err) {
+ browser.test.fail(`Unexpected error: ${err}`);
+ browser.test.sendMessage("test-storage:done", url);
+ }
+ }
+
+ function testWorker() {
+ this.onmessage = () => {
+ try {
+ void indexedDB;
+ postMessage({ pass: true });
+ } catch (err) {
+ postMessage({ pass: false });
+ throw err;
+ }
+ };
+ }
+
+ async function createExtension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ files: {
+ "test_web_storages.js": testWebStorages,
+ "worker.js": testWorker,
+ "page_subframe.html": createPage({ script: "test_web_storages.js" }),
+ "page_with_subframe.html": createPage({
+ body: '<iframe src="page_subframe.html"></iframe>',
+ }),
+ "page.html": createPage({
+ script: "test_web_storages.js",
+ }),
+ },
+ });
+
+ await extension.startup();
+
+ const EXT_BASE_URL = `moz-extension://${extension.uuid}/`;
+
+ return { extension, EXT_BASE_URL };
+ }
+
+ const cookieBehaviors = [
+ "BEHAVIOR_LIMIT_FOREIGN",
+ "BEHAVIOR_REJECT_FOREIGN",
+ "BEHAVIOR_REJECT",
+ "BEHAVIOR_REJECT_TRACKER",
+ "BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN",
+ ];
+ equal(
+ cookieBehaviors.length,
+ Ci.nsICookieService.BEHAVIOR_LAST,
+ "all behaviors should be covered"
+ );
+
+ for (const behavior of cookieBehaviors) {
+ info(
+ `Test extension page access to indexedDB & localStorage with ${behavior}`
+ );
+ ok(
+ behavior in Ci.nsICookieService,
+ `${behavior} is a valid CookieBehavior`
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService[behavior]
+ );
+
+ // Create a new extension to ensure that the cookieBehavior just set is going to be
+ // used for the requests triggered by the extension page.
+ const { extension, EXT_BASE_URL } = await createExtension();
+ const extPage = await ExtensionTestUtils.loadContentPage("about:blank", {
+ extension,
+ remote: extension.extension.remote,
+ });
+
+ info("Test from a top level extension page");
+ await extPage.loadURL(`${EXT_BASE_URL}page.html`);
+
+ let testedFromURL = await extension.awaitMessage("test-storage:done");
+ equal(
+ testedFromURL,
+ `${EXT_BASE_URL}page.html`,
+ "Got the results from the expected url"
+ );
+
+ info("Test from a sub frame extension page");
+ await extPage.loadURL(`${EXT_BASE_URL}page_with_subframe.html`);
+
+ testedFromURL = await extension.awaitMessage("test-storage:done");
+ equal(
+ testedFromURL,
+ `${EXT_BASE_URL}page_subframe.html`,
+ "Got the results from the expected url"
+ );
+
+ await extPage.close();
+ await extension.unload();
+ }
+});
+
+add_task(async function test_ext_page_3rdparty_cookies() {
+ // Disable tracking protection to test cookies on BEHAVIOR_REJECT_TRACKER
+ // (otherwise tracking protection would block the tracker iframe and
+ // we would not be actually checking the cookie behavior).
+ Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false);
+ await UrlClassifierTestUtils.addTestTrackers();
+ registerCleanupFunction(function() {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref("privacy.trackingprotection.enabled");
+ Services.cookies.removeAll();
+ });
+
+ function testRequestScript() {
+ browser.test.onMessage.addListener((msg, url) => {
+ const done = () => {
+ browser.test.sendMessage(`${msg}:done`);
+ };
+
+ switch (msg) {
+ case "xhr": {
+ let req = new XMLHttpRequest();
+ req.onload = done;
+ req.open("GET", url);
+ req.send();
+ break;
+ }
+ case "fetch": {
+ window.fetch(url).then(done);
+ break;
+ }
+ case "worker fetch": {
+ const worker = new Worker("test_worker.js");
+ worker.onmessage = evt => {
+ if (evt.data.requestDone) {
+ done();
+ }
+ };
+ worker.postMessage({ url });
+ break;
+ }
+ default: {
+ browser.test.fail(`Received an unexpected message: ${msg}`);
+ done();
+ }
+ }
+ });
+
+ browser.test.sendMessage("testRequestScript:ready", window.location.href);
+ }
+
+ function testWorker() {
+ this.onmessage = evt => {
+ fetch(evt.data.url).then(() => {
+ postMessage({ requestDone: true });
+ });
+ };
+ }
+
+ async function createExtension() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*", "http://itisatracker.org/*"],
+ },
+ files: {
+ "test_worker.js": testWorker,
+ "test_request.js": testRequestScript,
+ "page_subframe.html": createPage({ script: "test_request.js" }),
+ "page_with_subframe.html": createPage({
+ body: '<iframe src="page_subframe.html"></iframe>',
+ }),
+ "page.html": createPage({ script: "test_request.js" }),
+ },
+ });
+
+ await extension.startup();
+
+ const EXT_BASE_URL = `moz-extension://${extension.uuid}`;
+
+ return { extension, EXT_BASE_URL };
+ }
+
+ const testUrl = "http://example.com/test-cookies";
+ const testRequests = ["xhr", "fetch", "worker fetch"];
+ const tests = [
+ { behavior: "BEHAVIOR_ACCEPT", cookiesCount: 1 },
+ { behavior: "BEHAVIOR_REJECT_FOREIGN", cookiesCount: 1 },
+ { behavior: "BEHAVIOR_REJECT", cookiesCount: 0 },
+ { behavior: "BEHAVIOR_LIMIT_FOREIGN", cookiesCount: 1 },
+ { behavior: "BEHAVIOR_REJECT_TRACKER", cookiesCount: 1 },
+ ];
+
+ function clearAllCookies() {
+ Services.cookies.removeAll();
+ let cookies = Services.cookies.cookies;
+ equal(cookies.length, 0, "There shouldn't be any cookies after clearing");
+ }
+
+ async function runTestRequests(extension, cookiesCount, msg) {
+ for (const testRequest of testRequests) {
+ clearAllCookies();
+ extension.sendMessage(testRequest, testUrl);
+ await extension.awaitMessage(`${testRequest}:done`);
+ assertCookiesForHost(
+ testUrl,
+ cookiesCount,
+ `${msg}: cookies count on ${testRequest} "${testUrl}"`
+ );
+ }
+ }
+
+ for (const { behavior, cookiesCount } of tests) {
+ info(`Test cookies on http requests with ${behavior}`);
+ ok(
+ behavior in Ci.nsICookieService,
+ `${behavior} is a valid CookieBehavior`
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ Ci.nsICookieService[behavior]
+ );
+
+ // Create a new extension to ensure that the cookieBehavior just set is going to be
+ // used for the requests triggered by the extension page.
+ const { extension, EXT_BASE_URL } = await createExtension();
+
+ // Run all the test requests on a top level extension page.
+ let extPage = await ExtensionTestUtils.loadContentPage(
+ `${EXT_BASE_URL}/page.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("testRequestScript:ready");
+ await runTestRequests(
+ extension,
+ cookiesCount,
+ `Test top level extension page on ${behavior}`
+ );
+ await extPage.close();
+
+ // Rerun all the test requests on a sub frame extension page.
+ extPage = await ExtensionTestUtils.loadContentPage(
+ `${EXT_BASE_URL}/page_with_subframe.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("testRequestScript:ready");
+ await runTestRequests(
+ extension,
+ cookiesCount,
+ `Test sub frame extension page on ${behavior}`
+ );
+ await extPage.close();
+
+ await extension.unload();
+ }
+
+ // Test tracking url blocking from a webpage subframe.
+ info(
+ "Testing blocked tracker cookies in webpage subframe on BEHAVIOR_REJECT_TRACKERS"
+ );
+ Services.prefs.setIntPref(
+ "network.cookie.cookieBehavior",
+ BEHAVIOR_REJECT_TRACKER
+ );
+
+ const trackerURL = "http://itisatracker.org/test-cookies";
+ const { extension, EXT_BASE_URL } = await createExtension();
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `${EXT_BASE_URL}/_generated_background_page.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ clearAllCookies();
+
+ await extPage.spawn(
+ "http://example.com/page-with-tracker.html",
+ async iframeURL => {
+ const iframe = this.content.document.createElement("iframe");
+ iframe.setAttribute("src", iframeURL);
+ return new Promise(resolve => {
+ iframe.onload = () => resolve();
+ this.content.document.body.appendChild(iframe);
+ });
+ }
+ );
+
+ assertCookiesForHost(
+ trackerURL,
+ 0,
+ "Test cookies on web subframe inside top level extension page on BEHAVIOR_REJECT_TRACKER"
+ );
+ clearAllCookies();
+
+ await extPage.close();
+ await extension.unload();
+});
+
+// Test that a webpage embedded as a subframe of an extension page is not allowed to use
+// IndexedDB and register a ServiceWorker when it shouldn't be based on the cookieBehavior.
+add_task(
+ async function test_webpage_subframe_storage_respect_cookiesBehavior() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*"],
+ web_accessible_resources: ["subframe.html"],
+ },
+ files: {
+ "toplevel.html": createPage({
+ body: `
+ <iframe id="ext" src="subframe.html"></iframe>
+ <iframe id="web" src="http://example.com/subframe.html"></iframe>
+ `,
+ }),
+ "subframe.html": createPage(),
+ },
+ });
+
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT);
+
+ await extension.startup();
+
+ let extensionPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/toplevel.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+
+ let results = await extensionPage.spawn(null, async () => {
+ let extFrame = this.content.document.querySelector("iframe#ext");
+ let webFrame = this.content.document.querySelector("iframe#web");
+
+ function testIDB(win) {
+ try {
+ void win.indexedDB;
+ return { success: true };
+ } catch (err) {
+ return { error: `${err}` };
+ }
+ }
+
+ async function testServiceWorker(win) {
+ try {
+ await win.navigator.serviceWorker.register("sw.js");
+ return { success: true };
+ } catch (err) {
+ return { error: `${err}` };
+ }
+ }
+
+ return {
+ extTopLevel: testIDB(this.content),
+ extSubFrame: testIDB(extFrame.contentWindow),
+ webSubFrame: testIDB(webFrame.contentWindow),
+ webServiceWorker: await testServiceWorker(webFrame.contentWindow),
+ };
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/subframe.html"
+ );
+
+ results.extSubFrameContent = await contentPage.spawn(
+ extension.uuid,
+ uuid => {
+ return new Promise(resolve => {
+ let frame = this.content.document.createElement("iframe");
+ frame.setAttribute("src", `moz-extension://${uuid}/subframe.html`);
+ frame.onload = () => {
+ try {
+ void frame.contentWindow.indexedDB;
+ resolve({ success: true });
+ } catch (err) {
+ resolve({ error: `${err}` });
+ }
+ };
+ this.content.document.body.appendChild(frame);
+ });
+ }
+ );
+
+ Assert.deepEqual(
+ results.extTopLevel,
+ { success: true },
+ "IndexedDB allowed in a top level extension page"
+ );
+
+ Assert.deepEqual(
+ results.extSubFrame,
+ { success: true },
+ "IndexedDB allowed in a subframe extension page with a top level extension page"
+ );
+
+ Assert.deepEqual(
+ results.webSubFrame,
+ { error: "SecurityError: The operation is insecure." },
+ "IndexedDB not allowed in a subframe webpage with a top level extension page"
+ );
+ Assert.deepEqual(
+ results.webServiceWorker,
+ { error: "SecurityError: The operation is insecure." },
+ "IndexedDB and Cache not allowed in a service worker registered in the subframe webpage extension page"
+ );
+
+ Assert.deepEqual(
+ results.extSubFrameContent,
+ { success: true },
+ "IndexedDB allowed in a subframe extension page with a top level webpage"
+ );
+
+ await extensionPage.close();
+ await contentPage.close();
+
+ await extension.unload();
+ }
+);
+
+// Test that the webpage's indexedDB and localStorage are still not allowed from a content script
+// when the cookie behavior doesn't allow it, even when they are allowed in the extension pages.
+add_task(async function test_content_script_on_cookieBehaviorReject() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT);
+
+ function contentScript() {
+ // Ensure that when the current cookieBehavior doesn't allow a webpage to use indexedDB
+ // or localStorage, then a WebExtension content script is not allowed to use it as well.
+ browser.test.assertThrows(
+ () => indexedDB,
+ /The operation is insecure/,
+ "a content script can't use indexedDB from a page where it is disallowed"
+ );
+
+ browser.test.assertThrows(
+ () => localStorage,
+ /The operation is insecure/,
+ "a content script can't use localStorage from a page where it is disallowed"
+ );
+
+ browser.test.notifyPass("cs_disallowed_storage");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitFinish("cs_disallowed_storage");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(function clear_cookieBehavior_pref() {
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+});
+
+// Test that localStorage is not in session-only mode for the extension pages,
+// even when the session-only mode has been globally enabled, but that the
+// lifetime policy currently set is respected in webpage subframes embedded in
+// an extension page.
+add_task(async function test_localStorage_on_session_lifetimePolicy() {
+ // localStorage in session-only mode.
+ Services.prefs.setIntPref("network.cookie.lifetimePolicy", ACCEPT_SESSION);
+
+ function extPageScript() {
+ localStorage.setItem("test-key", "test-value");
+
+ browser.test.sendMessage("bg_localStorage_set");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*", "http://itisatracker.org/*"],
+ },
+ files: {
+ "ext.js": extPageScript,
+ "ext.html": createPage({
+ body: `<iframe src="http://example.com"></iframe>`,
+ script: "ext.js",
+ }),
+ },
+ });
+
+ await extension.startup();
+
+ let extensionPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/ext.html`,
+ {
+ extension,
+ remote: extension.extension.remote,
+ }
+ );
+ await extension.awaitMessage("bg_localStorage_set");
+
+ const results = await extensionPage.spawn(null, async () => {
+ const iframe = this.content.document.querySelector("iframe").contentWindow;
+ const { localStorage } = this.content;
+
+ await this.content.fetch("http://itisatracker.org/test-cookies");
+ await iframe.fetch("http://example.com/test-cookies");
+
+ return {
+ topLevel: {
+ isSessionOnly: localStorage.isSessionOnly,
+ domStorageLength: localStorage.length,
+ domStorageStoredValue: localStorage.getItem("test-key"),
+ },
+ webFrame: {
+ isSessionOnly: iframe.localStorage.isSessionOnly,
+ },
+ };
+ });
+
+ equal(
+ results.topLevel.isSessionOnly,
+ false,
+ "the extension localStorage is not set in session-only mode"
+ );
+ equal(
+ results.topLevel.domStorageLength,
+ 1,
+ "the extension storage contains the expected number of keys"
+ );
+ equal(
+ results.topLevel.domStorageStoredValue,
+ "test-value",
+ "the extension storage contains the expected data"
+ );
+
+ equal(
+ results.webFrame.isSessionOnly,
+ true,
+ "the webpage sub frame localStorage is in session-only mode"
+ );
+
+ let cookies = assertCookiesForHost(
+ "http://example.com",
+ 1,
+ "Got a cookie from the extension page request"
+ );
+ ok(
+ cookies[0].isSession,
+ "Got a session cookie from the extension page request"
+ );
+
+ cookies = assertCookiesForHost(
+ "http://itisatracker.org",
+ 1,
+ "Got a cookie from the web page request"
+ );
+ ok(cookies[0].isSession, "Got a session cookie from the web page request");
+
+ await extensionPage.close();
+
+ await extension.unload();
+});
+
+add_task(function clear_lifetimePolicy_pref() {
+ Services.prefs.clearUserPref("network.cookie.lifetimePolicy");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js
new file mode 100644
index 0000000000..700794b46c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js
@@ -0,0 +1,334 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["example.org", "example.net", "example.com"],
+});
+
+function promiseSetCookies() {
+ return new Promise(resolve => {
+ server.registerPathHandler("/setCookies", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Set-Cookie", "none=a; sameSite=none", true);
+ response.setHeader("Set-Cookie", "lax=b; sameSite=lax", true);
+ response.setHeader("Set-Cookie", "strict=c; sameSite=strict", true);
+ response.write("<html></html>");
+ resolve();
+ });
+ });
+}
+
+function promiseLoadedCookies() {
+ return new Promise(resolve => {
+ let cookies;
+
+ server.registerPathHandler("/checkCookies", (request, response) => {
+ cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+
+ response.setStatusLine(request.httpVersion, 302, "Moved Permanently");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Location", "/ready");
+ });
+
+ server.registerPathHandler("/navigate", (request, response) => {
+ cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ "<html><script>location = '/checkCookies';</script></html>"
+ );
+ });
+
+ server.registerPathHandler("/fetch", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html><script>fetch('/checkCookies');</script></html>");
+ });
+
+ server.registerPathHandler("/nestedfetch", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ "<html><iframe src='http://example.net/nestedfetch2'></iframe></html>"
+ );
+ });
+
+ server.registerPathHandler("/nestedfetch2", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ "<html><iframe src='http://example.org/fetch'></iframe></html>"
+ );
+ });
+
+ server.registerPathHandler("/ready", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html></html>");
+
+ resolve(cookies);
+ });
+ });
+}
+
+add_task(async function setup() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 0);
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true);
+
+ // We don't want to have 'secure' cookies because our test http server doesn't run in https.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+
+ // Let's set 3 cookies before loading the extension.
+ let cookiesPromise = promiseSetCookies();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/setCookies"
+ );
+ await cookiesPromise;
+ await contentPage.close();
+ Assert.equal(Services.cookies.cookies.length, 3);
+});
+
+add_task(async function test_cookies_firstParty() {
+ async function pageScript() {
+ const ifr = document.createElement("iframe");
+ ifr.src = "http://example.org/" + location.search.slice(1);
+ document.body.appendChild(ifr);
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["*://example.org/"],
+ },
+ files: {
+ "page.html": `<body><script src="page.js"></script></body>`,
+ "page.js": pageScript,
+ },
+ });
+
+ await extension.startup();
+
+ // This page will load example.org in an iframe.
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let cookiesPromise = promiseLoadedCookies();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ url + "?checkCookies",
+ { extension }
+ );
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ // Let's navigate.
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(url + "?navigate", {
+ extension,
+ });
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ // Let's run a fetch()
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(url + "?fetch", {
+ extension,
+ });
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ // Let's run a fetch() from a nested iframe (extension -> example.net ->
+ // example.org -> fetch)
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(url + "?nestedfetch", {
+ extension,
+ });
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a");
+ await contentPage.close();
+
+ // Let's run a fetch() from a nested iframe (extension -> example.org -> fetch)
+ cookiesPromise = promiseLoadedCookies();
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ url + "?nestedfetch2",
+ {
+ extension,
+ }
+ );
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_cookies_iframes() {
+ server.registerPathHandler("/echocookies", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write(
+ request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""
+ );
+ });
+
+ server.registerPathHandler("/contentScriptHere", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html></html>");
+ });
+
+ server.registerPathHandler("/pageWithFrames", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+
+ response.write(`
+ <html>
+ <iframe src="http://example.com/contentScriptHere"></iframe>
+ <iframe src="http://example.net/contentScriptHere"></iframe>
+ </html>
+ `);
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["*://example.org/"],
+ content_scripts: [
+ {
+ js: ["contentScript.js"],
+ matches: [
+ "*://example.com/contentScriptHere",
+ "*://example.net/contentScriptHere",
+ ],
+ run_at: "document_end",
+ all_frames: true,
+ },
+ ],
+ },
+ files: {
+ "contentScript.js": async () => {
+ const res = await fetch("http://example.org/echocookies");
+ const cookies = await res.text();
+ browser.test.assertEq(
+ "none=a",
+ cookies,
+ "expected cookies in content script"
+ );
+ browser.test.sendMessage("extfetch:" + location.hostname);
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/pageWithFrames"
+ );
+ await Promise.all([
+ extension.awaitMessage("extfetch:example.com"),
+ extension.awaitMessage("extfetch:example.net"),
+ ]);
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_cookies_background() {
+ async function background() {
+ const res = await fetch("http://example.org/echocookies", {
+ credentials: "include",
+ });
+ const cookies = await res.text();
+ browser.test.sendMessage("fetchcookies", cookies);
+ }
+
+ const tests = [
+ {
+ permissions: ["http://example.org/*"],
+ cookies: "none=a; lax=b; strict=c",
+ },
+ {
+ permissions: [],
+ cookies: "none=a",
+ },
+ ];
+
+ for (let test of tests) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: test.permissions,
+ },
+ });
+
+ server.registerPathHandler("/echocookies", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader(
+ "Access-Control-Allow-Origin",
+ `moz-extension://${extension.uuid}`,
+ false
+ );
+ response.setHeader("Access-Control-Allow-Credentials", "true", false);
+ response.write(
+ request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""
+ );
+ });
+
+ await extension.startup();
+ equal(
+ await extension.awaitMessage("fetchcookies"),
+ test.cookies,
+ "extension with permissions can see SameSite-restricted cookies"
+ );
+
+ await extension.unload();
+ }
+});
+
+add_task(async function test_cookies_contentScript() {
+ server.registerPathHandler("/empty", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<html><body></body></html>");
+ });
+
+ async function contentScript() {
+ let res = await fetch("http://example.org/checkCookies");
+ browser.test.assertEq(location.origin + "/ready", res.url, "request OK");
+ browser.test.sendMessage("fetch-done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ run_at: "document_end",
+ js: ["contentscript.js"],
+ matches: ["*://*/*"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let cookiesPromise = promiseLoadedCookies();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/empty"
+ );
+ await extension.awaitMessage("fetch-done");
+
+ // Let's check the cookies received during the last loading.
+ Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c");
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js
new file mode 100644
index 0000000000..2847698340
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js
@@ -0,0 +1,109 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.org"] });
+server.registerPathHandler("/sameSiteCookiesApiTest", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_samesite_cookies() {
+ function contentScript() {
+ document.cookie = "test1=whatever";
+ document.cookie = "test2=whatever; SameSite=lax";
+ document.cookie = "test3=whatever; SameSite=strict";
+ browser.runtime.sendMessage("do-check-cookies");
+ }
+ async function background() {
+ await new Promise(resolve => {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq("do-check-cookies", msg, "expected message");
+ resolve();
+ });
+ });
+
+ const url = "https://example.org/";
+
+ // Baseline. Every cookie must have the expected sameSite.
+ let cookie = await browser.cookies.get({ url, name: "test1" });
+ browser.test.assertEq(
+ "no_restriction",
+ cookie.sameSite,
+ "Expected sameSite for test1"
+ );
+
+ cookie = await browser.cookies.get({ url, name: "test2" });
+ browser.test.assertEq(
+ "lax",
+ cookie.sameSite,
+ "Expected sameSite for test2"
+ );
+
+ cookie = await browser.cookies.get({ url, name: "test3" });
+ browser.test.assertEq(
+ "strict",
+ cookie.sameSite,
+ "Expected sameSite for test3"
+ );
+
+ // Testing cookies.getAll + cookies.set
+ let cookies = await browser.cookies.getAll({ url, name: "test3" });
+ browser.test.assertEq(1, cookies.length, "There is only one test3 cookie");
+
+ cookie = await browser.cookies.set({
+ url,
+ name: "test3",
+ value: "newvalue",
+ });
+ browser.test.assertEq(
+ "no_restriction",
+ cookie.sameSite,
+ "sameSite defaults to no_restriction"
+ );
+
+ for (let sameSite of ["no_restriction", "lax", "strict"]) {
+ cookie = await browser.cookies.set({ url, name: "test3", sameSite });
+ browser.test.assertEq(
+ sameSite,
+ cookie.sameSite,
+ `Expected sameSite=${sameSite} in return value of cookies.set`
+ );
+ cookies = await browser.cookies.getAll({ url, name: "test3" });
+ browser.test.assertEq(
+ 1,
+ cookies.length,
+ `test3 is still the only cookie after setting sameSite=${sameSite}`
+ );
+ browser.test.assertEq(
+ sameSite,
+ cookies[0].sameSite,
+ `test3 was updated to sameSite=${sameSite}`
+ );
+ }
+
+ browser.test.notifyPass("cookies");
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["cookies", "*://example.org/"],
+ content_scripts: [
+ {
+ matches: ["*://example.org/sameSiteCookiesApiTest*"],
+ js: ["contentscript.js"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/sameSiteCookiesApiTest"
+ );
+ await extension.awaitFinish("cookies");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js
new file mode 100644
index 0000000000..a0a552f64f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js
@@ -0,0 +1,316 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+add_task(async function testExtensionDebuggingUtilsCleanup() {
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ const expectedEmptyDebugUtils = {
+ hiddenXULWindow: null,
+ cacheSize: 0,
+ };
+
+ let { hiddenXULWindow, debugBrowserPromises } = ExtensionParent.DebugUtils;
+
+ deepEqual(
+ { hiddenXULWindow, cacheSize: debugBrowserPromises.size },
+ expectedEmptyDebugUtils,
+ "No ExtensionDebugUtils resources has been allocated yet"
+ );
+
+ await extension.startup();
+
+ await extension.awaitMessage("background.ready");
+
+ hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow;
+ deepEqual(
+ { hiddenXULWindow, cacheSize: debugBrowserPromises.size },
+ expectedEmptyDebugUtils,
+ "No debugging resources has been yet allocated once the extension is running"
+ );
+
+ const fakeAddonActor = {
+ addonId: extension.id,
+ };
+
+ const anotherAddonActor = {
+ addonId: extension.id,
+ };
+
+ const waitFirstBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser(
+ fakeAddonActor
+ );
+ const waitSecondBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser(
+ anotherAddonActor
+ );
+
+ const addonDebugBrowser = await waitFirstBrowser;
+ equal(
+ addonDebugBrowser.isRemoteBrowser,
+ extension.extension.remote,
+ "The addon debugging browser has the expected remote type"
+ );
+
+ equal(
+ await waitSecondBrowser,
+ addonDebugBrowser,
+ "Two addon debugging actors related to the same addon get the same browser element "
+ );
+
+ equal(
+ debugBrowserPromises.size,
+ 1,
+ "The expected resources has been allocated"
+ );
+
+ const nonExistentAddonActor = {
+ addonId: "non-existent-addon@test",
+ };
+
+ const waitRejection = ExtensionParent.DebugUtils.getExtensionProcessBrowser(
+ nonExistentAddonActor
+ );
+
+ await Assert.rejects(
+ waitRejection,
+ /Extension not found/,
+ "Reject with the expected message for non existent addons"
+ );
+
+ equal(
+ debugBrowserPromises.size,
+ 1,
+ "No additional debugging resources has been allocated"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ debugBrowserPromises.size,
+ 1,
+ "The addon debugging browser is cached until all the related actors have released it"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ anotherAddonActor
+ );
+
+ hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow;
+
+ deepEqual(
+ { hiddenXULWindow, cacheSize: debugBrowserPromises.size },
+ expectedEmptyDebugUtils,
+ "All the allocated debugging resources has been cleared"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionDebuggingUtilsAddonReloaded() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "test-reloaded@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background.ready");
+
+ let fakeAddonActor = {
+ addonId: extension.id,
+ };
+
+ const addonDebugBrowser = await ExtensionParent.DebugUtils.getExtensionProcessBrowser(
+ fakeAddonActor
+ );
+ equal(
+ addonDebugBrowser.isRemoteBrowser,
+ extension.extension.remote,
+ "The addon debugging browser has the expected remote type"
+ );
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "Got the expected number of requested debug browsers"
+ );
+
+ const { chromeDocument } = ExtensionParent.DebugUtils.hiddenXULWindow;
+
+ ok(
+ addonDebugBrowser.parentElement === chromeDocument.documentElement,
+ "The addon debugging browser is part of the hiddenXULWindow chromeDocument"
+ );
+
+ await extension.unload();
+
+ // Install an extension with the same id to recreate for the DebugUtils
+ // conditions similar to an addon reloaded while the Addon Debugger is opened.
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "test-reloaded@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background.ready");
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "Got the expected number of requested debug browsers"
+ );
+
+ const newAddonDebugBrowser = await ExtensionParent.DebugUtils.getExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ addonDebugBrowser,
+ newAddonDebugBrowser,
+ "The existent debugging browser has been reused"
+ );
+
+ equal(
+ newAddonDebugBrowser.isRemoteBrowser,
+ extension.extension.remote,
+ "The addon debugging browser has the expected remote type"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 0,
+ "All the addon debugging browsers has been released"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function testExtensionDebuggingUtilsWithMultipleAddons() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "test-addon-1@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+ let anotherExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "test-addon-2@test.mozilla.com",
+ },
+ },
+ },
+ background() {
+ browser.test.sendMessage("background.ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background.ready");
+
+ await anotherExtension.startup();
+ await anotherExtension.awaitMessage("background.ready");
+
+ const fakeAddonActor = {
+ addonId: extension.id,
+ };
+
+ const anotherFakeAddonActor = {
+ addonId: anotherExtension.id,
+ };
+
+ const { DebugUtils } = ExtensionParent;
+ const debugBrowser = await DebugUtils.getExtensionProcessBrowser(
+ fakeAddonActor
+ );
+ const anotherDebugBrowser = await DebugUtils.getExtensionProcessBrowser(
+ anotherFakeAddonActor
+ );
+
+ const chromeDocument = DebugUtils.hiddenXULWindow.chromeDocument;
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 2,
+ "Got the expected number of debug browsers requested"
+ );
+ ok(
+ debugBrowser.parentElement === chromeDocument.documentElement,
+ "The first debug browser is part of the hiddenXUL chromeDocument"
+ );
+ ok(
+ anotherDebugBrowser.parentElement === chromeDocument.documentElement,
+ "The second debug browser is part of the hiddenXUL chromeDocument"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ fakeAddonActor
+ );
+
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "Got the expected number of debug browsers requested"
+ );
+
+ ok(
+ anotherDebugBrowser.parentElement === chromeDocument.documentElement,
+ "The second debug browser is still part of the hiddenXUL chromeDocument"
+ );
+
+ ok(
+ debugBrowser.parentElement == null,
+ "The first debug browser has been removed from the hiddenXUL chromeDocument"
+ );
+
+ await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(
+ anotherFakeAddonActor
+ );
+
+ ok(
+ anotherDebugBrowser.parentElement == null,
+ "The second debug browser has been removed from the hiddenXUL chromeDocument"
+ );
+ equal(
+ ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 0,
+ "All the addon debugging browsers has been released"
+ );
+
+ await extension.unload();
+ await anotherExtension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dns.js b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js
new file mode 100644
index 0000000000..d7f9d6efe9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js
@@ -0,0 +1,176 @@
+"use strict";
+
+// Some test machines and android are not returning ipv6, turn it
+// off to get consistent test results.
+Services.prefs.setBoolPref("network.dns.disableIPv6", true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+function getExtension(background = undefined) {
+ let manifest = {
+ permissions: ["dns", "proxy"],
+ };
+ return ExtensionTestUtils.loadExtension({
+ manifest,
+ background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "proxy") {
+ await browser.proxy.settings.set({ value: data });
+ browser.test.sendMessage("proxied");
+ return;
+ }
+ browser.test.log(`=== dns resolve test ${JSON.stringify(data)}`);
+ browser.dns
+ .resolve(data.hostname, data.flags)
+ .then(result => {
+ browser.test.log(
+ `=== dns resolve result ${JSON.stringify(result)}`
+ );
+ browser.test.sendMessage("resolved", result);
+ })
+ .catch(e => {
+ browser.test.log(`=== dns resolve error ${e.message}`);
+ browser.test.sendMessage("resolved", { message: e.message });
+ });
+ });
+ browser.test.sendMessage("ready");
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ });
+}
+
+const tests = [
+ {
+ request: {
+ hostname: "localhost",
+ },
+ expect: {
+ addresses: ["127.0.0.1"], // ipv6 disabled , "::1"
+ },
+ },
+ {
+ request: {
+ hostname: "localhost",
+ flags: ["offline"],
+ },
+ expect: {
+ addresses: ["127.0.0.1"], // ipv6 disabled , "::1"
+ },
+ },
+ {
+ request: {
+ hostname: "test.example",
+ },
+ expect: {
+ // android will error with offline
+ error: /NS_ERROR_UNKNOWN_HOST|NS_ERROR_OFFLINE/,
+ },
+ },
+ {
+ request: {
+ hostname: "127.0.0.1",
+ flags: ["canonical_name"],
+ },
+ expect: {
+ canonicalName: "127.0.0.1",
+ addresses: ["127.0.0.1"],
+ },
+ },
+ {
+ request: {
+ hostname: "localhost",
+ flags: ["disable_ipv6"],
+ },
+ expect: {
+ addresses: ["127.0.0.1"],
+ },
+ },
+];
+
+add_task(async function startup() {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_dns_resolve() {
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ for (let test of tests) {
+ extension.sendMessage("resolve", test.request);
+ let result = await extension.awaitMessage("resolved");
+ if (test.expect.error) {
+ ok(
+ test.expect.error.test(result.message),
+ `expected error ${result.message}`
+ );
+ } else {
+ equal(
+ result.canonicalName,
+ test.expect.canonicalName,
+ "canonicalName match"
+ );
+ // It seems there are platform differences happening that make this
+ // testing difficult. We're going to rely on other existing dns tests to validate
+ // the dns service itself works and only validate that we're getting generally
+ // expected results in the webext api.
+ ok(
+ result.addresses.length >= test.expect.addresses.length,
+ "expected number of addresses returned"
+ );
+ if (test.expect.addresses.length && result.addresses.length) {
+ ok(
+ result.addresses.includes(test.expect.addresses[0]),
+ "got expected ip address"
+ );
+ }
+ }
+ }
+
+ await extension.unload();
+});
+
+add_task(async function test_dns_resolve_socks() {
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ extension.sendMessage("proxy", {
+ proxyType: "manual",
+ socks: "127.0.0.1",
+ socksVersion: 5,
+ proxyDNS: true,
+ });
+ await extension.awaitMessage("proxied");
+ equal(
+ Services.prefs.getIntPref("network.proxy.type"),
+ 1 /* PROXYCONFIG_MANUAL */,
+ "manual proxy"
+ );
+ equal(
+ Services.prefs.getStringPref("network.proxy.socks"),
+ "127.0.0.1",
+ "socks proxy"
+ );
+ ok(
+ Services.prefs.getBoolPref("network.proxy.socks_remote_dns"),
+ "socks remote dns"
+ );
+ extension.sendMessage("resolve", {
+ hostname: "mozilla.org",
+ });
+ let result = await extension.awaitMessage("resolved");
+ ok(
+ /NS_ERROR_UNKNOWN_PROXY_HOST/.test(result.message),
+ `expected error ${result.message}`
+ );
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js
new file mode 100644
index 0000000000..f65df707e1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js
@@ -0,0 +1,38 @@
+/* -*- 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_downloads_api_namespace_and_permissions() {
+ function backgroundScript() {
+ browser.test.assertTrue(!!browser.downloads, "`downloads` API is present.");
+ browser.test.assertTrue(
+ !!browser.downloads.FilenameConflictAction,
+ "`downloads.FilenameConflictAction` enum is present."
+ );
+ browser.test.assertTrue(
+ !!browser.downloads.InterruptReason,
+ "`downloads.InterruptReason` enum is present."
+ );
+ browser.test.assertTrue(
+ !!browser.downloads.DangerType,
+ "`downloads.DangerType` enum is present."
+ );
+ browser.test.assertTrue(
+ !!browser.downloads.State,
+ "`downloads.State` enum is present."
+ );
+ browser.test.notifyPass("downloads tests");
+ }
+
+ let extensionData = {
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads", "downloads.open"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("downloads tests");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js
new file mode 100644
index 0000000000..aa91cd7c88
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js
@@ -0,0 +1,216 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { UrlClassifierTestUtils } = ChromeUtils.import(
+ "resource://testing-common/UrlClassifierTestUtils.jsm"
+);
+
+// Value for network.cookie.cookieBehavior to reject all third-party cookies.
+const { BEHAVIOR_REJECT_FOREIGN } = Ci.nsICookieService;
+
+const server = createHttpServer({ hosts: ["example.net", "itisatracker.org"] });
+server.registerPathHandler("/setcookies", (request, response) => {
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Set-Cookie", "c_none=1; sameSite=none", true);
+ response.setHeader("Set-Cookie", "c_lax=1; sameSite=lax", true);
+ response.setHeader("Set-Cookie", "c_strict=1; sameSite=strict", true);
+});
+
+server.registerPathHandler("/download", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+
+ let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+ // Assign the result through the MIME-type, to make it easier to read the
+ // result via the downloads API.
+ response.setHeader("Content-Type", `dummy/${encodeURIComponent(cookies)}`);
+ // Response of length 7.
+ response.write("1234567");
+});
+
+server.registerPathHandler("/redirect", (request, response) => {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", "/download");
+});
+
+function createDownloadTestExtension(extraPermissions = []) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads", ...extraPermissions],
+ },
+ incognitoOverride: "spanning",
+ background() {
+ async function getCookiesForDownload(url) {
+ let donePromise = new Promise(resolve => {
+ browser.downloads.onChanged.addListener(async delta => {
+ if (delta.state?.current === "complete") {
+ resolve(delta.id);
+ }
+ });
+ });
+ // TODO bug 1653636: Remove this when the correct browsing mode is used.
+ const incognito = browser.extension.inIncognitoContext;
+ let downloadId = await browser.downloads.download({ url, incognito });
+ browser.test.assertEq(await donePromise, downloadId, "got download");
+ let [download] = await browser.downloads.search({ id: downloadId });
+ browser.test.log(`Download results: ${JSON.stringify(download)}`);
+
+ // Delete the file since we aren't interested in it.
+ // TODO bug 1654819: On Windows the file may be recreated.
+ await browser.downloads.removeFile(download.id);
+ // Sanity check to verify that we got the result from /download.
+ browser.test.assertEq(7, download.fileSize, "download succeeded");
+
+ // The "/download" endpoint mirrors received cookies via Content-Type.
+ let cookies = decodeURIComponent(download.mime.replace("dummy/", ""));
+ return cookies;
+ }
+
+ browser.test.onMessage.addListener(async url => {
+ browser.test.sendMessage("result", await getCookiesForDownload(url));
+ });
+ },
+ });
+}
+
+async function downloadAndGetCookies(extension, url) {
+ extension.sendMessage(url);
+ return extension.awaitMessage("result");
+}
+
+add_task(async function setup() {
+ const nsIFile = Ci.nsIFile;
+ const downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+ // Support sameSite=none despite the server using http instead of https.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+ async function loadAndClose(url) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ await contentPage.close();
+ }
+ // Generate cookies for use in this test.
+ await loadAndClose("http://example.net/setcookies");
+ await loadAndClose("http://itisatracker.org/setcookies");
+
+ await UrlClassifierTestUtils.addTestTrackers();
+ registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.cookies.removeAll();
+
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ downloadDir.remove(false);
+ });
+});
+
+// Checks that (sameSite) cookies are included in download requests.
+add_task(async function download_cookies_basic() {
+ let extension = createDownloadTestExtension(["*://example.net/*"]);
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with sameSite cookies"
+ );
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/redirect"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with redirect"
+ );
+
+ await runWithPrefs(
+ [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]],
+ async () => {
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with all third-party cookies disabled"
+ );
+ }
+ );
+
+ await extension.unload();
+});
+
+// Checks that (sameSite) cookies are included even when tracking protection
+// would block cookies from third-party requests.
+add_task(async function download_cookies_from_tracker_url() {
+ let extension = createDownloadTestExtension(["*://itisatracker.org/*"]);
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://itisatracker.org/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download of itisatracker.org"
+ );
+
+ await extension.unload();
+});
+
+// Checks that (sameSite) cookies are included even without host permissions.
+add_task(async function download_cookies_without_host_permissions() {
+ let extension = createDownloadTestExtension();
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download without host permissions"
+ );
+
+ equal(
+ await downloadAndGetCookies(extension, "http://itisatracker.org/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download of itisatracker.org"
+ );
+
+ await runWithPrefs(
+ [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]],
+ async () => {
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download with all third-party cookies disabled"
+ );
+ }
+ );
+
+ await extension.unload();
+});
+
+// Checks that (sameSite) cookies from private browsing are included.
+add_task(async function download_cookies_in_perma_private_browsing() {
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+ let extension = createDownloadTestExtension(["*://example.net/*"]);
+ await extension.startup();
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "",
+ "Initially no cookies in permanent private browsing mode"
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.net/setcookies",
+ { privateBrowsing: true }
+ );
+
+ equal(
+ await downloadAndGetCookies(extension, "http://example.net/download"),
+ "c_none=1; c_lax=1; c_strict=1",
+ "Cookies for downloads.download in perma-private-browsing mode"
+ );
+
+ await extension.unload();
+ await contentPage.close();
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
new file mode 100644
index 0000000000..a9edb9d13e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js
@@ -0,0 +1,680 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { Downloads } = ChromeUtils.import(
+ "resource://gre/modules/Downloads.jsm"
+);
+
+const gServer = createHttpServer();
+gServer.registerDirectory("/data/", do_get_file("data"));
+
+gServer.registerPathHandler("/dir/", (_, res) => res.write("length=8"));
+
+const WINDOWS = AppConstants.platform == "win";
+
+const BASE = `http://localhost:${gServer.identity.primaryPort}/`;
+const FILE_NAME = "file_download.txt";
+const FILE_NAME_W_SPACES = "file download.txt";
+const FILE_URL = BASE + "data/" + FILE_NAME;
+const FILE_NAME_UNIQUE = "file_download(1).txt";
+const FILE_LEN = 46;
+
+let downloadDir;
+
+function setup() {
+ downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile,
+ downloadDir
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ let entries = downloadDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ ok(false, `Leftover file ${entry.path} in download directory`);
+ entry.remove(false);
+ }
+
+ downloadDir.remove(false);
+ });
+}
+
+function backgroundScript() {
+ let blobUrl;
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ let options = args[0];
+
+ if (options.blobme) {
+ let blob = new Blob(options.blobme);
+ delete options.blobme;
+ blobUrl = options.url = window.URL.createObjectURL(blob);
+ }
+
+ try {
+ let id = await browser.downloads.download(options);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "killTheBlob") {
+ window.URL.revokeObjectURL(blobUrl);
+ blobUrl = null;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+// This function is a bit of a sledgehammer, it looks at every download
+// the browser knows about and waits for all active downloads to complete.
+// But we only start one at a time and only do a handful in total, so
+// this lets us test download() without depending on anything else.
+async function waitForDownloads() {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ let inprogress = downloads.filter(dl => !dl.stopped);
+ return Promise.all(inprogress.map(dl => dl.whenSucceeded()));
+}
+
+// Create a file in the downloads directory.
+function touch(filename) {
+ let file = downloadDir.clone();
+ file.append(filename);
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+}
+
+// Remove a file in the downloads directory.
+function remove(filename, recursive = false) {
+ let file = downloadDir.clone();
+ file.append(filename);
+ file.remove(recursive);
+}
+
+add_task(async function test_downloads() {
+ setup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})()`,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ function download(options) {
+ extension.sendMessage("download.request", options);
+ return extension.awaitMessage("download.done");
+ }
+
+ async function testDownload(options, localFile, expectedSize, description) {
+ let msg = await download(options);
+ equal(
+ msg.status,
+ "success",
+ `downloads.download() works with ${description}`
+ );
+
+ await waitForDownloads();
+
+ let localPath = downloadDir.clone();
+ let parts = Array.isArray(localFile) ? localFile : [localFile];
+
+ parts.map(p => localPath.append(p));
+ equal(
+ localPath.fileSize,
+ expectedSize,
+ "Downloaded file has expected size"
+ );
+ localPath.remove(false);
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ info("extension started");
+
+ // Call download() with just the url property.
+ await testDownload({ url: FILE_URL }, FILE_NAME, FILE_LEN, "just source");
+
+ // Call download() with a filename property.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "newpath.txt",
+ },
+ "newpath.txt",
+ FILE_LEN,
+ "source and filename"
+ );
+
+ // Call download() with a filename with subdirs.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "sub/dir/file",
+ },
+ ["sub", "dir", "file"],
+ FILE_LEN,
+ "source and filename with subdirs"
+ );
+
+ // Call download() with a filename with existing subdirs.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "sub/dir/file2",
+ },
+ ["sub", "dir", "file2"],
+ FILE_LEN,
+ "source and filename with existing subdirs"
+ );
+
+ // Only run Windows path separator test on Windows.
+ if (WINDOWS) {
+ // Call download() with a filename with Windows path separator.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "sub\\dir\\file3",
+ },
+ ["sub", "dir", "file3"],
+ FILE_LEN,
+ "filename with Windows path separator"
+ );
+ }
+ remove("sub", true);
+
+ // Call download(), filename with subdir, skipping parts.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "skip//part",
+ },
+ ["skip", "part"],
+ FILE_LEN,
+ "source, filename, with subdir, skipping parts"
+ );
+ remove("skip", true);
+
+ // Check conflictAction of "uniquify".
+ touch(FILE_NAME);
+ await testDownload(
+ {
+ url: FILE_URL,
+ conflictAction: "uniquify",
+ },
+ FILE_NAME_UNIQUE,
+ FILE_LEN,
+ "conflictAction=uniquify"
+ );
+ // todo check that preexisting file was not modified?
+ remove(FILE_NAME);
+
+ // Check conflictAction of "overwrite".
+ touch(FILE_NAME);
+ await testDownload(
+ {
+ url: FILE_URL,
+ conflictAction: "overwrite",
+ },
+ FILE_NAME,
+ FILE_LEN,
+ "conflictAction=overwrite"
+ );
+
+ // Try to download in invalid url
+ await download({ url: "this is not a valid URL" }).then(msg => {
+ equal(msg.status, "error", "downloads.download() fails with invalid url");
+ ok(
+ /not a valid URL/.test(msg.errmsg),
+ "error message for invalid url is correct"
+ );
+ });
+
+ // Try to download to an empty path.
+ await download({
+ url: FILE_URL,
+ filename: "",
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with empty filename"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not be empty",
+ "error message for empty filename is correct"
+ );
+ });
+
+ // Try to download to an absolute path.
+ const absolutePath = OS.Path.join(
+ WINDOWS ? "\\tmp" : "/tmp",
+ "file_download.txt"
+ );
+ await download({
+ url: FILE_URL,
+ filename: absolutePath,
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with absolute filename"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not be an absolute path",
+ `error message for absolute path (${absolutePath}) is correct`
+ );
+ });
+
+ if (WINDOWS) {
+ await download({
+ url: FILE_URL,
+ filename: "C:\\file_download.txt",
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with absolute filename"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not be an absolute path",
+ "error message for absolute path with drive letter is correct"
+ );
+ });
+ }
+
+ // Try to download to a relative path containing ..
+ await download({
+ url: FILE_URL,
+ filename: OS.Path.join("..", "file_download.txt"),
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with back-references"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not contain back-references (..)",
+ "error message for back-references is correct"
+ );
+ });
+
+ // Try to download to a long relative path containing ..
+ await download({
+ url: FILE_URL,
+ filename: OS.Path.join("foo", "..", "..", "file_download.txt"),
+ }).then(msg => {
+ equal(
+ msg.status,
+ "error",
+ "downloads.download() fails with back-references"
+ );
+ equal(
+ msg.errmsg,
+ "filename must not contain back-references (..)",
+ "error message for back-references is correct"
+ );
+ });
+
+ // Test illegal characters.
+ await download({
+ url: FILE_URL,
+ filename: "like:this",
+ }).then(msg => {
+ equal(msg.status, "error", "downloads.download() fails with illegal chars");
+ equal(
+ msg.errmsg,
+ "filename must not contain illegal characters",
+ "error message correct"
+ );
+ });
+
+ // Try to download a blob url
+ const BLOB_STRING = "Hello, world";
+ await testDownload(
+ {
+ blobme: [BLOB_STRING],
+ filename: FILE_NAME,
+ },
+ FILE_NAME,
+ BLOB_STRING.length,
+ "blob url"
+ );
+ extension.sendMessage("killTheBlob");
+
+ // Try to download a blob url without a given filename
+ await testDownload(
+ {
+ blobme: [BLOB_STRING],
+ },
+ "download",
+ BLOB_STRING.length,
+ "blob url with no filename"
+ );
+ extension.sendMessage("killTheBlob");
+
+ // Download a normal URL with an empty filename part.
+ await testDownload(
+ {
+ url: BASE + "dir/",
+ },
+ "download",
+ 8,
+ "normal url with empty filename"
+ );
+
+ // Download a filename with multiple spaces, url is ignored for this test.
+ await testDownload(
+ {
+ url: FILE_URL,
+ filename: "a file.txt",
+ },
+ "a file.txt",
+ FILE_LEN,
+ "filename with multiple spaces"
+ );
+
+ // Download a normal URL with a leafname containing multiple spaces.
+ // Note: spaces are compressed by file name normalization.
+ await testDownload(
+ {
+ url: BASE + "data/" + FILE_NAME_W_SPACES,
+ },
+ FILE_NAME_W_SPACES.replace(/\s+/, " "),
+ FILE_LEN,
+ "leafname with multiple spaces"
+ );
+
+ // Check that the "incognito" property is supported.
+ await testDownload(
+ {
+ url: FILE_URL,
+ incognito: false,
+ },
+ FILE_NAME,
+ FILE_LEN,
+ "incognito=false"
+ );
+
+ await testDownload(
+ {
+ url: FILE_URL,
+ incognito: true,
+ },
+ FILE_NAME,
+ FILE_LEN,
+ "incognito=true"
+ );
+
+ await extension.unload();
+});
+
+async function testHttpErrors(allowHttpErrors) {
+ const server = createHttpServer();
+ const url = `http://localhost:${server.identity.primaryPort}/error`;
+ const content = "HTTP Error test";
+
+ server.registerPathHandler("/error", (request, response) => {
+ response.setStatusLine(
+ "1.1",
+ parseInt(request.queryString, 10),
+ "Some Error"
+ );
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Content-Length", content.length.toString());
+ response.write(content);
+ });
+
+ function background(code) {
+ let dlid = 0;
+ let expectedState;
+ browser.test.onMessage.addListener(async options => {
+ try {
+ expectedState = options.allowHttpErrors ? "complete" : "interrupted";
+ dlid = await browser.downloads.download(options);
+ } catch (err) {
+ browser.test.fail(`Unexpected error in downloads.download(): ${err}`);
+ }
+ });
+ function onChanged({ id, state }) {
+ if (dlid !== id || !state || state.current === "in_progress") {
+ return;
+ }
+ browser.test.assertEq(state.current, expectedState, "correct state");
+ browser.downloads.search({ id }).then(([download]) => {
+ browser.test.sendMessage("done", download.error);
+ });
+ }
+ browser.downloads.onChanged.addListener(onChanged);
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background,
+ });
+ await extension.startup();
+
+ async function download(code, expected_when_disallowed) {
+ const options = {
+ url: url + "?" + code,
+ filename: `test-${code}`,
+ conflictAction: "overwrite",
+ allowHttpErrors,
+ };
+ extension.sendMessage(options);
+ const rv = await extension.awaitMessage("done");
+
+ if (allowHttpErrors) {
+ const localPath = downloadDir.clone();
+ localPath.append(options.filename);
+ equal(
+ localPath.fileSize,
+ // The 20x No content errors will not produce any response body,
+ // only "true" errors do.
+ code >= 400 ? content.length : 0,
+ "Downloaded file has expected size" + code
+ );
+ localPath.remove(false);
+
+ ok(!rv, "error must be ignored and hence false-y");
+ return;
+ }
+
+ equal(
+ rv,
+ expected_when_disallowed,
+ "error must have the correct InterruptReason"
+ );
+ }
+
+ await download(204, "SERVER_BAD_CONTENT"); // No Content
+ await download(205, "SERVER_BAD_CONTENT"); // Reset Content
+ await download(404, "SERVER_BAD_CONTENT"); // Not Found
+ await download(403, "SERVER_FORBIDDEN"); // Forbidden
+ await download(402, "SERVER_UNAUTHORIZED"); // Unauthorized
+ await download(407, "SERVER_UNAUTHORIZED"); // Proxy auth required
+ await download(504, "SERVER_FAILED"); //General errors, here Gateway Timeout
+
+ await extension.unload();
+}
+
+add_task(function test_download_disallowed_http_errors() {
+ return testHttpErrors(false);
+});
+
+add_task(function test_download_allowed_http_errors() {
+ return testHttpErrors(true);
+});
+
+add_task(async function test_download_http_details() {
+ const server = createHttpServer();
+ const url = `http://localhost:${server.identity.primaryPort}/post-log`;
+
+ let received;
+ server.registerPathHandler("/post-log", (request, response) => {
+ received = request;
+ response.setHeader("Set-Cookie", "monster=", false);
+ });
+
+ // Confirm received vs. expected values.
+ function confirm(method, headers = {}, body) {
+ equal(received.method, method, "method is correct");
+
+ for (let name in headers) {
+ ok(received.hasHeader(name), `header ${name} received`);
+ equal(
+ received.getHeader(name),
+ headers[name],
+ `header ${name} is correct`
+ );
+ }
+
+ if (body) {
+ const str = NetUtil.readInputStreamToString(
+ received.bodyInputStream,
+ received.bodyInputStream.available()
+ );
+ equal(str, body, "body is correct");
+ }
+ }
+
+ function background() {
+ browser.test.onMessage.addListener(async options => {
+ try {
+ await browser.downloads.download(options);
+ } catch (err) {
+ browser.test.sendMessage("done", { err: err.message });
+ }
+ });
+ browser.downloads.onChanged.addListener(({ state }) => {
+ if (state && state.current === "complete") {
+ browser.test.sendMessage("done", { ok: true });
+ }
+ });
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background,
+ incognitoOverride: "spanning",
+ });
+ await extension.startup();
+
+ function download(options) {
+ options.url = url;
+ options.conflictAction = "overwrite";
+
+ extension.sendMessage(options);
+ return extension.awaitMessage("done");
+ }
+
+ // Test that site cookies are sent with download requests,
+ // and "incognito" downloads use a separate cookie jar.
+ let testDownloadCookie = async function(incognito) {
+ let result = await download({ incognito });
+ ok(result.ok, `preflight to set cookies with incognito=${incognito}`);
+ ok(!received.hasHeader("cookie"), "first request has no cookies");
+
+ result = await download({ incognito });
+ ok(result.ok, `download with cookie with incognito=${incognito}`);
+ equal(
+ received.getHeader("cookie"),
+ "monster=",
+ "correct cookie header sent for second download"
+ );
+ };
+
+ await testDownloadCookie(false);
+ await testDownloadCookie(true);
+
+ // Test method option.
+ let result = await download({});
+ ok(result.ok, "download works without the method option, defaults to GET");
+ confirm("GET");
+
+ result = await download({ method: "PUT" });
+ ok(!result.ok, "download rejected with PUT method");
+ ok(
+ /method: Invalid enumeration/.test(result.err),
+ "descriptive error message"
+ );
+
+ result = await download({ method: "POST" });
+ ok(result.ok, "download works with POST method");
+ confirm("POST");
+
+ // Test body option values.
+ result = await download({ body: [] });
+ ok(!result.ok, "download rejected because of non-string body");
+ ok(/body: Expected string/.test(result.err), "descriptive error message");
+
+ result = await download({ method: "POST", body: "of work" });
+ ok(result.ok, "download works with POST method and body");
+ confirm("POST", { "Content-Length": 7 }, "of work");
+
+ // Test custom headers.
+ result = await download({ headers: [{ name: "X-Custom" }] });
+ ok(!result.ok, "download rejected because of missing header value");
+ ok(/"value" is required/.test(result.err), "descriptive error message");
+
+ result = await download({ headers: [{ name: "X-Custom", value: "13" }] });
+ ok(result.ok, "download works with a custom header");
+ confirm("GET", { "X-Custom": "13" });
+
+ // Test Referer header.
+ const referer = "http://example.org/test";
+ result = await download({ headers: [{ name: "Referer", value: referer }] });
+ ok(result.ok, "download works with Referer header");
+ confirm("GET", { Referer: referer });
+
+ // Test forbidden headers.
+ result = await download({ headers: [{ name: "DNT", value: "1" }] });
+ ok(!result.ok, "download rejected because of forbidden header name DNT");
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ result = await download({
+ headers: [{ name: "Proxy-Connection", value: "keep" }],
+ });
+ ok(
+ !result.ok,
+ "download rejected because of forbidden header name prefix Proxy-"
+ );
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ result = await download({ headers: [{ name: "Sec-ret", value: "13" }] });
+ ok(
+ !result.ok,
+ "download rejected because of forbidden header name prefix Sec-"
+ );
+ ok(/Forbidden request header/.test(result.err), "descriptive error message");
+
+ remove("post-log");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js
new file mode 100644
index 0000000000..9de40a8c9c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js
@@ -0,0 +1,1069 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.import(
+ "resource://gre/modules/Downloads.jsm"
+);
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const ROOT = `http://localhost:${server.identity.primaryPort}`;
+const BASE = `${ROOT}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+
+// Keep these in sync with code in interruptible.sjs
+const INT_PARTIAL_LEN = 15;
+const INT_TOTAL_LEN = 31;
+
+const TEST_DATA = "This is 31 bytes of sample data";
+const TOTAL_LEN = TEST_DATA.length;
+const PARTIAL_LEN = 15;
+
+// A handler to let us systematically test pausing/resuming/canceling
+// of downloads. This target represents a small text file but a simple
+// GET will stall after sending part of the data, to give the test code
+// a chance to pause or do other operations on an in-progress download.
+// A resumed download (ie, a GET with a Range: header) will allow the
+// download to complete.
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ if (request.hasHeader("Range")) {
+ let start, end;
+ let matches = request
+ .getHeader("Range")
+ .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
+ if (matches != null) {
+ start = matches[1] ? parseInt(matches[1], 10) : 0;
+ end = matches[2] ? parseInt(matches[2], 10) : TOTAL_LEN - 1;
+ }
+
+ if (end == undefined || end >= TOTAL_LEN) {
+ response.setStatusLine(
+ request.httpVersion,
+ 416,
+ "Requested Range Not Satisfiable"
+ );
+ response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false);
+ response.finish();
+ return;
+ }
+
+ response.setStatusLine(request.httpVersion, 206, "Partial Content");
+ response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false);
+ response.write(TEST_DATA.slice(start, end + 1));
+ } else if (request.queryString.includes("stream")) {
+ response.processAsync();
+ response.setHeader("Content-Length", "10000", false);
+ response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
+ setInterval(() => {
+ response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
+ }, 50);
+ } else {
+ response.processAsync();
+ response.setHeader("Content-Length", `${TOTAL_LEN}`, false);
+ response.write(TEST_DATA.slice(0, PARTIAL_LEN));
+ }
+
+ registerCleanupFunction(() => {
+ try {
+ response.finish();
+ } catch (e) {
+ // This will throw, but we don't care at this point.
+ }
+ });
+}
+
+server.registerPrefixHandler("/interruptible/", handleRequest);
+
+let interruptibleCount = 0;
+function getInterruptibleUrl(filename = "interruptible.html") {
+ let n = interruptibleCount++;
+ return `${ROOT}/interruptible/${filename}?count=${n}`;
+}
+
+function backgroundScript() {
+ let events = new Set();
+ let eventWaiter = null;
+
+ browser.downloads.onCreated.addListener(data => {
+ events.add({ type: "onCreated", data });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+
+ browser.downloads.onChanged.addListener(data => {
+ events.add({ type: "onChanged", data });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+
+ browser.downloads.onErased.addListener(data => {
+ events.add({ type: "onErased", data });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+
+ // Returns a promise that will resolve when the given list of expected
+ // events have all been seen. By default, succeeds only if the exact list
+ // of expected events is seen in the given order. options.exact can be
+ // set to false to allow other events and options.inorder can be set to
+ // false to allow the events to arrive in any order.
+ function waitForEvents(expected, options = {}) {
+ function compare(a, b) {
+ if (typeof b == "object" && b != null) {
+ if (typeof a != "object") {
+ return false;
+ }
+ return Object.keys(b).every(fld => compare(a[fld], b[fld]));
+ }
+ return a == b;
+ }
+
+ const exact = "exact" in options ? options.exact : true;
+ const inorder = "inorder" in options ? options.inorder : true;
+ return new Promise((resolve, reject) => {
+ function check() {
+ function fail(msg) {
+ browser.test.fail(msg);
+ reject(new Error(msg));
+ }
+ if (events.size < expected.length) {
+ return;
+ }
+ if (exact && expected.length < events.size) {
+ fail(
+ `Got ${events.size} events but only expected ${expected.length}`
+ );
+ return;
+ }
+
+ let remaining = new Set(events);
+ if (inorder) {
+ for (let event of events) {
+ if (compare(event, expected[0])) {
+ expected.shift();
+ remaining.delete(event);
+ }
+ }
+ } else {
+ expected = expected.filter(val => {
+ for (let remainingEvent of remaining) {
+ if (compare(remainingEvent, val)) {
+ remaining.delete(remainingEvent);
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ // Events that did occur have been removed from expected so if
+ // expected is empty, we're done. If we didn't see all the
+ // expected events and we're not looking for an exact match,
+ // then we just may not have seen the event yet, so return without
+ // failing and check() will be called again when a new event arrives.
+ if (!expected.length) {
+ events = remaining;
+ eventWaiter = null;
+ resolve();
+ } else if (exact) {
+ fail(
+ `Mismatched event: expecting ${JSON.stringify(
+ expected[0]
+ )} but got ${JSON.stringify(Array.from(remaining)[0])}`
+ );
+ }
+ }
+ eventWaiter = check;
+ check();
+ });
+ }
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let match = msg.match(/(\w+).request$/);
+ if (!match) {
+ return;
+ }
+
+ let what = match[1];
+ if (what == "waitForEvents") {
+ try {
+ await waitForEvents(...args);
+ browser.test.sendMessage("waitForEvents.done", { status: "success" });
+ } catch (error) {
+ browser.test.sendMessage("waitForEvents.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (what == "clearEvents") {
+ events = new Set();
+ browser.test.sendMessage("clearEvents.done", { status: "success" });
+ } else {
+ try {
+ let result = await browser.downloads[what](...args);
+ browser.test.sendMessage(`${what}.done`, { status: "success", result });
+ } catch (error) {
+ browser.test.sendMessage(`${what}.done`, {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+let downloadDir;
+let extension;
+
+async function waitForCreatedPartFile(baseFilename = "interruptible.html") {
+ const partFilePath = `${downloadDir.path}/${baseFilename}.part`;
+
+ info(`Wait for ${partFilePath} to be created`);
+ let lastError;
+ await TestUtils.waitForCondition(
+ async () =>
+ OS.File.stat(partFilePath).then(
+ () => true,
+ err => {
+ lastError = err;
+ return false;
+ }
+ ),
+ `Wait for the ${partFilePath} to exists before pausing the download`
+ ).catch(err => {
+ if (lastError) {
+ throw lastError;
+ }
+ throw err;
+ });
+}
+
+async function clearDownloads(callback) {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ await Promise.all(downloads.map(download => list.remove(download)));
+
+ return downloads;
+}
+
+function runInExtension(what, ...args) {
+ extension.sendMessage(`${what}.request`, ...args);
+ return extension.awaitMessage(`${what}.done`);
+}
+
+// This is pretty simplistic, it looks for a progress update for a
+// download of the given url in which the total bytes are exactly equal
+// to the given value. Unless you know exactly how data will arrive from
+// the server (eg see interruptible.sjs), it probably isn't very useful.
+async function waitForProgress(url, testFn) {
+ let list = await Downloads.getList(Downloads.ALL);
+
+ return new Promise(resolve => {
+ const view = {
+ onDownloadChanged(download) {
+ if (download.source.url == url && testFn(download.currentBytes)) {
+ list.removeView(view);
+ resolve(download.currentBytes);
+ }
+ },
+ };
+ list.addView(view);
+ });
+}
+
+add_task(async function setup() {
+ const nsIFile = Ci.nsIFile;
+ downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`downloadDir ${downloadDir.path}`);
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ downloadDir.remove(true);
+
+ return clearDownloads();
+ });
+
+ await clearDownloads().then(downloads => {
+ info(`removed ${downloads.length} pre-existing downloads from history`);
+ });
+
+ extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+});
+
+add_task(async function test_events() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onCreated and onChanged events");
+});
+
+add_task(async function test_cancel() {
+ let url = getInterruptibleUrl();
+ info(url);
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ msg = await runInExtension("cancel", id);
+ equal(msg.status, "success", "cancel() succeeded");
+
+ // TODO bug 1256243: This sequence of events is bogus
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ paused: {
+ previous: true,
+ current: false,
+ },
+ },
+ },
+ ]);
+ equal(
+ msg.status,
+ "success",
+ "got onChanged events corresponding to cancel()"
+ );
+
+ msg = await runInExtension("search", { error: "USER_CANCELED" });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].id, id, "download.id is correct");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, false, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, false, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "error", "cannot pause a canceled download");
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "error", "cannot resume a canceled download");
+});
+
+add_task(async function test_pauseresume() {
+ const filename = "pauseresume.html";
+ let url = getInterruptibleUrl(filename);
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ // Prevent intermittent timeouts due to the part file not yet created
+ // (e.g. see Bug 1573360).
+ await waitForCreatedPartFile(filename);
+
+ info("Pause the download item");
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "success", "pause() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ canResume: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event corresponding to pause");
+
+ msg = await runInExtension("search", { paused: true });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].id, id, "download.id is correct");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, true, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, true, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].bytesReceived,
+ INT_PARTIAL_LEN,
+ "download.bytesReceived is correct"
+ );
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+
+ msg = await runInExtension("search", { error: "USER_CANCELED" });
+ equal(msg.status, "success", "search() succeeded");
+ let found = msg.result.filter(item => item.id == id);
+ equal(found.length, 1, "search() by error found the paused download");
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "error", "cannot pause an already paused download");
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "success", "resume() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "interrupted",
+ current: "in_progress",
+ },
+ paused: {
+ previous: true,
+ current: false,
+ },
+ canResume: {
+ previous: true,
+ current: false,
+ },
+ error: {
+ previous: "USER_CANCELED",
+ current: null,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged events for resume and complete");
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].state, "complete", "download.state is correct");
+ equal(msg.result[0].paused, false, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, false, "download.canResume is correct");
+ equal(msg.result[0].error, null, "download.error is correct");
+ equal(
+ msg.result[0].bytesReceived,
+ INT_TOTAL_LEN,
+ "download.bytesReceived is correct"
+ );
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, true, "download.exists is correct");
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "error", "cannot pause a completed download");
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "error", "cannot resume a completed download");
+});
+
+add_task(async function test_pausecancel() {
+ let url = getInterruptibleUrl();
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "success", "pause() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ canResume: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event corresponding to pause");
+
+ msg = await runInExtension("search", { paused: true });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].id, id, "download.id is correct");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, true, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, true, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].bytesReceived,
+ INT_PARTIAL_LEN,
+ "download.bytesReceived is correct"
+ );
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+
+ msg = await runInExtension("search", { error: "USER_CANCELED" });
+ equal(msg.status, "success", "search() succeeded");
+ let found = msg.result.filter(item => item.id == id);
+ equal(found.length, 1, "search() by error found the paused download");
+
+ msg = await runInExtension("cancel", id);
+ equal(msg.status, "success", "cancel() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ paused: {
+ previous: true,
+ current: false,
+ },
+ canResume: {
+ previous: true,
+ current: false,
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event for cancel");
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(msg.result[0].state, "interrupted", "download.state is correct");
+ equal(msg.result[0].paused, false, "download.paused is correct");
+ equal(
+ msg.result[0].estimatedEndTime,
+ null,
+ "download.estimatedEndTime is correct"
+ );
+ equal(msg.result[0].canResume, false, "download.canResume is correct");
+ equal(msg.result[0].error, "USER_CANCELED", "download.error is correct");
+ equal(
+ msg.result[0].totalBytes,
+ INT_TOTAL_LEN,
+ "download.totalBytes is correct"
+ );
+ equal(msg.result[0].exists, false, "download.exists is correct");
+});
+
+add_task(async function test_pause_resume_cancel_badargs() {
+ let BAD_ID = 1000;
+
+ let msg = await runInExtension("pause", BAD_ID);
+ equal(msg.status, "error", "pause() failed with a bad download id");
+ ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
+
+ msg = await runInExtension("resume", BAD_ID);
+ equal(msg.status, "error", "resume() failed with a bad download id");
+ ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
+
+ msg = await runInExtension("cancel", BAD_ID);
+ equal(msg.status, "error", "cancel() failed with a bad download id");
+ ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive");
+});
+
+add_task(async function test_file_removal() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+
+ equal(msg.status, "success", "got onCreated and onChanged events");
+
+ msg = await runInExtension("removeFile", id);
+ equal(msg.status, "success", "removeFile() succeeded");
+
+ msg = await runInExtension("removeFile", id);
+ equal(
+ msg.status,
+ "error",
+ "removeFile() fails since the file was already removed."
+ );
+ ok(
+ /file doesn't exist/.test(msg.errmsg),
+ "removeFile() failed on removed file."
+ );
+
+ msg = await runInExtension("removeFile", 1000);
+ ok(
+ /Invalid download id/.test(msg.errmsg),
+ "removeFile() failed due to non-existent id"
+ );
+});
+
+add_task(async function test_removal_of_incomplete_download() {
+ const filename = "remove-incomplete.html";
+ let url = getInterruptibleUrl(filename);
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN);
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id } },
+ ]);
+ equal(msg.status, "success", "got created and changed events");
+
+ await progressPromise;
+ info(`download reached ${INT_PARTIAL_LEN} bytes`);
+
+ // Prevent intermittent timeouts due to the part file not yet created
+ // (e.g. see Bug 1573360).
+ await waitForCreatedPartFile(filename);
+
+ msg = await runInExtension("pause", id);
+ equal(msg.status, "success", "pause() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "interrupted",
+ },
+ paused: {
+ previous: false,
+ current: true,
+ },
+ canResume: {
+ previous: false,
+ current: true,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ error: {
+ previous: null,
+ current: "USER_CANCELED",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged event corresponding to pause");
+
+ msg = await runInExtension("removeFile", id);
+ equal(msg.status, "error", "removeFile() on paused download failed");
+
+ ok(
+ /Cannot remove incomplete download/.test(msg.errmsg),
+ "removeFile() failed due to download being incomplete"
+ );
+
+ msg = await runInExtension("resume", id);
+ equal(msg.status, "success", "resume() succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "interrupted",
+ current: "in_progress",
+ },
+ paused: {
+ previous: true,
+ current: false,
+ },
+ canResume: {
+ previous: true,
+ current: false,
+ },
+ error: {
+ previous: "USER_CANCELED",
+ current: null,
+ },
+ },
+ },
+ {
+ type: "onChanged",
+ data: {
+ id,
+ state: {
+ previous: "in_progress",
+ current: "complete",
+ },
+ },
+ },
+ ]);
+ equal(msg.status, "success", "got onChanged events for resume and complete");
+
+ msg = await runInExtension("removeFile", id);
+ equal(
+ msg.status,
+ "success",
+ "removeFile() succeeded following completion of resumed download."
+ );
+});
+
+// Test erase(). We don't do elaborate testing of the query handling
+// since it uses the exact same engine as search() which is tested
+// more thoroughly in test_chrome_ext_downloads_search.html
+add_task(async function test_erase() {
+ await clearDownloads();
+
+ await runInExtension("clearEvents");
+
+ async function download() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download succeeded");
+ let id = msg.result;
+
+ msg = await runInExtension(
+ "waitForEvents",
+ [
+ {
+ type: "onChanged",
+ data: { id, state: { current: "complete" } },
+ },
+ ],
+ { exact: false }
+ );
+ equal(msg.status, "success", "download finished");
+
+ return id;
+ }
+
+ let ids = {};
+ ids.dl1 = await download();
+ ids.dl2 = await download();
+ ids.dl3 = await download();
+
+ let msg = await runInExtension("search", {});
+ equal(msg.status, "success", "search succeeded");
+ equal(msg.result.length, 3, "search found 3 downloads");
+
+ msg = await runInExtension("clearEvents");
+
+ msg = await runInExtension("erase", { id: ids.dl1 });
+ equal(msg.status, "success", "erase by id succeeded");
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onErased", data: ids.dl1 },
+ ]);
+ equal(msg.status, "success", "received onErased event");
+
+ msg = await runInExtension("search", {});
+ equal(msg.status, "success", "search succeeded");
+ equal(msg.result.length, 2, "search found 2 downloads");
+
+ msg = await runInExtension("erase", {});
+ equal(msg.status, "success", "erase everything succeeded");
+
+ msg = await runInExtension(
+ "waitForEvents",
+ [
+ { type: "onErased", data: ids.dl2 },
+ { type: "onErased", data: ids.dl3 },
+ ],
+ { inorder: false }
+ );
+ equal(msg.status, "success", "received 2 onErased events");
+
+ msg = await runInExtension("search", {});
+ equal(msg.status, "success", "search succeeded");
+ equal(msg.result.length, 0, "search found 0 downloads");
+});
+
+function loadImage(img, data) {
+ return new Promise(resolve => {
+ img.src = data;
+ img.onload = resolve;
+ });
+}
+
+add_task(async function test_getFileIcon() {
+ let webNav = Services.appShell.createWindowlessBrowser(false);
+ let docShell = webNav.docShell;
+
+ let system = Services.scriptSecurityManager.getSystemPrincipal();
+ docShell.createAboutBlankContentViewer(system, system);
+
+ let img = webNav.document.createElement("img");
+
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ msg = await runInExtension("getFileIcon", id);
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 32, "returns an icon with the right height");
+ equal(img.width, 32, "returns an icon with the right width");
+
+ msg = await runInExtension("waitForEvents", [
+ { type: "onCreated", data: { id, url: TXT_URL } },
+ { type: "onChanged" },
+ ]);
+ equal(msg.status, "success", "got events");
+
+ msg = await runInExtension("getFileIcon", id);
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 32, "returns an icon with the right height after download");
+ equal(img.width, 32, "returns an icon with the right width after download");
+
+ msg = await runInExtension("getFileIcon", id + 100);
+ equal(msg.status, "error", "getFileIcon() failed");
+ ok(msg.errmsg.includes("Invalid download id"), "download id is invalid");
+
+ msg = await runInExtension("getFileIcon", id, { size: 127 });
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 127, "returns an icon with the right custom height");
+ equal(img.width, 127, "returns an icon with the right custom width");
+
+ msg = await runInExtension("getFileIcon", id, { size: 1 });
+ equal(msg.status, "success", "getFileIcon() succeeded");
+ await loadImage(img, msg.result);
+ equal(img.height, 1, "returns an icon with the right custom height");
+ equal(img.width, 1, "returns an icon with the right custom width");
+
+ msg = await runInExtension("getFileIcon", id, { size: "foo" });
+ equal(msg.status, "error", "getFileIcon() fails");
+ ok(msg.errmsg.includes("Error processing size"), "size is not a number");
+
+ msg = await runInExtension("getFileIcon", id, { size: 0 });
+ equal(msg.status, "error", "getFileIcon() fails");
+ ok(msg.errmsg.includes("Error processing size"), "size is too small");
+
+ msg = await runInExtension("getFileIcon", id, { size: 128 });
+ equal(msg.status, "error", "getFileIcon() fails");
+ ok(msg.errmsg.includes("Error processing size"), "size is too big");
+
+ webNav.close();
+});
+
+add_task(async function test_estimatedendtime() {
+ // Note we are not testing the actual value calculation of estimatedEndTime,
+ // only whether it is null/non-null at the appropriate times.
+
+ let url = `${getInterruptibleUrl()}&stream=1`;
+ let msg = await runInExtension("download", { url });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+
+ let previousBytes = await waitForProgress(url, bytes => bytes > 0);
+ await waitForProgress(url, bytes => bytes > previousBytes);
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ ok(msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct");
+ ok(msg.result[0].bytesReceived > 0, "download.bytesReceived is correct");
+
+ msg = await runInExtension("cancel", id);
+
+ msg = await runInExtension("search", { id });
+ equal(msg.status, "success", "search() succeeded");
+ equal(msg.result.length, 1, "search() found 1 download");
+ ok(!msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct");
+});
+
+add_task(async function test_byExtension() {
+ let msg = await runInExtension("download", { url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ const id = msg.result;
+ msg = await runInExtension("search", { id });
+
+ equal(msg.result.length, 1, "search() found 1 download");
+ equal(
+ msg.result[0].byExtensionName,
+ "Generated extension",
+ "download.byExtensionName is correct"
+ );
+ equal(
+ msg.result[0].byExtensionId,
+ extension.id,
+ "download.byExtensionId is correct"
+ );
+});
+
+add_task(async function cleanup() {
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js
new file mode 100644
index 0000000000..b80e5f3274
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js
@@ -0,0 +1,308 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+
+add_task(function setup() {
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ info(`Using download directory ${downloadDir.path}`);
+
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue(
+ "browser.download.dir",
+ Ci.nsIFile,
+ downloadDir
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+
+ let entries = downloadDir.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ ok(false, `Leftover file ${entry.path} in download directory`);
+ entry.remove(false);
+ }
+
+ downloadDir.remove(false);
+ });
+});
+
+add_task(async function test_private_download() {
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ background: async function() {
+ function promiseEvent(eventTarget, accept) {
+ return new Promise(resolve => {
+ eventTarget.addListener(function listener(data) {
+ if (accept && !accept(data)) {
+ return;
+ }
+ eventTarget.removeListener(listener);
+ resolve(data);
+ });
+ });
+ }
+ let startTestPromise = promiseEvent(browser.test.onMessage);
+ let removeTestPromise = promiseEvent(
+ browser.test.onMessage,
+ msg => msg == "remove"
+ );
+ let onCreatedPromise = promiseEvent(browser.downloads.onCreated);
+ let onDonePromise = promiseEvent(
+ browser.downloads.onChanged,
+ delta => delta.state && delta.state.current === "complete"
+ );
+
+ browser.test.sendMessage("ready");
+ let { url, filename } = await startTestPromise;
+
+ browser.test.log("Starting private download");
+ let downloadId = await browser.downloads.download({
+ url,
+ filename,
+ incognito: true,
+ });
+ browser.test.sendMessage("downloadId", downloadId);
+
+ browser.test.log("Waiting for downloads.onCreated");
+ let createdItem = await onCreatedPromise;
+
+ browser.test.log("Waiting for completion notification");
+ await onDonePromise;
+
+ // test_ext_downloads_download.js already tests whether the file exists
+ // in the file system. Here we will only verify that the downloads API
+ // behaves in a meaningful way.
+
+ let [downloadItem] = await browser.downloads.search({ id: downloadId });
+ browser.test.assertEq(url, createdItem.url, "onCreated url should match");
+ browser.test.assertEq(url, downloadItem.url, "download url should match");
+ browser.test.assertTrue(
+ createdItem.incognito,
+ "created download should be private"
+ );
+ browser.test.assertTrue(
+ downloadItem.incognito,
+ "stored download should be private"
+ );
+
+ await removeTestPromise;
+ browser.test.log("Removing downloaded file");
+ browser.test.assertTrue(downloadItem.exists, "downloaded file exists");
+ await browser.downloads.removeFile(downloadId);
+
+ // Disabled because the assertion fails - https://bugzil.la/1381031
+ // let [downloadItem2] = await browser.downloads.search({id: downloadId});
+ // browser.test.assertFalse(downloadItem2.exists, "file should be deleted");
+
+ browser.test.log("Erasing private download from history");
+ let erasePromise = promiseEvent(browser.downloads.onErased);
+ await browser.downloads.erase({ id: downloadId });
+ browser.test.assertEq(
+ downloadId,
+ await erasePromise,
+ "onErased should be fired for the erased private download"
+ );
+
+ browser.test.notifyPass("private download test done");
+ },
+ manifest: {
+ applications: { gecko: { id: "@spanning" } },
+ permissions: ["downloads"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "@not_allowed" } },
+ permissions: ["downloads", "downloads.open"],
+ },
+ background: async function() {
+ browser.downloads.onCreated.addListener(() => {
+ browser.test.fail("download-onCreated");
+ });
+ browser.downloads.onChanged.addListener(() => {
+ browser.test.fail("download-onChanged");
+ });
+ browser.downloads.onErased.addListener(() => {
+ browser.test.fail("download-onErased");
+ });
+ browser.test.onMessage.addListener(async (msg, data) => {
+ if (msg == "download") {
+ let { url, filename, downloadId } = data;
+ await browser.test.assertRejects(
+ browser.downloads.download({
+ url,
+ filename,
+ incognito: true,
+ }),
+ /private browsing access not allowed/,
+ "cannot download using incognito without permission."
+ );
+
+ let downloads = await browser.downloads.search({ id: downloadId });
+ browser.test.assertEq(
+ downloads.length,
+ 0,
+ "cannot search for incognito downloads"
+ );
+ let erasing = await browser.downloads.erase({ id: downloadId });
+ browser.test.assertEq(
+ erasing.length,
+ 0,
+ "cannot erase incognito download"
+ );
+
+ await browser.test.assertRejects(
+ browser.downloads.removeFile(downloadId),
+ /Invalid download id/,
+ "cannot remove incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.pause(downloadId),
+ /Invalid download id/,
+ "cannot pause incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.resume(downloadId),
+ /Invalid download id/,
+ "cannot resume incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.cancel(downloadId),
+ /Invalid download id/,
+ "cannot cancel incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.removeFile(downloadId),
+ /Invalid download id/,
+ "cannot remove incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.show(downloadId),
+ /Invalid download id/,
+ "cannot show incognito download"
+ );
+ await browser.test.assertRejects(
+ browser.downloads.getFileIcon(downloadId),
+ /Invalid download id/,
+ "cannot show incognito download"
+ );
+ }
+ if (msg == "download.open") {
+ let { downloadId } = data;
+ await browser.test.assertRejects(
+ browser.downloads.open(downloadId),
+ /Invalid download id/,
+ "cannot open incognito download"
+ );
+ }
+ browser.test.sendMessage("continue");
+ });
+ },
+ });
+
+ await extension.startup();
+ await pb_extension.startup();
+ await pb_extension.awaitMessage("ready");
+ pb_extension.sendMessage({
+ url: TXT_URL,
+ filename: TXT_FILE,
+ });
+ let downloadId = await pb_extension.awaitMessage("downloadId");
+ extension.sendMessage("download", {
+ url: TXT_URL,
+ filename: TXT_FILE,
+ downloadId,
+ });
+ await extension.awaitMessage("continue");
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("download.open", { downloadId });
+ await extension.awaitMessage("continue");
+ });
+ pb_extension.sendMessage("remove");
+
+ await pb_extension.awaitFinish("private download test done");
+ await pb_extension.unload();
+ await extension.unload();
+});
+
+// Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1649463
+add_task(async function download_blob_in_perma_private_browsing() {
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+
+ // This script creates a blob:-URL and checks that the URL can be downloaded.
+ async function testScript() {
+ const blobUrl = URL.createObjectURL(new Blob(["data here"]));
+ const downloadId = await new Promise(resolve => {
+ browser.downloads.onChanged.addListener(delta => {
+ browser.test.log(`downloads.onChanged = ${JSON.stringify(delta)}`);
+ if (delta.state && delta.state.current !== "in_progress") {
+ resolve(delta.id);
+ }
+ });
+ browser.downloads.download({
+ url: blobUrl,
+ filename: "some-blob-download.txt",
+ });
+ });
+
+ let [downloadItem] = await browser.downloads.search({ id: downloadId });
+ browser.test.log(`Downloaded ${JSON.stringify(downloadItem)}`);
+ browser.test.assertEq(downloadItem.url, blobUrl, "expected blob URL");
+ // TODO bug 1653636: should be true because of perma-private browsing.
+ // browser.test.assertTrue(downloadItem.incognito, "download is private");
+ browser.test.assertFalse(
+ downloadItem.incognito,
+ "download is private [skipped - to be fixed in bug 1653636]"
+ );
+ browser.test.assertTrue(downloadItem.exists, "download exists");
+ await browser.downloads.removeFile(downloadId);
+
+ browser.test.sendMessage("downloadDone");
+ }
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "@private-download-ext" } },
+ permissions: ["downloads"],
+ },
+ background: testScript,
+ incognitoOverride: "spanning",
+ files: {
+ "test_part2.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <script src="test_part2.js"></script>
+ `,
+ "test_part2.js": testScript,
+ },
+ });
+ await pb_extension.startup();
+
+ info("Testing download of blob:-URL from extension's background page");
+ await pb_extension.awaitMessage("downloadDone");
+
+ info("Testing download of blob:-URL with different userContextId");
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${pb_extension.uuid}/test_part2.html`,
+ { extension: pb_extension, userContextId: 2 }
+ );
+ await pb_extension.awaitMessage("downloadDone");
+ await contentPage.close();
+
+ await pb_extension.unload();
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js
new file mode 100644
index 0000000000..f28a4c881f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js
@@ -0,0 +1,682 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.import(
+ "resource://gre/modules/Downloads.jsm"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+const TXT_FILE = "file_download.txt";
+const TXT_URL = BASE + "/" + TXT_FILE;
+const TXT_LEN = 46;
+const HTML_FILE = "file_download.html";
+const HTML_URL = BASE + "/" + HTML_FILE;
+const HTML_LEN = 117;
+const EMPTY_FILE = "empty_file_download.txt";
+const EMPTY_URL = BASE + "/" + EMPTY_FILE;
+const EMPTY_LEN = 0;
+const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN
+
+function backgroundScript() {
+ let complete = new Map();
+
+ function waitForComplete(id) {
+ if (complete.has(id)) {
+ return complete.get(id).promise;
+ }
+
+ let promise = new Promise(resolve => {
+ complete.set(id, { resolve });
+ });
+ complete.get(id).promise = promise;
+ return promise;
+ }
+
+ browser.downloads.onChanged.addListener(change => {
+ if (change.state && change.state.current == "complete") {
+ // Make sure we have a promise.
+ waitForComplete(change.id);
+ complete.get(change.id).resolve();
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ try {
+ let id = await browser.downloads.download(args[0]);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "search.request") {
+ try {
+ let downloads = await browser.downloads.search(args[0]);
+ browser.test.sendMessage("search.done", {
+ status: "success",
+ downloads,
+ });
+ } catch (error) {
+ browser.test.sendMessage("search.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "waitForComplete.request") {
+ await waitForComplete(args[0]);
+ browser.test.sendMessage("waitForComplete.done");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+async function clearDownloads(callback) {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ await Promise.all(downloads.map(download => list.remove(download)));
+
+ return downloads;
+}
+
+add_task(async function test_search() {
+ const nsIFile = Ci.nsIFile;
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`downloadDir ${downloadDir.path}`);
+
+ function downloadPath(filename) {
+ let path = downloadDir.clone();
+ path.append(filename);
+ return path.path;
+ }
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+ Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ Services.prefs.clearUserPref("privacy.reduceTimerPrecision");
+ await cleanupDir(downloadDir);
+ await clearDownloads();
+ });
+
+ await clearDownloads().then(downloads => {
+ info(`removed ${downloads.length} pre-existing downloads from history`);
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ });
+
+ async function download(options) {
+ extension.sendMessage("download.request", options);
+ let result = await extension.awaitMessage("download.done");
+
+ if (result.status == "success") {
+ info(`wait for onChanged event to indicate ${result.id} is complete`);
+ extension.sendMessage("waitForComplete.request", result.id);
+
+ await extension.awaitMessage("waitForComplete.done");
+ }
+
+ return result;
+ }
+
+ function search(query) {
+ extension.sendMessage("search.request", query);
+ return extension.awaitMessage("search.done");
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Do some downloads...
+ const time1 = new Date();
+
+ let downloadIds = {};
+ let msg = await download({ url: TXT_URL });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.txt1 = msg.id;
+
+ const TXT_FILE2 = "NewFile.txt";
+ msg = await download({ url: TXT_URL, filename: TXT_FILE2 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.txt2 = msg.id;
+
+ msg = await download({ url: EMPTY_URL });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.txt3 = msg.id;
+
+ const time2 = new Date();
+
+ msg = await download({ url: HTML_URL });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.html1 = msg.id;
+
+ const HTML_FILE2 = "renamed.html";
+ msg = await download({ url: HTML_URL, filename: HTML_FILE2 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.html2 = msg.id;
+
+ const time3 = new Date();
+
+ // Search for each individual download and check
+ // the corresponding DownloadItem.
+ async function checkDownloadItem(id, expect) {
+ let item = await search({ id });
+ equal(item.status, "success", "search() succeeded");
+ equal(item.downloads.length, 1, "search() found exactly 1 download");
+
+ Object.keys(expect).forEach(function(field) {
+ equal(
+ item.downloads[0][field],
+ expect[field],
+ `DownloadItem.${field} is correct"`
+ );
+ });
+ }
+ await checkDownloadItem(downloadIds.txt1, {
+ url: TXT_URL,
+ filename: downloadPath(TXT_FILE),
+ mime: "text/plain",
+ state: "complete",
+ bytesReceived: TXT_LEN,
+ totalBytes: TXT_LEN,
+ fileSize: TXT_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.txt2, {
+ url: TXT_URL,
+ filename: downloadPath(TXT_FILE2),
+ mime: "text/plain",
+ state: "complete",
+ bytesReceived: TXT_LEN,
+ totalBytes: TXT_LEN,
+ fileSize: TXT_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.txt3, {
+ url: EMPTY_URL,
+ filename: downloadPath(EMPTY_FILE),
+ mime: "text/plain",
+ state: "complete",
+ bytesReceived: EMPTY_LEN,
+ totalBytes: EMPTY_LEN,
+ fileSize: EMPTY_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.html1, {
+ url: HTML_URL,
+ filename: downloadPath(HTML_FILE),
+ mime: "text/html",
+ state: "complete",
+ bytesReceived: HTML_LEN,
+ totalBytes: HTML_LEN,
+ fileSize: HTML_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.html2, {
+ url: HTML_URL,
+ filename: downloadPath(HTML_FILE2),
+ mime: "text/html",
+ state: "complete",
+ bytesReceived: HTML_LEN,
+ totalBytes: HTML_LEN,
+ fileSize: HTML_LEN,
+ exists: true,
+ });
+
+ async function checkSearch(query, expected, description, exact) {
+ let item = await search(query);
+ equal(item.status, "success", "search() succeeded");
+ equal(
+ item.downloads.length,
+ expected.length,
+ `search() for ${description} found exactly ${expected.length} downloads`
+ );
+
+ let receivedIds = item.downloads.map(i => i.id);
+ if (exact) {
+ receivedIds.forEach((id, idx) => {
+ equal(
+ id,
+ downloadIds[expected[idx]],
+ `search() for ${description} returned ${expected[idx]} in position ${idx}`
+ );
+ });
+ } else {
+ Object.keys(downloadIds).forEach(key => {
+ const id = downloadIds[key];
+ const thisExpected = expected.includes(key);
+ equal(
+ receivedIds.includes(id),
+ thisExpected,
+ `search() for ${description} ${
+ thisExpected ? "includes" : "does not include"
+ } ${key}`
+ );
+ });
+ }
+ }
+
+ // Check that search with an invalid id returns nothing.
+ // NB: for now ids are not persistent and we start numbering them at 1
+ // so a sufficiently large number will be unused.
+ const INVALID_ID = 1000;
+ await checkSearch({ id: INVALID_ID }, [], "invalid id");
+
+ // Check that search on url works.
+ await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url");
+
+ // Check that regexp on url works.
+ const HTML_REGEX = "[download]{8}.html+$";
+ await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp");
+
+ // Check that compatible url+regexp works
+ await checkSearch(
+ { url: HTML_URL, urlRegex: HTML_REGEX },
+ ["html1", "html2"],
+ "compatible url+urlRegex"
+ );
+
+ // Check that incompatible url+regexp works
+ await checkSearch(
+ { url: TXT_URL, urlRegex: HTML_REGEX },
+ [],
+ "incompatible url+urlRegex"
+ );
+
+ // Check that search on filename works.
+ await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename");
+
+ // Check that regexp on filename works.
+ await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex");
+
+ // Check that compatible filename+regexp works
+ await checkSearch(
+ { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX },
+ ["html1"],
+ "compatible filename+filename regex"
+ );
+
+ // Check that incompatible filename+regexp works
+ await checkSearch(
+ { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX },
+ [],
+ "incompatible filename+filename regex"
+ );
+
+ // Check that simple positive search terms work.
+ await checkSearch(
+ { query: ["file_download"] },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "term file_download"
+ );
+ await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile");
+
+ // Check that positive search terms work case-insensitive.
+ await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe");
+
+ // Check that negative search terms work.
+ await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt");
+
+ // Check that positive and negative search terms together work.
+ await checkSearch(
+ { query: ["html", "-renamed"] },
+ ["html1"],
+ "positive and negative terms"
+ );
+
+ async function checkSearchWithDate(query, expected, description) {
+ const fields = Object.keys(query);
+ if (fields.length != 1 || !(query[fields[0]] instanceof Date)) {
+ throw new Error("checkSearchWithDate expects exactly one Date field");
+ }
+ const field = fields[0];
+ const date = query[field];
+
+ let newquery = {};
+
+ // Check as a Date
+ newquery[field] = date;
+ await checkSearch(newquery, expected, `${description} as Date`);
+
+ // Check as numeric milliseconds
+ newquery[field] = date.valueOf();
+ await checkSearch(newquery, expected, `${description} as numeric ms`);
+
+ // Check as stringified milliseconds
+ newquery[field] = date.valueOf().toString();
+ await checkSearch(newquery, expected, `${description} as string ms`);
+
+ // Check as ISO string
+ newquery[field] = date.toISOString();
+ await checkSearch(newquery, expected, `${description} as iso string`);
+ }
+
+ // Check startedBefore
+ await checkSearchWithDate({ startedBefore: time1 }, [], "before time1");
+ await checkSearchWithDate(
+ { startedBefore: time2 },
+ ["txt1", "txt2", "txt3"],
+ "before time2"
+ );
+ await checkSearchWithDate(
+ { startedBefore: time3 },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "before time3"
+ );
+
+ // Check startedAfter
+ await checkSearchWithDate(
+ { startedAfter: time1 },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "after time1"
+ );
+ await checkSearchWithDate(
+ { startedAfter: time2 },
+ ["html1", "html2"],
+ "after time2"
+ );
+ await checkSearchWithDate({ startedAfter: time3 }, [], "after time3");
+
+ // Check simple search on totalBytes
+ await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes");
+ await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes");
+
+ // Check simple test on totalBytes{Greater,Less}
+ // (NB: TXT_LEN < HTML_LEN < BIG_LEN)
+ await checkSearch(
+ { totalBytesGreater: 0 },
+ ["txt1", "txt2", "html1", "html2"],
+ "totalBytesGreater than 0"
+ );
+ await checkSearch(
+ { totalBytesGreater: TXT_LEN },
+ ["html1", "html2"],
+ `totalBytesGreater than ${TXT_LEN}`
+ );
+ await checkSearch(
+ { totalBytesGreater: HTML_LEN },
+ [],
+ `totalBytesGreater than ${HTML_LEN}`
+ );
+ await checkSearch(
+ { totalBytesLess: TXT_LEN },
+ ["txt3"],
+ `totalBytesLess than ${TXT_LEN}`
+ );
+ await checkSearch(
+ { totalBytesLess: HTML_LEN },
+ ["txt1", "txt2", "txt3"],
+ `totalBytesLess than ${HTML_LEN}`
+ );
+ await checkSearch(
+ { totalBytesLess: BIG_LEN },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ `totalBytesLess than ${BIG_LEN}`
+ );
+
+ // Bug 1503760 check if 0 byte files with no search query are returned.
+ await checkSearch(
+ {},
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "totalBytesGreater than -1"
+ );
+
+ // Check good combinations of totalBytes*.
+ await checkSearch(
+ { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN },
+ ["html1", "html2"],
+ "totalBytes and totalBytesGreater"
+ );
+ await checkSearch(
+ { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN },
+ ["txt1", "txt2"],
+ "totalBytes and totalBytesGreater"
+ );
+ await checkSearch(
+ { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 },
+ ["html1", "html2"],
+ "totalBytes and totalBytesLess and totalBytesGreater"
+ );
+
+ // Check bad combination of totalBytes*.
+ await checkSearch(
+ { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN },
+ [],
+ "bad totalBytesLess, totalBytesGreater combination"
+ );
+ await checkSearch(
+ { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN },
+ [],
+ "bad totalBytes, totalBytesGreater combination"
+ );
+ await checkSearch(
+ { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN },
+ [],
+ "bad totalBytes, totalBytesLess combination"
+ );
+
+ // Check mime.
+ await checkSearch(
+ { mime: "text/plain" },
+ ["txt1", "txt2", "txt3"],
+ "mime text/plain"
+ );
+ await checkSearch(
+ { mime: "text/html" },
+ ["html1", "html2"],
+ "mime text/htmlplain"
+ );
+ await checkSearch({ mime: "video/webm" }, [], "mime video/webm");
+
+ // Check fileSize.
+ await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize");
+ await checkSearch({ fileSize: HTML_LEN }, ["html1", "html2"], "fileSize");
+
+ // Fields like bytesReceived, paused, state, exists are meaningful
+ // for downloads that are in progress but have not yet completed.
+ // todo: add tests for these when we have better support for in-progress
+ // downloads (e.g., after pause(), resume() and cancel() are implemented)
+
+ // Check multiple query properties.
+ // We could make this testing arbitrarily complicated...
+ // We already tested combining fields with obvious interactions above
+ // (e.g., filename and filenameRegex or startTime and startedBefore/After)
+ // so now just throw as many fields as we can at a single search and
+ // make sure a simple case still works.
+ await checkSearch(
+ {
+ url: TXT_URL,
+ urlRegex: "download",
+ filename: downloadPath(TXT_FILE),
+ filenameRegex: "download",
+ query: ["download"],
+ startedAfter: time1.valueOf().toString(),
+ startedBefore: time2.valueOf().toString(),
+ totalBytes: TXT_LEN,
+ totalBytesGreater: 0,
+ totalBytesLess: BIG_LEN,
+ mime: "text/plain",
+ fileSize: TXT_LEN,
+ },
+ ["txt1"],
+ "many properties"
+ );
+
+ // Check simple orderBy (forward and backward).
+ await checkSearch(
+ { orderBy: ["startTime"] },
+ ["txt1", "txt2", "txt3", "html1", "html2"],
+ "orderBy startTime",
+ true
+ );
+ await checkSearch(
+ { orderBy: ["-startTime"] },
+ ["html2", "html1", "txt3", "txt2", "txt1"],
+ "orderBy -startTime",
+ true
+ );
+
+ // Check orderBy with multiple fields.
+ // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt
+ // EMPTY_URL begins with e which precedes f
+ await checkSearch(
+ { orderBy: ["url", "-startTime"] },
+ ["txt3", "html2", "html1", "txt2", "txt1"],
+ "orderBy with multiple fields",
+ true
+ );
+
+ // Check orderBy with limit.
+ await checkSearch(
+ { orderBy: ["url"], limit: 1 },
+ ["txt3"],
+ "orderBy with limit",
+ true
+ );
+
+ // Check bad arguments.
+ async function checkBadSearch(query, pattern, description) {
+ let item = await search(query);
+ equal(item.status, "error", "search() failed");
+ ok(
+ pattern.test(item.errmsg),
+ `error message for ${description} was correct (${item.errmsg}).`
+ );
+ }
+
+ await checkBadSearch(
+ "myquery",
+ /Incorrect argument type/,
+ "query is not an object"
+ );
+ await checkBadSearch(
+ { bogus: "boo" },
+ /Unexpected property/,
+ "query contains an unknown field"
+ );
+ await checkBadSearch(
+ { query: "query string" },
+ /Expected array/,
+ "query.query is a string"
+ );
+ await checkBadSearch(
+ { startedBefore: "i am not a time" },
+ /Type error/,
+ "query.startedBefore is not a valid time"
+ );
+ await checkBadSearch(
+ { startedAfter: "i am not a time" },
+ /Type error/,
+ "query.startedAfter is not a valid time"
+ );
+ await checkBadSearch(
+ { endedBefore: "i am not a time" },
+ /Type error/,
+ "query.endedBefore is not a valid time"
+ );
+ await checkBadSearch(
+ { endedAfter: "i am not a time" },
+ /Type error/,
+ "query.endedAfter is not a valid time"
+ );
+ await checkBadSearch(
+ { urlRegex: "[" },
+ /Invalid urlRegex/,
+ "query.urlRegexp is not a valid regular expression"
+ );
+ await checkBadSearch(
+ { filenameRegex: "[" },
+ /Invalid filenameRegex/,
+ "query.filenameRegexp is not a valid regular expression"
+ );
+ await checkBadSearch(
+ { orderBy: "startTime" },
+ /Expected array/,
+ "query.orderBy is not an array"
+ );
+ await checkBadSearch(
+ { orderBy: ["bogus"] },
+ /Invalid orderBy field/,
+ "query.orderBy references a non-existent field"
+ );
+
+ await extension.unload();
+});
+
+// Test that downloads with totalBytes of -1 (ie, that have not yet started)
+// work properly. See bug 1519762 for details of a past regression in
+// this area.
+add_task(async function test_inprogress() {
+ let resume,
+ resumePromise = new Promise(resolve => {
+ resume = resolve;
+ });
+ let hit = false;
+ server.registerPathHandler("/data/slow", async (request, response) => {
+ hit = true;
+ response.processAsync();
+ await resumePromise;
+ response.setHeader("Content-type", "text/plain");
+ response.write("");
+ response.finish();
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["downloads"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, url) => {
+ let id = await browser.downloads.download({ url });
+ let full = await browser.downloads.search({ id });
+
+ browser.test.assertEq(
+ full.length,
+ 1,
+ "Found new download in search results"
+ );
+ browser.test.assertEq(
+ full[0].totalBytes,
+ -1,
+ "New download still has totalBytes == -1"
+ );
+
+ browser.downloads.onChanged.addListener(info => {
+ if (info.id == id && info.state && info.state.current == "complete") {
+ browser.test.notifyPass("done");
+ }
+ });
+
+ browser.test.sendMessage("started");
+ });
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage("go", `${BASE}/slow`);
+ await extension.awaitMessage("started");
+ resume();
+ await extension.awaitFinish("done");
+ await extension.unload();
+ Assert.ok(hit, "slow path was actually hit");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js
new file mode 100644
index 0000000000..9a63369efb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js
@@ -0,0 +1,235 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { Downloads } = ChromeUtils.import(
+ "resource://gre/modules/Downloads.jsm"
+);
+
+function backgroundScript() {
+ let complete = new Map();
+
+ function waitForComplete(id) {
+ if (complete.has(id)) {
+ return complete.get(id).promise;
+ }
+
+ let promise = new Promise(resolve => {
+ complete.set(id, { resolve });
+ });
+ complete.get(id).promise = promise;
+ return promise;
+ }
+
+ browser.downloads.onChanged.addListener(change => {
+ if (change.state && change.state.current == "complete") {
+ // Make sure we have a promise.
+ waitForComplete(change.id);
+ complete.get(change.id).resolve();
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg == "download.request") {
+ try {
+ let id = await browser.downloads.download(args[0]);
+ browser.test.sendMessage("download.done", { status: "success", id });
+ } catch (error) {
+ browser.test.sendMessage("download.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "search.request") {
+ try {
+ let downloads = await browser.downloads.search(args[0]);
+ browser.test.sendMessage("search.done", {
+ status: "success",
+ downloads,
+ });
+ } catch (error) {
+ browser.test.sendMessage("search.done", {
+ status: "error",
+ errmsg: error.message,
+ });
+ }
+ } else if (msg == "waitForComplete.request") {
+ await waitForComplete(args[0]);
+ browser.test.sendMessage("waitForComplete.done");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+}
+
+async function clearDownloads(callback) {
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloads = await list.getAll();
+
+ await Promise.all(downloads.map(download => list.remove(download)));
+
+ return downloads;
+}
+
+add_task(async function test_decoded_filename_download() {
+ const server = createHttpServer();
+ server.registerPrefixHandler("/data/", (_, res) => res.write("length=8"));
+
+ const BASE = `http://localhost:${server.identity.primaryPort}/data`;
+ const FILE_NAME_ENCODED_1 = "file%2Fencode.txt";
+ const FILE_NAME_DECODED_1 = "file_encode.txt";
+ const FILE_NAME_ENCODED_URL_1 = BASE + "/" + FILE_NAME_ENCODED_1;
+ const FILE_NAME_ENCODED_2 = "file%F0%9F%9A%B2encoded.txt";
+ const FILE_NAME_DECODED_2 = "file\u{0001F6B2}encoded.txt";
+ const FILE_NAME_ENCODED_URL_2 = BASE + "/" + FILE_NAME_ENCODED_2;
+ const FILE_NAME_ENCODED_3 = "file%X%20encode.txt";
+ const FILE_NAME_DECODED_3 = "file%X encode.txt";
+ const FILE_NAME_ENCODED_URL_3 = BASE + "/" + FILE_NAME_ENCODED_3;
+ const FILE_ENCODED_LEN = 8;
+
+ const nsIFile = Ci.nsIFile;
+ let downloadDir = FileUtils.getDir("TmpD", ["downloads"]);
+ downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ info(`downloadDir ${downloadDir.path}`);
+
+ function downloadPath(filename) {
+ let path = downloadDir.clone();
+ path.append(filename);
+ return path.path;
+ }
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ await cleanupDir(downloadDir);
+ await clearDownloads();
+ });
+
+ await clearDownloads().then(downloads => {
+ info(`removed ${downloads.length} pre-existing downloads from history`);
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: backgroundScript,
+ manifest: {
+ permissions: ["downloads"],
+ },
+ });
+
+ async function download(options) {
+ extension.sendMessage("download.request", options);
+ let result = await extension.awaitMessage("download.done");
+
+ if (result.status == "success") {
+ info(`wait for onChanged event to indicate ${result.id} is complete`);
+ extension.sendMessage("waitForComplete.request", result.id);
+
+ await extension.awaitMessage("waitForComplete.done");
+ }
+
+ return result;
+ }
+
+ function search(query) {
+ extension.sendMessage("search.request", query);
+ return extension.awaitMessage("search.done");
+ }
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let downloadIds = {};
+ let msg = await download({ url: FILE_NAME_ENCODED_URL_1 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded1 = msg.id;
+
+ msg = await download({ url: FILE_NAME_ENCODED_URL_2 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded2 = msg.id;
+
+ msg = await download({ url: FILE_NAME_ENCODED_URL_3 });
+ equal(msg.status, "success", "download() succeeded");
+ downloadIds.fileEncoded3 = msg.id;
+
+ // Search for each individual download and check
+ // the corresponding DownloadItem.
+ async function checkDownloadItem(id, expect) {
+ let item = await search({ id });
+ equal(item.status, "success", "search() succeeded");
+ equal(item.downloads.length, 1, "search() found exactly 1 download");
+ Object.keys(expect).forEach(function(field) {
+ equal(
+ item.downloads[0][field],
+ expect[field],
+ `DownloadItem.${field} is correct"`
+ );
+ });
+ }
+
+ await checkDownloadItem(downloadIds.fileEncoded1, {
+ url: FILE_NAME_ENCODED_URL_1,
+ filename: downloadPath(FILE_NAME_DECODED_1),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.fileEncoded2, {
+ url: FILE_NAME_ENCODED_URL_2,
+ filename: downloadPath(FILE_NAME_DECODED_2),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ await checkDownloadItem(downloadIds.fileEncoded3, {
+ url: FILE_NAME_ENCODED_URL_3,
+ filename: downloadPath(FILE_NAME_DECODED_3),
+ state: "complete",
+ bytesReceived: FILE_ENCODED_LEN,
+ totalBytes: FILE_ENCODED_LEN,
+ fileSize: FILE_ENCODED_LEN,
+ exists: true,
+ });
+
+ // Searching for downloads by the decoded filename works correctly.
+ async function checkSearch(query, expected, description) {
+ let item = await search(query);
+ equal(item.status, "success", "search() succeeded");
+ equal(
+ item.downloads.length,
+ expected.length,
+ `search() for ${description} found exactly ${expected.length} downloads`
+ );
+ equal(
+ item.downloads[0].id,
+ downloadIds[expected[0]],
+ `search() for ${description} returned ${expected[0]} in position ${0}`
+ );
+ }
+
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_1) },
+ ["fileEncoded1"],
+ "filename"
+ );
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_2) },
+ ["fileEncoded2"],
+ "filename"
+ );
+ await checkSearch(
+ { filename: downloadPath(FILE_NAME_DECODED_3) },
+ ["fileEncoded3"],
+ "filename"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js
new file mode 100644
index 0000000000..ab18c9c371
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.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 test_error_location() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let { fileName } = new Error();
+
+ browser.test.sendMessage("fileName", fileName);
+
+ browser.runtime.sendMessage("Meh.", () => {});
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("Meh"),
+ error => {
+ return error.fileName === fileName && error.lineNumber === 9;
+ }
+ );
+
+ browser.test.notifyPass("error-location");
+ },
+ });
+
+ let fileName;
+ const { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+
+ fileName = await extension.awaitMessage("fileName");
+
+ await extension.awaitFinish("error-location");
+
+ await extension.unload();
+ });
+
+ let [msg] = messages.filter(m => m.message.includes("Unchecked lastError"));
+
+ equal(msg.sourceName, fileName, "Message source");
+ equal(msg.lineNumber, 6, "Message line");
+
+ let frame = msg.stack;
+ if (frame) {
+ equal(frame.source, fileName, "Frame source");
+ equal(frame.line, 6, "Frame line");
+ equal(frame.column, 23, "Frame column");
+ equal(frame.functionDisplayName, "background", "Frame function name");
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js
new file mode 100644
index 0000000000..ba53803f43
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js
@@ -0,0 +1,90 @@
+"use strict";
+
+AddonTestUtils.init(this);
+// This test expects and checks deprecation warnings.
+ExtensionTestUtils.failOnSchemaWarnings(false);
+
+function createEventPageExtension(eventPage) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: eventPage,
+ },
+ files: {
+ "event_page_script.js"() {
+ browser.test.log("running event page as background script");
+ browser.test.sendMessage("running", 1);
+ },
+ "event-page.html": `<!DOCTYPE html>
+ <html><head>
+ <meta charset="utf-8">
+ <script src="event_page_script.js"><\/script>
+ </head></html>`,
+ },
+ });
+}
+
+add_task(async function test_eventpages() {
+ let testCases = [
+ {
+ message: "testing event page running as a background page",
+ eventPage: {
+ page: "event-page.html",
+ persistent: false,
+ },
+ },
+ {
+ message: "testing event page scripts running as a background page",
+ eventPage: {
+ scripts: ["event_page_script.js"],
+ persistent: false,
+ },
+ },
+ {
+ message: "testing additional unrecognized properties on background page",
+ eventPage: {
+ scripts: ["event_page_script.js"],
+ nonExistentProp: true,
+ },
+ },
+ {
+ message: "testing persistent background page",
+ eventPage: {
+ page: "event-page.html",
+ persistent: true,
+ },
+ },
+ {
+ message:
+ "testing scripts with persistent background running as a background page",
+ eventPage: {
+ scripts: ["event_page_script.js"],
+ persistent: true,
+ },
+ },
+ ];
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ for (let test of testCases) {
+ info(test.message);
+
+ let extension = createEventPageExtension(test.eventPage);
+ await extension.startup();
+ let x = await extension.awaitMessage("running");
+ equal(x, 1, "got correct value from extension");
+ await extension.unload();
+ }
+ });
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ { message: /Event pages are not currently supported./ },
+ { message: /Event pages are not currently supported./ },
+ {
+ message: /Reading manifest: Warning processing background.nonExistentProp: An unexpected property was found/,
+ },
+ ],
+ },
+ true
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js
new file mode 100644
index 0000000000..1393888eca
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js
@@ -0,0 +1,358 @@
+"use strict";
+
+/* globals browser */
+const { AddonSettings } = ChromeUtils.import(
+ "resource://gre/modules/addons/AddonSettings.jsm"
+);
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ AddonTestUtils.overrideCertDB();
+ await ExtensionTestUtils.startAddonManager();
+});
+
+let fooExperimentAPIs = {
+ foo: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "parent.js",
+ paths: [["experiments", "foo", "parent"]],
+ },
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["experiments", "foo", "child"]],
+ },
+ },
+};
+
+let fooExperimentFiles = {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "experiments.foo",
+ types: [
+ {
+ id: "Meh",
+ type: "object",
+ properties: {},
+ },
+ ],
+ functions: [
+ {
+ name: "parent",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "child",
+ type: "function",
+ parameters: [],
+ returns: { type: "string" },
+ },
+ ],
+ },
+ ]),
+
+ /* globals ExtensionAPI */
+ "parent.js": () => {
+ this.foo = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ foo: {
+ parent() {
+ return Promise.resolve("parent");
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+
+ "child.js": () => {
+ this.foo = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ foo: {
+ child() {
+ return "child";
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+};
+
+async function testFooExperiment() {
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments,
+ "typeof browser.experiments"
+ );
+
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments.foo,
+ "typeof browser.experiments.foo"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.foo.child,
+ "typeof browser.experiments.foo.child"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.foo.parent,
+ "typeof browser.experiments.foo.parent"
+ );
+
+ browser.test.assertEq(
+ "child",
+ browser.experiments.foo.child(),
+ "foo.child()"
+ );
+
+ browser.test.assertEq(
+ "parent",
+ await browser.experiments.foo.parent(),
+ "await foo.parent()"
+ );
+}
+
+async function testFooFailExperiment() {
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments,
+ "typeof browser.experiments"
+ );
+
+ browser.test.assertEq(
+ "undefined",
+ typeof browser.experiments.foo,
+ "typeof browser.experiments.foo"
+ );
+}
+
+add_task(async function test_bundled_experiments() {
+ let testCases = [
+ { isSystem: true, temporarilyInstalled: true, shouldHaveExperiments: true },
+ {
+ isSystem: true,
+ temporarilyInstalled: false,
+ shouldHaveExperiments: true,
+ },
+ {
+ isPrivileged: true,
+ temporarilyInstalled: true,
+ shouldHaveExperiments: true,
+ },
+ {
+ isPrivileged: true,
+ temporarilyInstalled: false,
+ shouldHaveExperiments: true,
+ },
+ {
+ isPrivileged: false,
+ temporarilyInstalled: true,
+ shouldHaveExperiments: AddonSettings.EXPERIMENTS_ENABLED,
+ },
+ {
+ isPrivileged: false,
+ temporarilyInstalled: false,
+ shouldHaveExperiments: AppConstants.MOZ_APP_NAME == "thunderbird",
+ },
+ ];
+
+ async function background(shouldHaveExperiments) {
+ if (shouldHaveExperiments) {
+ await testFooExperiment();
+ } else {
+ await testFooFailExperiment();
+ }
+
+ browser.test.notifyPass("background.experiments.foo");
+ }
+
+ for (let testCase of testCases) {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: testCase.isPrivileged,
+ isSystem: testCase.isSystem,
+ temporarilyInstalled: testCase.temporarilyInstalled,
+
+ manifest: {
+ experiment_apis: fooExperimentAPIs,
+ },
+
+ background: `
+ ${testFooExperiment}
+ ${testFooFailExperiment}
+ (${background})(${testCase.shouldHaveExperiments});
+ `,
+
+ files: fooExperimentFiles,
+ });
+
+ await extension.startup();
+
+ await extension.awaitFinish("background.experiments.foo");
+
+ await extension.unload();
+ }
+});
+
+add_task(async function test_unbundled_experiments() {
+ async function background() {
+ await testFooExperiment();
+
+ browser.test.assertEq(
+ "object",
+ typeof browser.experiments.crunk,
+ "typeof browser.experiments.crunk"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.crunk.child,
+ "typeof browser.experiments.crunk.child"
+ );
+
+ browser.test.assertEq(
+ "function",
+ typeof browser.experiments.crunk.parent,
+ "typeof browser.experiments.crunk.parent"
+ );
+
+ browser.test.assertEq(
+ "crunk-child",
+ browser.experiments.crunk.child(),
+ "crunk.child()"
+ );
+
+ browser.test.assertEq(
+ "crunk-parent",
+ await browser.experiments.crunk.parent(),
+ "await crunk.parent()"
+ );
+
+ browser.test.notifyPass("background.experiments.crunk");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+
+ manifest: {
+ experiment_apis: fooExperimentAPIs,
+
+ permissions: ["experiments.crunk"],
+ },
+
+ background: `
+ ${testFooExperiment}
+ (${background})();
+ `,
+
+ files: fooExperimentFiles,
+ });
+
+ let apiExtension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+
+ manifest: {
+ applications: { gecko: { id: "crunk@experiments.addons.mozilla.org" } },
+
+ experiment_apis: {
+ crunk: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "parent.js",
+ paths: [["experiments", "crunk", "parent"]],
+ },
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["experiments", "crunk", "child"]],
+ },
+ },
+ },
+ },
+
+ files: {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "experiments.crunk",
+ types: [
+ {
+ id: "Meh",
+ type: "object",
+ properties: {},
+ },
+ ],
+ functions: [
+ {
+ name: "parent",
+ type: "function",
+ async: true,
+ parameters: [],
+ },
+ {
+ name: "child",
+ type: "function",
+ parameters: [],
+ returns: { type: "string" },
+ },
+ ],
+ },
+ ]),
+
+ "parent.js": () => {
+ this.crunk = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ crunk: {
+ parent() {
+ return Promise.resolve("crunk-parent");
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+
+ "child.js": () => {
+ this.crunk = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ experiments: {
+ crunk: {
+ child() {
+ return "crunk-child";
+ },
+ },
+ },
+ };
+ }
+ };
+ },
+ },
+ });
+
+ await apiExtension.startup();
+ await extension.startup();
+
+ await extension.awaitFinish("background.experiments.crunk");
+
+ await extension.unload();
+ await apiExtension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js
new file mode 100644
index 0000000000..72fa161965
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js
@@ -0,0 +1,80 @@
+/* -*- 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_is_allowed_incognito_access() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ async function background() {
+ let allowed = await browser.extension.isAllowedIncognitoAccess();
+
+ browser.test.assertEq(true, allowed, "isAllowedIncognitoAccess is true");
+ browser.test.notifyPass("isAllowedIncognitoAccess");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("isAllowedIncognitoAccess");
+ await extension.unload();
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+});
+
+add_task(async function test_is_denied_incognito_access() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ async function background() {
+ let allowed = await browser.extension.isAllowedIncognitoAccess();
+
+ browser.test.assertEq(false, allowed, "isAllowedIncognitoAccess is false");
+ browser.test.notifyPass("isNotAllowedIncognitoAccess");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("isNotAllowedIncognitoAccess");
+ await extension.unload();
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+});
+
+add_task(async function test_in_incognito_context_false() {
+ function background() {
+ browser.test.assertEq(
+ false,
+ browser.extension.inIncognitoContext,
+ "inIncognitoContext returned false"
+ );
+ browser.test.notifyPass("inIncognitoContext");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("inIncognitoContext");
+ await extension.unload();
+});
+
+add_task(async function test_is_allowed_file_scheme_access() {
+ async function background() {
+ let allowed = await browser.extension.isAllowedFileSchemeAccess();
+
+ browser.test.assertEq(false, allowed, "isAllowedFileSchemeAccess is false");
+ browser.test.notifyPass("isAllowedFileSchemeAccess");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("isAllowedFileSchemeAccess");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
new file mode 100644
index 0000000000..19e046e12d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
@@ -0,0 +1,887 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionPreferencesManager",
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+var { PromiseUtils } = ChromeUtils.import(
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+let lastSetPref;
+
+const STORE_TYPE = "prefs";
+
+// Test settings to use with the preferences manager.
+const SETTINGS = {
+ multiple_prefs: {
+ prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"],
+
+ initalValues: ["value1", "value2", "value3"],
+
+ valueFn(pref, value) {
+ return `${pref}-${value}`;
+ },
+
+ setCallback(value) {
+ let prefs = {};
+ for (let pref of this.prefNames) {
+ prefs[pref] = this.valueFn(pref, value);
+ }
+ return prefs;
+ },
+ },
+
+ singlePref: {
+ prefNames: ["my.single.pref"],
+
+ initalValues: ["value1"],
+
+ onPrefsChanged(item) {
+ lastSetPref = item;
+ },
+
+ valueFn(pref, value) {
+ return value;
+ },
+
+ setCallback(value) {
+ return { [this.prefNames[0]]: this.valueFn(null, value) };
+ },
+ },
+};
+
+ExtensionPreferencesManager.addSetting(
+ "multiple_prefs",
+ SETTINGS.multiple_prefs
+);
+ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref);
+
+// Set initial values for prefs.
+for (let setting in SETTINGS) {
+ setting = SETTINGS[setting];
+ for (let i = 0; i < setting.prefNames.length; i++) {
+ Preferences.set(setting.prefNames[i], setting.initalValues[i]);
+ }
+}
+
+function checkPrefs(settingObj, value, msg) {
+ for (let pref of settingObj.prefNames) {
+ equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg);
+ }
+}
+
+function checkOnPrefsChanged(setting, value, msg) {
+ if (value) {
+ deepEqual(lastSetPref, value, msg);
+ lastSetPref = null;
+ } else {
+ ok(!lastSetPref, msg);
+ }
+}
+
+add_task(async function test_preference_manager() {
+ await promiseStartupManager();
+
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let extensions = testExtensions.map(extension => extension.extension);
+
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ let newValue1 = "newValue1";
+ let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ extensions[1].id,
+ setting
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged has not been called yet"
+ );
+ }
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with no settings set."
+ );
+
+ let prefsChanged = await ExtensionPreferencesManager.setSetting(
+ extensions[1].id,
+ setting,
+ newValue1
+ );
+ ok(prefsChanged, "setSetting returns true when the pref(s) have been set.");
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "setSetting sets the prefs for the first extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[1].id, value: newValue1, key: setting },
+ "onPrefsChanged is called when pref changes"
+ );
+ }
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ extensions[1].id,
+ setting
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when a pref has been set."
+ );
+
+ let checkSetting = await ExtensionPreferencesManager.getSetting(setting);
+ equal(
+ checkSetting.value,
+ newValue1,
+ "getSetting returns the expected value."
+ );
+
+ let newValue2 = "newValue2";
+ prefsChanged = await ExtensionPreferencesManager.setSetting(
+ extensions[0].id,
+ setting,
+ newValue2
+ );
+ ok(
+ !prefsChanged,
+ "setSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "setSetting does not set the pref(s) for an earlier extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.disableSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ !prefsChanged,
+ "disableSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "disableSetting does not change the pref(s) for the non-top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change on disable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.enableSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ !prefsChanged,
+ "enableSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "enableSetting does not change the pref(s) for the non-top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change on enable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ !prefsChanged,
+ "removeSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "removeSetting does not change the pref(s) for the non-top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change on remove"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.setSetting(
+ extensions[0].id,
+ setting,
+ newValue2
+ );
+ ok(
+ !prefsChanged,
+ "setSetting returns false when the pref(s) have not been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "setSetting does not set the pref(s) for an earlier extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ null,
+ "onPrefsChanged isn't called without control change again"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.disableSetting(
+ extensions[1].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "disableSetting returns true when the pref(s) have been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue2,
+ "disableSetting sets the pref(s) to the next value when disabling the top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[0].id, key: setting, value: newValue2 },
+ "onPrefsChanged is called when control changes on disable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.enableSetting(
+ extensions[1].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "enableSetting returns true when the pref(s) have been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue1,
+ "enableSetting sets the pref(s) to the previous value(s)."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[1].id, key: setting, value: newValue1 },
+ "onPrefsChanged is called when control changes on enable"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ extensions[1].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "removeSetting returns true when the pref(s) have been set."
+ );
+ checkPrefs(
+ settingObj,
+ newValue2,
+ "removeSetting sets the pref(s) to the next value when removing the top extension."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { id: extensions[0].id, key: setting, value: newValue2 },
+ "onPrefsChanged is called when control changes on remove"
+ );
+ }
+
+ prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ extensions[0].id,
+ setting
+ );
+ ok(
+ prefsChanged,
+ "removeSetting returns true when the pref(s) have been set."
+ );
+ if (settingObj.onPrefsChanged) {
+ checkOnPrefsChanged(
+ setting,
+ { key: setting, initialValue: { "my.single.pref": "value1" } },
+ "onPrefsChanged is called when control is entirely removed"
+ );
+ }
+ for (let i = 0; i < settingObj.prefNames.length; i++) {
+ equal(
+ Preferences.get(settingObj.prefNames[i]),
+ settingObj.initalValues[i],
+ "removeSetting sets the pref(s) to the initial value(s) when removing the last extension."
+ );
+ }
+
+ checkSetting = await ExtensionPreferencesManager.getSetting(setting);
+ equal(
+ checkSetting,
+ null,
+ "getSetting returns null when nothing has been set."
+ );
+ }
+
+ // Tests for unsetAll.
+ let newValue3 = "newValue3";
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ await ExtensionPreferencesManager.setSetting(
+ extensions[0].id,
+ setting,
+ newValue3
+ );
+ checkPrefs(settingObj, newValue3, "setSetting set the pref.");
+ }
+
+ let setSettings = await ExtensionSettingsStore.getAllForExtension(
+ extensions[0].id,
+ STORE_TYPE
+ );
+ deepEqual(
+ setSettings,
+ Object.keys(SETTINGS),
+ "Expected settings were set for extension."
+ );
+ await ExtensionPreferencesManager.disableAll(extensions[0].id);
+
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ for (let i = 0; i < settingObj.prefNames.length; i++) {
+ equal(
+ Preferences.get(settingObj.prefNames[i]),
+ settingObj.initalValues[i],
+ "disableAll unset the pref."
+ );
+ }
+ }
+
+ setSettings = await ExtensionSettingsStore.getAllForExtension(
+ extensions[0].id,
+ STORE_TYPE
+ );
+ deepEqual(
+ setSettings,
+ Object.keys(SETTINGS),
+ "disableAll retains the settings."
+ );
+
+ await ExtensionPreferencesManager.enableAll(extensions[0].id);
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ checkPrefs(settingObj, newValue3, "enableAll re-set the pref.");
+ }
+
+ await ExtensionPreferencesManager.removeAll(extensions[0].id);
+
+ for (let setting in SETTINGS) {
+ let settingObj = SETTINGS[setting];
+ for (let i = 0; i < settingObj.prefNames.length; i++) {
+ equal(
+ Preferences.get(settingObj.prefNames[i]),
+ settingObj.initalValues[i],
+ "removeAll unset the pref."
+ );
+ }
+ }
+
+ setSettings = await ExtensionSettingsStore.getAllForExtension(
+ extensions[0].id,
+ STORE_TYPE
+ );
+ deepEqual(setSettings, [], "removeAll removed all settings.");
+
+ // Tests for preventing automatic changes to manually edited prefs.
+ for (let setting in SETTINGS) {
+ let apiValue = "newValue";
+ let manualValue = "something different";
+ let settingObj = SETTINGS[setting];
+ let extension = extensions[1];
+ await ExtensionPreferencesManager.setSetting(
+ extension.id,
+ setting,
+ apiValue
+ );
+
+ let checkResetPrefs = method => {
+ let prefNames = settingObj.prefNames;
+ for (let i = 0; i < prefNames.length; i++) {
+ if (i === 0) {
+ equal(
+ Preferences.get(prefNames[0]),
+ manualValue,
+ `${method} did not change a manually set pref.`
+ );
+ } else {
+ equal(
+ Preferences.get(prefNames[i]),
+ settingObj.valueFn(prefNames[i], apiValue),
+ `${method} did not change another pref when a pref was manually set.`
+ );
+ }
+ }
+ };
+
+ // Manually set the preference to a different value.
+ Preferences.set(settingObj.prefNames[0], manualValue);
+
+ await ExtensionPreferencesManager.disableAll(extension.id);
+ checkResetPrefs("disableAll");
+
+ await ExtensionPreferencesManager.enableAll(extension.id);
+ checkResetPrefs("enableAll");
+
+ await ExtensionPreferencesManager.removeAll(extension.id);
+ checkResetPrefs("removeAll");
+ }
+
+ // Test with an uninitialized pref.
+ let setting = "singlePref";
+ let settingObj = SETTINGS[setting];
+ let pref = settingObj.prefNames[0];
+ let newValue = "newValue";
+ Preferences.reset(pref);
+ await ExtensionPreferencesManager.setSetting(
+ extensions[1].id,
+ setting,
+ newValue
+ );
+ equal(
+ Preferences.get(pref),
+ settingObj.valueFn(pref, newValue),
+ "Uninitialized pref is set."
+ );
+ await ExtensionPreferencesManager.removeSetting(extensions[1].id, setting);
+ ok(!Preferences.has(pref), "removeSetting removed the pref.");
+
+ // Test levelOfControl with a locked pref.
+ setting = "multiple_prefs";
+ let prefToLock = SETTINGS[setting].prefNames[0];
+ Preferences.lock(prefToLock, 1);
+ ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`);
+ let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ extensions[1].id,
+ setting
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when a pref is locked."
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_manager_set_when_disabled() {
+ await promiseStartupManager();
+
+ let id = "@set-disabled-pref";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+
+ // We test both a default pref and a user-set pref. Get the default
+ // value off the pref we'll use. We fake the default pref by setting
+ // a value on it before creating the setting.
+ Services.prefs.setBoolPref("bar", true);
+
+ function isUndefinedPref(pref) {
+ try {
+ Services.prefs.getStringPref(pref);
+ return false;
+ } catch (e) {
+ return true;
+ }
+ }
+ ok(isUndefinedPref("foo"), "test pref is not set");
+
+ await ExtensionSettingsStore.initialize();
+ let lastItemChange = PromiseUtils.defer();
+ ExtensionPreferencesManager.addSetting("some-pref", {
+ prefNames: ["foo", "bar"],
+ onPrefsChanged(item) {
+ lastItemChange.resolve(item);
+ lastItemChange = PromiseUtils.defer();
+ },
+ setCallback(value) {
+ return { [this.prefNames[0]]: value, [this.prefNames[1]]: false };
+ },
+ });
+
+ await ExtensionPreferencesManager.setSetting(id, "some-pref", "my value");
+
+ let item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "my value", "The value has been set");
+ equal(
+ Services.prefs.getStringPref("foo"),
+ "my value",
+ "The user pref has been set"
+ );
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ false,
+ "The default pref has been set"
+ );
+
+ await ExtensionPreferencesManager.disableSetting(id, "some-pref");
+
+ // test that a disabled setting has been returned to the default value. In this
+ // case the pref is not a default pref, so it will be undefined.
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, undefined, "The value is back to default");
+ equal(item.initialValue.foo, undefined, "The initialValue is correct");
+ ok(isUndefinedPref("foo"), "user pref is not set");
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ true,
+ "The default pref has been restored to the default"
+ );
+
+ // test that setSetting() will enable a disabled setting
+ await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value");
+
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "new value", "The value is set again");
+ equal(
+ Services.prefs.getStringPref("foo"),
+ "new value",
+ "The user pref is set again"
+ );
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ false,
+ "The default pref has been set again"
+ );
+
+ // Force settings to be serialized and reloaded to mimick what happens
+ // with settings through a restart of Firefox. Bug 1576266.
+ await ExtensionSettingsStore._reloadFile(true);
+
+ // Now unload the extension to test prefs are reset properly.
+ let promise = lastItemChange.promise;
+ await extension.unload();
+
+ // Test that the pref is unset when an extension is uninstalled.
+ item = await promise;
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: { bar: true } },
+ "The value has been reset"
+ );
+ ok(isUndefinedPref("foo"), "user pref is not set");
+ equal(
+ Services.prefs.getBoolPref("bar"),
+ true,
+ "The default pref has been restored to the default"
+ );
+ Services.prefs.clearUserPref("bar");
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_default_upgraded() {
+ await promiseStartupManager();
+
+ let id = "@upgrade-pref";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+
+ // We set the default value for a pref here so it will be
+ // picked up by EPM.
+ let defaultPrefs = Services.prefs.getDefaultBranch(null);
+ defaultPrefs.setStringPref("bar", "initial default");
+
+ await ExtensionSettingsStore.initialize();
+ ExtensionPreferencesManager.addSetting("some-pref", {
+ prefNames: ["bar"],
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+ });
+
+ await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value");
+ let item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "new value", "The value is set");
+
+ defaultPrefs.setStringPref("bar", "new default");
+
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "new value", "The value is still set");
+
+ let prefsChanged = await ExtensionPreferencesManager.removeSetting(
+ id,
+ "some-pref"
+ );
+ ok(prefsChanged, "pref changed on removal of setting.");
+ equal(Preferences.get("bar"), "new default", "default value is correct");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_select() {
+ await promiseStartupManager();
+
+ let extensionData = {
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@one" } },
+ },
+ };
+ let one = ExtensionTestUtils.loadExtension(extensionData);
+
+ await one.startup();
+
+ // We set the default value for a pref here so it will be
+ // picked up by EPM.
+ let defaultPrefs = Services.prefs.getDefaultBranch(null);
+ defaultPrefs.setStringPref("bar", "initial default");
+
+ await ExtensionSettingsStore.initialize();
+ ExtensionPreferencesManager.addSetting("some-pref", {
+ prefNames: ["bar"],
+ setCallback(value) {
+ return { [this.prefNames[0]]: value };
+ },
+ });
+
+ ok(
+ await ExtensionPreferencesManager.setSetting(
+ one.id,
+ "some-pref",
+ "new value"
+ ),
+ "setting was changed"
+ );
+ let item = await ExtensionPreferencesManager.getSetting("some-pref");
+ equal(item.value, "new value", "The value is set");
+
+ // User-set the setting.
+ await ExtensionPreferencesManager.selectSetting(null, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: {} },
+ "The value is user-set"
+ );
+
+ // Extensions installed before cannot gain control again.
+ let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ one.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when user-set."
+ );
+
+ // Enabling the top-precedence addon does not take over a user-set setting.
+ await ExtensionPreferencesManager.disableSetting(one.id, "some-pref");
+ await ExtensionPreferencesManager.enableSetting(one.id, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: {} },
+ "The value is user-set"
+ );
+
+ // Upgrading does not override the user-set setting.
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.incognito = "not_allowed";
+ await one.upgrade(extensionData);
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ one.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon upgrade."
+ );
+
+ // We can re-select the extension.
+ await ExtensionPreferencesManager.selectSetting(one.id, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(item.value, "new value", "The value is extension set");
+
+ // An extension installed after user-set can take over the setting.
+ await ExtensionPreferencesManager.selectSetting(null, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ deepEqual(
+ item,
+ { key: "some-pref", initialValue: {} },
+ "The value is user-set"
+ );
+
+ let two = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@two" } },
+ },
+ });
+
+ await two.startup();
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ two.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon install."
+ );
+
+ await ExtensionPreferencesManager.setSetting(
+ two.id,
+ "some-pref",
+ "another value"
+ );
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "another value", "The value is set");
+
+ // A new installed extension can override a user selected extension.
+ let three = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@three" } },
+ },
+ });
+
+ // user selects specific extension to take control
+ await ExtensionPreferencesManager.selectSetting(one.id, "some-pref");
+
+ // two cannot control
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ two.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "not_controllable",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon install."
+ );
+
+ // three can control after install
+ await three.startup();
+ levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
+ three.id,
+ "some-pref"
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when user-set after addon install."
+ );
+
+ await ExtensionPreferencesManager.setSetting(
+ three.id,
+ "some-pref",
+ "third value"
+ );
+ item = ExtensionSettingsStore.getSetting("prefs", "some-pref");
+ equal(item.value, "third value", "The value is set");
+
+ // We have returned to precedence based settings.
+ await ExtensionPreferencesManager.removeSetting(three.id, "some-pref");
+ await ExtensionPreferencesManager.removeSetting(two.id, "some-pref");
+ item = await ExtensionPreferencesManager.getSetting("some-pref");
+ equal(item.value, "new value", "The value is extension set");
+
+ await one.unload();
+ await two.unload();
+ await three.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_preference_select() {
+ let prefNames = await ExtensionPreferencesManager.getManagedPrefDetails();
+ // Just check a subset of settings that are in this test file.
+ Assert.ok(prefNames.size > 0, "some prefs exist");
+ for (let settingName in SETTINGS) {
+ let setting = SETTINGS[settingName];
+ for (let prefName of setting.prefNames) {
+ Assert.equal(
+ prefNames.get(prefName),
+ settingName,
+ "setting retrieved prefNames"
+ );
+ }
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
new file mode 100644
index 0000000000..e4baa79a2c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js
@@ -0,0 +1,1089 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionSettingsStore",
+ "resource://gre/modules/ExtensionSettingsStore.jsm"
+);
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const ITEMS = {
+ key1: [
+ { key: "key1", value: "val1", id: "@first" },
+ { key: "key1", value: "val2", id: "@second" },
+ { key: "key1", value: "val3", id: "@third" },
+ ],
+ key2: [
+ { key: "key2", value: "val1-2", id: "@first" },
+ { key: "key2", value: "val2-2", id: "@second" },
+ { key: "key2", value: "val3-2", id: "@third" },
+ ],
+};
+const KEY_LIST = Object.keys(ITEMS);
+const TEST_TYPE = "myType";
+
+let callbackCount = 0;
+
+function initialValue(key) {
+ callbackCount++;
+ return `key:${key}`;
+}
+
+add_task(async function test_settings_store() {
+ await promiseStartupManager();
+
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@first" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@second" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@third" } },
+ },
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let extensions = testExtensions.map(extension => extension.extension);
+
+ let expectedCallbackCount = 0;
+
+ await Assert.rejects(
+ ExtensionSettingsStore.getLevelOfControl(1, TEST_TYPE, "key"),
+ /The ExtensionSettingsStore was accessed before the initialize promise resolved/,
+ "Accessing the SettingsStore before it is initialized throws an error."
+ );
+
+ // Initialize the SettingsStore.
+ await ExtensionSettingsStore.initialize();
+
+ // Add a setting for the second oldest extension, where it is the only setting for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 1;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with no settings set for a key."
+ );
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ expectedCallbackCount++;
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Adding initial item for a key returns that item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item with only one item in the list."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl with only one item in the list."
+ );
+ ok(
+ ExtensionSettingsStore.hasSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ ),
+ "hasSetting returns the correct value when an extension has a setting set."
+ );
+ item = await ExtensionSettingsStore.getSetting(
+ TEST_TYPE,
+ key,
+ extensions[extensionIndex].id
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting with id returns correct item with only one item in the list."
+ );
+ }
+
+ // Add a setting for the oldest extension.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ equal(
+ item,
+ null,
+ "An older extension adding a setting for a key returns null"
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item with more than one item in the list."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl when another extension is in control."
+ );
+ item = await ExtensionSettingsStore.getSetting(
+ TEST_TYPE,
+ key,
+ extensions[extensionIndex].id
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting with id returns correct item with more than one item in the list."
+ );
+ }
+
+ // Reload the settings store to emulate a browser restart.
+ await ExtensionSettingsStore._reloadFile();
+
+ // Add a setting for the newest extension.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 2;
+ let itemToAdd = ITEMS[key][extensionIndex];
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl for a more recent extension."
+ );
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Adding item for most recent extension returns that item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item with more than one item in the list."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl when this extension is in control."
+ );
+ item = await ExtensionSettingsStore.getSetting(
+ TEST_TYPE,
+ key,
+ extensions[extensionIndex].id
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting with id returns correct item with more than one item in the list."
+ );
+ }
+
+ for (let extension of extensions) {
+ let items = await ExtensionSettingsStore.getAllForExtension(
+ extension.id,
+ TEST_TYPE
+ );
+ deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys.");
+ }
+
+ // Attempting to remove a setting that has not been set should *not* throw an exception.
+ let removeResult = await ExtensionSettingsStore.removeSetting(
+ extensions[0].id,
+ "myType",
+ "unset_key"
+ );
+ equal(
+ removeResult,
+ null,
+ "Removing a setting that was not previously set returns null."
+ );
+
+ // Attempting to disable a setting that has not been set should throw an exception.
+ Assert.throws(
+ () =>
+ ExtensionSettingsStore.disable(extensions[0].id, "myType", "unset_key"),
+ /Cannot alter the setting for myType:unset_key as it does not exist/,
+ "disable rejects with an unset key."
+ );
+
+ // Attempting to enable a setting that has not been set should throw an exception.
+ Assert.throws(
+ () =>
+ ExtensionSettingsStore.enable(extensions[0].id, "myType", "unset_key"),
+ /Cannot alter the setting for myType:unset_key as it does not exist/,
+ "enable rejects with an unset key."
+ );
+
+ let expectedKeys = KEY_LIST;
+ // Disable the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key,
+ "new value",
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ equal(item, null, "Updating non-top item for a key returns null");
+ item = await ExtensionSettingsStore.disable(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(item, null, "Disabling non-top item for a key returns null.");
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(
+ extensions[extensionIndex].id,
+ TEST_TYPE
+ );
+ deepEqual(
+ allForExtension,
+ expectedKeys,
+ "getAllForExtension returns expected keys after a disable."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after a disable."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after disabling of non-top item."
+ );
+ }
+
+ // Re-enable the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.enable(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(item, null, "Enabling non-top item for a key returns null.");
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(
+ extensions[extensionIndex].id,
+ TEST_TYPE
+ );
+ deepEqual(
+ allForExtension,
+ expectedKeys,
+ "getAllForExtension returns expected keys after an enable."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after an enable."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after enabling of non-top item."
+ );
+ }
+
+ // Remove the non-top item for a key.
+ for (let key of KEY_LIST) {
+ let extensionIndex = 0;
+ let item = await ExtensionSettingsStore.removeSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(item, null, "Removing non-top item for a key returns null.");
+ expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key);
+ let allForExtension = await ExtensionSettingsStore.getAllForExtension(
+ extensions[extensionIndex].id,
+ TEST_TYPE
+ );
+ deepEqual(
+ allForExtension,
+ expectedKeys,
+ "getAllForExtension returns expected keys after a removal."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after a removal."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_other_extensions",
+ "getLevelOfControl returns correct levelOfControl after removal of non-top item."
+ );
+ ok(
+ !ExtensionSettingsStore.hasSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ key
+ ),
+ "hasSetting returns the correct value when an extension does not have a setting set."
+ );
+ }
+
+ for (let key of KEY_LIST) {
+ // Disable the top item for a key.
+ let item = await ExtensionSettingsStore.disable(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "Disabling top item for a key returns the new top item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item after a disable."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after disabling of top item."
+ );
+
+ // Re-enable the top item for a key.
+ item = await ExtensionSettingsStore.enable(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "Re-enabling top item for a key returns the old top item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][2],
+ "getSetting returns correct item after an enable."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after re-enabling top item."
+ );
+
+ // Remove the top item for a key.
+ item = await ExtensionSettingsStore.removeSetting(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "Removing top item for a key returns the new top item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ ITEMS[key][1],
+ "getSetting returns correct item after a removal."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[2].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after removal of top item."
+ );
+
+ // Add a setting for the current top item.
+ let itemToAdd = { key, value: `new-${key}`, id: "@second" };
+ item = await ExtensionSettingsStore.addSetting(
+ extensions[1].id,
+ TEST_TYPE,
+ itemToAdd.key,
+ itemToAdd.value,
+ initialValue
+ );
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Updating top item for a key returns that item."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns correct item after updating."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after updating."
+ );
+
+ // Disable the last remaining item for a key.
+ let expectedItem = { key, initialValue: initialValue(key) };
+ // We're using the callback to set the expected value, so we need to increment the
+ // expectedCallbackCount.
+ expectedCallbackCount++;
+ item = await ExtensionSettingsStore.disable(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ expectedItem,
+ "Disabling last item for a key returns the initial value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ expectedItem,
+ "getSetting returns the initial value after all are disabled."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after all are disabled."
+ );
+
+ // Re-enable the last remaining item for a key.
+ item = await ExtensionSettingsStore.enable(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ itemToAdd,
+ "Re-enabling last item for a key returns the old value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(
+ item,
+ itemToAdd,
+ "getSetting returns expected value after re-enabling."
+ );
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controlled_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after re-enabling."
+ );
+
+ // Remove the last remaining item for a key.
+ item = await ExtensionSettingsStore.removeSetting(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ deepEqual(
+ item,
+ expectedItem,
+ "Removing last item for a key returns the initial value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key);
+ deepEqual(item, null, "getSetting returns null after all are removed.");
+ levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl after all are removed."
+ );
+
+ // Attempting to remove a setting that has had all extensions removed should *not* throw an exception.
+ removeResult = await ExtensionSettingsStore.removeSetting(
+ extensions[1].id,
+ TEST_TYPE,
+ key
+ );
+ equal(
+ removeResult,
+ null,
+ "Removing a setting that has had all extensions removed returns null."
+ );
+ }
+
+ // Test adding a setting with a value in callbackArgument.
+ let extensionIndex = 0;
+ let testKey = "callbackArgumentKey";
+ let callbackArgumentValue = Date.now();
+ // Add the setting.
+ let item = await ExtensionSettingsStore.addSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ testKey,
+ 1,
+ initialValue,
+ callbackArgumentValue
+ );
+ expectedCallbackCount++;
+ equal(
+ callbackCount,
+ expectedCallbackCount,
+ "initialValueCallback called the expected number of times."
+ );
+ // Remove the setting which should return the initial value.
+ let expectedItem = {
+ key: testKey,
+ initialValue: initialValue(callbackArgumentValue),
+ };
+ // We're using the callback to set the expected value, so we need to increment the
+ // expectedCallbackCount.
+ expectedCallbackCount++;
+ item = await ExtensionSettingsStore.removeSetting(
+ extensions[extensionIndex].id,
+ TEST_TYPE,
+ testKey
+ );
+ deepEqual(
+ item,
+ expectedItem,
+ "Removing last item for a key returns the initial value."
+ );
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey);
+ deepEqual(item, null, "getSetting returns null after all are removed.");
+
+ item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key");
+ equal(
+ item,
+ null,
+ "getSetting returns a null item if the setting does not have any records."
+ );
+ let levelOfControl = await ExtensionSettingsStore.getLevelOfControl(
+ extensions[1].id,
+ TEST_TYPE,
+ "not a key"
+ );
+ equal(
+ levelOfControl,
+ "controllable_by_this_extension",
+ "getLevelOfControl returns correct levelOfControl if the setting does not have any records."
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_settings_store_setByUser() {
+ await promiseStartupManager();
+
+ // Create an array of test framework extension wrappers to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@first" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@second" } },
+ },
+ }),
+ ];
+
+ let type = "some_type";
+ let key = "some_key";
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Create an array actual Extension objects which correspond to the
+ // test framework extension wrappers.
+ let [one, two] = testExtensions.map(extension => extension.extension);
+ let initialCallback = () => "initial";
+
+ // Initialize the SettingsStore.
+ await ExtensionSettingsStore.initialize();
+
+ equal(
+ null,
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting is initially null"
+ );
+
+ let item = await ExtensionSettingsStore.addSetting(
+ one.id,
+ type,
+ key,
+ "one",
+ initialCallback
+ );
+ deepEqual(
+ { key, value: "one", id: one.id },
+ item,
+ "addSetting returns the first set item"
+ );
+
+ item = await ExtensionSettingsStore.addSetting(
+ two.id,
+ type,
+ key,
+ "two",
+ initialCallback
+ );
+ deepEqual(
+ { key, value: "two", id: two.id },
+ item,
+ "addSetting returns the second set item"
+ );
+
+ // a user-set selection reverts to precedence order when new
+ // extension sets the setting.
+ ExtensionSettingsStore.select(
+ ExtensionSettingsStore.SETTING_USER_SET,
+ type,
+ key
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after being set by user"
+ );
+
+ let three = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@third" } },
+ },
+ });
+ await three.startup();
+
+ item = await ExtensionSettingsStore.addSetting(
+ three.id,
+ type,
+ key,
+ "three",
+ initialCallback
+ );
+ deepEqual(
+ { key, value: "three", id: three.id },
+ item,
+ "addSetting returns the third set item"
+ );
+ deepEqual(
+ item,
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the third set item"
+ );
+
+ ExtensionSettingsStore.select(
+ ExtensionSettingsStore.SETTING_USER_SET,
+ type,
+ key
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after being set by user"
+ );
+
+ item = ExtensionSettingsStore.select(one.id, type, key);
+ deepEqual(
+ { key, value: "one", id: one.id },
+ item,
+ "selecting an extension returns the first set item after enable"
+ );
+
+ // Disabling a selected item returns to precedence order
+ ExtensionSettingsStore.disable(one.id, type, key);
+ deepEqual(
+ { key, value: "three", id: three.id },
+ ExtensionSettingsStore.getSetting(type, key),
+ "returning to precedence order sets the third set item"
+ );
+
+ // Test that disabling all then enabling one does not take over a user-set setting.
+ ExtensionSettingsStore.select(
+ ExtensionSettingsStore.SETTING_USER_SET,
+ type,
+ key
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after being set by user"
+ );
+
+ ExtensionSettingsStore.disable(three.id, type, key);
+ ExtensionSettingsStore.disable(two.id, type, key);
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after disabling all extensions"
+ );
+
+ ExtensionSettingsStore.enable(three.id, type, key);
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after enabling one extension"
+ );
+
+ // Ensure that calling addSetting again will not reset a user-set value when
+ // the extension install date is older than the user-set date.
+ item = await ExtensionSettingsStore.addSetting(
+ three.id,
+ type,
+ key,
+ "three",
+ initialCallback
+ );
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after calling addSetting for old addon"
+ );
+
+ item = ExtensionSettingsStore.enable(three.id, type, key);
+ equal(undefined, item, "enabling the active item does not return an item");
+ deepEqual(
+ { key, initialValue: "initial" },
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns the initial value after enabling one extension"
+ );
+
+ ExtensionSettingsStore.removeSetting(three.id, type, key);
+ ExtensionSettingsStore.removeSetting(two.id, type, key);
+ ExtensionSettingsStore.removeSetting(one.id, type, key);
+
+ equal(
+ null,
+ ExtensionSettingsStore.getSetting(type, key),
+ "getSetting returns null after removing all settings"
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_settings_store_add_disabled() {
+ await promiseStartupManager();
+
+ let id = "@add-on-disable";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+ await ExtensionSettingsStore.initialize();
+
+ await ExtensionSettingsStore.addSetting(
+ id,
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+
+ let item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, id, "The add-on is in control");
+ equal(item.value, "set", "The value is set");
+
+ ExtensionSettingsStore.disable(id, "foo", "bar");
+ item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, undefined, "The add-on is not in control");
+ equal(item.initialValue, "not set", "The value is not set");
+
+ await ExtensionSettingsStore.addSetting(
+ id,
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+ item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, id, "The add-on is in control");
+ equal(item.value, "set", "The value is set");
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_settings_uninstall_remove() {
+ await promiseStartupManager();
+
+ let id = "@add-on-uninstall";
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+ await ExtensionSettingsStore.initialize();
+
+ await ExtensionSettingsStore.addSetting(
+ id,
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+
+ let item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, id, "The add-on is in control");
+ equal(item.value, "set", "The value is set");
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+
+ item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item, null, "The add-on setting was removed");
+});
+
+add_task(async function test_exceptions() {
+ await ExtensionSettingsStore.initialize();
+
+ await Assert.rejects(
+ ExtensionSettingsStore.addSetting(
+ 1,
+ TEST_TYPE,
+ "key_not_a_function",
+ "val1",
+ "not a function"
+ ),
+ /initialValueCallback must be a function/,
+ "addSetting rejects with a callback that is not a function."
+ );
+});
+
+add_task(async function test_get_all_settings() {
+ await promiseStartupManager();
+
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@first" } },
+ },
+ }),
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ applications: { gecko: { id: "@second" } },
+ },
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ await ExtensionSettingsStore.initialize();
+
+ let items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(items.length, 0, "There are no addons controlling this setting yet");
+
+ await ExtensionSettingsStore.addSetting(
+ "@first",
+ "foo",
+ "bar",
+ "set",
+ () => "not set"
+ );
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(items.length, 1, "The add-on setting has 1 addon trying to control it");
+
+ await ExtensionSettingsStore.addSetting(
+ "@second",
+ "foo",
+ "bar",
+ "setting",
+ () => "not set"
+ );
+
+ let item = ExtensionSettingsStore.getSetting("foo", "bar");
+ equal(item.id, "@second", "The second add-on is in control");
+ equal(item.value, "setting", "The second value is set");
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(
+ items.length,
+ 2,
+ "The add-on setting has 2 addons trying to control it"
+ );
+
+ await ExtensionSettingsStore.removeSetting("@first", "foo", "bar");
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(items.length, 1, "There is only 1 addon controlling this setting");
+
+ await ExtensionSettingsStore.removeSetting("@second", "foo", "bar");
+
+ items = ExtensionSettingsStore.getAllSettings("foo", "bar");
+ equal(
+ items.length,
+ 0,
+ "There is no longer any addon controlling this setting"
+ );
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js
new file mode 100644
index 0000000000..8044a07c71
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js
@@ -0,0 +1,151 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS";
+const HISTOGRAM_KEYED = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS_BY_ADDONID";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_telemetry() {
+ function contentScript() {
+ browser.test.sendMessage("content-script-run");
+ }
+
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_end",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_end",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ clearHistograms();
+
+ let process = IS_OOP ? "content" : "parent";
+ ok(
+ !(HISTOGRAM in getSnapshots(process)),
+ `No data recorded for histogram: ${HISTOGRAM}.`
+ );
+ ok(
+ !(HISTOGRAM_KEYED in getKeyedSnapshots(process)),
+ `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.`
+ );
+
+ await extension1.startup();
+ let extensionId = extension1.extension.id;
+
+ info(`Started extension with id ${extensionId}`);
+
+ ok(
+ !(HISTOGRAM in getSnapshots(process)),
+ `No data recorded for histogram after startup: ${HISTOGRAM}.`
+ );
+ ok(
+ !(HISTOGRAM_KEYED in getKeyedSnapshots(process)),
+ `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.`
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension1.awaitMessage("content-script-run");
+ await promiseTelemetryRecorded(HISTOGRAM, process, 1);
+ await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1);
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+
+ await contentPage.close();
+ await extension1.unload();
+
+ await extension2.startup();
+ let extensionId2 = extension2.extension.id;
+
+ info(`Started extension with id ${extensionId2}`);
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 1,
+ `No new data recorded for histogram after extension2 startup: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+ ok(
+ !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]),
+ `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.`
+ );
+
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension2.awaitMessage("content-script-run");
+ await promiseTelemetryRecorded(HISTOGRAM, process, 2);
+ await promiseKeyedTelemetryRecorded(
+ HISTOGRAM_KEYED,
+ process,
+ extensionId2,
+ 1
+ );
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 2,
+ `Data recorded for histogram: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.`
+ );
+
+ await contentPage.close();
+ await extension2.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js
new file mode 100644
index 0000000000..5e995b3aa6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.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";
+
+const { ExtensionTestCommon } = ChromeUtils.import(
+ "resource://testing-common/ExtensionTestCommon.jsm"
+);
+
+add_task(async function extension_startup_early_error() {
+ const EXTENSION_ID = "@extension-with-package-error";
+ let extension = ExtensionTestCommon.generate({
+ manifest: {
+ applications: { gecko: { id: EXTENSION_ID } },
+ },
+ });
+
+ extension.initLocale = async function() {
+ // Simulate error that happens during startup.
+ extension.packagingError("dummy error");
+ };
+
+ let startupPromise = extension.startup();
+
+ let policy = WebExtensionPolicy.getByID(EXTENSION_ID);
+ ok(policy, "WebExtensionPolicy instantiated at startup");
+ let readyPromise = policy.readyPromise;
+ ok(readyPromise, "WebExtensionPolicy.readyPromise is set");
+
+ await Assert.rejects(
+ startupPromise,
+ /dummy error/,
+ "Extension with packaging error should fail to load"
+ );
+
+ Assert.equal(
+ WebExtensionPolicy.getByID(EXTENSION_ID),
+ null,
+ "WebExtensionPolicy should be unregistered"
+ );
+
+ Assert.equal(
+ await readyPromise,
+ null,
+ "policy.readyPromise should be resolved with null"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js
new file mode 100644
index 0000000000..fe1fab4ea2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js
@@ -0,0 +1,93 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const HISTOGRAM = "WEBEXT_EXTENSION_STARTUP_MS";
+const HISTOGRAM_KEYED = "WEBEXT_EXTENSION_STARTUP_MS_BY_ADDONID";
+
+function processSnapshot(snapshot) {
+ return snapshot.sum > 0;
+}
+
+function processKeyedSnapshot(snapshot) {
+ let res = {};
+ for (let key of Object.keys(snapshot)) {
+ res[key] = snapshot[key].sum > 0;
+ }
+ return res;
+}
+
+add_task(async function test_telemetry() {
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ let extension1 = ExtensionTestUtils.loadExtension({});
+ let extension2 = ExtensionTestUtils.loadExtension({});
+
+ clearHistograms();
+
+ assertHistogramEmpty(HISTOGRAM);
+ assertKeyedHistogramEmpty(HISTOGRAM_KEYED);
+
+ await extension1.startup();
+
+ 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}`
+ );
+
+ 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();
+
+ 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}`
+ );
+
+ await extension1.unload();
+ await extension2.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js
new file mode 100644
index 0000000000..c05188cd38
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js
@@ -0,0 +1,193 @@
+"use strict";
+
+const FILE_DUMMY_URL = Services.io.newFileURI(
+ do_get_file("data/dummy_page.html")
+).spec;
+
+// 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();
+
+// XHR/fetch from content script to the page itself is allowed.
+add_task(async function content_script_xhr_to_self() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["file:///*"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": async () => {
+ let response = await fetch(document.URL);
+ browser.test.assertEq(200, response.status, "expected load");
+ let responseText = await response.text();
+ browser.test.assertTrue(
+ responseText.includes("<p>Page</p>"),
+ `expected file content in response of ${response.url}`
+ );
+
+ // Now with content.fetch:
+ response = await content.fetch(document.URL);
+ browser.test.assertEq(200, response.status, "expected load (content)");
+
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL);
+ await extension.awaitMessage("done");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// XHR/fetch for other file is not allowed, even with file://-permissions.
+add_task(async function content_script_xhr_to_other_file_not_allowed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["file:///*"],
+ content_scripts: [
+ {
+ matches: ["file:///*"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ files: {
+ "content_script.js": async () => {
+ let otherFileUrl = document.URL.replace(
+ "dummy_page.html",
+ "file_sample.html"
+ );
+ let x = new XMLHttpRequest();
+ x.open("GET", otherFileUrl);
+ await new Promise(resolve => {
+ x.onloadend = resolve;
+ x.send();
+ });
+ browser.test.assertEq(0, x.status, "expected error");
+ browser.test.assertEq("", x.responseText, "request should fail");
+
+ // Now with content.XMLHttpRequest.
+ x = new content.XMLHttpRequest();
+ x.open("GET", otherFileUrl);
+ x.onloadend = () => {
+ browser.test.assertEq(0, x.status, "expected error (content)");
+ browser.test.sendMessage("done");
+ };
+ x.send();
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL);
+ await extension.awaitMessage("done");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// "file://" permission does not grant access to files in the extension page.
+add_task(async function file_access_from_extension_page_not_allowed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["file:///*"],
+ description: FILE_DUMMY_URL,
+ },
+ async background() {
+ const FILE_DUMMY_URL = browser.runtime.getManifest().description;
+
+ await browser.test.assertRejects(
+ fetch(FILE_DUMMY_URL),
+ /NetworkError when attempting to fetch resource/,
+ "block request to file from background page despite file permission"
+ );
+
+ // Regression test for bug 1420296 .
+ await browser.test.assertRejects(
+ fetch(FILE_DUMMY_URL, { mode: "same-origin" }),
+ /NetworkError when attempting to fetch resource/,
+ "block request to file from background page despite 'same-origin' mode"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("done");
+
+ await extension.unload();
+});
+
+// webRequest listeners should see subresource requests from file:-principals.
+add_task(async function webRequest_script_request_from_file_principals() {
+ // Extension without file:-permission should not see the request.
+ let extensionWithoutFilePermission = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.net/", "webRequest"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.fail(`Unexpected request from ${details.originUrl}`);
+ },
+ { urls: ["http://example.net/intercept_by_webRequest.js"] }
+ );
+ },
+ });
+
+ // Extension with <all_urls> (which matches the resource URL at example.net
+ // and the origin at file://*/*) can see the request.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["<all_urls>", "webRequest", "webRequestBlocking"],
+ web_accessible_resources: ["testDONE.html"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ ({ originUrl }) => {
+ browser.test.assertTrue(
+ /^file:.*file_do_load_script_subresource.html/.test(originUrl),
+ `expected script to be loaded from a local file (${originUrl})`
+ );
+ let redirectUrl = browser.runtime.getURL("testDONE.html");
+ return {
+ redirectUrl: `data:text/javascript,location.href='${redirectUrl}';`,
+ };
+ },
+ { urls: ["http://example.net/intercept_by_webRequest.js"] },
+ ["blocking"]
+ );
+ },
+ files: {
+ "testDONE.html": `<!DOCTYPE html><script src="testDONE.js"></script>`,
+ "testDONE.js"() {
+ browser.test.sendMessage("webRequest_redirect_completed");
+ },
+ },
+ });
+
+ await extensionWithoutFilePermission.startup();
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ Services.io.newFileURI(
+ do_get_file("data/file_do_load_script_subresource.html")
+ ).spec
+ );
+ await extension.awaitMessage("webRequest_redirect_completed");
+ await contentPage.close();
+
+ await extension.unload();
+ await extensionWithoutFilePermission.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js
new file mode 100644
index 0000000000..69c24cfc4b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js
@@ -0,0 +1,208 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+let getExtension = () => {
+ return ExtensionTestUtils.loadExtension({
+ background: async function() {
+ const runningListener = isRunning => {
+ if (isRunning) {
+ browser.test.sendMessage("started");
+ } else {
+ browser.test.sendMessage("stopped");
+ }
+ };
+
+ browser.test.onMessage.addListener(async (message, data) => {
+ let result;
+ switch (message) {
+ case "start":
+ result = await browser.geckoProfiler.start({
+ bufferSize: 10000,
+ windowLength: 20,
+ interval: 0.5,
+ features: ["js"],
+ threads: ["GeckoMain"],
+ });
+ browser.test.assertEq(undefined, result, "start returns nothing.");
+ break;
+ case "stop":
+ result = await browser.geckoProfiler.stop();
+ browser.test.assertEq(undefined, result, "stop returns nothing.");
+ break;
+ case "pause":
+ result = await browser.geckoProfiler.pause();
+ browser.test.assertEq(undefined, result, "pause returns nothing.");
+ browser.test.sendMessage("paused");
+ break;
+ case "resume":
+ result = await browser.geckoProfiler.resume();
+ browser.test.assertEq(undefined, result, "resume returns nothing.");
+ browser.test.sendMessage("resumed");
+ break;
+ case "test profile":
+ result = await browser.geckoProfiler.getProfile();
+ browser.test.assertTrue(
+ "libs" in result,
+ "The profile contains libs."
+ );
+ browser.test.assertTrue(
+ "meta" in result,
+ "The profile contains meta."
+ );
+ browser.test.assertTrue(
+ "threads" in result,
+ "The profile contains threads."
+ );
+ browser.test.assertTrue(
+ result.threads.some(t => t.name == "GeckoMain"),
+ "The profile contains a GeckoMain thread."
+ );
+ browser.test.sendMessage("tested profile");
+ break;
+ case "test dump to file":
+ try {
+ await browser.geckoProfiler.dumpProfileToFile(data.fileName);
+ browser.test.sendMessage("tested dump to file", {});
+ } catch (e) {
+ browser.test.sendMessage("tested dump to file", {
+ error: e.message,
+ });
+ }
+ break;
+ case "test profile as array buffer":
+ let arrayBuffer = await browser.geckoProfiler.getProfileAsArrayBuffer();
+ browser.test.assertTrue(
+ arrayBuffer.byteLength >= 2,
+ "The profile array buffer contains data."
+ );
+ let textDecoder = new TextDecoder();
+ let profile = JSON.parse(textDecoder.decode(arrayBuffer));
+ browser.test.assertTrue(
+ "libs" in profile,
+ "The profile contains libs."
+ );
+ browser.test.assertTrue(
+ "meta" in profile,
+ "The profile contains meta."
+ );
+ browser.test.assertTrue(
+ "threads" in profile,
+ "The profile contains threads."
+ );
+ browser.test.assertTrue(
+ profile.threads.some(t => t.name == "GeckoMain"),
+ "The profile contains a GeckoMain thread."
+ );
+ browser.test.sendMessage("tested profile as array buffer");
+ break;
+ case "remove runningListener":
+ browser.geckoProfiler.onRunning.removeListener(runningListener);
+ browser.test.sendMessage("removed runningListener");
+ break;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+
+ browser.geckoProfiler.onRunning.addListener(runningListener);
+ },
+
+ manifest: {
+ permissions: ["geckoProfiler"],
+ applications: {
+ gecko: {
+ id: "profilertest@mozilla.com",
+ },
+ },
+ },
+ });
+};
+
+let verifyProfileData = bytes => {
+ let textDecoder = new TextDecoder();
+ let profile = JSON.parse(textDecoder.decode(bytes));
+ ok("libs" in profile, "The profile contains libs.");
+ ok("meta" in profile, "The profile contains meta.");
+ ok("threads" in profile, "The profile contains threads.");
+ ok(
+ profile.threads.some(t => t.name == "GeckoMain"),
+ "The profile contains a GeckoMain thread."
+ );
+};
+
+add_task(async function testProfilerControl() {
+ const acceptedExtensionIdsPref =
+ "extensions.geckoProfiler.acceptedExtensionIds";
+ Services.prefs.setCharPref(
+ acceptedExtensionIdsPref,
+ "profilertest@mozilla.com"
+ );
+
+ let extension = getExtension();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extension.awaitMessage("stopped");
+
+ extension.sendMessage("start");
+ await extension.awaitMessage("started");
+
+ extension.sendMessage("test profile");
+ await extension.awaitMessage("tested profile");
+
+ const profilerPath = OS.Path.join(OS.Constants.Path.profileDir, "profiler");
+ let data, fileName, targetPath;
+
+ // test with file name only
+ fileName = "bar.profile";
+ targetPath = OS.Path.join(profilerPath, fileName);
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, undefined, "No error thrown");
+ ok(await OS.File.exists(targetPath), "Saved gecko profile exists.");
+ verifyProfileData(await OS.File.read(targetPath));
+
+ // test overwriting the formerly created file
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, undefined, "No error thrown");
+ ok(await OS.File.exists(targetPath), "Saved gecko profile exists.");
+ verifyProfileData(await OS.File.read(targetPath));
+
+ // test with a POSIX path, which is not allowed
+ fileName = "foo/bar.profile";
+ targetPath = OS.Path.join(profilerPath, ...fileName.split("/"));
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, "Path cannot contain a subdirectory.");
+ ok(!(await OS.File.exists(targetPath)), "Gecko profile hasn't been saved.");
+
+ // test with a non POSIX path which is not allowed
+ fileName = "foo\\bar.profile";
+ targetPath = OS.Path.join(profilerPath, ...fileName.split("\\"));
+ extension.sendMessage("test dump to file", { fileName });
+ data = await extension.awaitMessage("tested dump to file");
+ equal(data.error, "Path cannot contain a subdirectory.");
+ ok(!(await OS.File.exists(targetPath)), "Gecko profile hasn't been saved.");
+
+ extension.sendMessage("test profile as array buffer");
+ await extension.awaitMessage("tested profile as array buffer");
+
+ extension.sendMessage("pause");
+ await extension.awaitMessage("paused");
+
+ extension.sendMessage("resume");
+ await extension.awaitMessage("resumed");
+
+ extension.sendMessage("stop");
+ await extension.awaitMessage("stopped");
+
+ extension.sendMessage("remove runningListener");
+ await extension.awaitMessage("removed runningListener");
+
+ await extension.unload();
+
+ Services.prefs.clearUserPref(acceptedExtensionIdsPref);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js
new file mode 100644
index 0000000000..d0bbf7e60f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js
@@ -0,0 +1,56 @@
+"use strict";
+
+add_task(async function() {
+ const acceptedExtensionIdsPref =
+ "extensions.geckoProfiler.acceptedExtensionIds";
+ Services.prefs.setCharPref(
+ acceptedExtensionIdsPref,
+ "profilertest@mozilla.com"
+ );
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: () => {
+ browser.test.sendMessage(
+ "features",
+ Object.values(browser.geckoProfiler.ProfilerFeature)
+ );
+ },
+ manifest: {
+ permissions: ["geckoProfiler"],
+ applications: {
+ gecko: {
+ id: "profilertest@mozilla.com",
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+ let acceptedFeatures = await extension.awaitMessage("features");
+ await extension.unload();
+
+ Services.prefs.clearUserPref(acceptedExtensionIdsPref);
+
+ const allFeaturesAcceptedByProfiler = Services.profiler.GetAllFeatures();
+ ok(
+ allFeaturesAcceptedByProfiler.length >= 2,
+ "Either we've massively reduced the profiler's feature set, or something is wrong."
+ );
+
+ // Check that the list of available values in the ProfilerFeature enum
+ // matches the list of features supported by the profiler.
+ for (const feature of allFeaturesAcceptedByProfiler) {
+ ok(
+ acceptedFeatures.includes(feature),
+ `The schema of the geckoProfiler.start() method should accept the "${feature}" feature.`
+ );
+ }
+ for (const feature of acceptedFeatures) {
+ ok(
+ // Bug 1594566 - ignore Responsiveness until the extension is updated
+ allFeaturesAcceptedByProfiler.includes(feature) ||
+ feature == "responsiveness",
+ `The schema of the geckoProfiler.start() method mentions a "${feature}" feature which is not supported by the profiler.`
+ );
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js
new file mode 100644
index 0000000000..63b1016293
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js
@@ -0,0 +1,61 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessage.addListener(([url1, url2]) => {
+ let url3 = browser.runtime.getURL("test_file.html");
+ let url4 = browser.extension.getURL("test_file.html");
+
+ browser.test.assertTrue(url1 !== undefined, "url1 defined");
+
+ browser.test.assertTrue(
+ url1.startsWith("moz-extension://"),
+ "url1 has correct scheme"
+ );
+ browser.test.assertTrue(
+ url1.endsWith("test_file.html"),
+ "url1 has correct leaf name"
+ );
+
+ browser.test.assertEq(url1, url2, "url2 matches");
+ browser.test.assertEq(url1, url3, "url3 matches");
+ browser.test.assertEq(url1, url4, "url4 matches");
+
+ browser.test.notifyPass("geturl");
+ });
+ },
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js"() {
+ let url1 = browser.runtime.getURL("test_file.html");
+ let url2 = browser.extension.getURL("test_file.html");
+ browser.runtime.sendMessage([url1, url2]);
+ },
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ await extension.awaitFinish("geturl");
+
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js
new file mode 100644
index 0000000000..9709df842d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js
@@ -0,0 +1,574 @@
+"use strict";
+
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+
+// 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`;
+
+var originalReqLocales = Services.locale.requestedLocales;
+
+registerCleanupFunction(() => {
+ Preferences.reset("intl.accept_languages");
+ Services.locale.requestedLocales = originalReqLocales;
+});
+
+add_task(async function test_i18n() {
+ function runTests(assertEq) {
+ let _ = browser.i18n.getMessage.bind(browser.i18n);
+
+ let url = browser.runtime.getURL("/");
+ assertEq(
+ url,
+ `moz-extension://${_("@@extension_id")}/`,
+ "@@extension_id builtin message"
+ );
+
+ assertEq("Foo.", _("Foo"), "Simple message in selected locale.");
+
+ assertEq("(bar)", _("bar"), "Simple message fallback in default locale.");
+
+ assertEq("", _("some-unknown-locale-string"), "Unknown locale string.");
+
+ assertEq("", _("@@unknown_builtin_string"), "Unknown built-in string.");
+ assertEq(
+ "",
+ _("@@bidi_unknown_builtin_string"),
+ "Unknown built-in bidi string."
+ );
+
+ assertEq("Føo.", _("Föo"), "Multi-byte message in selected locale.");
+
+ let substitutions = [];
+ substitutions[4] = "5";
+ substitutions[13] = "14";
+
+ assertEq(
+ "'$0' '14' '' '5' '$$$$' '$'.",
+ _("basic_substitutions", substitutions),
+ "Basic numeric substitutions"
+ );
+
+ assertEq(
+ "'$0' '' 'just a string' '' '$$$$' '$'.",
+ _("basic_substitutions", "just a string"),
+ "Basic numeric substitutions, with non-array value"
+ );
+
+ let values = _("named_placeholder_substitutions", [
+ "(subst $1 $2)",
+ "(2 $1 $2)",
+ ]).split("\n");
+
+ assertEq(
+ "_foo_ (subst $1 $2) _bar_",
+ values[0],
+ "Named and numeric substitution"
+ );
+
+ assertEq(
+ "(2 $1 $2)",
+ values[1],
+ "Numeric substitution amid named placeholders"
+ );
+
+ assertEq("$bad name$", values[2], "Named placeholder with invalid key");
+
+ assertEq("", values[3], "Named placeholder with an invalid value");
+
+ assertEq(
+ "Accepted, but shouldn't break.",
+ values[4],
+ "Named placeholder with a strange content value"
+ );
+
+ assertEq("$foo", values[5], "Non-placeholder token that should be ignored");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ default_locale: "jp",
+
+ content_scripts: [
+ { matches: ["http://*/*/file_sample.html"], js: ["content.js"] },
+ ],
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {
+ foo: {
+ message: "Foo.",
+ description: "foo",
+ },
+
+ föo: {
+ message: "Føo.",
+ description: "foo",
+ },
+
+ basic_substitutions: {
+ message: "'$0' '$14' '$1' '$5' '$$$$$' '$$'.",
+ description: "foo",
+ },
+
+ Named_placeholder_substitutions: {
+ message:
+ "$Foo$\n$2\n$bad name$\n$bad_value$\n$bad_content_value$\n$foo",
+ description: "foo",
+ placeholders: {
+ foO: {
+ content: "_foo_ $1 _bar_",
+ description: "foo",
+ },
+
+ "bad name": {
+ content: "Nope.",
+ description: "bad name",
+ },
+
+ bad_value: "Nope.",
+
+ bad_content_value: {
+ content: ["Accepted, but shouldn't break."],
+ description: "bad value",
+ },
+ },
+ },
+
+ broken_placeholders: {
+ message: "$broken$",
+ description: "broken placeholders",
+ placeholders: "foo.",
+ },
+ },
+
+ "_locales/jp/messages.json": {
+ foo: {
+ message: "(foo)",
+ description: "foo",
+ },
+
+ bar: {
+ message: "(bar)",
+ description: "bar",
+ },
+ },
+
+ "content.js":
+ "new " +
+ function(runTestsFn) {
+ runTestsFn((...args) => {
+ browser.runtime.sendMessage(["assertEq", ...args]);
+ });
+
+ browser.runtime.sendMessage(["content-script-finished"]);
+ } +
+ `(${runTests})`,
+ },
+
+ background:
+ "new " +
+ function(runTestsFn) {
+ browser.runtime.onMessage.addListener(([msg, ...args]) => {
+ if (msg == "assertEq") {
+ browser.test.assertEq(...args);
+ } else {
+ browser.test.sendMessage(msg, ...args);
+ }
+ });
+
+ runTestsFn(browser.test.assertEq.bind(browser.test));
+ } +
+ `(${runTests})`,
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension.awaitMessage("content-script-finished");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_i18n_negotiation() {
+ function runTests(expected) {
+ let _ = browser.i18n.getMessage.bind(browser.i18n);
+
+ browser.test.assertEq(expected, _("foo"), "Got expected message");
+ }
+
+ let extensionData = {
+ manifest: {
+ default_locale: "en_US",
+
+ content_scripts: [
+ { matches: ["http://*/*/file_sample.html"], js: ["content.js"] },
+ ],
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {
+ foo: {
+ message: "English.",
+ description: "foo",
+ },
+ },
+
+ "_locales/jp/messages.json": {
+ foo: {
+ message: "\u65e5\u672c\u8a9e",
+ description: "foo",
+ },
+ },
+
+ "content.js":
+ "new " +
+ function(runTestsFn) {
+ browser.test.onMessage.addListener(expected => {
+ runTestsFn(expected);
+
+ browser.test.sendMessage("content-script-finished");
+ });
+ browser.test.sendMessage("content-ready");
+ } +
+ `(${runTests})`,
+ },
+
+ background:
+ "new " +
+ function(runTestsFn) {
+ browser.test.onMessage.addListener(expected => {
+ runTestsFn(expected);
+
+ browser.test.sendMessage("background-script-finished");
+ });
+ } +
+ `(${runTests})`,
+ };
+
+ // At the moment extension language negotiation is tied to Firefox language
+ // negotiation result. That means that to test an extension in `fr`, we need
+ // to mock `fr` being available in Firefox and then request it.
+ //
+ // In the future, we should provide some way for tests to decouple their
+ // language selection from that of Firefox.
+ Services.locale.availableLocales = ["en-US", "fr", "jp"];
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ for (let [lang, msg] of [
+ ["en-US", "English."],
+ ["jp", "\u65e5\u672c\u8a9e"],
+ ]) {
+ Services.locale.requestedLocales = [lang];
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("content-ready");
+
+ extension.sendMessage(msg);
+ await extension.awaitMessage("background-script-finished");
+ await extension.awaitMessage("content-script-finished");
+
+ await extension.unload();
+ }
+ Services.locale.requestedLocales = originalReqLocales;
+
+ await contentPage.close();
+});
+
+add_task(async function test_get_accept_languages() {
+ function checkResults(source, results, expected) {
+ browser.test.assertEq(
+ expected.length,
+ results.length,
+ `got expected number of languages in ${source}`
+ );
+ results.forEach((lang, index) => {
+ browser.test.assertEq(
+ expected[index],
+ lang,
+ `got expected language in ${source}`
+ );
+ });
+ }
+
+ function background(checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.getAcceptLanguages().then(results => {
+ checkResultsFn("background", results, expected);
+
+ browser.test.sendMessage("background-done");
+ });
+ });
+ }
+
+ function content(checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.getAcceptLanguages().then(results => {
+ checkResultsFn("contentScript", results, expected);
+
+ browser.test.sendMessage("content-done");
+ });
+ });
+ browser.test.sendMessage("content-loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background: `(${background})(${checkResults})`,
+
+ files: {
+ "content_script.js": `(${content})(${checkResults})`,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("content-loaded");
+
+ let expectedLangs = ["en-US", "en"];
+ extension.sendMessage(["expect-results", expectedLangs]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ expectedLangs = ["en-US", "en", "fr-CA", "fr"];
+ Preferences.set("intl.accept_languages", expectedLangs.toString());
+ extension.sendMessage(["expect-results", expectedLangs]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+ Preferences.reset("intl.accept_languages");
+
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_get_ui_language() {
+ function getResults() {
+ return {
+ getUILanguage: browser.i18n.getUILanguage(),
+ getMessage: browser.i18n.getMessage("@@ui_locale"),
+ };
+ }
+
+ function checkResults(source, results, expected) {
+ browser.test.assertEq(
+ expected,
+ results.getUILanguage,
+ `Got expected getUILanguage result in ${source}`
+ );
+ browser.test.assertEq(
+ expected,
+ results.getMessage,
+ `Got expected getMessage result in ${source}`
+ );
+ }
+
+ function background(getResultsFn, checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ checkResultsFn("background", getResultsFn(), expected);
+
+ browser.test.sendMessage("background-done");
+ });
+ }
+
+ function content(getResultsFn, checkResultsFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ checkResultsFn("contentScript", getResultsFn(), expected);
+
+ browser.test.sendMessage("content-done");
+ });
+ browser.test.sendMessage("content-loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background: `(${background})(${getResults}, ${checkResults})`,
+
+ files: {
+ "content_script.js": `(${content})(${getResults}, ${checkResults})`,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("content-loaded");
+
+ extension.sendMessage(["expect-results", "en-US"]);
+
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ // We don't currently have a good way to mock this.
+ if (false) {
+ Services.locale.requestedLocales = ["he"];
+
+ extension.sendMessage(["expect-results", "he"]);
+
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+ }
+
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_detect_language() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // This is not supported on Android.
+ return;
+ }
+
+ const af_string =
+ " aam skukuza die naam beteken hy wat skoonvee of hy wat alles onderstebo keer wysig " +
+ "bosveldkampe boskampe is kleiner afgeleë ruskampe wat oor min fasiliteite beskik daar is geen restaurante " +
+ "of winkels nie en slegs oornagbesoekers word toegelaat bateleur";
+ // String with intermixed French/English text
+ const fr_en_string =
+ "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 jumped over the lazy dog";
+
+ function checkResult(source, result, expected) {
+ browser.test.assertEq(
+ expected.isReliable,
+ result.isReliable,
+ "result.confident is true"
+ );
+ browser.test.assertEq(
+ expected.languages.length,
+ result.languages.length,
+ `result.languages contains the expected number of languages in ${source}`
+ );
+ expected.languages.forEach((lang, index) => {
+ browser.test.assertEq(
+ lang.percentage,
+ result.languages[index].percentage,
+ `element ${index} of result.languages array has the expected percentage in ${source}`
+ );
+ browser.test.assertEq(
+ lang.language,
+ result.languages[index].language,
+ `element ${index} of result.languages array has the expected language in ${source}`
+ );
+ });
+ }
+
+ function backgroundScript(checkResultFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.detectLanguage(msg).then(result => {
+ checkResultFn("background", result, expected);
+ browser.test.sendMessage("background-done");
+ });
+ });
+ }
+
+ function content(checkResultFn) {
+ browser.test.onMessage.addListener(([msg, expected]) => {
+ browser.i18n.detectLanguage(msg).then(result => {
+ checkResultFn("contentScript", result, expected);
+ browser.test.sendMessage("content-done");
+ });
+ });
+ browser.test.sendMessage("content-loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background: `(${backgroundScript})(${checkResult})`,
+
+ files: {
+ "content_script.js": `(${content})(${checkResult})`,
+ },
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("content-loaded");
+
+ let expected = {
+ isReliable: true,
+ languages: [
+ {
+ language: "fr",
+ percentage: 67,
+ },
+ {
+ language: "en",
+ percentage: 32,
+ },
+ ],
+ };
+ extension.sendMessage([fr_en_string, expected]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ expected = {
+ isReliable: true,
+ languages: [
+ {
+ language: "af",
+ percentage: 99,
+ },
+ ],
+ };
+ extension.sendMessage([af_string, expected]);
+ await extension.awaitMessage("background-done");
+ await extension.awaitMessage("content-done");
+
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js
new file mode 100644
index 0000000000..c644ba9782
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js
@@ -0,0 +1,197 @@
+"use strict";
+
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// Some multibyte characters. This sample was taken from the encoding/api-basics.html web platform test.
+const MULTIBYTE_STRING = "z\xA2\u6C34\uD834\uDD1E\uF8FF\uDBFF\uDFFD\uFFFE";
+let getCSS = (a, b) => `a { content: '${a}'; } b { content: '${b}'; }`;
+
+let extensionData = {
+ background: function() {
+ function backgroundFetch(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.overrideMimeType("text/plain");
+ xhr.open("GET", url);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = reject;
+ xhr.send();
+ });
+ }
+
+ Promise.all([
+ backgroundFetch("foo.css"),
+ backgroundFetch("bar.CsS?x#y"),
+ backgroundFetch("foo.txt"),
+ ]).then(results => {
+ browser.test.assertEq(
+ "body { max-width: 42px; }",
+ results[0],
+ "CSS file localized"
+ );
+ browser.test.assertEq(
+ "body { max-width: 42px; }",
+ results[1],
+ "CSS file localized"
+ );
+
+ browser.test.assertEq(
+ "body { __MSG_foo__; }",
+ results[2],
+ "Text file not localized"
+ );
+
+ browser.test.notifyPass("i18n-css");
+ });
+
+ browser.test.sendMessage("ready", browser.runtime.getURL("/"));
+ },
+
+ manifest: {
+ applications: {
+ gecko: {
+ id: "i18n_css@mochi.test",
+ },
+ },
+
+ web_accessible_resources: [
+ "foo.css",
+ "foo.txt",
+ "locale.css",
+ "multibyte.css",
+ ],
+
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ css: ["foo.css"],
+ run_at: "document_start",
+ },
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content.js"],
+ },
+ ],
+
+ default_locale: "en",
+ },
+
+ files: {
+ "_locales/en/messages.json": JSON.stringify({
+ foo: {
+ message: "max-width: 42px",
+ description: "foo",
+ },
+ multibyteKey: {
+ message: MULTIBYTE_STRING,
+ },
+ }),
+
+ "content.js": function() {
+ let style = getComputedStyle(document.body);
+ browser.test.sendMessage("content-maxWidth", style.maxWidth);
+ },
+
+ "foo.css": "body { __MSG_foo__; }",
+ "bar.CsS": "body { __MSG_foo__; }",
+ "foo.txt": "body { __MSG_foo__; }",
+ "locale.css":
+ '* { content: "__MSG_@@ui_locale__ __MSG_@@bidi_dir__ __MSG_@@bidi_reversed_dir__ __MSG_@@bidi_start_edge__ __MSG_@@bidi_end_edge__" }',
+ "multibyte.css": getCSS("__MSG_multibyteKey__", MULTIBYTE_STRING),
+ },
+};
+
+async function test_i18n_css(options = {}) {
+ extensionData.useAddonManager = options.useAddonManager;
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ let baseURL = await extension.awaitMessage("ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ let css = await contentPage.fetch(baseURL + "foo.css");
+
+ equal(
+ css,
+ "body { max-width: 42px; }",
+ "CSS file localized in mochitest scope"
+ );
+
+ let maxWidth = await extension.awaitMessage("content-maxWidth");
+
+ equal(maxWidth, "42px", "stylesheet correctly applied");
+
+ css = await contentPage.fetch(baseURL + "locale.css");
+ equal(
+ css,
+ '* { content: "en-US ltr rtl left right" }',
+ "CSS file localized in mochitest scope"
+ );
+
+ css = await contentPage.fetch(baseURL + "multibyte.css");
+ equal(
+ css,
+ getCSS(MULTIBYTE_STRING, MULTIBYTE_STRING),
+ "CSS file contains multibyte string"
+ );
+
+ await contentPage.close();
+
+ // We don't currently have a good way to mock this.
+ if (false) {
+ const DIR = "intl.l10n.pseudo";
+
+ // We don't wind up actually switching the chrome registry locale, since we
+ // don't have a chrome package for Hebrew. So just override it, and force
+ // RTL directionality.
+ const origReqLocales = Services.locale.requestedLocales;
+ Services.locale.requestedLocales = ["he"];
+ Preferences.set(DIR, "bidi");
+
+ css = await fetch(baseURL + "locale.css");
+ equal(
+ css,
+ '* { content: "he rtl ltr right left" }',
+ "CSS file localized in mochitest scope"
+ );
+
+ Services.locale.requestedLocales = origReqLocales;
+ Preferences.reset(DIR);
+ }
+
+ await extension.awaitFinish("i18n-css");
+ await extension.unload();
+}
+
+add_task(async function startup() {
+ await promiseStartupManager();
+});
+add_task(test_i18n_css);
+add_task(async function test_i18n_css_xpi() {
+ await test_i18n_css({ useAddonManager: "temporary" });
+});
+add_task(async function startup() {
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js
new file mode 100644
index 0000000000..8225278a7f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js
@@ -0,0 +1,270 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { MockRegistrar } = ChromeUtils.import(
+ "resource://testing-common/MockRegistrar.jsm"
+);
+
+let idleService = {
+ _observers: new Set(),
+ _activity: {
+ addCalls: [],
+ removeCalls: [],
+ observerFires: [],
+ },
+ _reset: function() {
+ this._observers.clear();
+ this._activity.addCalls = [];
+ this._activity.removeCalls = [];
+ this._activity.observerFires = [];
+ },
+ _fireObservers: function(state) {
+ for (let observer of this._observers.values()) {
+ observer.observe(observer, state, null);
+ this._activity.observerFires.push(state);
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]),
+ idleTime: 19999,
+ addIdleObserver: function(observer, time) {
+ this._observers.add(observer);
+ this._activity.addCalls.push(time);
+ },
+ removeIdleObserver: function(observer, time) {
+ this._observers.delete(observer);
+ this._activity.removeCalls.push(time);
+ },
+};
+
+function checkActivity(expectedActivity) {
+ let { expectedAdd, expectedRemove, expectedFires } = expectedActivity;
+ let { addCalls, removeCalls, observerFires } = idleService._activity;
+ equal(
+ expectedAdd.length,
+ addCalls.length,
+ "idleService.addIdleObserver was called the expected number of times"
+ );
+ equal(
+ expectedRemove.length,
+ removeCalls.length,
+ "idleService.removeIdleObserver was called the expected number of times"
+ );
+ equal(
+ expectedFires.length,
+ observerFires.length,
+ "idle observer was fired the expected number of times"
+ );
+ deepEqual(
+ addCalls,
+ expectedAdd,
+ "expected interval passed to idleService.addIdleObserver"
+ );
+ deepEqual(
+ removeCalls,
+ expectedRemove,
+ "expected interval passed to idleService.removeIdleObserver"
+ );
+ deepEqual(
+ observerFires,
+ expectedFires,
+ "expected topic passed to idle observer"
+ );
+}
+
+add_task(async function setup() {
+ let fakeIdleService = MockRegistrar.register(
+ "@mozilla.org/widget/useridleservice;1",
+ idleService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(fakeIdleService);
+ });
+});
+
+add_task(async function testQueryStateActive() {
+ function background() {
+ browser.idle.queryState(20).then(
+ status => {
+ browser.test.assertEq("active", status, "Idle status is active");
+ browser.test.notifyPass("idle");
+ },
+ err => {
+ browser.test.fail(`Error: ${err} :: ${err.stack}`);
+ browser.test.notifyFail("idle");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("idle");
+ await extension.unload();
+});
+
+add_task(async function testQueryStateIdle() {
+ function background() {
+ browser.idle.queryState(15).then(
+ status => {
+ browser.test.assertEq("idle", status, "Idle status is idle");
+ browser.test.notifyPass("idle");
+ },
+ err => {
+ browser.test.fail(`Error: ${err} :: ${err.stack}`);
+ browser.test.notifyFail("idle");
+ }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("idle");
+ await extension.unload();
+});
+
+add_task(async function testOnlySetDetectionInterval() {
+ function background() {
+ browser.idle.setDetectionInterval(99);
+ browser.test.sendMessage("detectionIntervalSet");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("detectionIntervalSet");
+ idleService._fireObservers("idle");
+ checkActivity({ expectedAdd: [], expectedRemove: [], expectedFires: [] });
+ await extension.unload();
+});
+
+add_task(async function testSetDetectionIntervalBeforeAddingListener() {
+ function background() {
+ browser.idle.setDetectionInterval(99);
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "idle",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.test.sendMessage("listenerAdded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("listenerAdded");
+ idleService._fireObservers("idle");
+ await extension.awaitMessage("listenerFired");
+ checkActivity({
+ expectedAdd: [99],
+ expectedRemove: [],
+ expectedFires: ["idle"],
+ });
+ // 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 testSetDetectionIntervalAfterAddingListener() {
+ function background() {
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "idle",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.idle.setDetectionInterval(99);
+ browser.test.sendMessage("detectionIntervalSet");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("detectionIntervalSet");
+ idleService._fireObservers("idle");
+ await extension.awaitMessage("listenerFired");
+ checkActivity({
+ expectedAdd: [60, 99],
+ expectedRemove: [60],
+ expectedFires: ["idle"],
+ });
+
+ // 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 testOnlyAddingListener() {
+ function background() {
+ browser.idle.onStateChanged.addListener(newState => {
+ browser.test.assertEq(
+ "active",
+ newState,
+ "listener fired with the expected state"
+ );
+ browser.test.sendMessage("listenerFired");
+ });
+ browser.test.sendMessage("listenerAdded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["idle"],
+ },
+ });
+
+ idleService._reset();
+ await extension.startup();
+ await extension.awaitMessage("listenerAdded");
+ idleService._fireObservers("active");
+ await extension.awaitMessage("listenerFired");
+ // check that "idle-daily" topic does not cause a listener to fire
+ idleService._fireObservers("idle-daily");
+ checkActivity({
+ expectedAdd: [60],
+ expectedRemove: [],
+ expectedFires: ["active", "idle-daily"],
+ });
+
+ // Defer unloading the extension so the asynchronous event listener
+ // reply finishes.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js
new file mode 100644
index 0000000000..9b17a633e9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js
@@ -0,0 +1,302 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+
+// Assert on the expected "addonsManager.action" telemetry events (and optional filter events to verify
+// by using a given actionType).
+function assertActionAMTelemetryEvent(
+ expectedActionEvents,
+ assertMessage,
+ { actionType } = {}
+) {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ ok(
+ snapshot.parent && !!snapshot.parent.length,
+ "Got parent telemetry events in the snapshot"
+ );
+
+ const events = snapshot.parent
+ .filter(([timestamp, category, method, object, value, extra]) => {
+ return (
+ category === "addonsManager" &&
+ method === "action" &&
+ (!actionType ? true : extra && extra.action === actionType)
+ );
+ })
+ .map(([timestamp, category, method, object, value, extra]) => {
+ return { method, object, value, extra };
+ });
+
+ Assert.deepEqual(events, expectedActionEvents, assertMessage);
+}
+
+async function runIncognitoTest(
+ extensionData,
+ privateBrowsingAllowed,
+ allowPrivateBrowsingByDefault
+) {
+ Services.prefs.setBoolPref(
+ "extensions.allowPrivateBrowsingByDefault",
+ allowPrivateBrowsingByDefault
+ );
+
+ let wrapper = ExtensionTestUtils.loadExtension(extensionData);
+ await wrapper.startup();
+ let { extension } = wrapper;
+
+ if (!allowPrivateBrowsingByDefault) {
+ // Check the permission if we're not allowPrivateBrowsingByDefault.
+ equal(
+ extension.permissions.has("internal:privateBrowsingAllowed"),
+ privateBrowsingAllowed,
+ "privateBrowsingAllowed in serialized extension"
+ );
+ }
+ equal(
+ extension.privateBrowsingAllowed,
+ privateBrowsingAllowed,
+ "privateBrowsingAllowed in extension"
+ );
+ equal(
+ extension.policy.privateBrowsingAllowed,
+ privateBrowsingAllowed,
+ "privateBrowsingAllowed on policy"
+ );
+
+ await wrapper.unload();
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+}
+
+add_task(async function test_extension_incognito_spanning() {
+ await runIncognitoTest({}, false, false);
+ await runIncognitoTest({}, true, true);
+});
+
+// Test that when we are restricted, we can override the restriction for tests.
+add_task(async function test_extension_incognito_override_spanning() {
+ let extensionData = {
+ incognitoOverride: "spanning",
+ };
+ await runIncognitoTest(extensionData, true, false);
+});
+
+// This tests that a privileged extension will always have private browsing.
+add_task(async function test_extension_incognito_privileged() {
+ let extensionData = {
+ isPrivileged: true,
+ };
+ await runIncognitoTest(extensionData, true, true);
+ await runIncognitoTest(extensionData, true, false);
+});
+
+// We only test spanning upgrades since that is the only allowed
+// incognito type prior to feature being turned on.
+add_task(async function test_extension_incognito_spanning_grandfathered() {
+ await AddonTestUtils.promiseStartupManager();
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", true);
+ Services.prefs.setBoolPref("extensions.incognito.migrated", false);
+
+ // This extension gets disabled before the "upgrade", it should not
+ // get grandfathered permissions.
+ const disabledAddonId = "disabled-ext@mozilla.com";
+ let disabledWrapper = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: disabledAddonId } },
+ incognito: "spanning",
+ },
+ useAddonManager: "permanent",
+ });
+ await disabledWrapper.startup();
+ let disabledPolicy = WebExtensionPolicy.getByID(disabledAddonId);
+
+ // Verify policy settings.
+ equal(
+ disabledPolicy.permissions.includes("internal:privateBrowsingAllowed"),
+ false,
+ "privateBrowsingAllowed is not in permissions for disabled addon"
+ );
+ equal(
+ disabledPolicy.privateBrowsingAllowed,
+ true,
+ "privateBrowsingAllowed in disabled addon"
+ );
+
+ let disabledAddon = await AddonManager.getAddonByID(disabledAddonId);
+ await disabledAddon.disable();
+
+ // This extension gets grandfathered permissions for private browsing.
+ let addonId = "grandfathered@mozilla.com";
+ let wrapper = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: addonId } },
+ incognito: "spanning",
+ },
+ useAddonManager: "permanent",
+ });
+ await wrapper.startup();
+ let policy = WebExtensionPolicy.getByID(addonId);
+
+ // Verify policy settings.
+ equal(
+ policy.permissions.includes("internal:privateBrowsingAllowed"),
+ false,
+ "privateBrowsingAllowed is not in permissions"
+ );
+ equal(
+ policy.privateBrowsingAllowed,
+ true,
+ "privateBrowsingAllowed in extension"
+ );
+
+ // Turn on incognito support and update the browser.
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+ // Disable the addonsManager telemetry event category, to ensure that it will
+ // be enabled automatically during the AddonManager/XPIProvider startup and
+ // the telemetry event recorded (See Bug 1540112 for a rationale).
+ Services.telemetry.setEventRecordingEnabled("addonsManager", false);
+ await AddonTestUtils.promiseRestartManager("2");
+ await wrapper.awaitStartup();
+
+ // Did it upgrade?
+ ok(
+ Services.prefs.getBoolPref("extensions.incognito.migrated", false),
+ "pref marked as migrated"
+ );
+
+ // Verify policy settings.
+ policy = WebExtensionPolicy.getByID(addonId);
+ ok(
+ policy.permissions.includes("internal:privateBrowsingAllowed"),
+ "privateBrowsingAllowed is in permissions"
+ );
+ equal(
+ policy.privateBrowsingAllowed,
+ true,
+ "privateBrowsingAllowed in extension"
+ );
+
+ // Verify the disabled addon did not get permissions.
+ disabledAddon = await AddonManager.getAddonByID(disabledAddonId);
+ await disabledAddon.enable();
+ disabledPolicy = WebExtensionPolicy.getByID(disabledAddonId);
+
+ // Verify policy settings.
+ equal(
+ disabledPolicy.permissions.includes("internal:privateBrowsingAllowed"),
+ false,
+ "privateBrowsingAllowed is not in permissions for disabled addon"
+ );
+ equal(
+ disabledPolicy.privateBrowsingAllowed,
+ false,
+ "privateBrowsingAllowed in disabled addon"
+ );
+
+ await wrapper.unload();
+ await disabledWrapper.unload();
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+ Services.prefs.clearUserPref("extensions.incognito.migrated");
+
+ const expectedEvents = [
+ {
+ method: "action",
+ object: "appUpgrade",
+ value: "on",
+ extra: { addonId, action: "privateBrowsingAllowed" },
+ },
+ ];
+
+ assertActionAMTelemetryEvent(
+ expectedEvents,
+ "Got the expected telemetry events for the grandfathered extensions",
+ { actionType: "privateBrowsingAllowed" }
+ );
+});
+
+add_task(async function test_extension_privileged_not_allowed() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ let addonId = "privileged_not_allowed@mochi.test";
+ let extensionData = {
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id: addonId } },
+ incognito: "not_allowed",
+ },
+ useAddonManager: "permanent",
+ isPrivileged: true,
+ };
+ let wrapper = ExtensionTestUtils.loadExtension(extensionData);
+ await wrapper.startup();
+ let policy = WebExtensionPolicy.getByID(addonId);
+ equal(
+ policy.extension.isPrivileged,
+ true,
+ "The test extension is privileged"
+ );
+ equal(
+ policy.privateBrowsingAllowed,
+ false,
+ "privateBrowsingAllowed is false"
+ );
+
+ await wrapper.unload();
+});
+
+// Test that we remove pb permission if an extension is updated to not_allowed.
+add_task(async function test_extension_upgrade_not_allowed() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ let addonId = "upgrade@mochi.test";
+ let extensionData = {
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id: addonId } },
+ incognito: "spanning",
+ },
+ useAddonManager: "permanent",
+ incognitoOverride: "spanning",
+ };
+ let wrapper = ExtensionTestUtils.loadExtension(extensionData);
+ await wrapper.startup();
+
+ let policy = WebExtensionPolicy.getByID(addonId);
+
+ equal(
+ policy.privateBrowsingAllowed,
+ true,
+ "privateBrowsingAllowed in extension"
+ );
+
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.incognito = "not_allowed";
+ await wrapper.upgrade(extensionData);
+
+ equal(wrapper.version, "2.0", "Expected extension version");
+ policy = WebExtensionPolicy.getByID(addonId);
+ equal(
+ policy.privateBrowsingAllowed,
+ false,
+ "privateBrowsingAllowed is false"
+ );
+
+ await wrapper.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js
new file mode 100644
index 0000000000..e520c48f26
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js
@@ -0,0 +1,101 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function test_indexedDB_principal() {
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", true);
+
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ async background() {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg == "create-storage") {
+ let request = window.indexedDB.open("TestDatabase");
+ request.onupgradeneeded = function(e) {
+ let db = e.target.result;
+ db.createObjectStore("TestStore");
+ };
+ request.onsuccess = function(e) {
+ let db = e.target.result;
+ let tx = db.transaction("TestStore", "readwrite");
+ let store = tx.objectStore("TestStore");
+ tx.oncomplete = () => browser.test.sendMessage("storage-created");
+ store.add("foo", "bar");
+ tx.onerror = function(e) {
+ browser.test.fail(`Failed with error ${tx.error.message}`);
+ // Don't wait for timeout
+ browser.test.sendMessage("storage-created");
+ };
+ };
+ request.onerror = function(e) {
+ browser.test.fail(`Failed with error ${request.error.message}`);
+ // Don't wait for timeout
+ browser.test.sendMessage("storage-created");
+ };
+ return;
+ }
+ if (msg == "check-storage") {
+ let dbRequest = window.indexedDB.open("TestDatabase");
+ dbRequest.onupgradeneeded = function() {
+ browser.test.fail("Database should exist");
+ browser.test.notifyFail("done");
+ };
+ dbRequest.onsuccess = function(e) {
+ let db = e.target.result;
+ let transaction = db.transaction("TestStore");
+ transaction.onerror = function(e) {
+ browser.test.fail(
+ `Failed with error ${transaction.error.message}`
+ );
+ browser.test.notifyFail("done");
+ };
+ let objectStore = transaction.objectStore("TestStore");
+ let request = objectStore.get("bar");
+ request.onsuccess = function(event) {
+ browser.test.assertEq(
+ request.result,
+ "foo",
+ "Got the expected data"
+ );
+ browser.test.notifyPass("done");
+ };
+ request.onerror = function(e) {
+ browser.test.fail(`Failed with error ${request.error.message}`);
+ browser.test.notifyFail("done");
+ };
+ };
+ dbRequest.onerror = function(e) {
+ browser.test.fail(`Failed with error ${dbRequest.error.message}`);
+ browser.test.notifyFail("done");
+ };
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+ extension.sendMessage("create-storage");
+ await extension.awaitMessage("storage-created");
+
+ await extension.addon.disable();
+
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", false);
+
+ await extension.addon.enable();
+ await extension.awaitStartup();
+
+ extension.sendMessage("check-storage");
+ await extension.awaitFinish("done");
+
+ await extension.unload();
+ Services.prefs.clearUserPref("privacy.firstparty.isolate");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js
new file mode 100644
index 0000000000..dd90d9bbc8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.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`;
+
+// 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_parent_to_child() {
+ async function background() {
+ const dbName = "broken-blob";
+ const dbStore = "blob-store";
+ const dbVersion = 1;
+ const blobContent = "Hello World!";
+
+ let db = await new Promise((resolve, reject) => {
+ let dbOpen = indexedDB.open(dbName, dbVersion);
+ dbOpen.onerror = event => {
+ browser.test.fail(`Error opening the DB: ${event.target.error}`);
+ browser.test.notifyFail("test-completed");
+ reject();
+ };
+ dbOpen.onsuccess = event => {
+ resolve(event.target.result);
+ };
+ dbOpen.onupgradeneeded = event => {
+ let dbobj = event.target.result;
+ dbobj.onerror = error => {
+ browser.test.fail(`Error updating the DB: ${error.target.error}`);
+ browser.test.notifyFail("test-completed");
+ reject();
+ };
+ dbobj.createObjectStore(dbStore);
+ };
+ });
+
+ async function save(blob) {
+ let txn = db.transaction([dbStore], "readwrite");
+ let store = txn.objectStore(dbStore);
+ let req = store.put(blob, "key");
+
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => {
+ resolve();
+ };
+ req.onerror = event => {
+ browser.test.fail(
+ `Error saving the blob into the DB: ${event.target.error}`
+ );
+ browser.test.notifyFail("test-completed");
+ reject();
+ };
+ });
+ }
+
+ async function load() {
+ let txn = db.transaction([dbStore], "readonly");
+ let store = txn.objectStore(dbStore);
+ let req = store.getAll();
+
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ })
+ .then(loadDetails => {
+ let blobs = [];
+ loadDetails.forEach(details => {
+ blobs.push(details);
+ });
+ return blobs[0];
+ })
+ .catch(err => {
+ browser.test.fail(
+ `Error loading the blob from the DB: ${err} :: ${err.stack}`
+ );
+ browser.test.notifyFail("test-completed");
+ });
+ }
+
+ browser.test.log("Blob creation");
+ await save(new Blob([blobContent]));
+ let blob = await load();
+
+ db.close();
+
+ browser.runtime.onMessage.addListener(([msg, what]) => {
+ browser.test.log("Message received from content: " + msg);
+ if (msg == "script-ready") {
+ return Promise.resolve({ blob });
+ }
+
+ if (msg == "script-value") {
+ browser.test.assertEq(blobContent, what, "blob content matches");
+ browser.test.notifyPass("test-completed");
+ return;
+ }
+
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ });
+
+ browser.test.sendMessage("bg-ready");
+ }
+
+ function contentScriptStart() {
+ browser.runtime.sendMessage(["script-ready"], response => {
+ let reader = new FileReader();
+ reader.addEventListener(
+ "load",
+ () => {
+ browser.runtime.sendMessage(["script-value", reader.result]);
+ },
+ { once: true }
+ );
+ reader.readAsText(response.blob);
+ });
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script_start.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "content_script_start.js": contentScriptStart,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("bg-ready");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitFinish("test-completed");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
new file mode 100644
index 0000000000..728df04c60
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js
@@ -0,0 +1,39 @@
+/* -*- 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_json_parser() {
+ const ID = "json@test.web.extension";
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ // This is a manifest.
+ "applications": {"gecko": {"id": "${ID}"}},
+ "name": "This \" is // not a comment",
+ "version": "0.1\\" // , "description": "This is not a description"
+ }`,
+ },
+ });
+
+ let expectedManifest = {
+ applications: { gecko: { id: ID } },
+ name: 'This " is // not a comment',
+ version: "0.1\\",
+ };
+
+ let fileURI = Services.io.newFileURI(xpi);
+ let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`);
+
+ let extension = new ExtensionData(uri);
+
+ await extension.parseManifest();
+
+ Assert.deepEqual(
+ extension.rawManifest,
+ expectedManifest,
+ "Manifest with correctly-filtered comments"
+ );
+
+ Services.obs.notifyObservers(xpi, "flush-cache-entry");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js
new file mode 100644
index 0000000000..b8eb3830fa
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js
@@ -0,0 +1,150 @@
+"use strict";
+
+const { L10nRegistry, FileSource } = ChromeUtils.import(
+ "resource://gre/modules/L10nRegistry.jsm"
+);
+const { FileUtils } = ChromeUtils.import(
+ "resource://gre/modules/FileUtils.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+add_task(async function setup() {
+ // Add a test .ftl file
+ // (Note: other tests do this by patching L10nRegistry.load() but in
+ // this test L10nRegistry is also loaded in the extension process --
+ // just adding a new resource is easier than trying to patch
+ // L10nRegistry in all processes)
+ let dir = FileUtils.getDir("TmpD", ["l10ntest"]);
+ dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ await OS.File.writeAtomic(
+ OS.Path.join(dir.path, "test.ftl"),
+ "key = value\n"
+ );
+
+ let target = Services.io.newFileURI(dir);
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+
+ resProto.setSubstitution("l10ntest", target);
+
+ const source = new FileSource(
+ "test",
+ Services.locale.requestedLocales,
+ "resource://l10ntest/"
+ );
+ L10nRegistry.registerSources([source]);
+});
+
+// Test that privileged extensions can use fluent to get strings from
+// language packs (and that unprivileged extensions cannot)
+add_task(async function test_l10n_dom() {
+ const PAGE = `<!DOCTYPE html>
+ <html><head>
+ <meta charset="utf8">
+ <link rel="localization" href="test.ftl"/>
+ <script src="page.js"></script>
+ </head></html>`;
+
+ function SCRIPT() {
+ window.addEventListener(
+ "load",
+ async () => {
+ try {
+ await document.l10n.ready;
+ let result = await document.l10n.formatValue("key");
+ browser.test.sendMessage("result", { success: true, result });
+ } catch (err) {
+ browser.test.sendMessage("result", {
+ success: false,
+ msg: err.message,
+ });
+ }
+ },
+ { once: true }
+ );
+ }
+
+ async function runTest(isPrivileged) {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ manifest: {
+ web_accessible_resources: ["page.html"],
+ },
+ isPrivileged,
+ files: {
+ "page.html": PAGE,
+ "page.js": SCRIPT,
+ },
+ });
+
+ await extension.startup();
+ let url = await extension.awaitMessage("ready");
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ let results = await extension.awaitMessage("result");
+ await page.close();
+ await extension.unload();
+
+ return results;
+ }
+
+ // Everything should work for a privileged extension
+ let results = await runTest(true);
+ equal(results.success, true, "Translation succeeded in privileged extension");
+ equal(results.result, "value", "Translation got the right value");
+
+ // In an unprivleged extension, document.l10n shouldn't show up
+ results = await runTest(false);
+ equal(results.success, false, "Translation failed in unprivileged extension");
+ equal(
+ results.msg.endsWith("document.l10n is undefined"),
+ true,
+ "Translation failed due to missing document.l10n"
+ );
+});
+
+add_task(async function test_l10n_manifest() {
+ // Fluent can't be used to localize properties that the AddonManager
+ // reads (see comment inside ExtensionData.parseManifest for details)
+ // so test by localizing a property that only the extension framework
+ // cares about: page_action. This means we can only do this test from
+ // browser.
+ if (AppConstants.MOZ_BUILD_APP != "browser") {
+ return;
+ }
+
+ AddonTestUtils.initializeURLPreloader();
+
+ async function runTest(isPrivileged) {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged,
+ manifest: {
+ l10n_resources: ["test.ftl"],
+ page_action: {
+ default_title: "__MSG_key__",
+ },
+ },
+ });
+
+ await extension.startup();
+ let title = extension.extension.manifest.page_action.default_title;
+ await extension.unload();
+ return title;
+ }
+
+ let title = await runTest(true);
+ equal(
+ title,
+ "value",
+ "Manifest key localized with fluent in privileged extension"
+ );
+ title = await runTest(false);
+ equal(
+ title,
+ "__MSG_key__",
+ "Manifest key not localized in unprivileged extension"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js
new file mode 100644
index 0000000000..9ae4a4a873
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js
@@ -0,0 +1,50 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function backgroundScript() {
+ let hasRun = localStorage.getItem("has-run");
+ let result;
+ if (!hasRun) {
+ localStorage.setItem("has-run", "yup");
+ localStorage.setItem("test-item", "item1");
+ result = "item1";
+ } else {
+ let data = localStorage.getItem("test-item");
+ if (data == "item1") {
+ localStorage.setItem("test-item", "item2");
+ result = "item2";
+ } else if (data == "item2") {
+ localStorage.removeItem("test-item");
+ result = "deleted";
+ } else if (!data) {
+ localStorage.clear();
+ result = "cleared";
+ }
+ }
+ browser.test.sendMessage("result", result);
+ browser.test.notifyPass("localStorage");
+}
+
+const ID = "test-webextension@mozilla.com";
+let extensionData = {
+ manifest: { applications: { gecko: { id: ID } } },
+ background: backgroundScript,
+};
+
+add_task(async function test_localStorage() {
+ const RESULTS = ["item1", "item2", "deleted", "cleared", "item1"];
+
+ for (let expected of RESULTS) {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ let actual = await extension.awaitMessage("result");
+
+ await extension.awaitFinish("localStorage");
+ await extension.unload();
+
+ equal(actual, expected, "got expected localStorage data");
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management.js b/toolkit/components/extensions/test/xpcshell/test_ext_management.js
new file mode 100644
index 0000000000..c6c73b2249
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js
@@ -0,0 +1,205 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_management_permission() {
+ async function background() {
+ const permObj = { permissions: ["management"] };
+
+ let hasPerm = await browser.permissions.contains(permObj);
+ browser.test.assertTrue(!hasPerm, "does not have management permission");
+ browser.test.assertTrue(
+ !!browser.management,
+ "management namespace exists"
+ );
+ // These require permission
+ let requires_permission = [
+ "getAll",
+ "get",
+ "install",
+ "setEnabled",
+ "onDisabled",
+ "onEnabled",
+ "onInstalled",
+ "onUninstalled",
+ ];
+
+ async function testAvailable() {
+ // These are always available regardless of permission.
+ for (let fn of ["getSelf", "uninstallSelf"]) {
+ browser.test.assertTrue(
+ !!browser.management[fn],
+ `management.${fn} exists`
+ );
+ }
+
+ let hasPerm = await browser.permissions.contains(permObj);
+ for (let fn of requires_permission) {
+ browser.test.assertEq(
+ hasPerm,
+ !!browser.management[fn],
+ `management.${fn} does not exist`
+ );
+ }
+ }
+
+ await testAvailable();
+
+ browser.test.onMessage.addListener(async msg => {
+ browser.test.log("test with permission");
+
+ // get permission
+ await browser.permissions.request(permObj);
+ let hasPerm = await browser.permissions.contains(permObj);
+ browser.test.assertTrue(
+ hasPerm,
+ "management permission.request accepted"
+ );
+ await testAvailable();
+
+ browser.management.onInstalled.addListener(() => {
+ browser.test.fail("onInstalled listener invoked");
+ });
+
+ browser.test.log("test without permission");
+ // remove permission
+ await browser.permissions.remove(permObj);
+ hasPerm = await browser.permissions.contains(permObj);
+ browser.test.assertFalse(
+ hasPerm,
+ "management permission.request removed"
+ );
+ await testAvailable();
+
+ browser.test.sendMessage("done");
+ });
+
+ browser.test.sendMessage("started");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "management@test",
+ },
+ },
+ optional_permissions: ["management"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("started");
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ });
+ await extension.awaitMessage("done");
+
+ // Verify the onInstalled listener does not get used.
+ // The listener will make the test fail if fired.
+ let ext2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: {
+ id: "on-installed@test",
+ },
+ },
+ optional_permissions: ["management"],
+ },
+ useAddonManager: "temporary",
+ });
+ await ext2.startup();
+ await ext2.unload();
+
+ await extension.unload();
+});
+
+add_task(async function test_management_getAll() {
+ const id1 = "get_all_test1@tests.mozilla.com";
+ const id2 = "get_all_test2@tests.mozilla.com";
+
+ function getManifest(id) {
+ return {
+ applications: {
+ gecko: {
+ id,
+ },
+ },
+ name: id,
+ version: "1.0",
+ short_name: id,
+ permissions: ["management"],
+ };
+ }
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, id) => {
+ let addon = await browser.management.get(id);
+ browser.test.sendMessage("addon", addon);
+ });
+
+ let addons = await browser.management.getAll();
+ browser.test.assertEq(
+ 2,
+ addons.length,
+ "management.getAll returned correct number of add-ons."
+ );
+ browser.test.sendMessage("addons", addons);
+ }
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest: getManifest(id1),
+ useAddonManager: "temporary",
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: getManifest(id2),
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension1.startup();
+ await extension2.startup();
+
+ let addons = await extension2.awaitMessage("addons");
+ for (let id of [id1, id2]) {
+ let addon = addons.find(a => {
+ return a.id === id;
+ });
+ equal(
+ addon.name,
+ id,
+ `The extension with id ${id} was returned by getAll.`
+ );
+ equal(addon.shortName, id, "Additional extension metadata was correct");
+ }
+
+ extension2.sendMessage("getAddon", id1);
+ let addon = await extension2.awaitMessage("addon");
+ equal(addon.name, id1, `The extension with id ${id1} was returned by get.`);
+ equal(addon.shortName, id1, "Additional extension metadata was correct");
+
+ extension2.sendMessage("getAddon", id2);
+ addon = await extension2.awaitMessage("addon");
+ equal(addon.name, id2, `The extension with id ${id2} was returned by get.`);
+ equal(addon.shortName, id2, "Additional extension metadata was correct");
+
+ await extension2.unload();
+ await extension1.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js
new file mode 100644
index 0000000000..caed4f5525
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js
@@ -0,0 +1,146 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+const { MockRegistrar } = ChromeUtils.import(
+ "resource://testing-common/MockRegistrar.jsm"
+);
+
+const id = "uninstall_self_test@tests.mozilla.com";
+
+const manifest = {
+ applications: {
+ gecko: {
+ id,
+ },
+ },
+ name: "test extension name",
+ version: "1.0",
+};
+
+const waitForUninstalled = () =>
+ new Promise(resolve => {
+ const listener = {
+ onUninstalled: async addon => {
+ equal(addon.id, id, "The expected add-on has been uninstalled");
+ let checkedAddon = await AddonManager.getAddonByID(addon.id);
+ equal(checkedAddon, null, "Add-on no longer exists");
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addAddonListener(listener);
+ });
+
+let promptService = {
+ _response: null,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: function(...args) {
+ this._confirmExArgs = args;
+ return this._response;
+ },
+};
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ let fakePromptService = MockRegistrar.register(
+ "@mozilla.org/embedcomp/prompt-service;1",
+ promptService
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(fakePromptService);
+ });
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_management_uninstall_no_prompt() {
+ function background() {
+ browser.test.onMessage.addListener(msg => {
+ browser.management.uninstallSelf();
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on is installed");
+ extension.sendMessage("uninstall");
+ await waitForUninstalled();
+ Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry");
+});
+
+add_task(async function test_management_uninstall_prompt_uninstall() {
+ promptService._response = 0;
+
+ function background() {
+ browser.test.onMessage.addListener(msg => {
+ browser.management.uninstallSelf({ showConfirmDialog: true });
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on is installed");
+ extension.sendMessage("uninstall");
+ await waitForUninstalled();
+
+ // Test localization strings
+ equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`);
+ equal(
+ promptService._confirmExArgs[2],
+ `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?`
+ );
+ equal(promptService._confirmExArgs[4], "Uninstall");
+ equal(promptService._confirmExArgs[5], "Keep Installed");
+ Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry");
+});
+
+add_task(async function test_management_uninstall_prompt_keep() {
+ promptService._response = 1;
+
+ function background() {
+ browser.test.onMessage.addListener(async msg => {
+ await browser.test.assertRejects(
+ browser.management.uninstallSelf({ showConfirmDialog: true }),
+ "User cancelled uninstall of extension",
+ "Expected rejection when user declines uninstall"
+ );
+
+ browser.test.sendMessage("uninstall-rejected");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on is installed");
+
+ extension.sendMessage("uninstall");
+ await extension.awaitMessage("uninstall-rejected");
+
+ addon = await AddonManager.getAddonByID(id);
+ notEqual(addon, null, "Add-on remains installed");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js
new file mode 100644
index 0000000000..cf6749f7a8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js
@@ -0,0 +1,95 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function testIconPaths(icon, manifest, expectedError) {
+ let normalized = await ExtensionTestUtils.normalizeManifest(manifest);
+
+ if (expectedError) {
+ ok(
+ expectedError.test(normalized.error),
+ `Should have an error for ${JSON.stringify(icon)}`
+ );
+ } else {
+ ok(!normalized.error, `Should not have an error ${JSON.stringify(icon)}`);
+ }
+}
+
+add_task(async function test_manifest() {
+ let badpaths = ["", " ", "\t", "http://foo.com/icon.png"];
+ for (let path of badpaths) {
+ await testIconPaths(
+ path,
+ {
+ icons: path,
+ },
+ /Error processing icons/
+ );
+
+ await testIconPaths(
+ path,
+ {
+ icons: {
+ "16": path,
+ },
+ },
+ /Error processing icons/
+ );
+ }
+
+ let paths = [
+ "icon.png",
+ "/icon.png",
+ "./icon.png",
+ "path to an icon.png",
+ " icon.png",
+ ];
+ for (let path of paths) {
+ // manifest.icons is an object
+ await testIconPaths(
+ path,
+ {
+ icons: path,
+ },
+ /Error processing icons/
+ );
+
+ await testIconPaths(path, {
+ icons: {
+ "16": path,
+ },
+ });
+ }
+});
+
+add_task(async function test_manifest_warnings_on_unexpected_props() {
+ let extension = await ExtensionTestUtils.loadExtension({
+ manifest: {
+ background: {
+ scripts: ["bg.js"],
+ wrong_prop: true,
+ },
+ },
+ files: {
+ "bg.js": "",
+ },
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ // Retrieve the warning message collected by the Extension class
+ // packagingWarning method.
+ const { warnings } = extension.extension;
+ equal(warnings.length, 1, "Got the expected number of manifest warnings");
+
+ const expectedMessage =
+ "Reading manifest: Warning processing background.wrong_prop";
+ ok(
+ warnings[0].startsWith(expectedMessage),
+ "Got the expected warning message format"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
new file mode 100644
index 0000000000..92dd5ee821
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js
@@ -0,0 +1,82 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+
+add_task(async function test_manifest_csp() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ content_security_policy: "script-src 'self'; object-src 'none'",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+ equal(
+ normalized.value.content_security_policy,
+ "script-src 'self'; object-src 'none'",
+ "Should have the expected policy string"
+ );
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ content_security_policy: "object-src 'none'",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ equal(normalized.error, undefined, "Should not have an error");
+
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ "Error processing content_security_policy: Policy is missing a required ‘script-src’ directive",
+ ],
+ "Should have the expected warning"
+ );
+
+ equal(
+ normalized.value.content_security_policy,
+ null,
+ "Invalid policy string should be omitted"
+ );
+});
+
+add_task(async function test_manifest_csp_v3() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "script-src 'self' 'unsafe-eval'; object-src 'none'",
+ },
+ });
+
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ "Error processing content_security_policy.extension_pages: ‘script-src’ directive contains a forbidden 'unsafe-eval' keyword",
+ ],
+ "Should have the expected warning"
+ );
+ equal(
+ normalized.value.content_security_policy.extension_pages,
+ null,
+ "Should have the expected policy string"
+ );
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ manifest_version: 3,
+ content_security_policy: {
+ extension_pages: "object-src 'none'",
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 1, "Should have warnings");
+ Assert.deepEqual(
+ normalized.errors,
+ [
+ "Error processing content_security_policy.extension_pages: Policy is missing a required ‘script-src’ directive",
+ ],
+ "Should have the expected warning for extension_pages CSP"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js
new file mode 100644
index 0000000000..5aa44c5885
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.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 test_manifest_incognito() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ incognito: "spanning",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+ equal(
+ normalized.value.incognito,
+ "spanning",
+ "Should have the expected incognito string"
+ );
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ incognito: "not_allowed",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+ equal(
+ normalized.value.incognito,
+ "not_allowed",
+ "Should have the expected incognito string"
+ );
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ incognito: "split",
+ });
+
+ equal(
+ normalized.error,
+ 'Error processing incognito: Invalid enumeration value "split"',
+ "Should have an error"
+ );
+ Assert.deepEqual(normalized.errors, [], "Should not have a warning");
+ equal(
+ normalized.value,
+ undefined,
+ "Invalid incognito string should be undefined"
+ );
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js
new file mode 100644
index 0000000000..39119513fb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js
@@ -0,0 +1,12 @@
+/* -*- 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_manifest_minimum_chrome_version() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ minimum_chrome_version: "42",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js
new file mode 100644
index 0000000000..943e8b7270
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js
@@ -0,0 +1,12 @@
+/* -*- 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_manifest_minimum_opera_version() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ minimum_opera_version: "48",
+ });
+
+ equal(normalized.error, undefined, "Should not have an error");
+ equal(normalized.errors.length, 0, "Should not have warnings");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js
new file mode 100644
index 0000000000..8cd44f06dc
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js
@@ -0,0 +1,35 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+async function test_theme_property(property) {
+ let normalized = await ExtensionTestUtils.normalizeManifest(
+ {
+ theme: {
+ [property]: {},
+ },
+ },
+ "manifest.ThemeManifest"
+ );
+
+ if (property === "unrecognized_key") {
+ const expectedWarning = `Warning processing theme.${property}`;
+ ok(
+ normalized.errors[0].includes(expectedWarning),
+ `The manifest warning ${JSON.stringify(
+ normalized.errors[0]
+ )} must contain ${JSON.stringify(expectedWarning)}`
+ );
+ } else {
+ equal(normalized.errors.length, 0, "Should have a warning");
+ }
+ equal(normalized.error, undefined, "Should not have an error");
+}
+
+add_task(async function test_manifest_themes() {
+ await test_theme_property("images");
+ await test_theme_property("colors");
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await test_theme_property("unrecognized_key");
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js
new file mode 100644
index 0000000000..c629c51509
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js
@@ -0,0 +1,270 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let {
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+);
+
+const PAGE_HTML = `<!DOCTYPE html><meta charset="utf-8"><script src="script.js"></script>`;
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-page-event", "start-background-page"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+async function test(what, background, script) {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/*"],
+ js: ["script.js"],
+ },
+ ],
+ },
+
+ files: {
+ "page.html": PAGE_HTML,
+ "script.js": script,
+ },
+
+ background,
+ });
+
+ info(`Set up ${what} listener`);
+ await extension.startup();
+ await extension.awaitMessage("bg-ran");
+
+ info(`Test wakeup for ${what} from an extension page`);
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ function awaitBgEvent() {
+ return new Promise(resolve =>
+ extension.extension.once("background-page-event", resolve)
+ );
+ }
+
+ let events = trackEvents(extension);
+
+ let url = extension.extension.baseURI.resolve("page.html");
+
+ let [, page] = await Promise.all([
+ awaitBgEvent(),
+ ExtensionTestUtils.loadContentPage(url, { extension }),
+ ]);
+
+ equal(
+ events.get("background-page-event"),
+ true,
+ "Should have gotten a background page event"
+ );
+ equal(
+ events.get("start-background-page"),
+ false,
+ "Background page should not be started"
+ );
+
+ equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message");
+
+ let promise = extension.awaitMessage("bg-ran");
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ await promise;
+
+ equal(
+ events.get("start-background-page"),
+ true,
+ "Should have gotten start-background-page event"
+ );
+
+ await extension.awaitFinish("messaging-test");
+ ok(true, "Background page loaded and received message from extension page");
+
+ await page.close();
+
+ info(`Test wakeup for ${what} from a content script`);
+ ExtensionParent._resetStartupPromises();
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ events = trackEvents(extension);
+
+ [, page] = await Promise.all([
+ awaitBgEvent(),
+ ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ ),
+ ]);
+
+ equal(
+ events.get("background-page-event"),
+ true,
+ "Should have gotten a background page event"
+ );
+ equal(
+ events.get("start-background-page"),
+ false,
+ "Background page should not be started"
+ );
+
+ equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message");
+
+ promise = extension.awaitMessage("bg-ran");
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ await promise;
+
+ equal(
+ events.get("start-background-page"),
+ true,
+ "Should have gotten start-background-page event"
+ );
+
+ await extension.awaitFinish("messaging-test");
+ ok(true, "Background page loaded and received message from content script");
+
+ await page.close();
+ await extension.unload();
+
+ await promiseShutdownManager();
+ ExtensionParent._resetStartupPromises();
+}
+
+add_task(function test_onMessage() {
+ function script() {
+ browser.runtime.sendMessage("ping").then(reply => {
+ browser.test.assertEq(
+ reply,
+ "pong",
+ "Extension page received pong reply"
+ );
+ browser.test.notifyPass("messaging-test");
+ });
+ }
+
+ async function background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.assertEq(
+ msg,
+ "ping",
+ "Background page received ping message"
+ );
+ return Promise.resolve("pong");
+ });
+
+ // addListener() returns right away but make a round trip to the
+ // main process to ensure the persistent onMessage listener is recorded.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage("bg-ran");
+ }
+
+ return test("onMessage", background, script);
+});
+
+add_task(function test_onConnect() {
+ function script() {
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "pong", "Extension page received pong reply");
+ browser.test.notifyPass("messaging-test");
+ });
+ port.postMessage("ping");
+ }
+
+ async function background() {
+ browser.runtime.onConnect.addListener(port => {
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(
+ msg,
+ "ping",
+ "Background page received ping message"
+ );
+ port.postMessage("pong");
+ });
+ });
+
+ // addListener() returns right away but make a round trip to the
+ // main process to ensure the persistent onMessage listener is recorded.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage("bg-ran");
+ }
+
+ return test("onConnect", background, script);
+});
+
+// Test that messaging works if the background page is started before
+// any messages are exchanged. (See bug 1467136 for an example of how
+// this broke at one point).
+add_task(async function test_other_startup() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+
+ async background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.notifyPass("startup");
+ });
+
+ // addListener() returns right away but make a round trip to the
+ // main process to ensure the persistent onMessage listener is recorded.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage("bg-ran");
+ },
+
+ files: {
+ "page.html": PAGE_HTML,
+ "script.js"() {
+ browser.runtime.sendMessage("ping");
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-ran");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ // Start the background page. No message have been sent at this point.
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ await extension.awaitMessage("bg-ran");
+
+ // Now that the background page is fully started, load a new page that
+ // sends a message to the background page.
+ let url = extension.extension.baseURI.resolve("page.html");
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+
+ await extension.awaitFinish("startup");
+
+ await page.close();
+ await extension.unload();
+
+ await promiseShutdownManager();
+ ExtensionParent._resetStartupPromises();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js
new file mode 100644
index 0000000000..f71001a74d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.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";
+
+/* globals chrome */
+
+const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes";
+const PREF_MAX_WRITE =
+ "webextensions.native-messaging.max-output-message-bytes";
+
+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("<!DOCTYPE html><html></html>");
+});
+
+const ECHO_BODY = String.raw`
+ import struct
+ import sys
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ sys.exit(0)
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+const INFO_BODY = String.raw`
+ import json
+ import os
+ import struct
+ import sys
+
+ msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()})
+ if sys.version_info >= (3,):
+ sys.stdout.buffer.write(struct.pack('@I', len(msg)))
+ else:
+ sys.stdout.write(struct.pack('@I', len(msg)))
+ sys.stdout.write(msg)
+ sys.exit(0)
+`;
+
+const STDERR_LINES = ["hello stderr", "this should be a separate line"];
+let STDERR_MSG = STDERR_LINES.join("\\n");
+
+const STDERR_BODY = String.raw`
+ import sys
+ sys.stderr.write("${STDERR_MSG}")
+`;
+
+let SCRIPTS = [
+ {
+ name: "echo",
+ description: "a native app that echoes back messages it receives",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ },
+ {
+ name: "info",
+ description: "a native app that gives some info about how it was started",
+ script: INFO_BODY.replace(/^ {2}/gm, ""),
+ },
+ {
+ name: "stderr",
+ description: "a native app that writes to stderr and then exits",
+ script: STDERR_BODY.replace(/^ {2}/gm, ""),
+ },
+];
+
+if (AppConstants.platform == "win") {
+ SCRIPTS.push({
+ name: "echocmd",
+ description: "echo but using a .cmd file",
+ scriptExtension: "cmd",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ });
+}
+
+add_task(async function setup() {
+ await setupHosts(SCRIPTS);
+});
+
+// Test the basic operation of native messaging with a simple
+// script that echoes back whatever message is sent to it.
+add_task(async function test_happy_path() {
+ function background() {
+ let port = browser.runtime.connectNative("echo");
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("message", msg);
+ });
+ browser.test.onMessage.addListener((what, payload) => {
+ if (what == "send") {
+ if (payload._json) {
+ let json = payload._json;
+ payload.toJSON = () => json;
+ delete payload._json;
+ }
+ port.postMessage(payload);
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ const tests = [
+ {
+ data: "this is a string",
+ what: "simple string",
+ },
+ {
+ data: "Это юникода",
+ what: "unicode string",
+ },
+ {
+ data: { test: "hello" },
+ what: "simple object",
+ },
+ {
+ data: {
+ what: "An object with a few properties",
+ number: 123,
+ bool: true,
+ nested: { what: "another object" },
+ },
+ what: "object with several properties",
+ },
+
+ {
+ data: {
+ ignoreme: true,
+ _json: { data: "i have a tojson method" },
+ },
+ expected: { data: "i have a tojson method" },
+ what: "object with toJSON() method",
+ },
+ ];
+ for (let test of tests) {
+ extension.sendMessage("send", test.data);
+ let response = await extension.awaitMessage("message");
+ let expected = test.expected || test.data;
+ deepEqual(response, expected, `Echoed a message of type ${test.what}`);
+ }
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is still running");
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+});
+
+// Just test that the given app (which should be the echo script above)
+// can be started. Used to test corner cases in how the native application
+// is located/launched.
+async function simpleTest(app) {
+ function background(appname) {
+ let port = browser.runtime.connectNative(appname);
+ let MSG = "test";
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(MSG, msg, "Got expected message back");
+ browser.test.sendMessage("done");
+ });
+ port.postMessage(MSG);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${background})(${JSON.stringify(app)});`,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is still running");
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+}
+
+if (AppConstants.platform == "win") {
+ // "relative.echo" has a relative path in the host manifest.
+ add_task(function test_relative_path() {
+ return simpleTest("relative.echo");
+ });
+
+ // "echocmd" uses a .cmd file instead of a .bat file
+ add_task(function test_cmd_file() {
+ return simpleTest("echocmd");
+ });
+}
+
+// Test sendNativeMessage()
+add_task(async function test_sendNativeMessage() {
+ async function background() {
+ let MSG = { test: "hello world" };
+
+ // Check error handling
+ await browser.test.assertRejects(
+ browser.runtime.sendNativeMessage("nonexistent", MSG),
+ /Attempt to postMessage on disconnected port/,
+ "sendNativeMessage() to a nonexistent app failed"
+ );
+
+ // Check regular message exchange
+ let reply = await browser.runtime.sendNativeMessage("echo", MSG);
+
+ let expected = JSON.stringify(MSG);
+ let received = JSON.stringify(reply);
+ browser.test.assertEq(expected, received, "Received echoed native message");
+
+ browser.test.sendMessage("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+
+ // With sendNativeMessage(), the subprocess should be disconnected
+ // after exchanging a single message.
+ await waitForSubprocessExit();
+
+ await extension.unload();
+});
+
+// Test calling Port.disconnect()
+add_task(async function test_disconnect() {
+ function background() {
+ let port = browser.runtime.connectNative("echo");
+ port.onMessage.addListener((msg, msgPort) => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onMessage handler should receive the port as the second argument"
+ );
+ browser.test.sendMessage("message", msg);
+ });
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.fail("onDisconnect should not be called for disconnect()");
+ });
+ browser.test.onMessage.addListener((what, payload) => {
+ if (what == "send") {
+ if (payload._json) {
+ let json = payload._json;
+ payload.toJSON = () => json;
+ delete payload._json;
+ }
+ port.postMessage(payload);
+ } else if (what == "disconnect") {
+ try {
+ port.disconnect();
+ browser.test.assertThrows(
+ () => port.postMessage("void"),
+ "Attempt to postMessage on disconnected port"
+ );
+ browser.test.sendMessage("disconnect-result", { success: true });
+ } catch (err) {
+ browser.test.sendMessage("disconnect-result", {
+ success: false,
+ errmsg: err.message,
+ });
+ }
+ }
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage("send", "test");
+ let response = await extension.awaitMessage("message");
+ equal(response, "test", "Echoed a string");
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is running");
+
+ extension.sendMessage("disconnect");
+ response = await extension.awaitMessage("disconnect-result");
+ equal(response.success, true, "disconnect succeeded");
+
+ info("waiting for subprocess to exit");
+ await waitForSubprocessExit();
+ procCount = await getSubprocessCount();
+ equal(procCount, 0, "subprocess is no longer running");
+
+ extension.sendMessage("disconnect");
+ response = await extension.awaitMessage("disconnect-result");
+ equal(response.success, true, "second call to disconnect silently ignored");
+
+ await extension.unload();
+});
+
+// Test the limit on message size for writing
+add_task(async function test_write_limit() {
+ Services.prefs.setIntPref(PREF_MAX_WRITE, 10);
+ function clearPref() {
+ Services.prefs.clearUserPref(PREF_MAX_WRITE);
+ }
+ registerCleanupFunction(clearPref);
+
+ function background() {
+ const PAYLOAD = "0123456789A";
+ let port = browser.runtime.connectNative("echo");
+ try {
+ port.postMessage(PAYLOAD);
+ browser.test.sendMessage("result", null);
+ } catch (ex) {
+ browser.test.sendMessage("result", ex.message);
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let errmsg = await extension.awaitMessage("result");
+ notEqual(
+ errmsg,
+ null,
+ "native postMessage() failed for overly large message"
+ );
+
+ await extension.unload();
+ await waitForSubprocessExit();
+
+ clearPref();
+});
+
+// Test the limit on message size for reading
+add_task(async function test_read_limit() {
+ Services.prefs.setIntPref(PREF_MAX_READ, 10);
+ function clearPref() {
+ Services.prefs.clearUserPref(PREF_MAX_READ);
+ }
+ registerCleanupFunction(clearPref);
+
+ function background() {
+ const PAYLOAD = "0123456789A";
+ let port = browser.runtime.connectNative("echo");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ "Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.",
+ port.error && port.error.message
+ );
+ browser.test.sendMessage("result", "disconnected");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", "message");
+ });
+ port.postMessage(PAYLOAD);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let result = await extension.awaitMessage("result");
+ equal(
+ result,
+ "disconnected",
+ "native port disconnected on receiving large message"
+ );
+
+ await extension.unload();
+ await waitForSubprocessExit();
+
+ clearPref();
+});
+
+// Test that an extension without the nativeMessaging permission cannot
+// use native messaging.
+add_task(async function test_ext_permission() {
+ function background() {
+ browser.test.assertEq(
+ chrome.runtime.connectNative,
+ undefined,
+ "chrome.runtime.connectNative does not exist without nativeMessaging permission"
+ );
+ browser.test.assertEq(
+ browser.runtime.connectNative,
+ undefined,
+ "browser.runtime.connectNative does not exist without nativeMessaging permission"
+ );
+ browser.test.assertEq(
+ chrome.runtime.sendNativeMessage,
+ undefined,
+ "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission"
+ );
+ browser.test.assertEq(
+ browser.runtime.sendNativeMessage,
+ undefined,
+ "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission"
+ );
+ browser.test.sendMessage("finished");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {},
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+ await extension.unload();
+});
+
+// Test that an extension that is not listed in allowed_extensions for
+// a native application cannot use that application.
+add_task(async function test_app_permission() {
+ function background() {
+ let port = browser.runtime.connectNative("echo");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ "No such native application echo",
+ port.error && port.error.message
+ );
+ browser.test.sendMessage("result", "disconnected");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", "message");
+ });
+ port.postMessage({ test: "test" });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(
+ {
+ background,
+ manifest: {
+ permissions: ["nativeMessaging"],
+ },
+ },
+ "somethingelse@tests.mozilla.org"
+ );
+
+ await extension.startup();
+
+ let result = await extension.awaitMessage("result");
+ equal(
+ result,
+ "disconnected",
+ "connectNative() failed without native app permission"
+ );
+
+ await extension.unload();
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 0, "No child process was started");
+});
+
+// Test that the command-line arguments and working directory for the
+// native application are as expected.
+add_task(async function test_child_process() {
+ function background() {
+ let port = browser.runtime.connectNative("info");
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", msg);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let msg = await extension.awaitMessage("result");
+ equal(msg.args.length, 3, "Received two command line arguments");
+ equal(
+ msg.args[1],
+ getPath("info.json"),
+ "Command line argument is the path to the native host manifest"
+ );
+ equal(
+ msg.args[2],
+ ID,
+ "Second command line argument is the ID of the calling extension"
+ );
+ equal(
+ msg.cwd.replace(/^\/private\//, "/"),
+ OS.Path.join(tmpDir.path, TYPE_SLUG),
+ "Working directory is the directory containing the native appliation"
+ );
+
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+});
+
+add_task(async function test_stderr() {
+ function background() {
+ let port = browser.runtime.connectNative("stderr");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ null,
+ port.error,
+ "Normal application exit is not an error"
+ );
+ browser.test.sendMessage("finished");
+ });
+ }
+
+ let { messages } = await promiseConsoleOutput(async function() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("finished");
+ await extension.unload();
+
+ await waitForSubprocessExit();
+ });
+
+ let lines = STDERR_LINES.map(line =>
+ messages.findIndex(msg => msg.message.includes(line))
+ );
+ notEqual(lines[0], -1, "Saw first line of stderr output on the console");
+ notEqual(lines[1], -1, "Saw second line of stderr output on the console");
+ notEqual(
+ lines[0],
+ lines[1],
+ "Stderr output lines are separated in the console"
+ );
+});
+
+// Test that calling connectNative() multiple times works
+// (see bug 1313980 for a previous regression in this area)
+add_task(async function test_multiple_connects() {
+ async function background() {
+ function once() {
+ return new Promise(resolve => {
+ let MSG = "hello";
+ let port = browser.runtime.connectNative("echo");
+
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(MSG, msg, "Got expected message back");
+ port.disconnect();
+ resolve();
+ });
+ port.postMessage(MSG);
+ });
+ }
+
+ await once();
+ await once();
+ browser.test.notifyPass("multiple-connect");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("multiple-connect");
+ await extension.unload();
+});
+
+// Test that native messaging is always rejected on content scripts
+add_task(async function test_connect_native_from_content_script() {
+ async function testScript() {
+ let port = browser.runtime.connectNative("echo");
+ port.onDisconnect.addListener(msgPort => {
+ browser.test.assertEq(
+ port,
+ msgPort,
+ "onDisconnect handler should receive the port as the first argument"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ port.error && port.error.message
+ );
+ browser.test.sendMessage("result", "disconnected");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.sendMessage("result", "message");
+ });
+ port.postMessage({ test: "test" });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ run_at: "document_end",
+ js: ["test.js"],
+ matches: ["http://example.com/dummy"],
+ },
+ ],
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ files: {
+ "test.js": testScript,
+ },
+ });
+
+ await extension.startup();
+
+ const page = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ let result = await extension.awaitMessage("result");
+ equal(result, "disconnected", "connectNative() failed from content script");
+
+ await page.close();
+ await extension.unload();
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 0, "No child process was started");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js
new file mode 100644
index 0000000000..073c83bfd4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js
@@ -0,0 +1,130 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const MAX_ROUND_TRIP_TIME_MS =
+ AppConstants.DEBUG || AppConstants.ASAN ? 60 : 30;
+const MAX_RETRIES = 5;
+
+const ECHO_BODY = String.raw`
+ import struct
+ import sys
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ sys.exit(0)
+
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+const SCRIPTS = [
+ {
+ name: "echo",
+ description: "A native app that echoes back messages it receives",
+ script: ECHO_BODY.replace(/^ {2}/gm, ""),
+ },
+];
+
+add_task(async function setup() {
+ await setupHosts(SCRIPTS);
+});
+
+add_task(async function test_round_trip_perf() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg != "run-tests") {
+ return;
+ }
+
+ let port = browser.runtime.connectNative("echo");
+
+ function next() {
+ port.postMessage({
+ 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.",
+ ],
+ },
+ },
+ });
+ }
+
+ const COUNT = 1000;
+ let now;
+ function finish() {
+ let roundTripTime = (Date.now() - now) / COUNT;
+
+ port.disconnect();
+ browser.test.sendMessage("result", roundTripTime);
+ }
+
+ let count = 0;
+ port.onMessage.addListener(() => {
+ if (count == 0) {
+ // Skip the first round, since it includes the time it takes
+ // the app to start up.
+ now = Date.now();
+ }
+
+ if (count++ <= COUNT) {
+ next();
+ } else {
+ finish();
+ }
+ });
+
+ next();
+ });
+ },
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+
+ let roundTripTime = Infinity;
+ for (
+ let i = 0;
+ i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS;
+ i++
+ ) {
+ extension.sendMessage("run-tests");
+ roundTripTime = await extension.awaitMessage("result");
+ }
+
+ await extension.unload();
+
+ ok(
+ roundTripTime <= MAX_ROUND_TRIP_TIME_MS,
+ `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms`
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js
new file mode 100644
index 0000000000..0de24c0c6e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js
@@ -0,0 +1,85 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const WONTDIE_BODY = String.raw`
+ import signal
+ import struct
+ import sys
+ import time
+
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+ stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+ stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+ def spin():
+ while True:
+ try:
+ signal.pause()
+ except AttributeError:
+ time.sleep(5)
+
+ while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ spin()
+
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+const SCRIPTS = [
+ {
+ name: "wontdie",
+ description:
+ "a native app that does not exit when stdin closes or on SIGTERM",
+ script: WONTDIE_BODY.replace(/^ {2}/gm, ""),
+ },
+];
+
+add_task(async function setup() {
+ await setupHosts(SCRIPTS);
+});
+
+// Test that an unresponsive native application still gets killed eventually
+add_task(async function test_unresponsive_native_app() {
+ // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it
+ // just for this test?
+
+ function background() {
+ let port = browser.runtime.connectNative("wontdie");
+
+ const MSG = "echo me";
+ // bounce a message to make sure the process actually starts
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, MSG, "Received echoed message");
+ browser.test.sendMessage("ready");
+ });
+ port.postMessage(MSG);
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: ID } },
+ permissions: ["nativeMessaging"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let procCount = await getSubprocessCount();
+ equal(procCount, 1, "subprocess is running");
+
+ let exitPromise = waitForSubprocessExit();
+ await extension.unload();
+ await exitPromise;
+
+ procCount = await getSubprocessCount();
+ equal(procCount, 0, "subprocess was successfully killed");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js
new file mode 100644
index 0000000000..758bf48d0b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js
@@ -0,0 +1,190 @@
+"use strict";
+
+const Cm = Components.manager;
+
+const uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(
+ Ci.nsIUUIDGenerator
+);
+
+var mockNetworkStatusService = {
+ contractId: "@mozilla.org/network/network-link-service;1",
+
+ _mockClassId: uuidGenerator.generateUUID(),
+
+ _originalClassId: "",
+
+ QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]),
+
+ createInstance(outer, iiD) {
+ if (outer) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION);
+ }
+ return this.QueryInterface(iiD);
+ },
+
+ register() {
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ if (!registrar.isCIDRegistered(this._mockClassId)) {
+ this._originalClassId = registrar.contractIDToCID(this.contractId);
+ registrar.registerFactory(
+ this._mockClassId,
+ "Unregister after testing",
+ this.contractId,
+ this
+ );
+ }
+ },
+
+ unregister() {
+ let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.unregisterFactory(this._mockClassId, this);
+ registrar.registerFactory(this._originalClassId, "", this.contractId, null);
+ },
+
+ _isLinkUp: true,
+ _linkStatusKnown: false,
+ _linkType: Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN,
+
+ get isLinkUp() {
+ return this._isLinkUp;
+ },
+
+ get linkStatusKnown() {
+ return this._linkStatusKnown;
+ },
+
+ setLinkStatus(status) {
+ switch (status) {
+ case "up":
+ this._isLinkUp = true;
+ this._linkStatusKnown = true;
+ this._networkID = "foo";
+ break;
+ case "down":
+ this._isLinkUp = false;
+ this._linkStatusKnown = true;
+ this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN;
+ this._networkID = undefined;
+ break;
+ case "changed":
+ this._linkStatusKnown = true;
+ this._networkID = "foo";
+ break;
+ case "unknown":
+ this._linkStatusKnown = false;
+ this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN;
+ this._networkID = undefined;
+ break;
+ }
+ Services.obs.notifyObservers(null, "network:link-status-changed", status);
+ },
+
+ get linkType() {
+ return this._linkType;
+ },
+
+ setLinkType(val) {
+ this._linkType = val;
+ this._linkStatusKnown = true;
+ this._isLinkUp = true;
+ this._networkID = "bar";
+ Services.obs.notifyObservers(
+ null,
+ "network:link-type-changed",
+ this._linkType
+ );
+ },
+
+ get networkID() {
+ return this._networkID;
+ },
+};
+
+// nsINetworkLinkService is not directly testable. With the mock service above,
+// we just exercise a couple small things here to validate the api works somewhat.
+add_task(async function test_networkStatus() {
+ mockNetworkStatusService.register();
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "networkstatus@tests.mozilla.org" } },
+ permissions: ["networkStatus"],
+ },
+ isPrivileged: true,
+ async background() {
+ browser.networkStatus.onConnectionChanged.addListener(async details => {
+ browser.test.log(`connection status ${JSON.stringify(details)}`);
+ browser.test.sendMessage("connect-changed", {
+ details,
+ linkInfo: await browser.networkStatus.getLinkInfo(),
+ });
+ });
+ browser.test.sendMessage(
+ "linkdata",
+ await browser.networkStatus.getLinkInfo()
+ );
+ },
+ });
+
+ async function test(expected, change) {
+ if (change.status) {
+ info(`test link change status to ${change.status}`);
+ mockNetworkStatusService.setLinkStatus(change.status);
+ } else if (change.link) {
+ info(`test link change type to ${change.link}`);
+ mockNetworkStatusService.setLinkType(change.link);
+ }
+ let { details, linkInfo } = await extension.awaitMessage("connect-changed");
+ equal(details.type, expected.type, "network type is correct");
+ equal(details.status, expected.status, `network status is correct`);
+ equal(details.id, expected.id, "network id");
+ Assert.deepEqual(
+ linkInfo,
+ details,
+ "getLinkInfo should resolve to the same details received from onConnectionChanged"
+ );
+ }
+
+ await extension.startup();
+
+ let data = await extension.awaitMessage("linkdata");
+ equal(data.type, "unknown", "network type is unknown");
+ equal(data.status, "unknown", `network status is ${data.status}`);
+ equal(data.id, undefined, "network id");
+
+ await test(
+ { type: "unknown", status: "up", id: "foo" },
+ { status: "changed" }
+ );
+
+ await test(
+ { type: "wifi", status: "up", id: "bar" },
+ { link: Ci.nsINetworkLinkService.LINK_TYPE_WIFI }
+ );
+
+ await test({ type: "unknown", status: "down" }, { status: "down" });
+
+ await test({ type: "unknown", status: "unknown" }, { status: "unknown" });
+
+ await extension.unload();
+ mockNetworkStatusService.unregister();
+});
+
+add_task(async function test_networkStatus_permission() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: { id: "networkstatus-permission@tests.mozilla.org" },
+ },
+ permissions: ["networkStatus"],
+ },
+ async background() {
+ browser.test.assertEq(
+ undefined,
+ browser.networkStatus,
+ "networkStatus is privileged"
+ );
+ },
+ });
+ await extension.startup();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js
new file mode 100644
index 0000000000..400d60c4a1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js
@@ -0,0 +1,108 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1";
+
+const createdAlerts = [];
+
+const mockAlertsService = {
+ showPersistentNotification(persistentData, alert, alertListener) {
+ this.showAlert(alert, alertListener);
+ },
+
+ showAlert(alert, listener) {
+ createdAlerts.push(alert);
+ listener.observe(null, "alertfinished", alert.cookie);
+ },
+
+ showAlertNotification(
+ imageUrl,
+ title,
+ text,
+ textClickable,
+ cookie,
+ alertListener,
+ name,
+ dir,
+ lang,
+ data,
+ principal,
+ privateBrowsing
+ ) {
+ this.showAlert({ cookie, title, text, privateBrowsing }, alertListener);
+ },
+
+ closeAlert(name) {
+ // This mock immediately close the alert on show, so this is empty.
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]),
+
+ createInstance(outer, iid) {
+ if (outer != null) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION);
+ }
+ return this.QueryInterface(iid);
+ },
+};
+
+const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ Components.ID("{173a036a-d678-4415-9cff-0baff6bfe554}"),
+ "alerts service",
+ ALERTS_SERVICE_CONTRACT_ID,
+ mockAlertsService
+);
+
+add_task(async function test_notification_privateBrowsing_flag() {
+ let extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["notifications"],
+ },
+ files: {
+ "page.html": `<meta charset="utf-8"><script src="page.js"></script>`,
+ async "page.js"() {
+ let closedPromise = new Promise(resolve => {
+ browser.notifications.onClosed.addListener(resolve);
+ });
+ let createdId = await browser.notifications.create("notifid", {
+ type: "basic",
+ title: "titl",
+ message: "msg",
+ });
+ let closedId = await closedPromise;
+ browser.test.assertEq(createdId, closedId, "ID of closed notification");
+ browser.test.assertEq(
+ "{}",
+ JSON.stringify(await browser.notifications.getAll()),
+ "no notifications left"
+ );
+ browser.test.sendMessage("notification_closed");
+ },
+ },
+ });
+ await extension.startup();
+
+ async function checkPrivateBrowsingFlag(privateBrowsing) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/page.html`,
+ { extension, remote: extension.extension.remote, privateBrowsing }
+ );
+ await extension.awaitMessage("notification_closed");
+ await contentPage.close();
+
+ Assert.equal(createdAlerts.length, 1, "expected one alert");
+ let notification = createdAlerts.shift();
+ Assert.equal(notification.cookie, "notifid", "notification id");
+ Assert.equal(notification.title, "titl", "notification title");
+ Assert.equal(notification.text, "msg", "notification text");
+ Assert.equal(notification.privateBrowsing, privateBrowsing, "pbm flag");
+ }
+
+ await checkPrivateBrowsingFlag(false);
+ await checkPrivateBrowsingFlag(true);
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js
new file mode 100644
index 0000000000..1213ae4f23
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.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";
+
+const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1";
+const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ Components.ID("{18f25bb4-ab12-4e24-b3b0-69215056160b}"),
+ "unsupported alerts service",
+ ALERTS_SERVICE_CONTRACT_ID,
+ {} // This object lacks an implementation of nsIAlertsService.
+);
+
+add_task(async function test_notification_unsupported_backend() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["notifications"],
+ },
+ async background() {
+ let closedPromise = new Promise(resolve => {
+ browser.notifications.onClosed.addListener(resolve);
+ });
+ let createdId = await browser.notifications.create("notifid", {
+ type: "basic",
+ title: "titl",
+ message: "msg",
+ });
+ let closedId = await closedPromise;
+ browser.test.assertEq(createdId, closedId, "ID of closed notification");
+ browser.test.assertEq(
+ "{}",
+ JSON.stringify(await browser.notifications.getAll()),
+ "no notifications left"
+ );
+ browser.test.sendMessage("notification_closed");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("notification_closed");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js
new file mode 100644
index 0000000000..7da12b40aa
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js
@@ -0,0 +1,30 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+function backgroundScript() {
+ function listener() {
+ browser.test.notifyFail("listener should not be invoked");
+ }
+
+ browser.runtime.onMessage.addListener(listener);
+ browser.runtime.onMessage.removeListener(listener);
+ browser.runtime.sendMessage("hello");
+
+ // Make sure that, if we somehow fail to remove the listener, then we'll run
+ // the listener before the test is marked as passing.
+ setTimeout(function() {
+ browser.test.notifyPass("onmessage_removelistener");
+ }, 0);
+}
+
+let extensionData = {
+ background: backgroundScript,
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("onmessage_removelistener");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js
new file mode 100644
index 0000000000..7e1370d00f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.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";
+
+const { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+const ENABLE_COUNTER_PREF =
+ "extensions.webextensions.enablePerformanceCounters";
+const TIMING_MAX_AGE = "extensions.webextensions.performanceCountersMaxAge";
+
+let { ParentAPIManager } = ExtensionParent;
+
+function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout
+}
+
+async function retrieveSpecificCounter(apiName, expectedCount) {
+ let currentCount = 0;
+ let data;
+ while (currentCount < expectedCount) {
+ data = await ParentAPIManager.retrievePerformanceCounters();
+ for (let [console, counters] of data) {
+ for (let [api, counter] of counters) {
+ if (api == apiName) {
+ currentCount += counter.calls;
+ }
+ }
+ }
+ await sleep(100);
+ }
+ return data;
+}
+
+async function test_counter() {
+ async function background() {
+ // creating a bookmark is done in the parent
+ let folder = await browser.bookmarks.create({ title: "Folder" });
+ await browser.bookmarks.create({
+ title: "Bookmark",
+ url: "http://example.com",
+ parentId: folder.id,
+ });
+
+ // getURL() is done in the child, let do three
+ browser.extension.getURL("beasts/frog.html");
+ browser.extension.getURL("beasts/frog2.html");
+ browser.extension.getURL("beasts/frog3.html");
+ browser.test.sendMessage("done");
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ permissions: ["bookmarks"],
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("done");
+
+ let counters = await retrieveSpecificCounter("getURL", 3);
+ await extension.unload();
+
+ // check that the bookmarks.create API was tracked
+ let counter = counters.get(extension.id).get("bookmarks.create");
+ ok(counter.calls > 0);
+ ok(counter.duration > 0);
+
+ // check that the getURL API was tracked
+ counter = counters.get(extension.id).get("getURL");
+ ok(counter.calls > 0);
+ ok(counter.duration > 0);
+}
+
+add_task(function test_performance_counter() {
+ return runWithPrefs(
+ [
+ [ENABLE_COUNTER_PREF, true],
+ [TIMING_MAX_AGE, 1],
+ ],
+ test_counter
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js
new file mode 100644
index 0000000000..cc15e28200
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js
@@ -0,0 +1,654 @@
+"use strict";
+
+let { ExtensionTestCommon } = ChromeUtils.import(
+ "resource://testing-common/ExtensionTestCommon.jsm"
+);
+
+let bundle;
+if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ bundle = Services.strings.createBundle(
+ "chrome://messenger/locale/addons.properties"
+ );
+} else {
+ bundle = Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+}
+const DUMMY_APP_NAME = "Dummy brandName";
+
+const { createAppInfo } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+async function getManifestPermissions(extensionData) {
+ let extension = ExtensionTestCommon.generate(extensionData);
+ // Some tests contain invalid permissions; ignore the warnings about their invalidity.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.loadManifest();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ const { manifestPermissions } = extension;
+ await extension.cleanupGeneratedFile();
+ return manifestPermissions;
+}
+
+function getPermissionWarnings(manifestPermissions, options) {
+ let info = {
+ permissions: manifestPermissions,
+ appName: DUMMY_APP_NAME,
+ };
+ let { msgs } = ExtensionData.formatPermissionStrings(info, bundle, options);
+ return msgs;
+}
+
+async function getPermissionWarningsForUpdate(
+ oldExtensionData,
+ newExtensionData
+) {
+ let oldPerms = await getManifestPermissions(oldExtensionData);
+ let newPerms = await getManifestPermissions(newExtensionData);
+ let difference = Extension.comparePermissions(oldPerms, newPerms);
+ return getPermissionWarnings(difference);
+}
+
+// Tests that the expected permission warnings are generated for various
+// combinations of host permissions.
+add_task(async function host_permissions() {
+ let { PluralForm } = ChromeUtils.import(
+ "resource://gre/modules/PluralForm.jsm"
+ );
+
+ let permissionTestCases = [
+ {
+ description: "Empty manifest without permissions",
+ manifest: {},
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "Invalid match patterns",
+ manifest: {
+ permissions: [
+ "https:///",
+ "https://",
+ "https://*",
+ "about:ugh",
+ "about:*",
+ "about://*/",
+ "resource://*/",
+ ],
+ },
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "moz-extension: permissions",
+ manifest: {
+ permissions: ["moz-extension://*/*", "moz-extension://uuid/"],
+ },
+ // moz-extension:-origin does not appear in the permission list,
+ // but it is implicitly granted anyway.
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "*. host permission",
+ manifest: {
+ // This permission is rejected by the manifest and ignored.
+ permissions: ["http://*./"],
+ },
+ expectedOrigins: [],
+ expectedWarnings: [],
+ },
+ {
+ description: "<all_urls> permission",
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ expectedOrigins: ["<all_urls>"],
+ expectedWarnings: [
+ bundle.GetStringFromName("webextPerms.hostDescription.allUrls"),
+ ],
+ },
+ {
+ description: "file: permissions",
+ manifest: {
+ permissions: ["file://*/"],
+ },
+ expectedOrigins: ["file://*/"],
+ expectedWarnings: [
+ bundle.GetStringFromName("webextPerms.hostDescription.allUrls"),
+ ],
+ },
+ {
+ description: "http: permission",
+ manifest: {
+ permissions: ["http://*/"],
+ },
+ expectedOrigins: ["http://*/"],
+ expectedWarnings: [
+ bundle.GetStringFromName("webextPerms.hostDescription.allUrls"),
+ ],
+ },
+ {
+ description: "*://*/ permission",
+ manifest: {
+ permissions: ["*://*/"],
+ },
+ expectedOrigins: ["*://*/"],
+ expectedWarnings: [
+ bundle.GetStringFromName("webextPerms.hostDescription.allUrls"),
+ ],
+ },
+ {
+ description: "content_script[*].matches",
+ manifest: {
+ content_scripts: [
+ {
+ // This test uses the manifest file without loading the content script
+ // file, so we can use a non-existing dummy file.
+ js: ["dummy.js"],
+ matches: ["https://*/"],
+ },
+ ],
+ },
+ expectedOrigins: ["https://*/"],
+ expectedWarnings: [
+ bundle.GetStringFromName("webextPerms.hostDescription.allUrls"),
+ ],
+ },
+ {
+ description: "A few host permissions",
+ manifest: {
+ permissions: ["http://a/", "http://*.b/", "http://c/*"],
+ },
+ expectedOrigins: ["http://a/", "http://*.b/", "http://c/*"],
+ expectedWarnings: [
+ // Wildcard hosts take precedence in the permission list.
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "b",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "a",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "c",
+ ]),
+ ],
+ },
+ {
+ description: "many host permission",
+ manifest: {
+ permissions: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ ],
+ },
+ expectedOrigins: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ ],
+ expectedWarnings: [
+ // Wildcard hosts take precedence in the permission list.
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "1",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "2",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "3",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "4",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "a",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "b",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "c",
+ ]),
+ PluralForm.get(
+ 2,
+ bundle.GetStringFromName("webextPerms.hostDescription.tooManySites")
+ ).replace("#1", "2"),
+ ],
+ options: {
+ collapseOrigins: true,
+ },
+ },
+ {
+ description:
+ "many host permissions without item limit in the warning list",
+ manifest: {
+ permissions: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ "http://*.5/",
+ ],
+ },
+ expectedOrigins: [
+ "http://a/",
+ "http://b/",
+ "http://c/",
+ "http://d/",
+ "http://e/*",
+ "http://*.1/",
+ "http://*.2/",
+ "http://*.3/",
+ "http://*.4/",
+ "http://*.5/",
+ ],
+ expectedWarnings: [
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "1",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "2",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "3",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "4",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "5",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "a",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "b",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "c",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "d",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [
+ "e",
+ ]),
+ ],
+ },
+ ];
+ for (let {
+ description,
+ manifest,
+ expectedOrigins,
+ expectedWarnings,
+ options,
+ } of permissionTestCases) {
+ let manifestPermissions = await getManifestPermissions({
+ manifest,
+ });
+
+ deepEqual(
+ manifestPermissions.origins,
+ expectedOrigins,
+ `Expected origins (${description})`
+ );
+ deepEqual(
+ manifestPermissions.permissions,
+ [],
+ `Expected no non-host permissions (${description})`
+ );
+
+ let warnings = getPermissionWarnings(manifestPermissions, options);
+ deepEqual(warnings, expectedWarnings, `Expected warnings (${description})`);
+ }
+});
+
+// Tests that the expected permission warnings are generated for a mix of host
+// permissions and API permissions.
+add_task(async function api_permissions() {
+ let manifestPermissions = await getManifestPermissions({
+ manifest: {
+ permissions: [
+ "activeTab",
+ "webNavigation",
+ "tabs",
+ "nativeMessaging",
+ "http://x/",
+ "http://*.x/",
+ "http://*.tld/",
+ ],
+ },
+ });
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: ["http://x/", "http://*.x/", "http://*.tld/"],
+ permissions: ["activeTab", "webNavigation", "tabs", "nativeMessaging"],
+ },
+ "Expected origins and permissions"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ [
+ // Host permissions first, with wildcards on top.
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "x",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "tld",
+ ]),
+ bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["x"]),
+ // nativeMessaging permission warning first of all permissions.
+ bundle.formatStringFromName("webextPerms.description.nativeMessaging", [
+ DUMMY_APP_NAME,
+ ]),
+ // Other permissions in alphabetical order.
+ // Note: activeTab has no permission warning string.
+ bundle.GetStringFromName("webextPerms.description.tabs"),
+ bundle.GetStringFromName("webextPerms.description.webNavigation"),
+ ],
+ "Expected warnings"
+ );
+});
+
+// Tests that the expected permission warnings are generated for a mix of host
+// permissions and API permissions, for a privileged extension that uses the
+// mozillaAddons permission.
+add_task(async function privileged_with_mozillaAddons() {
+ let manifestPermissions = await getManifestPermissions({
+ isPrivileged: true,
+ manifest: {
+ permissions: [
+ "mozillaAddons",
+ "mozillaAddons",
+ "mozillaAddons",
+ "resource://x/*",
+ "http://a/",
+ "about:reader*",
+ ],
+ },
+ });
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: ["resource://x/*", "http://a/", "about:reader*"],
+ permissions: ["mozillaAddons"],
+ },
+ "Expected origins and permissions for privileged add-on with mozillaAddons"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ [bundle.GetStringFromName("webextPerms.hostDescription.allUrls")],
+ "Expected warnings for privileged add-on with mozillaAddons permission."
+ );
+});
+
+// Similar to the privileged_with_mozillaAddons test, except the test extension
+// is unprivileged and not allowed to use the mozillaAddons permission.
+add_task(async function unprivileged_with_mozillaAddons() {
+ let manifestPermissions = await getManifestPermissions({
+ manifest: {
+ permissions: [
+ "mozillaAddons",
+ "mozillaAddons",
+ "mozillaAddons",
+ "resource://x/*",
+ "http://a/",
+ "about:reader*",
+ ],
+ },
+ });
+ deepEqual(
+ manifestPermissions,
+ {
+ origins: ["http://a/"],
+ permissions: [],
+ },
+ "Expected origins and permissions for unprivileged add-on with mozillaAddons"
+ );
+
+ deepEqual(
+ getPermissionWarnings(manifestPermissions),
+ [bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["a"])],
+ "Expected warnings for unprivileged add-on with mozillaAddons permission."
+ );
+});
+
+// Tests that an update with less permissions has no warning.
+add_task(async function update_drop_permission() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["<all_urls>", "https://a/", "http://b/"],
+ },
+ },
+ {
+ manifest: {
+ permissions: [
+ "https://a/",
+ "http://b/",
+ "ftp://host_matching_all_urls/",
+ ],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [],
+ "An update with fewer permissions should not have any warnings"
+ );
+});
+
+// Tests that an update that switches from "*://*/*" to "<all_urls>" does not
+// result in additional permission warnings.
+add_task(async function update_all_urls_permission() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["*://*/*"],
+ },
+ },
+ {
+ manifest: {
+ permissions: ["<all_urls>"],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [],
+ "An update from a wildcard host to <all_urls> should not have any warnings"
+ );
+});
+
+// Tests that an update where a new permission whose domain overlaps with
+// an existing permission does not result in additional permission warnings.
+add_task(async function update_change_permissions() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["https://a/", "http://*.b/", "http://c/", "http://f/"],
+ },
+ },
+ {
+ manifest: {
+ permissions: [
+ // (no new warning) Unchanged permission from old extension.
+ "https://a/",
+ // (no new warning) Different schemes, host should match "*.b" wildcard.
+ "ftp://ftp.b/",
+ "ws://ws.b/",
+ "wss://wss.b",
+ "https://https.b/",
+ "http://http.b/",
+ "*://*.b/",
+ "http://b/",
+
+ // (expect warning) Wildcard was added.
+ "http://*.c/",
+ // (no new warning) file:-scheme, but host "f" is same as "http://f/".
+ "file://f/",
+ // (expect warning) New permission was added.
+ "proxy",
+ ],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [
+ bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [
+ "c",
+ ]),
+ bundle.formatStringFromName("webextPerms.description.proxy", [
+ DUMMY_APP_NAME,
+ ]),
+ ],
+ "Expected permission warnings for new permissions only"
+ );
+});
+
+// Tests that a privileged extension with the mozillaAddons permission can be
+// updated without errors.
+add_task(async function update_privileged_with_mozillaAddons() {
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ isPrivileged: true,
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/"],
+ },
+ },
+ {
+ isPrivileged: true,
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/", "resource://b/"],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["b"])],
+ "Expected permission warnings for new host only"
+ );
+});
+
+// Tests that an unprivileged extension cannot get privileged permissions
+// through an update.
+add_task(async function update_unprivileged_with_mozillaAddons() {
+ // Unprivileged
+ let warnings = await getPermissionWarningsForUpdate(
+ {
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/"],
+ },
+ },
+ {
+ manifest: {
+ permissions: ["mozillaAddons", "resource://a/", "resource://b/"],
+ },
+ }
+ );
+ deepEqual(
+ warnings,
+ [],
+ "resource:-scheme is unsupported for unprivileged extensions"
+ );
+});
+
+// Tests that invalid permission warning for privileged permissions requested
+// without the privilged signature are emitted by the Extension class instance
+// but not for the ExtensionData instances (on which the signature is not
+// available and the warning would be emitted even for the ones signed correctly).
+add_task(
+ async function test_invalid_permission_warning_on_privileged_permission() {
+ await AddonTestUtils.promiseStartupManager();
+
+ async function testInvalidPermissionWarning({ isPrivileged }) {
+ let id = isPrivileged
+ ? "privileged-addon@mochi.test"
+ : "nonprivileged-addon@mochi.test";
+
+ let expectedWarnings = isPrivileged
+ ? []
+ : ["Reading manifest: Invalid extension permission: mozillaAddons"];
+
+ const ext = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["mozillaAddons"],
+ applications: { gecko: { id } },
+ },
+ background() {},
+ });
+
+ await ext.startup();
+ const { warnings } = ext.extension;
+ Assert.deepEqual(
+ warnings,
+ expectedWarnings,
+ `Got the expected warning for ${id}`
+ );
+ await ext.unload();
+ }
+
+ await testInvalidPermissionWarning({ isPrivileged: false });
+ await testInvalidPermissionWarning({ isPrivileged: true });
+
+ info("Test invalid permission warning on ExtensionData instance");
+ // Generate an extension (just to be able to reuse its rootURI for the
+ // ExtensionData instance created below).
+ let generatedExt = ExtensionTestCommon.generate({
+ manifest: {
+ permissions: ["mozillaAddons"],
+ applications: { gecko: { id: "extension-data@mochi.test" } },
+ },
+ });
+
+ // Verify that XPIInstall.jsm will not collect the warning for the
+ // privileged permission as expected.
+ const extData = new ExtensionData(generatedExt.rootURI);
+ await extData.loadManifest();
+ Assert.deepEqual(
+ extData.warnings,
+ [],
+ "No warnings for mozillaAddons permission collected for the ExtensionData instance"
+ );
+
+ // This assertion is just meant to prevent the test to pass if there were no warnings
+ // because some errors prevented the warnings to be collected).
+ Assert.deepEqual(
+ extData.errors,
+ [],
+ "No errors collected by the ExtensionData instance"
+ );
+ // Cleanup the generated xpi file.
+ await generatedExt.cleanupGeneratedFile();
+
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js
new file mode 100644
index 0000000000..4b9ade044c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js
@@ -0,0 +1,235 @@
+"use strict";
+
+const server = createHttpServer({
+ hosts: ["xpcshell.test", "example.com", "example.org"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/example.txt", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+server.registerPathHandler("/return_headers.sjs", (request, response) => {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ for (let { data: header } of request.headers) {
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+/* eslint-disable mozilla/balanced-listeners */
+
+add_task(async function test_simple() {
+ async function runTests(cx) {
+ function xhr(XMLHttpRequest) {
+ return url => {
+ return new Promise((resolve, reject) => {
+ let req = new XMLHttpRequest();
+ req.open("GET", url);
+ req.addEventListener("load", resolve);
+ req.addEventListener("error", reject);
+ req.send();
+ });
+ };
+ }
+
+ function run(shouldFail, fetch) {
+ function passListener() {
+ browser.test.succeed(`${cx}.${fetch.name} pass listener`);
+ }
+
+ function failListener() {
+ browser.test.fail(`${cx}.${fetch.name} fail listener`);
+ }
+
+ /* eslint-disable no-else-return */
+ if (shouldFail) {
+ return fetch("http://example.org/example.txt").then(
+ failListener,
+ passListener
+ );
+ } else {
+ return fetch("http://example.com/example.txt").then(
+ passListener,
+ failListener
+ );
+ }
+ /* eslint-enable no-else-return */
+ }
+
+ try {
+ await run(true, xhr(XMLHttpRequest));
+ await run(false, xhr(XMLHttpRequest));
+ await run(true, xhr(window.XMLHttpRequest));
+ await run(false, xhr(window.XMLHttpRequest));
+ await run(true, fetch);
+ await run(false, fetch);
+ await run(true, window.fetch);
+ await run(false, window.fetch);
+ } catch (err) {
+ browser.test.fail(`Error: ${err} :: ${err.stack}`);
+ browser.test.notifyFail("permission_xhr");
+ }
+ }
+
+ async function background(runTestsFn) {
+ await runTestsFn("bg");
+ browser.test.notifyPass("permission_xhr");
+ }
+
+ let extensionData = {
+ background: `(${background})(${runTests})`,
+ manifest: {
+ permissions: ["http://example.com/"],
+ content_scripts: [
+ {
+ matches: ["http://xpcshell.test/data/file_permission_xhr.html"],
+ js: ["content.js"],
+ },
+ ],
+ },
+ files: {
+ "content.js": `(${async runTestsFn => {
+ await runTestsFn("content");
+
+ window.wrappedJSObject.privilegedFetch = fetch;
+ window.wrappedJSObject.privilegedXHR = XMLHttpRequest;
+
+ window.addEventListener("message", function rcv({ data }) {
+ switch (data.msg) {
+ case "test":
+ break;
+
+ case "assertTrue":
+ browser.test.assertTrue(data.condition, data.description);
+ break;
+
+ case "finish":
+ window.removeEventListener("message", rcv);
+ browser.test.sendMessage("content-script-finished");
+ break;
+ }
+ });
+ window.postMessage("test", "*");
+ }})(${runTests})`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://xpcshell.test/data/file_permission_xhr.html"
+ );
+ await extension.awaitMessage("content-script-finished");
+ await contentPage.close();
+
+ await extension.awaitFinish("permission_xhr");
+ await extension.unload();
+});
+
+// This test case ensures that a WebExtension content script can still use the same
+// XMLHttpRequest and fetch APIs that the webpage can use and be recognized from
+// the target server with the same origin and referer headers of the target webpage
+// (see Bug 1295660 for a rationale).
+add_task(async function test_page_xhr() {
+ async function contentScript() {
+ const content = this.content;
+
+ const { webpageFetchResult, webpageXhrResult } = await new Promise(
+ resolve => {
+ const listenPageMessage = event => {
+ if (!event.data || event.data.type !== "testPageGlobals") {
+ return;
+ }
+
+ window.removeEventListener("message", listenPageMessage);
+
+ browser.test.assertEq(
+ true,
+ !!content.XMLHttpRequest,
+ "The content script should have access to content.XMLHTTPRequest"
+ );
+ browser.test.assertEq(
+ true,
+ !!content.fetch,
+ "The content script should have access to window.pageFetch"
+ );
+
+ resolve(event.data);
+ };
+
+ window.addEventListener("message", listenPageMessage);
+
+ window.postMessage({}, "*");
+ }
+ );
+
+ const url = new URL("/return_headers.sjs", location).href;
+
+ await Promise.all([
+ new Promise((resolve, reject) => {
+ const req = new content.XMLHttpRequest();
+ req.open("GET", url);
+ req.addEventListener("load", () =>
+ resolve(JSON.parse(req.responseText))
+ );
+ req.addEventListener("error", reject);
+ req.send();
+ }),
+ content.fetch(url).then(res => res.json()),
+ ])
+ .then(async ([xhrResult, fetchResult]) => {
+ browser.test.assertEq(
+ webpageFetchResult.referer,
+ fetchResult.referer,
+ "window.pageFetch referrer is the same of a webpage fetch request"
+ );
+ browser.test.assertEq(
+ webpageFetchResult.origin,
+ fetchResult.origin,
+ "window.pageFetch origin is the same of a webpage fetch request"
+ );
+
+ browser.test.assertEq(
+ webpageXhrResult.referer,
+ xhrResult.referer,
+ "content.XMLHttpRequest referrer is the same of a webpage fetch request"
+ );
+ })
+ .catch(error => {
+ browser.test.fail(`Unexpected error: ${error}`);
+ });
+
+ browser.test.notifyPass("content-script-page-xhr");
+ }
+
+ let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://xpcshell.test/*"],
+ js: ["content.js"],
+ },
+ ],
+ },
+ files: {
+ "content.js": `(${contentScript})()`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://xpcshell.test/data/file_page_xhr.html"
+ );
+ await extension.awaitFinish("content-script-page-xhr");
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
new file mode 100644
index 0000000000..385563bab2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
@@ -0,0 +1,845 @@
+"use strict";
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+const { ExtensionPermissions } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+// ExtensionParent.jsm is being imported lazily because when it is imported Services.appinfo will be
+// retrieved and cached (as a side-effect of Schemas.jsm being imported), and so Services.appinfo
+// will not be returning the version set by AddonTestUtils.createAppInfo and this test will
+// fail on non-nightly builds (because the cached appinfo.version will be undefined and
+// AddonManager startup will fail).
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+let sawPrompt = false;
+let acceptPrompt = false;
+const observer = {
+ observe(subject, topic, data) {
+ if (topic == "webextension-optional-permission-prompt") {
+ sawPrompt = true;
+ let { resolve } = subject.wrappedJSObject;
+ resolve(acceptPrompt);
+ }
+ },
+};
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ true
+ );
+ Services.obs.addObserver(observer, "webextension-optional-permission-prompt");
+ registerCleanupFunction(() => {
+ Services.obs.removeObserver(
+ observer,
+ "webextension-optional-permission-prompt"
+ );
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await AddonTestUtils.promiseStartupManager();
+ AddonTestUtils.usePrivilegedSignatures = false;
+});
+
+add_task(async function test_permissions_on_startup() {
+ let extensionId = "@permissionTest";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: { id: extensionId },
+ },
+ permissions: ["tabs"],
+ },
+ useAddonManager: "permanent",
+ async background() {
+ let perms = await browser.permissions.getAll();
+ browser.test.sendMessage("permissions", perms);
+ },
+ });
+ let adding = {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ };
+ await extension.startup();
+ let perms = await extension.awaitMessage("permissions");
+ equal(perms.permissions.length, 1, "one permission");
+ equal(perms.permissions[0], "tabs", "internal permission not present");
+
+ const { StartupCache } = ExtensionParent;
+
+ // StartupCache.permissions will not contain the extension permissions.
+ let manifestData = await StartupCache.permissions.get(extensionId, () => {
+ return { permissions: [], origins: [] };
+ });
+ equal(manifestData.permissions.length, 0, "no permission");
+
+ perms = await ExtensionPermissions.get(extensionId);
+ equal(perms.permissions.length, 0, "no permissions");
+ await ExtensionPermissions.add(extensionId, adding);
+
+ // Restart the extension and re-test the permissions.
+ await ExtensionPermissions._uninit();
+ await AddonTestUtils.promiseRestartManager();
+ let restarted = extension.awaitMessage("permissions");
+ await extension.awaitStartup();
+ perms = await restarted;
+
+ manifestData = await StartupCache.permissions.get(extensionId, () => {
+ return { permissions: [], origins: [] };
+ });
+ deepEqual(
+ manifestData.permissions,
+ adding.permissions,
+ "StartupCache.permissions contains permission"
+ );
+
+ equal(perms.permissions.length, 1, "one permission");
+ equal(perms.permissions[0], "tabs", "internal permission not present");
+ let added = await ExtensionPermissions._get(extensionId);
+ deepEqual(added, adding, "permissions were retained");
+
+ await extension.unload();
+});
+
+add_task(async function test_permissions() {
+ const REQUIRED_PERMISSIONS = ["downloads"];
+ const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"];
+ const REQUIRED_ORIGINS_NORMALIZED = ["*://site.com/*", "*://*.domain.com/*"];
+
+ const OPTIONAL_PERMISSIONS = ["idle", "clipboardWrite"];
+ const OPTIONAL_ORIGINS = [
+ "http://optionalsite.com/",
+ "https://*.optionaldomain.com/",
+ ];
+ const OPTIONAL_ORIGINS_NORMALIZED = [
+ "http://optionalsite.com/*",
+ "https://*.optionaldomain.com/*",
+ ];
+
+ function background() {
+ browser.test.onMessage.addListener(async (method, arg) => {
+ if (method == "getAll") {
+ let perms = await browser.permissions.getAll();
+ let url = browser.extension.getURL("*");
+ perms.origins = perms.origins.filter(i => i != url);
+ browser.test.sendMessage("getAll.result", perms);
+ } else if (method == "contains") {
+ let result = await browser.permissions.contains(arg);
+ browser.test.sendMessage("contains.result", result);
+ } else if (method == "request") {
+ try {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", {
+ status: "success",
+ result,
+ });
+ } catch (err) {
+ browser.test.sendMessage("request.result", {
+ status: "error",
+ message: err.message,
+ });
+ }
+ } else if (method == "remove") {
+ let result = await browser.permissions.remove(arg);
+ browser.test.sendMessage("remove.result", result);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: [...REQUIRED_PERMISSIONS, ...REQUIRED_ORIGINS],
+ optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS],
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ function call(method, arg) {
+ extension.sendMessage(method, arg);
+ return extension.awaitMessage(`${method}.result`);
+ }
+
+ let result = await call("getAll");
+ deepEqual(result.permissions, REQUIRED_PERMISSIONS);
+ deepEqual(result.origins, REQUIRED_ORIGINS_NORMALIZED);
+
+ for (let perm of REQUIRED_PERMISSIONS) {
+ result = await call("contains", { permissions: [perm] });
+ equal(result, true, `contains() returns true for fixed permission ${perm}`);
+ }
+ for (let origin of REQUIRED_ORIGINS) {
+ result = await call("contains", { origins: [origin] });
+ equal(result, true, `contains() returns true for fixed origin ${origin}`);
+ }
+
+ // None of the optional permissions should be available yet
+ for (let perm of OPTIONAL_PERMISSIONS) {
+ result = await call("contains", { permissions: [perm] });
+ equal(result, false, `contains() returns false for permission ${perm}`);
+ }
+ for (let origin of OPTIONAL_ORIGINS) {
+ result = await call("contains", { origins: [origin] });
+ equal(result, false, `contains() returns false for origin ${origin}`);
+ }
+
+ result = await call("contains", {
+ permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS],
+ });
+ equal(
+ result,
+ false,
+ "contains() returns false for a mix of available and unavailable permissions"
+ );
+
+ let perm = OPTIONAL_PERMISSIONS[0];
+ result = await call("request", { permissions: [perm] });
+ equal(
+ result.status,
+ "error",
+ "request() fails if not called from an event handler"
+ );
+ ok(
+ /request may only be called from a user input handler/.test(result.message),
+ "error message for calling request() outside an event handler is reasonable"
+ );
+ result = await call("contains", { permissions: [perm] });
+ equal(
+ result,
+ false,
+ "Permission requested outside an event handler was not granted"
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ result = await call("request", { permissions: ["notifications"] });
+ equal(
+ result.status,
+ "error",
+ "request() for permission not in optional_permissions should fail"
+ );
+ ok(
+ /since it was not declared in optional_permissions/.test(result.message),
+ "error message for undeclared optional_permission is reasonable"
+ );
+
+ // Check request() when the prompt is canceled.
+ acceptPrompt = false;
+ result = await call("request", { permissions: [perm] });
+ equal(result.status, "success", "request() returned cleanly");
+ equal(
+ result.result,
+ false,
+ "request() returned false for rejected permission"
+ );
+
+ result = await call("contains", { permissions: [perm] });
+ equal(result, false, "Rejected permission was not granted");
+
+ // Call request() and accept the prompt
+ acceptPrompt = true;
+ let allOptional = {
+ permissions: OPTIONAL_PERMISSIONS,
+ origins: OPTIONAL_ORIGINS,
+ };
+ result = await call("request", allOptional);
+ equal(result.status, "success", "request() returned cleanly");
+ equal(
+ result.result,
+ true,
+ "request() returned true for accepted permissions"
+ );
+
+ // Verify that requesting a permission/origin in the wrong field fails
+ let originsAsPerms = {
+ permissions: OPTIONAL_ORIGINS,
+ };
+ let permsAsOrigins = {
+ origins: OPTIONAL_PERMISSIONS,
+ };
+
+ result = await call("request", originsAsPerms);
+ equal(
+ result.status,
+ "error",
+ "Requesting an origin as a permission should fail"
+ );
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ "Error message for origin as permission is reasonable"
+ );
+
+ result = await call("request", permsAsOrigins);
+ equal(
+ result.status,
+ "error",
+ "Requesting a permission as an origin should fail"
+ );
+ ok(
+ /Type error for parameter permissions \(Error processing origins/.test(
+ result.message
+ ),
+ "Error message for permission as origin is reasonable"
+ );
+ });
+
+ let allPermissions = {
+ permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS],
+ origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED],
+ };
+
+ result = await call("getAll");
+ deepEqual(
+ result,
+ allPermissions,
+ "getAll() returns required and runtime requested permissions"
+ );
+
+ result = await call("contains", allPermissions);
+ equal(
+ result,
+ true,
+ "contains() returns true for runtime requested permissions"
+ );
+
+ // Restart, verify permissions are still present
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ result = await call("getAll");
+ deepEqual(
+ result,
+ allPermissions,
+ "Runtime requested permissions are still present after restart"
+ );
+
+ // Check remove()
+ result = await call("remove", { permissions: OPTIONAL_PERMISSIONS });
+ equal(result, true, "remove() succeeded");
+
+ let perms = {
+ permissions: REQUIRED_PERMISSIONS,
+ origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED],
+ };
+ result = await call("getAll");
+ deepEqual(result, perms, "Expected permissions remain after removing some");
+
+ result = await call("remove", { origins: OPTIONAL_ORIGINS });
+ equal(result, true, "remove() succeeded");
+
+ perms.origins = REQUIRED_ORIGINS_NORMALIZED;
+ result = await call("getAll");
+ deepEqual(result, perms, "Back to default permissions after removing more");
+
+ await extension.unload();
+});
+
+add_task(async function test_startup() {
+ async function background() {
+ browser.test.onMessage.addListener(async perms => {
+ await browser.permissions.request(perms);
+ browser.test.sendMessage("requested");
+ });
+
+ let all = await browser.permissions.getAll();
+ let url = browser.extension.getURL("*");
+ all.origins = all.origins.filter(i => i != url);
+ browser.test.sendMessage("perms", all);
+ }
+
+ const PERMS1 = {
+ permissions: ["clipboardRead", "tabs"],
+ };
+ const PERMS2 = {
+ origins: ["https://site2.com/*"],
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: PERMS1.permissions,
+ },
+ useAddonManager: "permanent",
+ });
+ let extension2 = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: PERMS2.origins,
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension1.startup();
+ await extension2.startup();
+
+ let perms = await extension1.awaitMessage("perms");
+ perms = await extension2.awaitMessage("perms");
+
+ await withHandlingUserInput(extension1, async () => {
+ extension1.sendMessage(PERMS1);
+ await extension1.awaitMessage("requested");
+ });
+
+ await withHandlingUserInput(extension2, async () => {
+ extension2.sendMessage(PERMS2);
+ await extension2.awaitMessage("requested");
+ });
+
+ // Restart everything, and force the permissions store to be
+ // re-read on startup
+ await ExtensionPermissions._uninit();
+ await AddonTestUtils.promiseRestartManager();
+ await extension1.awaitStartup();
+ await extension2.awaitStartup();
+
+ async function checkPermissions(extension, permissions) {
+ perms = await extension.awaitMessage("perms");
+ let expect = Object.assign({ permissions: [], origins: [] }, permissions);
+ deepEqual(perms, expect, "Extension got correct permissions on startup");
+ }
+
+ await checkPermissions(extension1, PERMS1);
+ await checkPermissions(extension2, PERMS2);
+
+ await extension1.unload();
+ await extension2.unload();
+});
+
+// Test that we don't prompt for permissions an extension already has.
+add_task(async function test_alreadyGranted() {
+ const REQUIRED_PERMISSIONS = [
+ "geolocation",
+ "*://required-host.com/",
+ "*://*.required-domain.com/",
+ ];
+ const OPTIONAL_PERMISSIONS = [
+ ...REQUIRED_PERMISSIONS,
+ "clipboardRead",
+ "*://optional-host.com/",
+ "*://*.optional-domain.com/",
+ ];
+
+ function pageScript() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", result);
+ } else if (msg == "remove") {
+ let result = await browser.permissions.remove(arg);
+ browser.test.sendMessage("remove.result", result);
+ } else if (msg == "close") {
+ window.close();
+ }
+ });
+
+ browser.test.sendMessage("page-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+
+ manifest: {
+ permissions: REQUIRED_PERMISSIONS,
+ optional_permissions: OPTIONAL_PERMISSIONS,
+ },
+
+ files: {
+ "page.html": `<html><head>
+ <script src="page.js"><\/script>
+ </head></html>`,
+
+ "page.js": pageScript,
+ },
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ let url = await extension.awaitMessage("ready");
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("page-ready");
+
+ async function checkRequest(arg, expectPrompt, msg) {
+ sawPrompt = false;
+ extension.sendMessage("request", arg);
+ let result = await extension.awaitMessage("request.result");
+ ok(result, "request() call succeeded");
+ equal(
+ sawPrompt,
+ expectPrompt,
+ `Got ${expectPrompt ? "" : "no "}permission prompt for ${msg}`
+ );
+ }
+
+ await checkRequest(
+ { permissions: ["geolocation"] },
+ false,
+ "required permission from manifest"
+ );
+ await checkRequest(
+ { origins: ["http://required-host.com/"] },
+ false,
+ "origin permission from manifest"
+ );
+ await checkRequest(
+ { origins: ["http://host.required-domain.com/"] },
+ false,
+ "wildcard origin permission from manifest"
+ );
+
+ await checkRequest(
+ { permissions: ["clipboardRead"] },
+ true,
+ "optional permission"
+ );
+ await checkRequest(
+ { permissions: ["clipboardRead"] },
+ false,
+ "already granted optional permission"
+ );
+
+ await checkRequest(
+ { origins: ["http://optional-host.com/"] },
+ true,
+ "optional origin"
+ );
+ await checkRequest(
+ { origins: ["http://optional-host.com/"] },
+ false,
+ "already granted origin permission"
+ );
+
+ await checkRequest(
+ { origins: ["http://*.optional-domain.com/"] },
+ true,
+ "optional wildcard origin"
+ );
+ await checkRequest(
+ { origins: ["http://*.optional-domain.com/"] },
+ false,
+ "already granted optional wildcard origin"
+ );
+ await checkRequest(
+ { origins: ["http://host.optional-domain.com/"] },
+ false,
+ "host matching optional wildcard origin"
+ );
+ await page.close();
+ });
+
+ await extension.unload();
+});
+
+// IMPORTANT: Do not change this list without review from a Web Extensions peer!
+
+const GRANTED_WITHOUT_USER_PROMPT = [
+ "activeTab",
+ "activityLog",
+ "alarms",
+ "captivePortal",
+ "contextMenus",
+ "contextualIdentities",
+ "cookies",
+ "dns",
+ "geckoProfiler",
+ "identity",
+ "idle",
+ "menus",
+ "menus.overrideContext",
+ "mozillaAddons",
+ "networkStatus",
+ "normandyAddonStudy",
+ "search",
+ "storage",
+ "telemetry",
+ "theme",
+ "unlimitedStorage",
+ "urlbar",
+ "webRequest",
+ "webRequestBlocking",
+];
+
+add_task(function test_permissions_have_localization_strings() {
+ let noPromptNames = Schemas.getPermissionNames([
+ "PermissionNoPrompt",
+ "OptionalPermissionNoPrompt",
+ ]);
+ Assert.deepEqual(
+ GRANTED_WITHOUT_USER_PROMPT,
+ noPromptNames,
+ "List of no-prompt permissions is correct."
+ );
+
+ const bundle = Services.strings.createBundle(BROWSER_PROPERTIES);
+
+ for (const perm of Schemas.getPermissionNames()) {
+ try {
+ const str = bundle.GetStringFromName(`webextPerms.description.${perm}`);
+
+ ok(str.length, `Found localization string for '${perm}' permission`);
+ } catch (e) {
+ ok(
+ GRANTED_WITHOUT_USER_PROMPT.includes(perm),
+ `Permission '${perm}' intentionally granted without prompting the user`
+ );
+ }
+ }
+});
+
+// Check <all_urls> used as an optional API permission.
+add_task(async function test_optional_all_urls() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ optional_permissions: ["<all_urls>"],
+ },
+
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ let before = !!browser.tabs.captureVisibleTab;
+ let granted = await browser.permissions.request({
+ origins: ["<all_urls>"],
+ });
+ let after = !!browser.tabs.captureVisibleTab;
+
+ browser.test.sendMessage("results", [before, granted, after]);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ let [before, granted, after] = await extension.awaitMessage("results");
+
+ equal(
+ before,
+ false,
+ "captureVisibleTab() unavailable before optional permission request()"
+ );
+ equal(granted, true, "request() for optional permissions granted");
+ equal(
+ after,
+ true,
+ "captureVisibleTab() available after optional permission request()"
+ );
+ });
+
+ await extension.unload();
+});
+
+// Check that optional permissions are not included in update prompts
+add_task(async function test_permissions_prompt() {
+ function background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("result", result);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["tabs", "https://test1.example.com/*"],
+ optional_permissions: ["clipboardWrite", "<all_urls>"],
+
+ content_scripts: [
+ {
+ matches: ["https://test2.example.com/*"],
+ js: [],
+ },
+ ],
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", {
+ permissions: ["clipboardWrite"],
+ origins: ["https://test2.example.com/*"],
+ });
+ let result = await extension.awaitMessage("result");
+ equal(result, true, "request() for optional permissions succeeded");
+ });
+
+ const PERMS = ["history", "tabs"];
+ const ORIGINS = ["https://test1.example.com/*", "https://test3.example.com/"];
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ background,
+ manifest: {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "2.0",
+
+ applications: { gecko: { id: extension.id } },
+
+ permissions: [...PERMS, ...ORIGINS],
+ optional_permissions: ["clipboardWrite", "<all_urls>"],
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ Services.prefs.setBoolPref("extensions.webextPermissionPrompts", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextPermissionPrompts");
+ });
+
+ let perminfo;
+ install.promptHandler = info => {
+ perminfo = info;
+ return Promise.resolve();
+ };
+
+ await AddonTestUtils.promiseCompleteInstall(install);
+ await extension.awaitStartup();
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+ let perms = perminfo.addon.userPermissions;
+ deepEqual(
+ perms.permissions,
+ PERMS,
+ "Update details includes only manifest api permissions"
+ );
+ deepEqual(
+ perms.origins,
+ ORIGINS,
+ "Update details includes only manifest origin permissions"
+ );
+
+ await extension.unload();
+});
+
+// Check that internal permissions can not be set and are not returned by the API.
+add_task(async function test_internal_permissions() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ function background() {
+ browser.test.onMessage.addListener(async (method, arg) => {
+ try {
+ if (method == "getAll") {
+ let perms = await browser.permissions.getAll();
+ browser.test.sendMessage("getAll.result", perms);
+ } else if (method == "contains") {
+ let result = await browser.permissions.contains(arg);
+ browser.test.sendMessage("contains.result", {
+ status: "success",
+ result,
+ });
+ } else if (method == "request") {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", {
+ status: "success",
+ result,
+ });
+ } else if (method == "remove") {
+ let result = await browser.permissions.remove(arg);
+ browser.test.sendMessage("remove.result", result);
+ }
+ } catch (err) {
+ browser.test.sendMessage(`${method}.result`, {
+ status: "error",
+ message: err.message,
+ });
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+ permissions: [],
+ },
+ useAddonManager: "permanent",
+ incognitoOverride: "spanning",
+ });
+
+ let perm = "internal:privateBrowsingAllowed";
+
+ await extension.startup();
+
+ function call(method, arg) {
+ extension.sendMessage(method, arg);
+ return extension.awaitMessage(`${method}.result`);
+ }
+
+ let result = await call("getAll");
+ ok(!result.permissions.includes(perm), "internal not returned");
+
+ result = await call("contains", { permissions: [perm] });
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ `Unable to check for internal permission: ${result.message}`
+ );
+
+ result = await call("remove", { permissions: [perm] });
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ `Unable to remove for internal permission ${result.message}`
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ result = await call("request", {
+ permissions: [perm],
+ origins: [],
+ });
+ ok(
+ /Type error for parameter permissions \(Error processing permissions/.test(
+ result.message
+ ),
+ `Unable to request internal permission ${result.message}`
+ );
+ });
+
+ await extension.unload();
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js
new file mode 100644
index 0000000000..910aef6df7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js
@@ -0,0 +1,397 @@
+"use strict";
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+const { ExtensionPermissions } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionParent",
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+let OptionalPermissions;
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await AddonTestUtils.promiseStartupManager();
+ AddonTestUtils.usePrivilegedSignatures = false;
+
+ // We want to get a list of optional permissions prior to loading an extension,
+ // so we'll get ExtensionParent to do that for us.
+ await ExtensionParent.apiManager.lazyInit();
+
+ // These permissions have special behaviors and/or are not mapped directly to an
+ // api namespace. They will have their own tests for specific behavior.
+ let ignore = [
+ "activeTab",
+ "clipboardRead",
+ "clipboardWrite",
+ "devtools",
+ "downloads.open",
+ "geolocation",
+ "management",
+ "menus.overrideContext",
+ "search",
+ "tabHide",
+ "tabs",
+ "webRequestBlocking",
+ ];
+ OptionalPermissions = Schemas.getPermissionNames([
+ "OptionalPermission",
+ "OptionalPermissionNoPrompt",
+ ]).filter(n => !ignore.includes(n));
+});
+
+add_task(async function test_api_on_permissions_changed() {
+ async function background() {
+ let manifest = browser.runtime.getManifest();
+ let permObj = { permissions: manifest.optional_permissions, origins: [] };
+
+ function verifyPermissions(enabled) {
+ for (let perm of manifest.optional_permissions) {
+ browser.test.assertEq(
+ enabled,
+ !!browser[perm],
+ `${perm} API is ${
+ enabled ? "injected" : "removed"
+ } after permission request`
+ );
+ }
+ }
+
+ browser.permissions.onAdded.addListener(details => {
+ browser.test.assertEq(
+ JSON.stringify(details.permissions),
+ JSON.stringify(manifest.optional_permissions),
+ "expected permissions added"
+ );
+ verifyPermissions(true);
+ browser.test.sendMessage("added");
+ });
+
+ browser.permissions.onRemoved.addListener(details => {
+ browser.test.assertEq(
+ JSON.stringify(details.permissions),
+ JSON.stringify(manifest.optional_permissions),
+ "expected permissions removed"
+ );
+ verifyPermissions(false);
+ browser.test.sendMessage("removed");
+ });
+
+ browser.test.onMessage.addListener((msg, enabled) => {
+ if (msg === "request") {
+ browser.permissions.request(permObj);
+ } else if (msg === "verify_access") {
+ verifyPermissions(enabled);
+ browser.test.sendMessage("verified");
+ } else if (msg === "revoke") {
+ browser.permissions.remove(permObj);
+ }
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: OptionalPermissions,
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ function addPermissions() {
+ extension.sendMessage("request");
+ return extension.awaitMessage("added");
+ }
+
+ function removePermissions() {
+ extension.sendMessage("revoke");
+ return extension.awaitMessage("removed");
+ }
+
+ function verifyPermissions(enabled) {
+ extension.sendMessage("verify_access", enabled);
+ return extension.awaitMessage("verified");
+ }
+
+ await withHandlingUserInput(extension, async () => {
+ await addPermissions();
+ await removePermissions();
+ await addPermissions();
+ });
+
+ // reset handlingUserInput for the restart
+ extensionHandlers.delete(extension);
+
+ // Verify access on restart
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+ await verifyPermissions(true);
+
+ await withHandlingUserInput(extension, async () => {
+ await removePermissions();
+ });
+
+ // Add private browsing to be sure it doesn't come through.
+ let permObj = {
+ permissions: OptionalPermissions.concat("internal:privateBrowsingAllowed"),
+ origins: [],
+ };
+
+ // enable the permissions while the addon is running
+ await ExtensionPermissions.add(extension.id, permObj, extension.extension);
+ await extension.awaitMessage("added");
+ await verifyPermissions(true);
+
+ // disable the permissions while the addon is running
+ await ExtensionPermissions.remove(extension.id, permObj, extension.extension);
+ await extension.awaitMessage("removed");
+ await verifyPermissions(false);
+
+ // Add private browsing to test internal permission. If it slips through,
+ // we would get an error for an additional added message.
+ await ExtensionPermissions.add(
+ extension.id,
+ { permissions: ["internal:privateBrowsingAllowed"], origins: [] },
+ extension.extension
+ );
+
+ // disable the addon and re-test revoking permissions.
+ await withHandlingUserInput(extension, async () => {
+ await addPermissions();
+ });
+ let addon = await AddonManager.getAddonByID(extension.id);
+ await addon.disable();
+ await ExtensionPermissions.remove(extension.id, permObj);
+ await addon.enable();
+ await extension.awaitStartup();
+
+ await verifyPermissions(false);
+ let perms = await ExtensionPermissions.get(extension.id);
+ equal(perms.permissions.length, 0, "no permissions on startup");
+
+ await extension.unload();
+});
+
+add_task(async function test_geo_permissions() {
+ async function background() {
+ const permObj = { permissions: ["geolocation"] };
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "request") {
+ await browser.permissions.request(permObj);
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ }
+ let result = await browser.permissions.contains(permObj);
+ browser.test.sendMessage("done", result);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: { gecko: { id: "geo-test@test" } },
+ optional_permissions: ["geolocation"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ let principal = policy.extension.principal;
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.UNKNOWN_ACTION,
+ "geolocation not allowed on install"
+ );
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ ok(await extension.awaitMessage("done"), "permission granted");
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.ALLOW_ACTION,
+ "geolocation allowed after requested"
+ );
+
+ extension.sendMessage("remove");
+ ok(!(await extension.awaitMessage("done")), "permission revoked");
+
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.UNKNOWN_ACTION,
+ "geolocation not allowed after removed"
+ );
+
+ // re-grant to test update removal
+ extension.sendMessage("request");
+ ok(await extension.awaitMessage("done"), "permission granted");
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.ALLOW_ACTION,
+ "geolocation allowed after re-requested"
+ );
+ });
+
+ // We should not have geo permission after this upgrade.
+ await extension.upgrade({
+ manifest: {
+ applications: { gecko: { id: "geo-test@test" } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ equal(
+ Services.perms.testPermissionFromPrincipal(principal, "geo"),
+ Services.perms.UNKNOWN_ACTION,
+ "geolocation not allowed after upgrade"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_browserSetting_permissions() {
+ async function background() {
+ const permObj = { permissions: ["browserSettings"] };
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "request") {
+ await browser.permissions.request(permObj);
+ await browser.browserSettings.cacheEnabled.set({ value: false });
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ }
+ browser.test.sendMessage("done");
+ });
+ }
+
+ function cacheIsEnabled() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: ["browserSettings"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ ok(cacheIsEnabled(), "setting is not set after startup");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(!cacheIsEnabled(), "setting was set after request");
+
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(cacheIsEnabled(), "setting is reset after remove");
+
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(!cacheIsEnabled(), "setting was set after request");
+ });
+
+ await ExtensionPermissions._uninit();
+ extensionHandlers.delete(extension);
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(cacheIsEnabled(), "setting is reset after remove");
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_privacy_permissions() {
+ async function background() {
+ const permObj = { permissions: ["privacy"] };
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "request") {
+ await browser.permissions.request(permObj);
+ await browser.privacy.websites.trackingProtectionMode.set({
+ value: "always",
+ });
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ }
+ browser.test.sendMessage("done");
+ });
+ }
+
+ function hasSetting() {
+ return Services.prefs.getBoolPref("privacy.trackingprotection.enabled");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: ["privacy"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ ok(!hasSetting(), "setting is not set after startup");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(hasSetting(), "setting was set after request");
+
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(!hasSetting(), "setting is reset after remove");
+
+ extension.sendMessage("request");
+ await extension.awaitMessage("done");
+ ok(hasSetting(), "setting was set after request");
+ });
+
+ await ExtensionPermissions._uninit();
+ extensionHandlers.delete(extension);
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("remove");
+ await extension.awaitMessage("done");
+ ok(!hasSetting(), "setting is reset after remove");
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js
new file mode 100644
index 0000000000..4b9dccf7b4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js
@@ -0,0 +1,252 @@
+"use strict";
+
+const { ExtensionPermissions } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+ await AddonTestUtils.promiseStartupManager();
+ AddonTestUtils.usePrivilegedSignatures = false;
+});
+
+add_task(async function test_migrated_permission_to_optional() {
+ let id = "permission-upgrade@test";
+ let extensionData = {
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id } },
+ permissions: [
+ "webRequest",
+ "tabs",
+ "http://example.net/*",
+ "http://example.com/*",
+ ],
+ },
+ useAddonManager: "permanent",
+ };
+
+ function checkPermissions() {
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(policy.hasPermission("webRequest"), "addon has webRequest permission");
+ ok(policy.hasPermission("tabs"), "addon has tabs permission");
+ ok(
+ policy.canAccessURI(Services.io.newURI("http://example.net/")),
+ "addon has example.net host permission"
+ );
+ ok(
+ policy.canAccessURI(Services.io.newURI("http://example.com/")),
+ "addon has example.com host permission"
+ );
+ ok(
+ !policy.canAccessURI(Services.io.newURI("http://other.com/")),
+ "addon does not have other.com host permission"
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ checkPermissions();
+
+ // Move to using optional permission
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.permissions = ["tabs", "http://example.net/*"];
+ extensionData.manifest.optional_permissions = [
+ "webRequest",
+ "http://example.com/*",
+ "http://other.com/*",
+ ];
+
+ // Restart the addon manager to flush the AddonInternal instance created
+ // when installing the addon above. See bug 1622117.
+ await AddonTestUtils.promiseRestartManager();
+ await extension.upgrade(extensionData);
+
+ equal(extension.version, "2.0", "Expected extension version");
+ checkPermissions();
+
+ await extension.unload();
+});
+
+// This tests that settings are removed if a required permission is removed.
+// We use two settings APIs to make sure the one we keep permission to is not
+// removed inadvertantly.
+add_task(async function test_required_permissions_removed() {
+ function cacheIsEnabled() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ }
+
+ let extData = {
+ background() {
+ if (browser.browserSettings) {
+ browser.browserSettings.cacheEnabled.set({ value: false });
+ }
+ browser.privacy.services.passwordSavingEnabled.set({ value: false });
+ },
+ manifest: {
+ applications: { gecko: { id: "pref-test@test" } },
+ permissions: ["tabs", "browserSettings", "privacy", "http://test.com/*"],
+ },
+ useAddonManager: "permanent",
+ };
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ ok(
+ Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting intial value as expected"
+ );
+ await extension.startup();
+ ok(!cacheIsEnabled(), "setting is set after startup");
+
+ extData.manifest.permissions = ["tabs"];
+ extData.manifest.optional_permissions = ["privacy"];
+ await extension.upgrade(extData);
+ ok(cacheIsEnabled(), "setting is reset after upgrade");
+ ok(
+ !Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting is still set after upgrade"
+ );
+
+ await extension.unload();
+});
+
+// This tests that settings are removed if a granted permission is removed.
+// We use two settings APIs to make sure the one we keep permission to is not
+// removed inadvertantly.
+add_task(async function test_granted_permissions_removed() {
+ function cacheIsEnabled() {
+ return (
+ Services.prefs.getBoolPref("browser.cache.disk.enable") &&
+ Services.prefs.getBoolPref("browser.cache.memory.enable")
+ );
+ }
+
+ let extData = {
+ async background() {
+ browser.test.onMessage.addListener(async msg => {
+ await browser.permissions.request({ permissions: msg.permissions });
+ if (browser.browserSettings) {
+ browser.browserSettings.cacheEnabled.set({ value: false });
+ }
+ browser.privacy.services.passwordSavingEnabled.set({ value: false });
+ browser.test.sendMessage("done");
+ });
+ },
+ // "tabs" is never granted, it is included to exercise the removal code
+ // that called during the upgrade.
+ manifest: {
+ applications: { gecko: { id: "pref-test@test" } },
+ optional_permissions: [
+ "tabs",
+ "browserSettings",
+ "privacy",
+ "http://test.com/*",
+ ],
+ },
+ useAddonManager: "permanent",
+ };
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ ok(
+ Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting intial value as expected"
+ );
+ await extension.startup();
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage({ permissions: ["browserSettings", "privacy"] });
+ await extension.awaitMessage("done");
+ });
+ ok(!cacheIsEnabled(), "setting is set after startup");
+
+ extData.manifest.permissions = ["privacy"];
+ delete extData.manifest.optional_permissions;
+ await extension.upgrade(extData);
+ ok(cacheIsEnabled(), "setting is reset after upgrade");
+ ok(
+ !Services.prefs.getBoolPref("signon.rememberSignons"),
+ "privacy setting is still set after upgrade"
+ );
+
+ await extension.unload();
+});
+
+// Test an update where an add-on becomes a theme.
+add_task(async function test_addon_to_theme_update() {
+ let id = "theme-test@test";
+ let extData = {
+ manifest: {
+ applications: { gecko: { id } },
+ version: "1.0",
+ optional_permissions: ["tabs"],
+ },
+ async background() {
+ browser.test.onMessage.addListener(async msg => {
+ await browser.permissions.request({ permissions: msg.permissions });
+ browser.test.sendMessage("done");
+ });
+ },
+ useAddonManager: "permanent",
+ };
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage({ permissions: ["tabs"] });
+ await extension.awaitMessage("done");
+ });
+
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(policy.hasPermission("tabs"), "addon has tabs permission");
+
+ await extension.upgrade({
+ manifest: {
+ applications: { gecko: { id } },
+ version: "2.0",
+ theme: {
+ images: {
+ theme_frame: "image1.png",
+ },
+ },
+ },
+ useAddonManager: "permanent",
+ });
+ // When a theme is installed, it starts off in disabled mode, as seen in
+ // toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js .
+ // But if we upgrade from an enabled extension, the theme is enabled.
+ equal(extension.addon.userDisabled, false, "Theme is enabled");
+
+ policy = WebExtensionPolicy.getByID(id);
+ ok(!policy.hasPermission("tabs"), "addon tabs permission was removed");
+ let perms = await ExtensionPermissions._get(id);
+ ok(!perms?.permissions?.length, "no retained permissions");
+
+ extData.manifest.version = "3.0";
+ extData.manifest.permissions = ["privacy"];
+ await extension.upgrade(extData);
+
+ policy = WebExtensionPolicy.getByID(id);
+ ok(!policy.hasPermission("tabs"), "addon tabs permission not added");
+ ok(policy.hasPermission("privacy"), "addon privacy permission added");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js
new file mode 100644
index 0000000000..917a609e32
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js
@@ -0,0 +1,160 @@
+"use strict";
+
+const { ExtensionPermissions } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+const observer = {
+ observe(subject, topic, data) {
+ if (topic == "webextension-optional-permission-prompt") {
+ let { resolve } = subject.wrappedJSObject;
+ resolve(true);
+ }
+ },
+};
+
+// Look up the cached permissions, if any.
+async function getCachedPermissions(extensionId) {
+ const NotFound = Symbol("extension ID not found in permissions cache");
+ try {
+ return await ExtensionParent.StartupCache.permissions.get(
+ extensionId,
+ () => {
+ // Throw error to prevent the key from being created.
+ throw NotFound;
+ }
+ );
+ } catch (e) {
+ if (e === NotFound) {
+ return null;
+ }
+ throw e;
+ }
+}
+
+// Look up the permissions from the file. Internal methods are used to avoid
+// inadvertently changing the permissions in the cache or the database.
+async function getStoredPermissions(extensionId) {
+ if (await ExtensionPermissions._has(extensionId)) {
+ return ExtensionPermissions._get(extensionId);
+ }
+ return null;
+}
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ true
+ );
+ Services.obs.addObserver(observer, "webextension-optional-permission-prompt");
+ await AddonTestUtils.promiseStartupManager();
+ registerCleanupFunction(async () => {
+ await AddonTestUtils.promiseShutdownManager();
+ Services.obs.removeObserver(
+ observer,
+ "webextension-optional-permission-prompt"
+ );
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+});
+
+// This test must run before any restart of the addonmanager so the
+// ExtensionAddonObserver works.
+add_task(async function test_permissions_removed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ optional_permissions: ["idle"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, arg) => {
+ if (msg == "request") {
+ try {
+ let result = await browser.permissions.request(arg);
+ browser.test.sendMessage("request.result", result);
+ } catch (err) {
+ browser.test.sendMessage("request.result", err.message);
+ }
+ }
+ });
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", { permissions: ["idle"], origins: [] });
+ let result = await extension.awaitMessage("request.result");
+ equal(result, true, "request() for optional permissions succeeded");
+ });
+
+ let id = extension.id;
+ let perms = await ExtensionPermissions.get(id);
+ equal(perms.permissions.length, 1, "optional permission added");
+
+ Assert.deepEqual(
+ await getCachedPermissions(id),
+ {
+ permissions: ["idle"],
+ origins: [],
+ },
+ "Optional permission added to cache"
+ );
+ Assert.deepEqual(
+ await getStoredPermissions(id),
+ {
+ permissions: ["idle"],
+ origins: [],
+ },
+ "Optional permission added to persistent file"
+ );
+
+ await extension.unload();
+
+ // Directly read from the internals instead of using ExtensionPermissions.get,
+ // because the latter will lazily cache the extension ID.
+ Assert.deepEqual(
+ await getCachedPermissions(id),
+ null,
+ "Cached permissions removed"
+ );
+ Assert.deepEqual(
+ await getStoredPermissions(id),
+ null,
+ "Stored permissions removed"
+ );
+
+ perms = await ExtensionPermissions.get(id);
+ equal(perms.permissions.length, 0, "no permissions after uninstall");
+ equal(perms.origins.length, 0, "no origin permissions after uninstall");
+
+ // The public ExtensionPermissions.get method should not store (empty)
+ // permissions in the persistent database. Polluting the cache is not ideal,
+ // but acceptable since the cache will eventually be cleared, and non-test
+ // code is not likely to call ExtensionPermissions.get() for non-installed
+ // extensions anyway.
+ Assert.deepEqual(await getCachedPermissions(id), perms, "Permissions cached");
+ Assert.deepEqual(
+ await getStoredPermissions(id),
+ null,
+ "Permissions not saved"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js
new file mode 100644
index 0000000000..7acb383053
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js
@@ -0,0 +1,521 @@
+"use strict";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionAPI } = ExtensionCommon;
+
+const SCHEMA = [
+ {
+ namespace: "eventtest",
+ events: [
+ {
+ name: "onEvent1",
+ type: "function",
+ extraParameters: [{ type: "any" }],
+ },
+ {
+ name: "onEvent2",
+ type: "function",
+ extraParameters: [{ type: "any" }],
+ },
+ ],
+ },
+];
+
+// The code in this class does not actually run in this test scope, it is
+// serialized into a string which is later loaded by the WebExtensions
+// framework in the same context as other extension APIs. By writing it
+// this way rather than as a big string constant we get lint coverage.
+// But eslint doesn't understand that this code runs in a different context
+// where the EventManager class is available so just tell it here:
+/* global EventManager */
+const API = class extends ExtensionAPI {
+ primeListener(extension, event, fire, params) {
+ Services.obs.notifyObservers(
+ { event, fire, params },
+ "prime-event-listener"
+ );
+
+ const FIRE_TOPIC = `fire-${event}`;
+
+ async function listener(subject, topic, data) {
+ try {
+ if (subject.wrappedJSObject.waitForBackground) {
+ await fire.wakeup();
+ }
+ await fire.async(subject.wrappedJSObject.listenerArgs);
+ } catch (err) {
+ let errSubject = { event, errorMessage: err.toString() };
+ Services.obs.notifyObservers(errSubject, "listener-callback-exception");
+ }
+ }
+ Services.obs.addObserver(listener, FIRE_TOPIC);
+
+ return {
+ unregister() {
+ Services.obs.notifyObservers(
+ { event, params },
+ "unregister-primed-listener"
+ );
+ Services.obs.removeObserver(listener, FIRE_TOPIC);
+ },
+ convert(_fire) {
+ Services.obs.notifyObservers(
+ { event, params },
+ "convert-event-listener"
+ );
+ fire = _fire;
+ },
+ };
+ }
+
+ getAPI(context) {
+ return {
+ eventtest: {
+ onEvent1: new EventManager({
+ context,
+ name: "test.event1",
+ persistent: {
+ module: "eventtest",
+ event: "onEvent1",
+ },
+ register: (fire, ...params) => {
+ let data = { event: "onEvent1", params };
+ Services.obs.notifyObservers(data, "register-event-listener");
+ return () => {
+ Services.obs.notifyObservers(data, "unregister-event-listener");
+ };
+ },
+ }).api(),
+
+ onEvent2: new EventManager({
+ context,
+ name: "test.event1",
+ persistent: {
+ module: "eventtest",
+ event: "onEvent2",
+ },
+ register: (fire, ...params) => {
+ let data = { event: "onEvent2", params };
+ Services.obs.notifyObservers(data, "register-event-listener");
+ return () => {
+ Services.obs.notifyObservers(data, "unregister-event-listener");
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
+
+const API_SCRIPT = `this.eventtest = ${API.toString()}`;
+
+const MODULE_INFO = {
+ eventtest: {
+ schema: `data:,${JSON.stringify(SCHEMA)}`,
+ scopes: ["addon_parent"],
+ paths: [["eventtest"]],
+ url: URL.createObjectURL(new Blob([API_SCRIPT])),
+ },
+};
+
+const global = this;
+
+// Wait for the given event (topic) to occur a specific number of times
+// (count). If fn is not supplied, the Promise returned from this function
+// resolves as soon as that many instances of the event have been observed.
+// If fn is supplied, this function also waits for the Promise that fn()
+// returns to complete and ensures that the given event does not occur more
+// than `count` times before then. On success, resolves with an array
+// of the subjects from each of the observed events.
+async function promiseObservable(topic, count, fn = null) {
+ let _countResolve;
+ let results = [];
+ function listener(subject, _topic, data) {
+ results.push(subject.wrappedJSObject);
+ if (results.length > count) {
+ ok(false, `Got unexpected ${topic} event`);
+ } else if (results.length == count) {
+ _countResolve();
+ }
+ }
+ Services.obs.addObserver(listener, topic);
+
+ try {
+ await Promise.all([
+ new Promise(resolve => {
+ _countResolve = resolve;
+ }),
+ fn && fn(),
+ ]);
+ } finally {
+ Services.obs.removeObserver(listener, topic);
+ }
+
+ return results;
+}
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+ );
+
+ AddonTestUtils.init(global);
+ AddonTestUtils.overrideCertDB();
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+ );
+
+ ExtensionParent.apiManager.registerModules(MODULE_INFO);
+});
+
+add_task(async function test_persistent_events() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ let register1 = true,
+ register2 = true;
+ if (localStorage.getItem("skip1")) {
+ register1 = false;
+ }
+ if (localStorage.getItem("skip2")) {
+ register2 = false;
+ }
+
+ let listener1 = arg => browser.test.sendMessage("listener1", arg);
+ let listener2 = arg => browser.test.sendMessage("listener2", arg);
+ let listener3 = arg => browser.test.sendMessage("listener3", arg);
+
+ if (register1) {
+ browser.eventtest.onEvent1.addListener(listener1, "listener1");
+ }
+ if (register2) {
+ browser.eventtest.onEvent1.addListener(listener2, "listener2");
+ browser.eventtest.onEvent2.addListener(listener3, "listener3");
+ }
+
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "unregister2") {
+ browser.eventtest.onEvent2.removeListener(listener3);
+ localStorage.setItem("skip2", true);
+ } else if (msg == "unregister1") {
+ localStorage.setItem("skip1", true);
+ browser.test.sendMessage("unregistered");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ function check(
+ info,
+ what,
+ { listener1 = true, listener2 = true, listener3 = true } = {}
+ ) {
+ let count = (listener1 ? 1 : 0) + (listener2 ? 1 : 0) + (listener3 ? 1 : 0);
+ equal(info.length, count, `Got ${count} ${what} events`);
+
+ let i = 0;
+ if (listener1) {
+ equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 1`);
+ deepEqual(
+ info[i].params,
+ ["listener1"],
+ `Got event1 ${what} args for listener 1`
+ );
+ ++i;
+ }
+
+ if (listener2) {
+ equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 2`);
+ deepEqual(
+ info[i].params,
+ ["listener2"],
+ `Got event1 ${what} args for listener 2`
+ );
+ ++i;
+ }
+
+ if (listener3) {
+ equal(info[i].event, "onEvent2", `Got ${what} on event2 for listener 3`);
+ deepEqual(
+ info[i].params,
+ ["listener3"],
+ `Got event2 ${what} args for listener 3`
+ );
+ ++i;
+ }
+ }
+
+ // Check that the regular event registration process occurs when
+ // the extension is installed.
+ let [info] = await Promise.all([
+ promiseObservable("register-event-listener", 3),
+ extension.startup(),
+ ]);
+ check(info, "register");
+
+ await extension.awaitMessage("ready");
+
+ // Check that the regular unregister process occurs when
+ // the browser shuts down.
+ [info] = await Promise.all([
+ promiseObservable("unregister-event-listener", 3),
+ new Promise(resolve => extension.extension.once("shutdown", resolve)),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ check(info, "unregister");
+
+ // Check that listeners are primed at the next browser startup.
+ [info] = await Promise.all([
+ promiseObservable("prime-event-listener", 3),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ check(info, "prime");
+
+ // Check that primed listeners are converted to regular listeners
+ // when the background page is started after browser startup.
+ let p = promiseObservable("convert-event-listener", 3);
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ info = await p;
+
+ check(info, "convert");
+
+ await extension.awaitMessage("ready");
+
+ // Check that when the event is triggered, all the plumbing worked
+ // correctly for the primed-then-converted listener.
+ let listenerArgs = { test: "kaboom" };
+ Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent1");
+
+ let details = await extension.awaitMessage("listener1");
+ deepEqual(details, listenerArgs, "Listener 1 fired");
+ details = await extension.awaitMessage("listener2");
+ deepEqual(details, listenerArgs, "Listener 2 fired");
+
+ // Check that the converted listener is properly unregistered at
+ // browser shutdown.
+ [info] = await Promise.all([
+ promiseObservable("unregister-primed-listener", 3),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+ check(info, "unregister");
+
+ // Start up again, listener should be primed
+ [info] = await Promise.all([
+ promiseObservable("prime-event-listener", 3),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ check(info, "prime");
+
+ // Check that triggering the event before the listener has been converted
+ // causes the background page to be loaded and the listener to be converted,
+ // and the listener is invoked.
+ p = promiseObservable("convert-event-listener", 3);
+ listenerArgs.test = "startup event";
+ Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent2");
+ info = await p;
+
+ check(info, "convert");
+
+ details = await extension.awaitMessage("listener3");
+ deepEqual(details, listenerArgs, "Listener 3 fired for event during startup");
+
+ await extension.awaitMessage("ready");
+
+ // Check that the unregister process works when we manually remove
+ // a listener.
+ p = promiseObservable("unregister-primed-listener", 1);
+ extension.sendMessage("unregister2");
+ info = await p;
+ check(info, "unregister", { listener1: false, listener2: false });
+
+ // Check that we only get unregisters for the remaining events after
+ // one listener has been removed.
+ info = await promiseObservable("unregister-primed-listener", 2, () =>
+ AddonTestUtils.promiseShutdownManager()
+ );
+ check(info, "unregister", { listener3: false });
+
+ // Check that after restart, only listeners that were present at
+ // the end of the last session are primed.
+ info = await promiseObservable("prime-event-listener", 2, () =>
+ AddonTestUtils.promiseStartupManager()
+ );
+ check(info, "prime", { listener3: false });
+
+ // Check that if the background script does not re-register listeners,
+ // the primed listeners are unregistered after the background page
+ // starts up.
+ p = promiseObservable("unregister-primed-listener", 1, () =>
+ extension.awaitMessage("ready")
+ );
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ info = await p;
+ check(info, "unregister", { listener1: false, listener3: false });
+
+ // Just listener1 should be registered now, fire event1 to confirm.
+ listenerArgs.test = "third time";
+ Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent1");
+ details = await extension.awaitMessage("listener1");
+ deepEqual(details, listenerArgs, "Listener 1 fired");
+
+ // Tell the extension not to re-register listener1 on the next startup
+ extension.sendMessage("unregister1");
+ await extension.awaitMessage("unregistered");
+
+ // Shut down, start up
+ info = await promiseObservable("unregister-primed-listener", 1, () =>
+ AddonTestUtils.promiseShutdownManager()
+ );
+ check(info, "unregister", { listener2: false, listener3: false });
+
+ info = await promiseObservable("prime-event-listener", 1, () =>
+ AddonTestUtils.promiseStartupManager()
+ );
+ check(info, "register", { listener2: false, listener3: false });
+
+ // Check that firing event1 causes the listener fire callback to
+ // reject.
+ p = promiseObservable("listener-callback-exception", 1);
+ Services.obs.notifyObservers(
+ { listenerArgs, waitForBackground: true },
+ "fire-onEvent1"
+ );
+ equal(
+ (await p)[0].errorMessage,
+ "Error: primed listener not re-registered",
+ "Primed listener that was not re-registered received an error when event was triggered during startup"
+ );
+
+ await extension.awaitMessage("ready");
+
+ await extension.unload();
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// This test checks whether primed listeners are correctly unregistered when
+// a background page load is interrupted. In particular, it verifies that the
+// fire.wakeup() and fire.async() promises settle eventually.
+add_task(async function test_shutdown_before_background_loaded() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {
+ let listener = arg => browser.test.sendMessage("triggered", arg);
+ browser.eventtest.onEvent1.addListener(listener, "triggered");
+ browser.test.sendMessage("bg_started");
+ },
+ });
+ await Promise.all([
+ promiseObservable("register-event-listener", 1),
+ extension.startup(),
+ ]);
+ await extension.awaitMessage("bg_started");
+
+ await Promise.all([
+ promiseObservable("unregister-event-listener", 1),
+ new Promise(resolve => extension.extension.once("shutdown", resolve)),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+
+ let primeListenerPromise = promiseObservable("prime-event-listener", 1);
+ let fire;
+ let fireWakeupBeforeBgFail;
+ let fireAsyncBeforeBgFail;
+
+ let bgAbortedPromise = new Promise(resolve => {
+ let Management = ExtensionParent.apiManager;
+ Management.once("extension-browser-inserted", (eventName, browser) => {
+ browser.loadURI = async () => {
+ // The fire.wakeup/fire.async promises created while loading the
+ // background page should settle when the page fails to load.
+ fire = (await primeListenerPromise)[0].fire;
+ fireWakeupBeforeBgFail = fire.wakeup();
+ fireAsyncBeforeBgFail = fire.async();
+
+ extension.extension.once("background-page-aborted", resolve);
+ info("Forcing the background load to fail");
+ browser.remove();
+ };
+ });
+ });
+
+ let unregisterPromise = promiseObservable("unregister-primed-listener", 1);
+
+ await Promise.all([
+ primeListenerPromise,
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ await bgAbortedPromise;
+ info("Loaded extension and aborted load of background page");
+
+ await unregisterPromise;
+ info("Primed listener has been unregistered");
+
+ await fireWakeupBeforeBgFail;
+ info("fire.wakeup() before background load failure should settle");
+
+ await Assert.rejects(
+ fireAsyncBeforeBgFail,
+ /Error: listener not re-registered/,
+ "fire.async before background load failure should be rejected"
+ );
+
+ await fire.wakeup();
+ info("fire.wakeup() after background load failure should settle");
+
+ await Assert.rejects(
+ fire.async(),
+ /Error: primed listener not re-registered/,
+ "fire.async after background load failure should be rejected"
+ );
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // End of the abnormal shutdown test. Now restart the extension to verify
+ // that the persistent listeners have not been unregistered.
+
+ // Suppress background page start until an explicit notification.
+ ExtensionParent._resetStartupPromises();
+ await Promise.all([
+ promiseObservable("prime-event-listener", 1),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+ info("Triggering persistent event to force the background page to start");
+ Services.obs.notifyObservers({ listenerArgs: 123 }, "fire-onEvent1");
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ await extension.awaitMessage("bg_started");
+ equal(await extension.awaitMessage("triggered"), 123, "triggered event");
+
+ await Promise.all([
+ promiseObservable("unregister-primed-listener", 1),
+ AddonTestUtils.promiseShutdownManager(),
+ ]);
+
+ // And lastly, verify that a primed listener is correctly removed when the
+ // extension unloads normally before the delayed background page can load.
+ ExtensionParent._resetStartupPromises();
+ await Promise.all([
+ promiseObservable("prime-event-listener", 1),
+ AddonTestUtils.promiseStartupManager(),
+ ]);
+
+ info("Unloading extension before background page has loaded");
+ await Promise.all([
+ promiseObservable("unregister-primed-listener", 1),
+ extension.unload(),
+ ]);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js
new file mode 100644
index 0000000000..14a18b8fac
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js
@@ -0,0 +1,964 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionPreferencesManager",
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// Currently security.tls.version.min has a different default
+// value in Nightly and Beta as opposed to Release builds.
+const tlsMinPref = Services.prefs.getIntPref("security.tls.version.min");
+if (tlsMinPref != 1 && tlsMinPref != 3) {
+ ok(false, "This test expects security.tls.version.min set to 1 or 3.");
+}
+const tlsMinVer = tlsMinPref === 3 ? "TLSv1.2" : "TLSv1";
+
+add_task(async function test_privacy() {
+ // Create an object to hold the values to which we will initialize the prefs.
+ const SETTINGS = {
+ "network.networkPredictionEnabled": {
+ "network.predictor.enabled": true,
+ "network.prefetch-next": true,
+ // This pref starts with a numerical value and we need to use whatever the
+ // default is or we encounter issues when the pref is reset during the test.
+ "network.http.speculative-parallel-limit": ExtensionPreferencesManager.getDefaultValue(
+ "network.http.speculative-parallel-limit"
+ ),
+ "network.dns.disablePrefetch": false,
+ },
+ "websites.hyperlinkAuditingEnabled": {
+ "browser.send_pings": true,
+ },
+ };
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let data = args[0];
+ // The second argument is the end of the api name,
+ // e.g., "network.networkPredictionEnabled".
+ let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy);
+ let settingData;
+ switch (msg) {
+ case "get":
+ settingData = await apiObj.get(data);
+ browser.test.sendMessage("gotData", settingData);
+ break;
+
+ case "set":
+ await apiObj.set(data);
+ settingData = await apiObj.get({});
+ browser.test.sendMessage("afterSet", settingData);
+ break;
+
+ case "clear":
+ await apiObj.clear(data);
+ settingData = await apiObj.get({});
+ browser.test.sendMessage("afterClear", settingData);
+ break;
+ }
+ });
+ }
+
+ // Set prefs to our initial values.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.set(pref, SETTINGS[setting][pref]);
+ }
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.reset(pref);
+ }
+ }
+ });
+
+ await promiseStartupManager();
+
+ // Create an array of extensions to install.
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ for (let setting in SETTINGS) {
+ testExtensions[0].sendMessage("get", {}, setting);
+ let data = await testExtensions[0].awaitMessage("gotData");
+ ok(data.value, "get returns expected value.");
+ equal(
+ data.levelOfControl,
+ "controllable_by_this_extension",
+ "get returns expected levelOfControl."
+ );
+
+ testExtensions[0].sendMessage("get", { incognito: true }, setting);
+ data = await testExtensions[0].awaitMessage("gotData");
+ ok(data.value, "get returns expected value with incognito.");
+ equal(
+ data.levelOfControl,
+ "not_controllable",
+ "get returns expected levelOfControl with incognito."
+ );
+
+ // Change the value to false.
+ testExtensions[0].sendMessage("set", { value: false }, setting);
+ data = await testExtensions[0].awaitMessage("afterSet");
+ ok(!data.value, "get returns expected value after setting.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "get returns expected levelOfControl after setting."
+ );
+
+ // Verify the prefs have been set to match the "false" setting.
+ for (let pref in SETTINGS[setting]) {
+ let msg = `${pref} set correctly for ${setting}`;
+ if (pref === "network.http.speculative-parallel-limit") {
+ equal(Preferences.get(pref), 0, msg);
+ } else {
+ equal(Preferences.get(pref), !SETTINGS[setting][pref], msg);
+ }
+ }
+
+ // Change the value with a newer extension.
+ testExtensions[1].sendMessage("set", { value: true }, setting);
+ data = await testExtensions[1].awaitMessage("afterSet");
+ ok(
+ data.value,
+ "get returns expected value after setting via newer extension."
+ );
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "get returns expected levelOfControl after setting."
+ );
+
+ // Verify the prefs have been set to match the "true" setting.
+ for (let pref in SETTINGS[setting]) {
+ let msg = `${pref} set correctly for ${setting}`;
+ if (pref === "network.http.speculative-parallel-limit") {
+ equal(
+ Preferences.get(pref),
+ ExtensionPreferencesManager.getDefaultValue(pref),
+ msg
+ );
+ } else {
+ equal(Preferences.get(pref), SETTINGS[setting][pref], msg);
+ }
+ }
+
+ // Change the value with an older extension.
+ testExtensions[0].sendMessage("set", { value: false }, setting);
+ data = await testExtensions[0].awaitMessage("afterSet");
+ ok(data.value, "Newer extension remains in control.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_other_extensions",
+ "get returns expected levelOfControl when controlled by other."
+ );
+
+ // Clear the value of the newer extension.
+ testExtensions[1].sendMessage("clear", {}, setting);
+ data = await testExtensions[1].awaitMessage("afterClear");
+ ok(!data.value, "Older extension gains control.");
+ equal(
+ data.levelOfControl,
+ "controllable_by_this_extension",
+ "Expected levelOfControl returned after clearing."
+ );
+
+ testExtensions[0].sendMessage("get", {}, setting);
+ data = await testExtensions[0].awaitMessage("gotData");
+ ok(!data.value, "Current, older extension has control.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "Expected levelOfControl returned after clearing."
+ );
+
+ // Set the value again with the newer extension.
+ testExtensions[1].sendMessage("set", { value: true }, setting);
+ data = await testExtensions[1].awaitMessage("afterSet");
+ ok(
+ data.value,
+ "get returns expected value after setting via newer extension."
+ );
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "get returns expected levelOfControl after setting."
+ );
+
+ // Unload the newer extension. Expect the older extension to regain control.
+ await testExtensions[1].unload();
+ testExtensions[0].sendMessage("get", {}, setting);
+ data = await testExtensions[0].awaitMessage("gotData");
+ ok(!data.value, "Older extension regained control.");
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ "Expected levelOfControl returned after unloading."
+ );
+
+ // Reload the extension for the next iteration of the loop.
+ testExtensions[1] = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ });
+ await testExtensions[1].startup();
+
+ // Clear the value of the older extension.
+ testExtensions[0].sendMessage("clear", {}, setting);
+ data = await testExtensions[0].awaitMessage("afterClear");
+ ok(data.value, "Setting returns to original value when all are cleared.");
+ equal(
+ data.levelOfControl,
+ "controllable_by_this_extension",
+ "Expected levelOfControl returned after clearing."
+ );
+
+ // Verify that our initial values were restored.
+ for (let pref in SETTINGS[setting]) {
+ equal(
+ Preferences.get(pref),
+ SETTINGS[setting][pref],
+ `${pref} was reset to its initial value.`
+ );
+ }
+ }
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_privacy_other_prefs() {
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.tls.version.min");
+ Services.prefs.clearUserPref("security.tls.version.max");
+ });
+
+ const cookieSvc = Ci.nsICookieService;
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const SETTINGS = {
+ "network.webRTCIPHandlingPolicy": {
+ "media.peerconnection.ice.default_address_only": false,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ },
+ "network.tlsVersionRestriction": {
+ "security.tls.version.min": tlsMinPref,
+ "security.tls.version.max": 4,
+ },
+ "network.peerConnectionEnabled": {
+ "media.peerconnection.enabled": true,
+ },
+ "services.passwordSavingEnabled": {
+ "signon.rememberSignons": true,
+ },
+ "websites.referrersEnabled": {
+ "network.http.sendRefererHeader": 2,
+ },
+ "websites.resistFingerprinting": {
+ "privacy.resistFingerprinting": true,
+ },
+ "websites.firstPartyIsolate": {
+ "privacy.firstparty.isolate": false,
+ },
+ "websites.cookieConfig": {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ };
+
+ let defaultPrefs = new Preferences({ defaultBranch: true });
+ let defaultCookieBehavior = defaultPrefs.get("network.cookie.cookieBehavior");
+ let defaultBehavior;
+ switch (defaultCookieBehavior) {
+ case cookieSvc.BEHAVIOR_ACCEPT:
+ defaultBehavior = "allow_all";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT_FOREIGN:
+ defaultBehavior = "reject_third_party";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT:
+ defaultBehavior = "reject_all";
+ break;
+ case cookieSvc.BEHAVIOR_LIMIT_FOREIGN:
+ defaultBehavior = "allow_visited";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT_TRACKER:
+ defaultBehavior = "reject_trackers";
+ break;
+ case cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
+ defaultBehavior = "reject_trackers_and_partition_foreign";
+ break;
+ default:
+ ok(
+ false,
+ `Unexpected cookie behavior encountered: ${defaultCookieBehavior}`
+ );
+ break;
+ }
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let data = args[0];
+ // The second argument is the end of the api name,
+ // e.g., "network.webRTCIPHandlingPolicy".
+ let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy);
+ let settingData;
+ switch (msg) {
+ case "set":
+ try {
+ await apiObj.set(data);
+ } catch (e) {
+ browser.test.sendMessage("settingThrowsException", {
+ message: e.message,
+ });
+ break;
+ }
+ settingData = await apiObj.get({});
+ browser.test.sendMessage("settingData", settingData);
+ break;
+ case "get":
+ settingData = await apiObj.get({});
+ browser.test.sendMessage("gettingData", settingData);
+ break;
+ }
+ });
+ }
+
+ // Set prefs to our initial values.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.set(pref, SETTINGS[setting][pref]);
+ }
+ }
+
+ registerCleanupFunction(() => {
+ // Reset the prefs.
+ for (let setting in SETTINGS) {
+ for (let pref in SETTINGS[setting]) {
+ Preferences.reset(pref);
+ }
+ }
+ });
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ async function testSetting(setting, value, expected, expectedValue = value) {
+ extension.sendMessage("set", { value: value }, setting);
+ let data = await extension.awaitMessage("settingData");
+ deepEqual(
+ data.value,
+ expectedValue,
+ `Got expected result on setting ${setting} to ${uneval(value)}`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} set correctly for ${expected[pref]}`
+ );
+ }
+ }
+
+ async function testSettingException(setting, value, expected) {
+ extension.sendMessage("set", { value: value }, setting);
+ let data = await extension.awaitMessage("settingThrowsException");
+ equal(data.message, expected);
+ }
+
+ async function testGetting(getting, expected, expectedValue) {
+ extension.sendMessage("get", null, getting);
+ let data = await extension.awaitMessage("gettingData");
+ deepEqual(
+ data.value,
+ expectedValue,
+ `Got expected result on getting ${getting}`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} get correctly for ${expected[pref]}`
+ );
+ }
+ }
+
+ await testSetting(
+ "network.webRTCIPHandlingPolicy",
+ "default_public_and_private_interfaces",
+ {
+ "media.peerconnection.ice.default_address_only": true,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ }
+ );
+ await testSetting(
+ "network.webRTCIPHandlingPolicy",
+ "default_public_interface_only",
+ {
+ "media.peerconnection.ice.default_address_only": true,
+ "media.peerconnection.ice.no_host": true,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ }
+ );
+ await testSetting(
+ "network.webRTCIPHandlingPolicy",
+ "disable_non_proxied_udp",
+ {
+ "media.peerconnection.ice.default_address_only": true,
+ "media.peerconnection.ice.no_host": true,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": true,
+ "media.peerconnection.ice.proxy_only": false,
+ }
+ );
+ await testSetting("network.webRTCIPHandlingPolicy", "proxy_only", {
+ "media.peerconnection.ice.default_address_only": false,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": true,
+ });
+ await testSetting("network.webRTCIPHandlingPolicy", "default", {
+ "media.peerconnection.ice.default_address_only": false,
+ "media.peerconnection.ice.no_host": false,
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": false,
+ "media.peerconnection.ice.proxy_only": false,
+ });
+
+ await testSetting("network.peerConnectionEnabled", false, {
+ "media.peerconnection.enabled": false,
+ });
+ await testSetting("network.peerConnectionEnabled", true, {
+ "media.peerconnection.enabled": true,
+ });
+
+ await testSetting("websites.referrersEnabled", false, {
+ "network.http.sendRefererHeader": 0,
+ });
+ await testSetting("websites.referrersEnabled", true, {
+ "network.http.sendRefererHeader": 2,
+ });
+
+ await testSetting("websites.resistFingerprinting", false, {
+ "privacy.resistFingerprinting": false,
+ });
+ await testSetting("websites.resistFingerprinting", true, {
+ "privacy.resistFingerprinting": true,
+ });
+
+ await testSetting("websites.trackingProtectionMode", "always", {
+ "privacy.trackingprotection.enabled": true,
+ "privacy.trackingprotection.pbmode.enabled": true,
+ });
+ await testSetting("websites.trackingProtectionMode", "never", {
+ "privacy.trackingprotection.enabled": false,
+ "privacy.trackingprotection.pbmode.enabled": false,
+ });
+ await testSetting("websites.trackingProtectionMode", "private_browsing", {
+ "privacy.trackingprotection.enabled": false,
+ "privacy.trackingprotection.pbmode.enabled": true,
+ });
+
+ await testSetting("services.passwordSavingEnabled", false, {
+ "signon.rememberSignons": false,
+ });
+ await testSetting("services.passwordSavingEnabled", true, {
+ "signon.rememberSignons": true,
+ });
+
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_third_party", nonPersistentCookies: true },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION,
+ }
+ );
+ // A missing nonPersistentCookies property should default to false.
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_third_party" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: "reject_third_party", nonPersistentCookies: false }
+ );
+ // A missing behavior property should reset the pref.
+ await testSetting(
+ "websites.cookieConfig",
+ { nonPersistentCookies: true },
+ {
+ "network.cookie.cookieBehavior": defaultCookieBehavior,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION,
+ },
+ { behavior: defaultBehavior, nonPersistentCookies: true }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_all" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: "reject_all", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "allow_visited" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_LIMIT_FOREIGN,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: "allow_visited", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "allow_all" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: "allow_all", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { nonPersistentCookies: true },
+ {
+ "network.cookie.cookieBehavior": defaultCookieBehavior,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION,
+ },
+ { behavior: defaultBehavior, nonPersistentCookies: true }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { nonPersistentCookies: false },
+ {
+ "network.cookie.cookieBehavior": defaultCookieBehavior,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: defaultBehavior, nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: "reject_trackers", nonPersistentCookies: false }
+ );
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers_and_partition_foreign" },
+ {
+ "network.cookie.cookieBehavior":
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ {
+ behavior: "reject_trackers_and_partition_foreign",
+ nonPersistentCookies: false,
+ }
+ );
+
+ // 1. Can't enable FPI when cookie behavior is "reject_trackers_and_partition_foreign"
+ await testSettingException(
+ "websites.firstPartyIsolate",
+ true,
+ "Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign'"
+ );
+
+ // 2. Change cookieConfig to reject_trackers should work normally.
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers" },
+ {
+ "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ { behavior: "reject_trackers", nonPersistentCookies: false }
+ );
+
+ // 3. Enable FPI
+ await testSetting("websites.firstPartyIsolate", true, {
+ "privacy.firstparty.isolate": true,
+ });
+
+ // 4. When FPI is enabled, change setting to "reject_trackers_and_partition_foreign" is invalid
+ await testSettingException(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers_and_partition_foreign" },
+ "Invalid cookieConfig 'reject_trackers_and_partition_foreign' when firstPartyIsolate is enabled"
+ );
+
+ // 5. Set conflict settings manually and check prefs.
+ Preferences.set("network.cookie.cookieBehavior", 5);
+ await testGetting(
+ "websites.firstPartyIsolate",
+ { "privacy.firstparty.isolate": true },
+ true
+ );
+ await testGetting(
+ "websites.cookieConfig",
+ {
+ "network.cookie.cookieBehavior":
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ {
+ behavior: "reject_trackers_and_partition_foreign",
+ nonPersistentCookies: false,
+ }
+ );
+
+ // 6. It is okay to set current saved value.
+ await testSetting("websites.firstPartyIsolate", true, {
+ "privacy.firstparty.isolate": true,
+ });
+ await testSetting(
+ "websites.cookieConfig",
+ { behavior: "reject_trackers_and_partition_foreign" },
+ {
+ "network.cookie.cookieBehavior":
+ cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY,
+ },
+ {
+ behavior: "reject_trackers_and_partition_foreign",
+ nonPersistentCookies: false,
+ }
+ );
+
+ await testSetting("websites.firstPartyIsolate", false, {
+ "privacy.firstparty.isolate": false,
+ });
+
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.2",
+ maximum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 4,
+ }
+ );
+
+ // Single values
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 4,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.3",
+ }
+ );
+
+ // Single values
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 4,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.3",
+ }
+ );
+
+ // Invalid values.
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "invalid",
+ maximum: "invalid",
+ },
+ "Setting TLS version invalid is not allowed for security reasons."
+ );
+
+ // Invalid values.
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "invalid2",
+ },
+ "Setting TLS version invalid2 is not allowed for security reasons."
+ );
+
+ // Invalid values.
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "invalid3",
+ },
+ "Setting TLS version invalid3 is not allowed for security reasons."
+ );
+
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.2",
+ },
+ {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: "TLSv1.2",
+ maximum: "TLSv1.3",
+ }
+ );
+
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1.2",
+ },
+ {
+ "security.tls.version.min": tlsMinPref,
+ "security.tls.version.max": 3,
+ },
+ {
+ minimum: tlsMinVer,
+ maximum: "TLSv1.2",
+ }
+ );
+
+ // Not supported version.
+ if (tlsMinPref === 3) {
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1",
+ },
+ "Setting TLS version TLSv1 is not allowed for security reasons."
+ );
+
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.1",
+ },
+ "Setting TLS version TLSv1.1 is not allowed for security reasons."
+ );
+
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1",
+ },
+ "Setting TLS version TLSv1 is not allowed for security reasons."
+ );
+
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1.1",
+ },
+ "Setting TLS version TLSv1.1 is not allowed for security reasons."
+ );
+ }
+
+ // Min vs Max
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.2",
+ },
+ "Setting TLS min version grater than the max version is not allowed."
+ );
+
+ // Min vs Max (with default max)
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.2",
+ maximum: "TLSv1.2",
+ },
+ {
+ "security.tls.version.min": 3,
+ "security.tls.version.max": 3,
+ }
+ );
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ },
+ "Setting TLS min version grater than the max version is not allowed."
+ );
+
+ // Max vs Min
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {
+ minimum: "TLSv1.3",
+ maximum: "TLSv1.3",
+ },
+ {
+ "security.tls.version.min": 4,
+ "security.tls.version.max": 4,
+ }
+ );
+ await testSettingException(
+ "network.tlsVersionRestriction",
+ {
+ maximum: "TLSv1.2",
+ },
+ "Setting TLS max version lower than the min version is not allowed."
+ );
+
+ // Empty value.
+ await testSetting(
+ "network.tlsVersionRestriction",
+ {},
+ {
+ "security.tls.version.min": tlsMinPref,
+ "security.tls.version.max": 4,
+ },
+ {
+ minimum: tlsMinVer,
+ maximum: "TLSv1.3",
+ }
+ );
+
+ const HTTPS_ONLY_PREF_NAME = "dom.security.https_only_mode";
+ const HTTPS_ONLY_PBM_PREF_NAME = "dom.security.https_only_mode_pbm";
+
+ Preferences.set(HTTPS_ONLY_PREF_NAME, false);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false);
+ await testGetting("network.httpsOnlyMode", {}, "never");
+
+ Preferences.set(HTTPS_ONLY_PREF_NAME, true);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false);
+ await testGetting("network.httpsOnlyMode", {}, "always");
+
+ Preferences.set(HTTPS_ONLY_PREF_NAME, false);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true);
+ await testGetting("network.httpsOnlyMode", {}, "private_browsing");
+
+ // Please note that if https_only_mode = true, then
+ // https_only_mode_pbm has no effect.
+ Preferences.set(HTTPS_ONLY_PREF_NAME, true);
+ Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true);
+ await testGetting("network.httpsOnlyMode", {}, "always");
+
+ // trying to "set" should have no effect when readonly!
+ extension.sendMessage("set", { value: "never" }, "network.httpsOnlyMode");
+ let readOnlyData = await extension.awaitMessage("settingData");
+ equal(readOnlyData.value, "always");
+
+ equal(Preferences.get(HTTPS_ONLY_PREF_NAME), true);
+ equal(Preferences.get(HTTPS_ONLY_PBM_PREF_NAME), true);
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_exceptions() {
+ async function background() {
+ await browser.test.assertRejects(
+ browser.privacy.network.networkPredictionEnabled.set({
+ value: true,
+ scope: "regular_only",
+ }),
+ "Firefox does not support the regular_only settings scope.",
+ "Expected rejection calling set with invalid scope."
+ );
+
+ await browser.test.assertRejects(
+ browser.privacy.network.networkPredictionEnabled.clear({
+ scope: "incognito_persistent",
+ }),
+ "Firefox does not support the incognito_persistent settings scope.",
+ "Expected rejection calling clear with invalid scope."
+ );
+
+ browser.test.notifyPass("exceptionTests");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["privacy"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("exceptionTests");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js
new file mode 100644
index 0000000000..ff0d4d9d48
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js
@@ -0,0 +1,201 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+ // eslint-disable-next-line no-shadow
+ const { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+ );
+ return Management;
+});
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionPreferencesManager",
+ "resource://gre/modules/ExtensionPreferencesManager.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function awaitEvent(eventName) {
+ return new Promise(resolve => {
+ let listener = (_eventName, ...args) => {
+ if (_eventName === eventName) {
+ Management.off(eventName, listener);
+ resolve(...args);
+ }
+ };
+
+ Management.on(eventName, listener);
+ });
+}
+
+function awaitPrefChange(prefName) {
+ return new Promise(resolve => {
+ let listener = args => {
+ Preferences.ignore(prefName, listener);
+ resolve();
+ };
+
+ Preferences.observe(prefName, listener);
+ });
+}
+
+add_task(async function test_disable() {
+ const OLD_ID = "old_id@tests.mozilla.org";
+ const NEW_ID = "new_id@tests.mozilla.org";
+
+ const PREF_TO_WATCH = "network.http.speculative-parallel-limit";
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "network.predictor.enabled": true,
+ "network.prefetch-next": true,
+ "network.http.speculative-parallel-limit": 10,
+ "network.dns.disablePrefetch": false,
+ };
+
+ // 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);
+ }
+ });
+
+ function checkPrefs(expected) {
+ for (let pref in PREFS) {
+ let msg = `${pref} set correctly.`;
+ let expectedValue = expected ? PREFS[pref] : !PREFS[pref];
+ if (pref === "network.http.speculative-parallel-limit") {
+ expectedValue = expected
+ ? ExtensionPreferencesManager.getDefaultValue(pref)
+ : 0;
+ }
+ equal(Preferences.get(pref), expectedValue, msg);
+ }
+ }
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ await browser.privacy.network.networkPredictionEnabled.set(data);
+ let settingData = await browser.privacy.network.networkPredictionEnabled.get(
+ {}
+ );
+ browser.test.sendMessage("privacyData", settingData);
+ });
+ }
+
+ await promiseStartupManager();
+
+ let testExtensions = [
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: {
+ gecko: {
+ id: OLD_ID,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+
+ ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ applications: {
+ gecko: {
+ id: NEW_ID,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ useAddonManager: "temporary",
+ }),
+ ];
+
+ for (let extension of testExtensions) {
+ await extension.startup();
+ }
+
+ // Set the value to true for the older extension.
+ testExtensions[0].sendMessage("set", { value: true });
+ let data = await testExtensions[0].awaitMessage("privacyData");
+ ok(data.value, "Value set to true for the older extension.");
+
+ // Set the value to false for the newest extension.
+ testExtensions[1].sendMessage("set", { value: false });
+ data = await testExtensions[1].awaitMessage("privacyData");
+ ok(!data.value, "Value set to false for the newest extension.");
+
+ // Verify the prefs have been set to match the "false" setting.
+ checkPrefs(false);
+
+ // Disable the newest extension.
+ let disabledPromise = awaitPrefChange(PREF_TO_WATCH);
+ let newAddon = await AddonManager.getAddonByID(NEW_ID);
+ await newAddon.disable();
+ await disabledPromise;
+
+ // Verify the prefs have been set to match the "true" setting.
+ checkPrefs(true);
+
+ // Disable the older extension.
+ disabledPromise = awaitPrefChange(PREF_TO_WATCH);
+ let oldAddon = await AddonManager.getAddonByID(OLD_ID);
+ await oldAddon.disable();
+ await disabledPromise;
+
+ // Verify the prefs have reverted back to their initial values.
+ for (let pref in PREFS) {
+ equal(Preferences.get(pref), PREFS[pref], `${pref} reset correctly.`);
+ }
+
+ // Re-enable the newest extension.
+ let enabledPromise = awaitEvent("ready");
+ await newAddon.enable();
+ await enabledPromise;
+
+ // Verify the prefs have been set to match the "false" setting.
+ checkPrefs(false);
+
+ // Re-enable the older extension.
+ enabledPromise = awaitEvent("ready");
+ await oldAddon.enable();
+ await enabledPromise;
+
+ // Verify the prefs have remained set to match the "false" setting.
+ checkPrefs(false);
+
+ for (let extension of testExtensions) {
+ await extension.unload();
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js
new file mode 100644
index 0000000000..8b9ae6be9c
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js
@@ -0,0 +1,167 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const {
+ createAppInfo,
+ createTempWebExtensionFile,
+ promiseCompleteAllInstalls,
+ promiseFindAddonUpdates,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+add_task(async function test_privacy_update() {
+ // Create a object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "network.predictor.enabled": true,
+ "network.prefetch-next": true,
+ "network.http.speculative-parallel-limit": 10,
+ "network.dns.disablePrefetch": false,
+ };
+
+ const EXTENSION_ID = "test_privacy_addon_update@tests.mozilla.org";
+ const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+
+ // 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);
+ }
+ });
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, data) => {
+ let settingData;
+ switch (msg) {
+ case "get":
+ settingData = await browser.privacy.network.networkPredictionEnabled.get(
+ {}
+ );
+ browser.test.sendMessage("privacyData", settingData);
+ break;
+
+ case "set":
+ await browser.privacy.network.networkPredictionEnabled.set(data);
+ settingData = await browser.privacy.network.networkPredictionEnabled.get(
+ {}
+ );
+ browser.test.sendMessage("privacyData", settingData);
+ break;
+ }
+ });
+ }
+
+ const testServer = createHttpServer();
+ const port = testServer.identity.primaryPort;
+
+ // The test extension uses an insecure update url.
+ Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+ testServer.registerPathHandler("/test_update.json", (request, response) => {
+ response.write(`{
+ "addons": {
+ "${EXTENSION_ID}": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://localhost:${port}/addons/test_privacy-2.0.xpi"
+ }
+ ]
+ }
+ }
+ }`);
+ });
+
+ let webExtensionFile = createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ background,
+ });
+
+ testServer.registerFile("/addons/test_privacy-2.0.xpi", webExtensionFile);
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: `http://localhost:${port}/test_update.json`,
+ },
+ },
+ permissions: ["privacy"],
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ // Change the value to false.
+ extension.sendMessage("set", { value: false });
+ let data = await extension.awaitMessage("privacyData");
+ ok(!data.value, "get returns expected value after setting.");
+
+ equal(
+ extension.version,
+ "1.0",
+ "The installed addon has the expected version."
+ );
+
+ let update = await promiseFindAddonUpdates(extension.addon);
+ let install = update.updateAvailable;
+
+ await promiseCompleteAllInstalls([install]);
+
+ await extension.awaitStartup();
+
+ equal(
+ extension.version,
+ "2.0",
+ "The updated addon has the expected version."
+ );
+
+ extension.sendMessage("get");
+ data = await extension.awaitMessage("privacyData");
+ ok(!data.value, "get returns expected value after updating.");
+
+ // Verify the prefs are still set to match the "false" setting.
+ for (let pref in PREFS) {
+ let msg = `${pref} set correctly.`;
+ let expectedValue =
+ pref === "network.http.speculative-parallel-limit" ? 0 : !PREFS[pref];
+ equal(Preferences.get(pref), expectedValue, msg);
+ }
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js
new file mode 100644
index 0000000000..27f537b73b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js
@@ -0,0 +1,116 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "authManager",
+ "@mozilla.org/network/http-auth-manager;1",
+ "nsIHttpAuthManager"
+);
+
+const proxy = createHttpServer();
+const proxyToken = "this_is_my_pass";
+
+// accept proxy connections for mozilla.org
+proxy.identity.add("http", "mozilla.org", 80);
+
+proxy.registerPathHandler("/", (request, response) => {
+ if (request.hasHeader("Proxy-Authorization")) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write(request.getHeader("Proxy-Authorization"));
+ } else {
+ response.setStatusLine(
+ request.httpVersion,
+ 407,
+ "Proxy authentication required"
+ );
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Proxy-Authenticate", "UnknownMeantToFail", false);
+ response.write("auth required");
+ }
+});
+
+function getExtension(background) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${proxy.identity.primaryPort}, "${proxyToken}")`,
+ });
+}
+
+add_task(async function test_webRequest_auth_proxy() {
+ function background(port, proxyToken) {
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "",
+ details.proxyInfo.username,
+ "proxy username not set"
+ );
+ browser.test.assertEq(
+ proxyToken,
+ details.proxyInfo.proxyAuthorizationHeader,
+ "proxy authorization header"
+ );
+ browser.test.assertEq(
+ proxyToken,
+ details.proxyInfo.connectionIsolationKey,
+ "proxy connection isolation"
+ );
+
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ // Using proxyAuthorizationHeader should prevent an auth request coming to us in the extension.
+ browser.test.fail("onAuthRequired");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return [
+ {
+ host: "localhost",
+ port,
+ type: "http",
+ proxyAuthorizationHeader: proxyToken,
+ connectionIsolationKey: proxyToken,
+ },
+ ];
+ },
+ { urls: ["<all_urls>"] },
+ ["requestHeaders"]
+ );
+ }
+
+ let extension = getExtension(background);
+
+ await extension.startup();
+
+ authManager.clearAll();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://mozilla.org/`
+ );
+
+ await extension.awaitFinish("requestCompleted");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js
new file mode 100644
index 0000000000..953bf4bea5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js
@@ -0,0 +1,633 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Preferences",
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const { ExtensionPermissions } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy
+ // storage mode will run in xpcshell-legacy-ep.ini
+ await ExtensionPermissions._uninit();
+
+ Services.prefs.setBoolPref(
+ "extensions.webextOptionalPermissionPrompts",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts");
+ });
+
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_browser_settings() {
+ const proxySvc = Ci.nsIProtocolProxyService;
+
+ // Create an object to hold the values to which we will initialize the prefs.
+ const PREFS = {
+ "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+ "network.proxy.http": "",
+ "network.proxy.http_port": 0,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ftp": "",
+ "network.proxy.ftp_port": 0,
+ "network.proxy.ssl": "",
+ "network.proxy.ssl_port": 0,
+ "network.proxy.socks": "",
+ "network.proxy.socks_port": 0,
+ "network.proxy.socks_version": 5,
+ "network.proxy.socks_remote_dns": false,
+ "network.proxy.no_proxies_on": "",
+ "network.proxy.autoconfig_url": "",
+ "signon.autologin.proxy": false,
+ };
+
+ async function background() {
+ browser.test.onMessage.addListener(async (msg, value) => {
+ let apiObj = browser.proxy.settings;
+ 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: ["proxy"],
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ async function testSetting(value, expected, expectedValue = value) {
+ extension.sendMessage("set", value);
+ let data = await extension.awaitMessage("settingData");
+ deepEqual(data.value, expectedValue, `The setting has the expected value.`);
+ equal(
+ data.levelOfControl,
+ "controlled_by_this_extension",
+ `The setting has the expected levelOfControl.`
+ );
+ for (let pref in expected) {
+ equal(
+ Preferences.get(pref),
+ expected[pref],
+ `${pref} set correctly for ${value}`
+ );
+ }
+ }
+
+ async function testProxy(config, expectedPrefs, expectedConfig = config) {
+ // proxy.settings is not supported on Android.
+ if (AppConstants.platform === "android") {
+ return Promise.resolve();
+ }
+
+ let proxyConfig = {
+ proxyType: "none",
+ autoConfigUrl: "",
+ autoLogin: false,
+ proxyDNS: false,
+ httpProxyAll: false,
+ socksVersion: 5,
+ passthrough: "",
+ http: "",
+ ftp: "",
+ ssl: "",
+ socks: "",
+ respectBeConservative: true,
+ };
+
+ expectedConfig.proxyType = expectedConfig.proxyType || "system";
+
+ return testSetting(
+ config,
+ expectedPrefs,
+ Object.assign(proxyConfig, expectedConfig)
+ );
+ }
+
+ await testProxy(
+ { proxyType: "none" },
+ { "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT }
+ );
+
+ await testProxy(
+ {
+ proxyType: "autoDetect",
+ autoLogin: true,
+ proxyDNS: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_WPAD,
+ "signon.autologin.proxy": true,
+ "network.proxy.socks_remote_dns": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "system",
+ autoLogin: false,
+ proxyDNS: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+ "signon.autologin.proxy": false,
+ "network.proxy.socks_remote_dns": false,
+ }
+ );
+
+ // Verify that proxyType is optional and it defaults to "system".
+ await testProxy(
+ {
+ autoLogin: false,
+ proxyDNS: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM,
+ "signon.autologin.proxy": false,
+ "network.proxy.socks_remote_dns": false,
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "autoConfig",
+ autoConfigUrl: "http://mozilla.org",
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_PAC,
+ "network.proxy.autoconfig_url": "http://mozilla.org",
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org",
+ autoConfigUrl: "",
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.autoconfig_url": "",
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ autoConfigUrl: "",
+ }
+ );
+
+ // When using proxyAll, we expect all proxies to be set to
+ // be the same as http.
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org:8080",
+ ftp: "http://www.mozilla.org:1234",
+ httpProxyAll: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 8080,
+ "network.proxy.ftp": "www.mozilla.org",
+ "network.proxy.ftp_port": 8080,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 8080,
+ "network.proxy.share_proxy_settings": true,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ ftp: "www.mozilla.org:8080",
+ ssl: "www.mozilla.org:8080",
+ socks: "",
+ httpProxyAll: true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:8081",
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 8080,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ftp": "www.mozilla.org",
+ "network.proxy.ftp_port": 8081,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 8082,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 8083,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org",
+ ftp: "ftp://www.mozilla.org",
+ ssl: "https://www.mozilla.org",
+ socks: "mozilla.org",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ftp": "www.mozilla.org",
+ "network.proxy.ftp_port": 21,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 443,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 1080,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": false,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:21",
+ ssl: "www.mozilla.org:443",
+ socks: "mozilla.org:1080",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org:80",
+ ftp: "ftp://www.mozilla.org:21",
+ ssl: "https://www.mozilla.org:443",
+ socks: "mozilla.org:1080",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ftp": "www.mozilla.org",
+ "network.proxy.ftp_port": 21,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 443,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 1080,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": true,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:21",
+ ssl: "www.mozilla.org:443",
+ socks: "mozilla.org:1080",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: true,
+ }
+ );
+
+ await testProxy(
+ {
+ proxyType: "manual",
+ http: "http://www.mozilla.org:80",
+ ftp: "ftp://www.mozilla.org:80",
+ ssl: "https://www.mozilla.org:80",
+ socks: "mozilla.org:80",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL,
+ "network.proxy.http": "www.mozilla.org",
+ "network.proxy.http_port": 80,
+ "network.proxy.share_proxy_settings": false,
+ "network.proxy.ftp": "www.mozilla.org",
+ "network.proxy.ftp_port": 80,
+ "network.proxy.ssl": "www.mozilla.org",
+ "network.proxy.ssl_port": 80,
+ "network.proxy.socks": "mozilla.org",
+ "network.proxy.socks_port": 80,
+ "network.proxy.socks_version": 4,
+ "network.proxy.no_proxies_on": ".mozilla.org",
+ "network.http.proxy.respect-be-conservative": false,
+ },
+ {
+ proxyType: "manual",
+ http: "www.mozilla.org:80",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:80",
+ ssl: "www.mozilla.org:80",
+ socks: "mozilla.org:80",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ respectBeConservative: false,
+ }
+ );
+
+ // Test resetting values.
+ await testProxy(
+ {
+ proxyType: "none",
+ http: "",
+ ftp: "",
+ ssl: "",
+ socks: "",
+ socksVersion: 5,
+ passthrough: "",
+ respectBeConservative: true,
+ },
+ {
+ "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT,
+ "network.proxy.http": "",
+ "network.proxy.http_port": 0,
+ "network.proxy.ftp": "",
+ "network.proxy.ftp_port": 0,
+ "network.proxy.ssl": "",
+ "network.proxy.ssl_port": 0,
+ "network.proxy.socks": "",
+ "network.proxy.socks_port": 0,
+ "network.proxy.socks_version": 5,
+ "network.proxy.no_proxies_on": "",
+ "network.http.proxy.respect-be-conservative": true,
+ }
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_bad_value_proxy_config() {
+ let background =
+ AppConstants.platform === "android"
+ ? async () => {
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "none",
+ },
+ }),
+ /proxy.settings is not supported on android/,
+ "proxy.settings.set rejects on Android."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.get({}),
+ /proxy.settings is not supported on android/,
+ "proxy.settings.get rejects on Android."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.clear({}),
+ /proxy.settings is not supported on android/,
+ "proxy.settings.clear rejects on Android."
+ );
+
+ browser.test.sendMessage("done");
+ }
+ : async () => {
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "abc",
+ },
+ }),
+ /abc is not a valid value for proxyType/,
+ "proxy.settings.set rejects with an invalid proxyType value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "autoConfig",
+ },
+ }),
+ /undefined is not a valid value for autoConfigUrl/,
+ "proxy.settings.set for type autoConfig rejects with an empty autoConfigUrl value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "autoConfig",
+ autoConfigUrl: "abc",
+ },
+ }),
+ /abc is not a valid value for autoConfigUrl/,
+ "proxy.settings.set rejects with an invalid autoConfigUrl value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "manual",
+ socksVersion: "abc",
+ },
+ }),
+ /abc is not a valid value for socksVersion/,
+ "proxy.settings.set rejects with an invalid socksVersion value."
+ );
+
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "manual",
+ socksVersion: 3,
+ },
+ }),
+ /3 is not a valid value for socksVersion/,
+ "proxy.settings.set rejects with an invalid socksVersion value."
+ );
+
+ browser.test.sendMessage("done");
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["proxy"],
+ },
+ incognitoOverride: "spanning",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+// Verify proxy prefs are unset on permission removal.
+add_task(async function test_proxy_settings_permissions() {
+ async function background() {
+ const permObj = { permissions: ["proxy"] };
+ browser.test.onMessage.addListener(async (msg, value) => {
+ if (msg === "request") {
+ browser.test.log("requesting proxy permission");
+ await browser.permissions.request(permObj);
+ browser.test.log("setting proxy values");
+ await browser.proxy.settings.set({ value });
+ browser.test.sendMessage("set");
+ } else if (msg === "remove") {
+ await browser.permissions.remove(permObj);
+ browser.test.sendMessage("removed");
+ }
+ });
+ }
+
+ let prefNames = [
+ "network.proxy.type",
+ "network.proxy.http",
+ "network.proxy.http_port",
+ "network.proxy.ftp",
+ "network.proxy.ftp_port",
+ "network.proxy.ssl",
+ "network.proxy.ssl_port",
+ "network.proxy.socks",
+ "network.proxy.socks_port",
+ "network.proxy.socks_version",
+ "network.proxy.no_proxies_on",
+ ];
+
+ function checkSettings(msg, expectUserValue = false) {
+ info(msg);
+ for (let pref of prefNames) {
+ equal(
+ expectUserValue,
+ Services.prefs.prefHasUserValue(pref),
+ `${pref} set as expected ${Preferences.get(pref)}`
+ );
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ optional_permissions: ["proxy"],
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ checkSettings("setting is not set after startup");
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("request", {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:8081",
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ });
+ await extension.awaitMessage("set");
+ checkSettings("setting was set after request", true);
+
+ extension.sendMessage("remove");
+ await extension.awaitMessage("removed");
+ checkSettings("setting is reset after remove");
+
+ // Set again to test after restart
+ extension.sendMessage("request", {
+ proxyType: "manual",
+ http: "www.mozilla.org:8080",
+ httpProxyAll: false,
+ ftp: "www.mozilla.org:8081",
+ ssl: "www.mozilla.org:8082",
+ socks: "mozilla.org:8083",
+ socksVersion: 4,
+ passthrough: ".mozilla.org",
+ });
+ await extension.awaitMessage("set");
+ checkSettings("setting was set after request", true);
+ });
+
+ // force the permissions store to be re-read on startup
+ await ExtensionPermissions._uninit();
+ resetHandlingUserInput();
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("remove");
+ await extension.awaitMessage("removed");
+ checkSettings("setting is reset after remove");
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
new file mode 100644
index 0000000000..db041d20d0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js
@@ -0,0 +1,302 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "authManager",
+ "@mozilla.org/network/http-auth-manager;1",
+ "nsIHttpAuthManager"
+);
+
+const proxy = createHttpServer();
+
+// accept proxy connections for mozilla.org
+proxy.identity.add("http", "mozilla.org", 80);
+proxy.identity.add("https", "407.example.com", 443);
+
+proxy.registerPathHandler("CONNECT", (request, response) => {
+ Assert.equal(request.method, "CONNECT");
+ switch (request.host) {
+ case "407.example.com":
+ response.setStatusLine(request.httpVersion, 407, "Authenticate");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false);
+ response.write("auth required");
+ break;
+ default:
+ response.setStatusLine(request.httpVersion, 500, "I am dumb");
+ }
+});
+
+proxy.registerPathHandler("/", (request, response) => {
+ if (request.hasHeader("Proxy-Authorization")) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write("ok, got proxy auth");
+ } else {
+ response.setStatusLine(
+ request.httpVersion,
+ 407,
+ "Proxy authentication required"
+ );
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false);
+ response.write("auth required");
+ }
+});
+
+function getExtension(background) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${proxy.identity.primaryPort})`,
+ });
+}
+
+add_task(async function test_webRequest_auth_proxy() {
+ async function background(port) {
+ let expecting = [
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onAuthRequired",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onCompleted",
+ ];
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onBeforeSendHeaders",
+ expecting.shift(),
+ "got expected event"
+ );
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "",
+ details.proxyInfo.username,
+ "proxy username not set"
+ );
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onSendHeaders.addListener(
+ details => {
+ browser.test.log(`onSendHeaders ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onSendHeaders",
+ expecting.shift(),
+ "got expected event"
+ );
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.log(`onAuthRequired ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onAuthRequired",
+ expecting.shift(),
+ "got expected event"
+ );
+ browser.test.assertTrue(details.isProxy, "proxied request");
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "localhost",
+ details.challenger.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.challenger.port, "proxy port");
+ return { authCredentials: { username: "puser", password: "ppass" } };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
+ browser.test.assertEq(
+ "onCompleted",
+ expecting.shift(),
+ "got expected event"
+ );
+ browser.test.assertEq(
+ "localhost",
+ details.proxyInfo.host,
+ "proxy host"
+ );
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("http", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "",
+ details.proxyInfo.username,
+ "proxy username not set by onAuthRequired"
+ );
+ browser.test.assertEq(
+ undefined,
+ details.proxyInfo.password,
+ "no proxy password"
+ );
+ browser.test.assertEq(expecting.length, 0, "got all expected events");
+ browser.test.sendMessage("done");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return [{ host: "localhost", port, type: "http" }];
+ },
+ { urls: ["<all_urls>"] },
+ ["requestHeaders"]
+ );
+ browser.test.sendMessage("ready");
+ }
+
+ let handlingExt = getExtension(background);
+
+ await handlingExt.startup();
+ await handlingExt.awaitMessage("ready");
+
+ authManager.clearAll();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://mozilla.org/`
+ );
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
+
+add_task(async function test_webRequest_auth_proxy_https() {
+ async function background(port) {
+ let authReceived = false;
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ if (authReceived) {
+ browser.test.sendMessage("done");
+ return { cancel: true };
+ }
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ authReceived = true;
+ return { authCredentials: { username: "puser", password: "ppass" } };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return [{ host: "localhost", port, type: "http" }];
+ },
+ { urls: ["<all_urls>"] },
+ ["requestHeaders"]
+ );
+ browser.test.sendMessage("ready");
+ }
+
+ let handlingExt = getExtension(background);
+
+ await handlingExt.startup();
+ await handlingExt.awaitMessage("ready");
+
+ authManager.clearAll();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `https://407.example.com/`
+ );
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
+
+add_task(async function test_webRequest_auth_proxy_system() {
+ async function background(port) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.fail("onBeforeRequest");
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.sendMessage("onAuthRequired");
+ // cancel is silently ignored, if it were not (e.g someone messes up in
+ // WebRequest.jsm and allows cancel) this test would fail.
+ return {
+ cancel: true,
+ authCredentials: { username: "puser", password: "ppass" },
+ };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ return { host: "localhost", port, type: "http" };
+ },
+ { urls: ["<all_urls>"] }
+ );
+ browser.test.sendMessage("ready");
+ }
+
+ let handlingExt = getExtension(background);
+
+ await handlingExt.startup();
+ await handlingExt.awaitMessage("ready");
+
+ authManager.clearAll();
+
+ function fetch(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.mozBackgroundRequest = true;
+ xhr.open("GET", url);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = () => {
+ reject(xhr.status);
+ };
+ xhr.send();
+ });
+ }
+
+ await Promise.all([
+ handlingExt.awaitMessage("onAuthRequired"),
+ fetch("http://mozilla.org"),
+ ]);
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js
new file mode 100644
index 0000000000..281804dccb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js
@@ -0,0 +1,107 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "HttpServer",
+ "resource://testing-common/httpd.js"
+);
+
+const {
+ createAppInfo,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// We cannot use createHttpServer because it also messes with proxies. We want
+// httpChannel to pick up the prefs we set and use those to proxy to our server.
+// If this were to fail, we would get an error about making a request out to
+// the network.
+const proxy = new HttpServer();
+proxy.start(-1);
+proxy.registerPathHandler("/fubar", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ proxy.stop(resolve);
+ });
+});
+
+add_task(async function test_proxy_settings() {
+ async function background(host, port) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ host,
+ details.proxyInfo.host,
+ "proxy host matched"
+ );
+ browser.test.assertEq(
+ port,
+ details.proxyInfo.port,
+ "proxy port matched"
+ );
+ },
+ { urls: ["http://example.com/*"] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.notifyPass("proxytest");
+ },
+ { urls: ["http://example.com/*"] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.notifyFail("proxytest");
+ },
+ { urls: ["http://example.com/*"] }
+ );
+
+ // Wait for the settings before testing a request.
+ await browser.proxy.settings.set({
+ value: {
+ proxyType: "manual",
+ http: `${host}:${port}`,
+ },
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "proxy.settings@mochi.test" } },
+ permissions: ["proxy", "webRequest", "<all_urls>"],
+ },
+ incognitoOverride: "spanning",
+ useAddonManager: "temporary",
+ background: `(${background})("${proxy.identity.primaryHost}", ${proxy.identity.primaryPort})`,
+ });
+
+ await promiseStartupManager();
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ equal(
+ Services.prefs.getStringPref("network.proxy.http"),
+ proxy.identity.primaryHost,
+ "proxy address is set"
+ );
+ equal(
+ Services.prefs.getIntPref("network.proxy.http_port"),
+ proxy.identity.primaryPort,
+ "proxy port is set"
+ );
+ let ok = extension.awaitFinish("proxytest");
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/fubar"
+ );
+ await ok;
+
+ await contentPage.close();
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js
new file mode 100644
index 0000000000..62436737f1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js
@@ -0,0 +1,557 @@
+"use strict";
+
+/* globals TCPServerSocket */
+
+const CC = Components.Constructor;
+
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+const currentThread = Cc["@mozilla.org/thread-manager;1"].getService()
+ .currentThread;
+
+// Most of the socks logic here is copied and upgraded to support authentication
+// for socks5. The original test is from netwerk/test/unit/test_socks.js
+
+// Socks 4 support was left in place for future tests.
+
+const STATE_WAIT_GREETING = 1;
+const STATE_WAIT_SOCKS4_REQUEST = 2;
+const STATE_WAIT_SOCKS4_USERNAME = 3;
+const STATE_WAIT_SOCKS4_HOSTNAME = 4;
+const STATE_WAIT_SOCKS5_GREETING = 5;
+const STATE_WAIT_SOCKS5_REQUEST = 6;
+const STATE_WAIT_SOCKS5_AUTH = 7;
+const STATE_WAIT_INPUT = 8;
+const STATE_FINISHED = 9;
+
+/**
+ * A basic socks proxy setup that handles a single http response page. This
+ * is used for testing socks auth with webrequest. We don't bother making
+ * sure we buffer ondata, etc., we'll never get anything but tiny chunks here.
+ */
+class SocksClient {
+ constructor(server, socket) {
+ this.server = server;
+ this.type = "";
+ this.username = "";
+ this.dest_name = "";
+ this.dest_addr = [];
+ this.dest_port = [];
+
+ this.inbuf = [];
+ this.state = STATE_WAIT_GREETING;
+ this.socket = socket;
+
+ socket.onclose = event => {
+ this.server.requestCompleted(this);
+ };
+ socket.ondata = event => {
+ let len = event.data.byteLength;
+
+ if (len == 0 && this.state == STATE_FINISHED) {
+ this.close();
+ this.server.requestCompleted(this);
+ return;
+ }
+
+ this.inbuf = new Uint8Array(event.data);
+ Promise.resolve().then(() => {
+ this.callState();
+ });
+ };
+ }
+
+ callState() {
+ switch (this.state) {
+ case STATE_WAIT_GREETING:
+ this.checkSocksGreeting();
+ break;
+ case STATE_WAIT_SOCKS4_REQUEST:
+ this.checkSocks4Request();
+ break;
+ case STATE_WAIT_SOCKS4_USERNAME:
+ this.checkSocks4Username();
+ break;
+ case STATE_WAIT_SOCKS4_HOSTNAME:
+ this.checkSocks4Hostname();
+ break;
+ case STATE_WAIT_SOCKS5_GREETING:
+ this.checkSocks5Greeting();
+ break;
+ case STATE_WAIT_SOCKS5_REQUEST:
+ this.checkSocks5Request();
+ break;
+ case STATE_WAIT_SOCKS5_AUTH:
+ this.checkSocks5Auth();
+ break;
+ case STATE_WAIT_INPUT:
+ this.checkRequest();
+ break;
+ default:
+ do_throw("server: read in invalid state!");
+ }
+ }
+
+ write(buf) {
+ this.socket.send(new Uint8Array(buf).buffer);
+ }
+
+ checkSocksGreeting() {
+ if (!this.inbuf.length) {
+ return;
+ }
+
+ if (this.inbuf[0] == 4) {
+ this.type = "socks4";
+ this.state = STATE_WAIT_SOCKS4_REQUEST;
+ this.checkSocks4Request();
+ } else if (this.inbuf[0] == 5) {
+ this.type = "socks";
+ this.state = STATE_WAIT_SOCKS5_GREETING;
+ this.checkSocks5Greeting();
+ } else {
+ do_throw("Unknown socks protocol!");
+ }
+ }
+
+ checkSocks4Request() {
+ if (this.inbuf.length < 8) {
+ return;
+ }
+
+ this.dest_port = this.inbuf.slice(2, 4);
+ this.dest_addr = this.inbuf.slice(4, 8);
+
+ this.inbuf = this.inbuf.slice(8);
+ this.state = STATE_WAIT_SOCKS4_USERNAME;
+ this.checkSocks4Username();
+ }
+
+ readString() {
+ let i = this.inbuf.indexOf(0);
+ let str = null;
+
+ if (i >= 0) {
+ let decoder = new TextDecoder();
+ str = decoder.decode(this.inbuf.slice(0, i));
+ this.inbuf = this.inbuf.slice(i + 1);
+ }
+
+ return str;
+ }
+
+ checkSocks4Username() {
+ let str = this.readString();
+
+ if (str == null) {
+ return;
+ }
+
+ this.username = str;
+ if (
+ this.dest_addr[0] == 0 &&
+ this.dest_addr[1] == 0 &&
+ this.dest_addr[2] == 0 &&
+ this.dest_addr[3] != 0
+ ) {
+ this.state = STATE_WAIT_SOCKS4_HOSTNAME;
+ this.checkSocks4Hostname();
+ } else {
+ this.sendSocks4Response();
+ }
+ }
+
+ checkSocks4Hostname() {
+ let str = this.readString();
+
+ if (str == null) {
+ return;
+ }
+
+ this.dest_name = str;
+ this.sendSocks4Response();
+ }
+
+ sendSocks4Response() {
+ this.state = STATE_WAIT_INPUT;
+ this.inbuf = [];
+ this.write([0, 0x5a, 0, 0, 0, 0, 0, 0]);
+ }
+
+ /**
+ * checks authentication information.
+ *
+ * buf[0] socks version
+ * buf[1] number of auth methods supported
+ * buf[2+nmethods] value for each auth method
+ *
+ * Response is
+ * byte[0] socks version
+ * byte[1] desired auth method
+ *
+ * For whatever reason, Firefox does not present auth method 0x02 however
+ * responding with that does cause Firefox to send authentication if
+ * the nsIProxyInfo instance has the data. IUUC Firefox should send
+ * supported methods, but I'm no socks expert.
+ */
+ checkSocks5Greeting() {
+ if (this.inbuf.length < 2) {
+ return;
+ }
+ let nmethods = this.inbuf[1];
+ if (this.inbuf.length < 2 + nmethods) {
+ return;
+ }
+
+ // See comment above, keeping for future update.
+ // let methods = this.inbuf.slice(2, 2 + nmethods);
+
+ this.inbuf = [];
+ if (this.server.password || this.server.username) {
+ this.state = STATE_WAIT_SOCKS5_AUTH;
+ this.write([5, 2]);
+ } else {
+ this.state = STATE_WAIT_SOCKS5_REQUEST;
+ this.write([5, 0]);
+ }
+ }
+
+ checkSocks5Auth() {
+ equal(this.inbuf[0], 0x01, "subnegotiation version");
+ let uname_len = this.inbuf[1];
+ let pass_len = this.inbuf[2 + uname_len];
+ let unnamebuf = this.inbuf.slice(2, 2 + uname_len);
+ let pass_start = 2 + uname_len + 1;
+ let pwordbuf = this.inbuf.slice(pass_start, pass_start + pass_len);
+ let decoder = new TextDecoder();
+ let username = decoder.decode(unnamebuf);
+ let password = decoder.decode(pwordbuf);
+ this.inbuf = [];
+ equal(username, this.server.username, "socks auth username");
+ equal(password, this.server.password, "socks auth password");
+ if (username == this.server.username && password == this.server.password) {
+ this.state = STATE_WAIT_SOCKS5_REQUEST;
+ // x00 is success, any other value closes the connection
+ this.write([1, 0]);
+ return;
+ }
+ this.state = STATE_FINISHED;
+ this.write([1, 1]);
+ }
+
+ checkSocks5Request() {
+ if (this.inbuf.length < 4) {
+ return;
+ }
+
+ let atype = this.inbuf[3];
+ let len;
+ let name = false;
+
+ switch (atype) {
+ case 0x01:
+ len = 4;
+ break;
+ case 0x03:
+ len = this.inbuf[4];
+ name = true;
+ break;
+ case 0x04:
+ len = 16;
+ break;
+ default:
+ do_throw("Unknown address type " + atype);
+ }
+
+ if (name) {
+ if (this.inbuf.length < 4 + len + 1 + 2) {
+ return;
+ }
+
+ let buf = this.inbuf.slice(5, 5 + len);
+ let decoder = new TextDecoder();
+ this.dest_name = decoder.decode(buf);
+ len += 1;
+ } else {
+ if (this.inbuf.length < 4 + len + 2) {
+ return;
+ }
+
+ this.dest_addr = this.inbuf.slice(4, 4 + len);
+ }
+
+ len += 4;
+ this.dest_port = this.inbuf.slice(len, len + 2);
+ this.inbuf = this.inbuf.slice(len + 2);
+ this.sendSocks5Response();
+ }
+
+ sendSocks5Response() {
+ let buf;
+ if (this.dest_addr.length == 16) {
+ // send a successful response with the address, [::1]:80
+ buf = [5, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 80];
+ } else {
+ // send a successful response with the address, 127.0.0.1:80
+ buf = [5, 0, 0, 1, 127, 0, 0, 1, 0, 80];
+ }
+ this.state = STATE_WAIT_INPUT;
+ this.inbuf = [];
+ this.write(buf);
+ }
+
+ checkRequest() {
+ let decoder = new TextDecoder();
+ let request = decoder.decode(this.inbuf);
+
+ if (request == "PING!") {
+ this.state = STATE_FINISHED;
+ this.socket.send("PONG!");
+ } else if (request.startsWith("GET / HTTP/1.1")) {
+ this.socket.send(
+ "HTTP/1.1 200 OK\r\n" +
+ "Content-Length: 2\r\n" +
+ "Content-Type: text/html\r\n" +
+ "\r\nOK"
+ );
+ this.state = STATE_FINISHED;
+ }
+ }
+
+ close() {
+ this.socket.close();
+ }
+}
+
+class SocksTestServer {
+ constructor() {
+ this.client_connections = new Set();
+ this.listener = new TCPServerSocket(-1, { binaryType: "arraybuffer" }, -1);
+ this.listener.onconnect = event => {
+ let client = new SocksClient(this, event.socket);
+ this.client_connections.add(client);
+ };
+ }
+
+ requestCompleted(client) {
+ this.client_connections.delete(client);
+ }
+
+ close() {
+ for (let client of this.client_connections) {
+ client.close();
+ }
+ this.client_connections = new Set();
+ if (this.listener) {
+ this.listener.close();
+ this.listener = null;
+ }
+ }
+
+ setUserPass(username, password) {
+ this.username = username;
+ this.password = password;
+ }
+}
+
+/**
+ * Tests the basic socks logic using a simple socket connection and the
+ * protocol proxy service. It seems TCPSocket has no way to tie proxy
+ * data to it, so we go old school here.
+ */
+class SocksTestClient {
+ constructor(socks, dest, resolve, reject) {
+ let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService(
+ Ci.nsIProtocolProxyService
+ );
+ let sts = Cc["@mozilla.org/network/socket-transport-service;1"].getService(
+ Ci.nsISocketTransportService
+ );
+
+ let pi_flags = 0;
+ if (socks.dns == "remote") {
+ pi_flags = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+ }
+
+ let pi = pps.newProxyInfoWithAuth(
+ socks.version,
+ socks.host,
+ socks.port,
+ socks.username,
+ socks.password,
+ "",
+ "",
+ pi_flags,
+ -1,
+ null
+ );
+
+ this.trans = sts.createTransport([], dest.host, dest.port, pi);
+ this.input = this.trans.openInputStream(
+ Ci.nsITransport.OPEN_BLOCKING,
+ 0,
+ 0
+ );
+ this.output = this.trans.openOutputStream(
+ Ci.nsITransport.OPEN_BLOCKING,
+ 0,
+ 0
+ );
+ this.outbuf = String();
+ this.resolve = resolve;
+ this.reject = reject;
+
+ this.write("PING!");
+ this.input.asyncWait(this, 0, 0, currentThread);
+ }
+
+ onInputStreamReady(stream) {
+ let len = 0;
+ try {
+ len = stream.available();
+ } catch (e) {
+ // This will happen on auth failure.
+ this.reject(e);
+ return;
+ }
+ let bin = new BinaryInputStream(stream);
+ let data = bin.readByteArray(len);
+ let decoder = new TextDecoder();
+ let result = decoder.decode(data);
+ if (result == "PONG!") {
+ this.resolve(result);
+ } else {
+ this.reject();
+ }
+ }
+
+ write(buf) {
+ this.outbuf += buf;
+ this.output.asyncWait(this, 0, 0, currentThread);
+ }
+
+ onOutputStreamReady(stream) {
+ let len = stream.write(this.outbuf, this.outbuf.length);
+ if (len != this.outbuf.length) {
+ this.outbuf = this.outbuf.substring(len);
+ stream.asyncWait(this, 0, 0, currentThread);
+ } else {
+ this.outbuf = String();
+ }
+ }
+
+ close() {
+ this.output.close();
+ }
+}
+
+const socksServer = new SocksTestServer();
+socksServer.setUserPass("foo", "bar");
+registerCleanupFunction(() => {
+ socksServer.close();
+});
+
+// A simple ping/pong to test the socks server.
+add_task(async function test_socks_server() {
+ let socks = {
+ version: "socks",
+ host: "127.0.0.1",
+ port: socksServer.listener.localPort,
+ username: "foo",
+ password: "bar",
+ dns: false,
+ };
+ let dest = {
+ host: "localhost",
+ port: 8888,
+ };
+
+ new Promise((resolve, reject) => {
+ new SocksTestClient(socks, dest, resolve, reject);
+ })
+ .then(result => {
+ equal("PONG!", result, "socks test ok");
+ })
+ .catch(result => {
+ ok(false, `socks test failed ${result}`);
+ });
+});
+
+add_task(async function test_webRequest_socks_proxy() {
+ async function background(port) {
+ function checkProxyData(details) {
+ browser.test.assertEq("127.0.0.1", details.proxyInfo.host, "proxy host");
+ browser.test.assertEq(port, details.proxyInfo.port, "proxy port");
+ browser.test.assertEq("socks", details.proxyInfo.type, "proxy type");
+ browser.test.assertEq(
+ "foo",
+ details.proxyInfo.username,
+ "proxy username not set"
+ );
+ browser.test.assertEq(
+ undefined,
+ details.proxyInfo.password,
+ "no proxy password passed to webrequest"
+ );
+ }
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ checkProxyData(details);
+ },
+ { urls: ["<all_urls>"] }
+ );
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ // We should never get onAuthRequired for socks proxy
+ browser.test.fail("onAuthRequired");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ checkProxyData(details);
+ browser.test.sendMessage("done");
+ },
+ { urls: ["<all_urls>"] }
+ );
+ browser.proxy.onRequest.addListener(
+ () => {
+ return [
+ {
+ type: "socks",
+ host: "127.0.0.1",
+ port,
+ username: "foo",
+ password: "bar",
+ },
+ ];
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${socksServer.listener.localPort})`,
+ });
+
+ // proxy.register is deprecated - bug 1443259.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await handlingExt.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://localhost/`
+ );
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js
new file mode 100644
index 0000000000..01f864cb7a
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js
@@ -0,0 +1,52 @@
+"use strict";
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+const proxy = createHttpServer();
+
+add_task(async function test_speculative_connect() {
+ function background() {
+ // Handle the proxy request.
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ browser.test.assertEq(
+ details.type,
+ "speculative",
+ "Should have seen a speculative proxy request."
+ );
+ return [{ type: "direct" }];
+ },
+ { urls: ["<all_urls>"], types: ["speculative"] }
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})()`,
+ });
+
+ Services.prefs.setBoolPref("network.http.debug-observations", true);
+
+ await handlingExt.startup();
+
+ let notificationPromise = ExtensionUtils.promiseObserved(
+ "speculative-connect-request"
+ );
+
+ let uri = Services.io.newURI(
+ `http://${proxy.identity.primaryHost}:${proxy.identity.primaryPort}`
+ );
+ Services.io.speculativeConnect(
+ uri,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null
+ );
+ await notificationPromise;
+
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js
new file mode 100644
index 0000000000..8d0f98f308
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js
@@ -0,0 +1,158 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let {
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+let nonProxiedRequests = 0;
+const nonProxiedServer = createHttpServer({ hosts: ["example.com"] });
+nonProxiedServer.registerPathHandler("/", (request, response) => {
+ nonProxiedRequests++;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+// No hosts defined to avoid proxy filter setup.
+let proxiedRequests = 0;
+const server = createHttpServer();
+server.identity.add("http", "proxied.example.com", 80);
+server.registerPathHandler("/", (request, response) => {
+ proxiedRequests++;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+);
+
+function promiseExtensionEvent(wrapper, event) {
+ return new Promise(resolve => {
+ wrapper.extension.once(event, resolve);
+ });
+}
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-page-event", "start-background-page"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+// Test that a proxy listener during startup does not immediately
+// start the background page, but the event is queued until the background
+// page is started.
+add_task(async function test_proxy_startup() {
+ await promiseStartupManager();
+
+ function background(proxyInfo) {
+ browser.proxy.onRequest.addListener(
+ details => {
+ // ignore speculative requests
+ if (details.type == "xmlhttprequest") {
+ browser.test.sendMessage("saw-request");
+ }
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+
+ let proxyInfo = {
+ host: server.identity.primaryHost,
+ port: server.identity.primaryPort,
+ type: "http",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["proxy", "http://proxied.example.com/*"],
+ },
+ background: `(${background})(${JSON.stringify(proxyInfo)})`,
+ });
+
+ await extension.startup();
+
+ // Initial requests to test the proxy and non-proxied servers.
+ await Promise.all([
+ extension.awaitMessage("saw-request"),
+ ExtensionTestUtils.fetch("http://proxied.example.com/?a=0"),
+ ]);
+ equal(1, proxiedRequests, "proxied request ok");
+ equal(0, nonProxiedRequests, "non proxied request ok");
+
+ await ExtensionTestUtils.fetch("http://example.com/?a=0");
+ equal(1, proxiedRequests, "proxied request ok");
+ equal(1, nonProxiedRequests, "non proxied request ok");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let events = trackEvents(extension);
+
+ // Initiate a non-proxied request to make sure the startup listeners are using
+ // the extensions filters/etc.
+ await ExtensionTestUtils.fetch("http://example.com/?a=1");
+ equal(1, proxiedRequests, "proxied request ok");
+ equal(2, nonProxiedRequests, "non proxied request ok");
+
+ equal(
+ events.get("background-page-event"),
+ false,
+ "Should not have gotten a background page event"
+ );
+
+ // Make a request that the extension will proxy once it is started.
+ let request = Promise.all([
+ extension.awaitMessage("saw-request"),
+ ExtensionTestUtils.fetch("http://proxied.example.com/?a=1"),
+ ]);
+
+ await promiseExtensionEvent(extension, "background-page-event");
+ equal(
+ events.get("background-page-event"),
+ true,
+ "Should have gotten a background page event"
+ );
+
+ // Test the background page startup.
+ equal(
+ events.get("start-background-page"),
+ false,
+ "Should have gotten a background page event"
+ );
+
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ await new Promise(executeSoon);
+
+ equal(
+ events.get("start-background-page"),
+ true,
+ "Should have gotten a background page event"
+ );
+
+ // Verify our proxied request finishes properly and that the
+ // request was not handled via our non-proxied server.
+ await request;
+ equal(2, proxiedRequests, "proxied request ok");
+ equal(2, nonProxiedRequests, "non proxied requests ok");
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js
new file mode 100644
index 0000000000..4c8175e0c0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js
@@ -0,0 +1,567 @@
+"use strict";
+
+// Tests whether we can redirect to a moz-extension: url.
+ChromeUtils.defineModuleGetter(
+ this,
+ "TestUtils",
+ "resource://testing-common/TestUtils.jsm"
+);
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/redirect", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", params.get("redirect_uri"));
+ response.write("redirecting");
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+function onStopListener(channel) {
+ return new Promise(resolve => {
+ let orig = channel.QueryInterface(Ci.nsITraceableChannel).setNewListener({
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsIStreamListener",
+ ]),
+ getFinalURI(request) {
+ let { loadInfo } = request;
+ return (loadInfo && loadInfo.resultPrincipalURI) || request.originalURI;
+ },
+ onDataAvailable(...args) {
+ orig.onDataAvailable(...args);
+ },
+ onStartRequest(request) {
+ orig.onStartRequest(request);
+ },
+ onStopRequest(request, statusCode) {
+ orig.onStopRequest(request, statusCode);
+ let URI = this.getFinalURI(request.QueryInterface(Ci.nsIChannel));
+ resolve(URI && URI.spec);
+ },
+ });
+ });
+}
+
+async function onModifyListener(originUrl, redirectToUrl) {
+ return TestUtils.topicObserved("http-on-modify-request", (subject, data) => {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ return channel.URI && channel.URI.spec == originUrl;
+ }).then(([subject, data]) => {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (redirectToUrl) {
+ channel.redirectTo(Services.io.newURI(redirectToUrl));
+ }
+ return channel;
+ });
+}
+
+function getExtension(
+ accessible = false,
+ background = undefined,
+ blocking = true
+) {
+ let manifest = {
+ permissions: ["webRequest", "<all_urls>"],
+ };
+ if (blocking) {
+ manifest.permissions.push("webRequestBlocking");
+ }
+ if (accessible) {
+ manifest.web_accessible_resources = ["finished.html"];
+ }
+ if (!background) {
+ background = () => {
+ // send the extensions public uri to the test.
+ let exturi = browser.extension.getURL("finished.html");
+ browser.test.sendMessage("redirectURI", exturi);
+ };
+ }
+ return ExtensionTestUtils.loadExtension({
+ manifest,
+ files: {
+ "finished.html": `
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>redirected!</h1>
+ </body>
+ </html>
+ `.trim(),
+ },
+ background,
+ });
+}
+
+async function redirection_test(url, channelRedirectUrl) {
+ // setup our observer
+ let watcher = onModifyListener(url, channelRedirectUrl).then(channel => {
+ return onStopListener(channel);
+ });
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.send();
+ return watcher;
+}
+
+// This test verifies failure without web_accessible_resources.
+add_task(async function test_redirect_to_non_accessible_resource() {
+ let extension = getExtension();
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let result = await redirection_test(url);
+ equal(result, url, `expected no redirect`);
+ await extension.unload();
+});
+
+// This test makes a request against a server that redirects with a 302.
+add_task(async function test_302_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let result = await redirection_test(url);
+ equal(result, redirectUrl, "redirect request is finished");
+ await extension.unload();
+});
+
+// This test uses channel.redirectTo during http-on-modify to redirect to the
+// moz-extension url.
+add_task(async function test_channel_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/dummy?r=${Math.random()}`;
+ let result = await redirection_test(url, redirectUrl);
+ equal(result, redirectUrl, "redirect request is finished");
+ await extension.unload();
+});
+
+// This test verifies failure without web_accessible_resources.
+add_task(async function test_content_redirect_to_non_accessible_resource() {
+ let extension = getExtension();
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let watcher = onModifyListener(url).then(channel => {
+ return onStopListener(channel);
+ });
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl: "about:blank",
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ "about:blank",
+ `expected no redirect`
+ );
+ equal(await watcher, url, "expected no redirect");
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server that redirects with a 302.
+add_task(async function test_content_302_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`);
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test uses channel.redirectTo during http-on-modify to redirect to the
+// moz-extension url.
+add_task(async function test_content_channel_redirect_to_extension() {
+ let extension = getExtension(true);
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let url = `${gServerUrl}/dummy?r=${Math.random()}`;
+ onModifyListener(url, redirectUrl);
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`);
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page.
+add_task(async function test_extension_302_redirect_web() {
+ function background(serverUrl) {
+ let expectedUrls = ["/redirect", "/dummy"];
+ let expected = [
+ "onBeforeRequest",
+ "onHeadersReceived",
+ "onBeforeRedirect",
+ "onBeforeRequest",
+ "onHeadersReceived",
+ "onResponseStarted",
+ "onCompleted",
+ ];
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertTrue(
+ details.url.includes(expectedUrls.shift()),
+ "onBeforeRequest url matches"
+ );
+ browser.test.assertEq(
+ expected.shift(),
+ "onBeforeRequest",
+ "onBeforeRequest matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ browser.test.assertEq(
+ expected.shift(),
+ "onHeadersReceived",
+ "onHeadersReceived matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onResponseStarted.addListener(
+ details => {
+ browser.test.assertEq(
+ expected.shift(),
+ "onResponseStarted",
+ "onResponseStarted matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onBeforeRedirect.addListener(
+ details => {
+ browser.test.assertTrue(
+ details.redirectUrl.includes("/dummy"),
+ "onBeforeRedirect matches redirectUrl"
+ );
+ browser.test.assertEq(
+ expected.shift(),
+ "onBeforeRedirect",
+ "onBeforeRedirect matches"
+ );
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.assertTrue(
+ details.url.includes("/dummy"),
+ "onCompleted expected url received"
+ );
+ browser.test.assertEq(
+ expected.shift(),
+ "onCompleted",
+ "onCompleted matches"
+ );
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: [serverUrl] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`);
+ browser.test.notifyFail("requestCompleted");
+ },
+ { urls: [serverUrl] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*")`,
+ false
+ );
+ await extension.startup();
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page, without
+// onBeforeRedirect. Bug 1448599
+add_task(async function test_extension_302_redirect_opening() {
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let expectData = [
+ {
+ event: "onBeforeRequest",
+ url: `${gServerUrl}/redirect`,
+ },
+ {
+ event: "onBeforeRequest",
+ url: redirectUrl,
+ },
+ ];
+ function background(serverUrl, expected) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let expect = expected.shift();
+ browser.test.assertEq(
+ expect.event,
+ "onBeforeRequest",
+ "onBeforeRequest event matches"
+ );
+ browser.test.assertTrue(
+ details.url.startsWith(expect.url),
+ "onBeforeRequest url matches"
+ );
+ if (expected.length === 0) {
+ browser.test.notifyPass("requestCompleted");
+ }
+ },
+ { urls: [serverUrl] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify(
+ expectData
+ )})`,
+ false
+ );
+ await extension.startup();
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page, without
+// onBeforeRedirect. Bug 1448599
+add_task(async function test_extension_302_redirect_modify() {
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let expectData = [
+ {
+ event: "onHeadersReceived",
+ url: `${gServerUrl}/redirect`,
+ },
+ {
+ event: "onHeadersReceived",
+ url: redirectUrl,
+ },
+ ];
+ function background(serverUrl, expected) {
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ let expect = expected.shift();
+ browser.test.assertEq(
+ expect.event,
+ "onHeadersReceived",
+ "onHeadersReceived event matches"
+ );
+ browser.test.assertTrue(
+ details.url.startsWith(expect.url),
+ "onHeadersReceived url matches"
+ );
+ if (expected.length === 0) {
+ browser.test.notifyPass("requestCompleted");
+ }
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify(
+ expectData
+ )})`,
+ false
+ );
+ await extension.startup();
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests redirect to another server page, without
+// onBeforeRedirect. Bug 1448599
+add_task(async function test_extension_302_redirect_tracing() {
+ let redirectUrl = `${gServerUrl}/dummy`;
+ let expectData = [
+ {
+ event: "onCompleted",
+ url: redirectUrl,
+ },
+ ];
+ function background(serverUrl, expected) {
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ let expect = expected.shift();
+ browser.test.assertEq(
+ expect.event,
+ "onCompleted",
+ "onCompleted event matches"
+ );
+ browser.test.assertTrue(
+ details.url.startsWith(expect.url),
+ "onCompleted url matches"
+ );
+ if (expected.length === 0) {
+ browser.test.notifyPass("requestCompleted");
+ }
+ },
+ { urls: [serverUrl] }
+ );
+ }
+ let extension = getExtension(
+ false,
+ `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify(
+ expectData
+ )})`,
+ false
+ );
+ await extension.startup();
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+});
+
+// This test makes a request against a server and tests webrequest. Currently
+// disabled due to NS_BINDING_ABORTED happening.
+add_task(async function test_extension_302_redirect() {
+ let extension = getExtension(true, () => {
+ let myuri = browser.extension.getURL("*");
+ let exturi = browser.extension.getURL("finished.html");
+ browser.webRequest.onBeforeRedirect.addListener(
+ details => {
+ browser.test.assertEq(details.redirectUrl, exturi, "redirect matches");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.assertEq(details.url, exturi, "expected url received");
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`);
+ browser.test.notifyFail("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ // send the extensions public uri to the test.
+ browser.test.sendMessage("redirectURI", exturi);
+ });
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(
+ contentPage.browser.documentURI.spec,
+ redirectUrl,
+ `expected content redirect`
+ );
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+}).skip();
+
+// This test makes a request and uses onBeforeRequet to redirect to moz-ext.
+// Currently disabled due to NS_BINDING_ABORTED happening.
+add_task(async function test_extension_redirect() {
+ let extension = getExtension(true, () => {
+ let myuri = browser.extension.getURL("*");
+ let exturi = browser.extension.getURL("finished.html");
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ return { redirectUrl: exturi };
+ },
+ { urls: ["<all_urls>", myuri] },
+ ["blocking"]
+ );
+ browser.webRequest.onBeforeRedirect.addListener(
+ details => {
+ browser.test.assertEq(details.redirectUrl, exturi, "redirect matches");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.assertEq(details.url, exturi, "expected url received");
+ browser.test.notifyPass("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`);
+ browser.test.notifyFail("requestCompleted");
+ },
+ { urls: ["<all_urls>", myuri] }
+ );
+ // send the extensions public uri to the test.
+ browser.test.sendMessage("redirectURI", exturi);
+ });
+ await extension.startup();
+ let redirectUrl = await extension.awaitMessage("redirectURI");
+ let completed = extension.awaitFinish("requestCompleted");
+ let url = `${gServerUrl}/dummy?r=${Math.random()}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ redirectUrl,
+ });
+ equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`);
+ await completed;
+ await contentPage.close();
+ await extension.unload();
+}).skip();
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js
new file mode 100644
index 0000000000..e42f45c019
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js
@@ -0,0 +1,26 @@
+/* -*- 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_connect_without_listener() {
+ function background() {
+ let port = browser.runtime.connect();
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ port.error && port.error.message
+ );
+ browser.test.notifyPass("port.onDisconnect was called");
+ });
+ }
+ let extensionData = {
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("port.onDisconnect was called");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js
new file mode 100644
index 0000000000..3f3b8f8e95
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js
@@ -0,0 +1,26 @@
+/* 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";
+
+add_task(async function setup() {
+ ExtensionTestUtils.mockAppInfo();
+});
+
+add_task(async function test_getBrowserInfo() {
+ async function background() {
+ let info = await browser.runtime.getBrowserInfo();
+
+ browser.test.assertEq(info.name, "XPCShell", "name is valid");
+ browser.test.assertEq(info.vendor, "Mozilla", "vendor is Mozilla");
+ browser.test.assertEq(info.version, "48", "version is correct");
+ browser.test.assertEq(info.buildID, "20160315", "buildID is correct");
+
+ browser.test.notifyPass("runtime.getBrowserInfo");
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({ background });
+ await extension.startup();
+ await extension.awaitFinish("runtime.getBrowserInfo");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js
new file mode 100644
index 0000000000..8f213b0dec
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.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";
+
+function backgroundScript() {
+ browser.runtime.getPlatformInfo(info => {
+ let validOSs = ["mac", "win", "android", "cros", "linux", "openbsd"];
+ let validArchs = [
+ "aarch64",
+ "arm",
+ "ppc64",
+ "s390x",
+ "sparc64",
+ "x86-32",
+ "x86-64",
+ ];
+
+ browser.test.assertTrue(validOSs.includes(info.os), "OS is valid");
+ browser.test.assertTrue(
+ validArchs.includes(info.arch),
+ "Architecture is valid"
+ );
+ browser.test.notifyPass("runtime.getPlatformInfo");
+ });
+}
+
+let extensionData = {
+ background: backgroundScript,
+};
+
+add_task(async function() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitFinish("runtime.getPlatformInfo");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js
new file mode 100644
index 0000000000..6967e81232
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js
@@ -0,0 +1,46 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_runtime_id() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background() {
+ browser.test.sendMessage("background-id", browser.runtime.id);
+ },
+
+ files: {
+ "content_script.js"() {
+ browser.test.sendMessage("content-id", browser.runtime.id);
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ let backgroundId = await extension.awaitMessage("background-id");
+ equal(
+ backgroundId,
+ extension.id,
+ "runtime.id from background script is correct"
+ );
+
+ let contentId = await extension.awaitMessage("content-id");
+ equal(contentId, extension.id, "runtime.id from content script is correct");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js
new file mode 100644
index 0000000000..6d71758a38
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js
@@ -0,0 +1,84 @@
+/* -*- 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_messaging_to_self_should_not_trigger_onMessage_onConnect() {
+ async function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq("msg from child", msg);
+ browser.test.sendMessage(
+ "sendMessage did not call same-frame onMessage"
+ );
+ });
+
+ browser.test.onMessage.addListener(msg => {
+ browser.test.assertEq(
+ "sendMessage with a listener in another frame",
+ msg
+ );
+ browser.runtime.sendMessage("should only reach another frame");
+ });
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("should not trigger same-frame onMessage"),
+ "Could not establish connection. Receiving end does not exist."
+ );
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("from-frame", port.name);
+ browser.runtime.connect({ name: "from-bg-2" });
+ });
+
+ await new Promise(resolve => {
+ let port = browser.runtime.connect({ name: "from-bg-1" });
+ port.onDisconnect.addListener(() => {
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ port.error.message
+ );
+ resolve();
+ });
+ });
+
+ let anotherFrame = document.createElement("iframe");
+ anotherFrame.src = browser.extension.getURL("extensionpage.html");
+ document.body.appendChild(anotherFrame);
+ }
+
+ function lastScript() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertEq("should only reach another frame", msg);
+ browser.runtime.sendMessage("msg from child");
+ });
+ browser.test.sendMessage("sendMessage callback called");
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("from-bg-2", port.name);
+ browser.test.sendMessage("connect did not call same-frame onConnect");
+ });
+ browser.runtime.connect({ name: "from-frame" });
+ }
+
+ let extensionData = {
+ background,
+ files: {
+ "lastScript.js": lastScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="lastScript.js"></script>`,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitMessage("sendMessage callback called");
+ extension.sendMessage("sendMessage with a listener in another frame");
+
+ await Promise.all([
+ extension.awaitMessage("connect did not call same-frame onConnect"),
+ extension.awaitMessage("sendMessage did not call same-frame onMessage"),
+ ]);
+
+ await extension.unload();
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js
new file mode 100644
index 0000000000..7c54389b39
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js
@@ -0,0 +1,401 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const {
+ createAppInfo,
+ createTempWebExtensionFile,
+ promiseAddonEvent,
+ promiseCompleteAllInstalls,
+ promiseFindAddonUpdates,
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+// Allow for unsigned addons.
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+// Ensure that the background page is automatically started after using
+// promiseStartupManager.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+function background() {
+ let onInstalledDetails = null;
+ let onStartupFired = false;
+
+ browser.runtime.onInstalled.addListener(details => {
+ onInstalledDetails = details;
+ });
+
+ browser.runtime.onStartup.addListener(() => {
+ onStartupFired = true;
+ });
+
+ browser.test.onMessage.addListener(message => {
+ if (message === "get-on-installed-details") {
+ onInstalledDetails = onInstalledDetails || { fired: false };
+ browser.test.sendMessage("on-installed-details", onInstalledDetails);
+ } else if (message === "did-on-startup-fire") {
+ browser.test.sendMessage("on-startup-fired", onStartupFired);
+ } else if (message === "reload-extension") {
+ browser.runtime.reload();
+ }
+ });
+
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("reloading");
+ browser.runtime.reload();
+ });
+}
+
+async function expectEvents(
+ extension,
+ {
+ onStartupFired,
+ onInstalledFired,
+ onInstalledReason,
+ onInstalledTemporary,
+ onInstalledPrevious,
+ }
+) {
+ extension.sendMessage("get-on-installed-details");
+ let details = await extension.awaitMessage("on-installed-details");
+ if (onInstalledFired) {
+ equal(
+ details.reason,
+ onInstalledReason,
+ "runtime.onInstalled fired with the correct reason"
+ );
+ equal(
+ details.temporary,
+ onInstalledTemporary,
+ "runtime.onInstalled fired with the correct temporary flag"
+ );
+ if (onInstalledPrevious) {
+ equal(
+ details.previousVersion,
+ onInstalledPrevious,
+ "runtime.onInstalled after update with correct previousVersion"
+ );
+ }
+ } else {
+ equal(
+ details.fired,
+ onInstalledFired,
+ "runtime.onInstalled should not have fired"
+ );
+ }
+
+ extension.sendMessage("did-on-startup-fire");
+ let fired = await extension.awaitMessage("on-startup-fired");
+ equal(
+ fired,
+ onStartupFired,
+ `Expected runtime.onStartup to ${onStartupFired ? "" : "not "} fire`
+ );
+}
+
+add_task(async function test_should_fire_on_addon_update() {
+ Preferences.set("extensions.logging.enabled", false);
+
+ await promiseStartupManager();
+
+ const EXTENSION_ID =
+ "test_runtime_on_installed_addon_update@tests.mozilla.org";
+
+ const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+
+ // The test extension uses an insecure update url.
+ Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+ const testServer = createHttpServer();
+ const port = testServer.identity.primaryPort;
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: `http://localhost:${port}/test_update.json`,
+ },
+ },
+ },
+ background,
+ });
+
+ testServer.registerPathHandler("/test_update.json", (request, response) => {
+ response.write(`{
+ "addons": {
+ "${EXTENSION_ID}": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi"
+ }
+ ]
+ }
+ }
+ }`);
+ });
+
+ let webExtensionFile = createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ testServer.registerFile(
+ "/addons/test_runtime_on_installed-2.0.xpi",
+ webExtensionFile
+ );
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ equal(addon.version, "1.0", "The installed addon has the correct version");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ let promiseInstalled = promiseAddonEvent("onInstalled");
+ await promiseCompleteAllInstalls([install]);
+
+ await extension.awaitMessage("reloading");
+
+ let [updated_addon] = await promiseInstalled;
+ equal(
+ updated_addon.version,
+ "2.0",
+ "The updated addon has the correct version"
+ );
+
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "update",
+ onInstalledPrevious: "1.0",
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_should_fire_on_browser_update() {
+ const EXTENSION_ID =
+ "test_runtime_on_installed_browser_update@tests.mozilla.org";
+
+ await promiseStartupManager("1");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ // Restart the browser.
+ await promiseRestartManager("1");
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: false,
+ });
+
+ // Update the browser.
+ await promiseRestartManager("2");
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "browser_update",
+ });
+
+ // Restart the browser.
+ await promiseRestartManager("2");
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: false,
+ });
+
+ // Update the browser again.
+ await promiseRestartManager("3");
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: true,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "browser_update",
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_should_not_fire_on_reload() {
+ const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ extension.sendMessage("reload-extension");
+ extension.setRestarting();
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: false,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_should_not_fire_on_restart() {
+ const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledTemporary: false,
+ onInstalledReason: "install",
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.disable();
+ await addon.enable();
+ await extension.awaitStartup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: false,
+ });
+
+ await extension.markUnloaded();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_temporary_installation() {
+ const EXTENSION_ID =
+ "test_runtime_on_installed_addon_temporary@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await expectEvents(extension, {
+ onStartupFired: false,
+ onInstalledFired: true,
+ onInstalledReason: "install",
+ onInstalledTemporary: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js
new file mode 100644
index 0000000000..7365a13f93
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js
@@ -0,0 +1,69 @@
+"use strict";
+
+add_task(async function test_port_disconnected_from_wrong_window() {
+ let extensionData = {
+ background() {
+ let num = 0;
+ let ports = {};
+ let done = false;
+
+ browser.runtime.onConnect.addListener(port => {
+ num++;
+ ports[num] = port;
+
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "port-2-response", "Got port 2 response");
+ browser.test.sendMessage(msg + "-received");
+ done = true;
+ });
+
+ port.onDisconnect.addListener(err => {
+ if (port === ports[1]) {
+ browser.test.log("Port 1 disconnected, sending message via port 2");
+ ports[2].postMessage("port-2-msg");
+ } else {
+ browser.test.assertTrue(
+ done,
+ "Port 2 disconnected only after a full roundtrip received"
+ );
+ }
+ });
+
+ browser.test.sendMessage("port-connect-" + num);
+ });
+ },
+ files: {
+ "page.html": `
+ <!DOCTYPE html><meta charset="utf8">
+ <script src="script.js"></script>
+ `,
+ "script.js"() {
+ let port = browser.runtime.connect();
+ port.onMessage.addListener(msg => {
+ browser.test.assertEq(msg, "port-2-msg", "Got message via port 2");
+ port.postMessage("port-2-response");
+ });
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ await extension.startup();
+
+ let page1 = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("port-connect-1");
+ info("First page opened port 1");
+
+ let page2 = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("port-connect-2");
+ info("Second page opened port 2");
+
+ info("Closing the first page should not close port 2");
+ await page1.close();
+ await extension.awaitMessage("port-2-response-received");
+ info("Roundtrip message through port 2 received");
+
+ await page2.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js
new file mode 100644
index 0000000000..7b0cf01d08
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js
@@ -0,0 +1,168 @@
+"use strict";
+
+let gcExperimentAPIs = {
+ gcHelper: {
+ schema: "schema.json",
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["gcHelper"]],
+ },
+ },
+};
+
+let gcExperimentFiles = {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "gcHelper",
+ functions: [
+ {
+ name: "forceGarbageCollect",
+ type: "function",
+ parameters: [],
+ async: true,
+ },
+ {
+ name: "registerWitness",
+ type: "function",
+ parameters: [
+ {
+ name: "obj",
+ // Expected type is "object", but using "any" here to ensure that
+ // the parameter is untouched (not normalized).
+ type: "any",
+ },
+ ],
+ returns: { type: "number" },
+ },
+ {
+ name: "isGarbageCollected",
+ type: "function",
+ parameters: [
+ {
+ name: "witnessId",
+ description: "return value of registerWitness",
+ type: "number",
+ },
+ ],
+ returns: { type: "boolean" },
+ },
+ ],
+ },
+ ]),
+ "child.js": () => {
+ let { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm");
+ /* globals ExtensionAPI */
+ this.gcHelper = class extends ExtensionAPI {
+ getAPI(context) {
+ let witnesses = new Map();
+ return {
+ gcHelper: {
+ async forceGarbageCollect() {
+ // Logic copied from test_ext_contexts_gc.js
+ for (let i = 0; i < 3; ++i) {
+ Cu.forceShrinkingGC();
+ Cu.forceCC();
+ Cu.forceGC();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ },
+ registerWitness(obj) {
+ let witnessId = witnesses.size;
+ witnesses.set(witnessId, Cu.getWeakReference(obj));
+ return witnessId;
+ },
+ isGarbageCollected(witnessId) {
+ return witnesses.get(witnessId).get() === null;
+ },
+ },
+ };
+ }
+ };
+ },
+};
+
+// Verify that the experiment is working as intended before using it in tests.
+add_task(async function test_gc_experiment() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ experiment_apis: gcExperimentAPIs,
+ },
+ files: gcExperimentFiles,
+ async background() {
+ let obj1 = {};
+ let obj2 = {};
+ let witness1 = browser.gcHelper.registerWitness(obj1);
+ let witness2 = browser.gcHelper.registerWitness(obj2);
+ obj1 = null;
+ await browser.gcHelper.forceGarbageCollect();
+ browser.test.assertTrue(
+ browser.gcHelper.isGarbageCollected(witness1),
+ "obj1 should have been garbage-collected"
+ );
+ browser.test.assertFalse(
+ browser.gcHelper.isGarbageCollected(witness2),
+ "obj2 should not have been garbage-collected"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
+
+add_task(async function test_port_gc() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ experiment_apis: gcExperimentAPIs,
+ },
+ files: gcExperimentFiles,
+ async background() {
+ let witnessPortSender;
+ let witnessPortReceiver;
+
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("daName", port.name, "expected port");
+ witnessPortReceiver = browser.gcHelper.registerWitness(port);
+ port.disconnect();
+ });
+
+ // runtime.connect() only triggers onConnect for different contexts,
+ // so create a frame to have a different context.
+ // A blank frame in a moz-extension:-document will have access to the
+ // extension APIs.
+ let frameWindow = await new Promise(resolve => {
+ let f = document.createElement("iframe");
+ f.onload = () => resolve(f.contentWindow);
+ document.body.append(f);
+ });
+ await new Promise(resolve => {
+ let port = frameWindow.browser.runtime.connect({ name: "daName" });
+ witnessPortSender = browser.gcHelper.registerWitness(port);
+ port.onDisconnect.addListener(() => resolve());
+ });
+
+ await browser.gcHelper.forceGarbageCollect();
+
+ browser.test.assertTrue(
+ browser.gcHelper.isGarbageCollected(witnessPortSender),
+ "runtime.connect() port should have been garbage-collected"
+ );
+ browser.test.assertTrue(
+ browser.gcHelper.isGarbageCollected(witnessPortReceiver),
+ "runtime.onConnect port should have been garbage-collected"
+ );
+
+ browser.test.sendMessage("done");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js
new file mode 100644
index 0000000000..a7404cf5dd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js
@@ -0,0 +1,452 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function runtimeSendMessageReply() {
+ function background() {
+ browser.runtime.onMessage.addListener((msg, sender, respond) => {
+ if (msg == "respond-now") {
+ respond(msg);
+ } else if (msg == "respond-soon") {
+ setTimeout(() => {
+ respond(msg);
+ }, 0);
+ return true;
+ } else if (msg == "respond-promise") {
+ return Promise.resolve(msg);
+ } else if (msg == "respond-promise-false") {
+ return Promise.resolve(false);
+ } else if (msg == "respond-false") {
+ // return false means that respond() is not expected to be called.
+ setTimeout(() => respond("should be ignored"));
+ return false;
+ } else if (msg == "respond-never") {
+ return undefined;
+ } else if (msg == "respond-error") {
+ return Promise.reject(new Error(msg));
+ } else if (msg == "throw-error") {
+ throw new Error(msg);
+ } else if (msg === "respond-uncloneable") {
+ return Promise.resolve(window);
+ } else if (msg === "reject-uncloneable") {
+ return Promise.reject(window);
+ } else if (msg == "reject-undefined") {
+ return Promise.reject();
+ } else if (msg == "throw-undefined") {
+ throw undefined; // eslint-disable-line no-throw-literal
+ }
+ });
+
+ browser.runtime.onMessage.addListener((msg, sender, respond) => {
+ if (msg == "respond-now") {
+ respond("hello");
+ } else if (msg == "respond-now-2") {
+ respond(msg);
+ }
+ });
+
+ browser.runtime.onMessage.addListener((msg, sender, respond) => {
+ if (msg == "respond-now") {
+ // If a response from another listener is received first, this
+ // exception should be ignored. Test fails if it is not.
+
+ // All this is of course stupid, but some extensions depend on it.
+ msg.blah.this.throws();
+ }
+ });
+
+ let childFrame = document.createElement("iframe");
+ childFrame.src = "extensionpage.html";
+ document.body.appendChild(childFrame);
+ }
+
+ function senderScript() {
+ Promise.all([
+ browser.runtime.sendMessage("respond-now"),
+ browser.runtime.sendMessage("respond-now-2"),
+ new Promise(resolve =>
+ browser.runtime.sendMessage("respond-soon", resolve)
+ ),
+ browser.runtime.sendMessage("respond-promise"),
+ browser.runtime.sendMessage("respond-promise-false"),
+ browser.runtime.sendMessage("respond-false"),
+ browser.runtime.sendMessage("respond-never"),
+ new Promise(resolve => {
+ browser.runtime.sendMessage("respond-never", response => {
+ resolve(response);
+ });
+ }),
+
+ browser.runtime
+ .sendMessage("respond-error")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("throw-error")
+ .catch(error => Promise.resolve({ error })),
+
+ browser.runtime
+ .sendMessage("respond-uncloneable")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("reject-uncloneable")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("reject-undefined")
+ .catch(error => Promise.resolve({ error })),
+ browser.runtime
+ .sendMessage("throw-undefined")
+ .catch(error => Promise.resolve({ error })),
+ ])
+ .then(
+ ([
+ respondNow,
+ respondNow2,
+ respondSoon,
+ respondPromise,
+ respondPromiseFalse,
+ respondFalse,
+ respondNever,
+ respondNever2,
+ respondError,
+ throwError,
+ respondUncloneable,
+ rejectUncloneable,
+ rejectUndefined,
+ throwUndefined,
+ ]) => {
+ browser.test.assertEq(
+ "respond-now",
+ respondNow,
+ "Got the expected immediate response"
+ );
+ browser.test.assertEq(
+ "respond-now-2",
+ respondNow2,
+ "Got the expected immediate response from the second listener"
+ );
+ browser.test.assertEq(
+ "respond-soon",
+ respondSoon,
+ "Got the expected delayed response"
+ );
+ browser.test.assertEq(
+ "respond-promise",
+ respondPromise,
+ "Got the expected promise response"
+ );
+ browser.test.assertEq(
+ false,
+ respondPromiseFalse,
+ "Got the expected false value as a promise result"
+ );
+ browser.test.assertEq(
+ undefined,
+ respondFalse,
+ "Got the expected no-response when onMessage returns false"
+ );
+ browser.test.assertEq(
+ undefined,
+ respondNever,
+ "Got the expected no-response resolution"
+ );
+ browser.test.assertEq(
+ undefined,
+ respondNever2,
+ "Got the expected no-response resolution"
+ );
+
+ browser.test.assertEq(
+ "respond-error",
+ respondError.error.message,
+ "Got the expected error response"
+ );
+ browser.test.assertEq(
+ "throw-error",
+ throwError.error.message,
+ "Got the expected thrown error response"
+ );
+
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ respondUncloneable.error.message,
+ "An uncloneable response should be ignored"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ rejectUncloneable.error.message,
+ "Got the expected error for a rejection with an uncloneable value"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ rejectUndefined.error.message,
+ "Got the expected error for a void rejection"
+ );
+ browser.test.assertEq(
+ "An unexpected error occurred",
+ throwUndefined.error.message,
+ "Got the expected error for a void throw"
+ );
+
+ browser.test.notifyPass("sendMessage");
+ }
+ )
+ .catch(e => {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("sendMessage");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "senderScript.js": senderScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("sendMessage");
+ await extension.unload();
+});
+
+add_task(async function runtimeSendMessageBlob() {
+ function background() {
+ browser.runtime.onMessage.addListener(msg => {
+ browser.test.assertTrue(msg.blob instanceof Blob, "Message is a blob");
+ return Promise.resolve(msg);
+ });
+
+ let childFrame = document.createElement("iframe");
+ childFrame.src = "extensionpage.html";
+ document.body.appendChild(childFrame);
+ }
+
+ function senderScript() {
+ browser.runtime
+ .sendMessage({ blob: new Blob(["hello"]) })
+ .then(response => {
+ browser.test.assertTrue(
+ response.blob instanceof Blob,
+ "Response is a blob"
+ );
+ browser.test.notifyPass("sendBlob");
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "senderScript.js": senderScript,
+ "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("sendBlob");
+ await extension.unload();
+});
+
+add_task(async function sendMessageResponseGC() {
+ function background() {
+ let savedResolve, savedRespond;
+
+ browser.runtime.onMessage.addListener((msg, _, respond) => {
+ browser.test.log(`Got request: ${msg}`);
+ switch (msg) {
+ case "ping":
+ respond("pong");
+ return;
+
+ case "promise-save":
+ return new Promise(resolve => {
+ savedResolve = resolve;
+ });
+ case "promise-resolve":
+ savedResolve("saved-resolve");
+ return;
+ case "promise-never":
+ return new Promise(r => {});
+
+ case "callback-save":
+ savedRespond = respond;
+ return true;
+ case "callback-call":
+ savedRespond("saved-respond");
+ return;
+ case "callback-never":
+ return true;
+ }
+ });
+
+ const frame = document.createElement("iframe");
+ frame.src = "page.html";
+ document.body.appendChild(frame);
+ }
+
+ function page() {
+ browser.test.onMessage.addListener(msg => {
+ browser.runtime.sendMessage(msg).then(
+ response => {
+ if (response) {
+ browser.test.log(`Got response: ${response}`);
+ browser.test.sendMessage(response);
+ }
+ },
+ error => {
+ browser.test.assertEq(
+ "Promised response from onMessage listener went out of scope",
+ error.message,
+ `Promise rejected with the correct error message`
+ );
+
+ browser.test.assertTrue(
+ /^moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js/.test(error.fileName),
+ `Promise rejected with the correct error filename: ${error.fileName}`
+ );
+
+ browser.test.assertEq(
+ 4,
+ error.lineNumber,
+ `Promise rejected with the correct error line number`
+ );
+
+ browser.test.assertTrue(
+ /moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js:4/.test(error.stack),
+ `Promise rejected with the correct error stack: ${error.stack}`
+ );
+ browser.test.sendMessage("rejected");
+ }
+ );
+ });
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "page.html":
+ "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>",
+ "page.js": page,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Setup long-running tasks before GC.
+ extension.sendMessage("promise-save");
+ extension.sendMessage("callback-save");
+
+ // Test returning a Promise that can never resolve.
+ extension.sendMessage("promise-never");
+
+ extension.sendMessage("ping");
+ await extension.awaitMessage("pong");
+
+ Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false);
+ await extension.awaitMessage("rejected");
+
+ // Test returning `true` without holding the response handle.
+ extension.sendMessage("callback-never");
+
+ extension.sendMessage("ping");
+ await extension.awaitMessage("pong");
+
+ Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false);
+ await extension.awaitMessage("rejected");
+
+ // Test that promises from long-running tasks didn't get GCd.
+ extension.sendMessage("promise-resolve");
+ await extension.awaitMessage("saved-resolve");
+
+ extension.sendMessage("callback-call");
+ await extension.awaitMessage("saved-respond");
+
+ ok("Long running tasks responded");
+ await extension.unload();
+});
+
+add_task(async function sendMessage_async_response_multiple_contexts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessage.addListener((msg, _, respond) => {
+ browser.test.log(`Background got request: ${msg}`);
+
+ switch (msg) {
+ case "ask-bg-fast":
+ respond("bg-respond");
+ return true;
+
+ case "ask-bg-slow":
+ return new Promise(r => setTimeout(() => r("bg-promise")), 1000);
+ }
+ });
+ browser.test.sendMessage("bg-ready");
+ },
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: ["cs.js"],
+ },
+ ],
+ },
+
+ files: {
+ "page.html":
+ "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>",
+ "page.js"() {
+ browser.runtime.onMessage.addListener((msg, _, respond) => {
+ browser.test.log(`Page got request: ${msg}`);
+
+ switch (msg) {
+ case "ask-page-fast":
+ respond("page-respond");
+ return true;
+
+ case "ask-page-slow":
+ return new Promise(r => setTimeout(() => r("page-promise")), 500);
+ }
+ });
+ browser.test.sendMessage("page-ready");
+ },
+
+ "cs.js"() {
+ Promise.all([
+ browser.runtime.sendMessage("ask-bg-fast"),
+ browser.runtime.sendMessage("ask-bg-slow"),
+ browser.runtime.sendMessage("ask-page-fast"),
+ browser.runtime.sendMessage("ask-page-slow"),
+ ]).then(responses => {
+ browser.test.assertEq(
+ responses.join(),
+ ["bg-respond", "bg-promise", "page-respond", "page-promise"].join(),
+ "Got all expected responses from correct contexts"
+ );
+ browser.test.notifyPass("cs-done");
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-ready");
+
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitMessage("page-ready");
+
+ let content = await ExtensionTestUtils.loadContentPage(
+ BASE_URL + "/file_sample.html"
+ );
+ await extension.awaitFinish("cs-done");
+ await content.close();
+
+ await page.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js
new file mode 100644
index 0000000000..ecbaba5cfe
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js
@@ -0,0 +1,101 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function() {
+ const ID1 = "sendMessage1@tests.mozilla.org";
+ const ID2 = "sendMessage2@tests.mozilla.org";
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener((...args) => {
+ browser.runtime.sendMessage(...args);
+ });
+
+ let frame = document.createElement("iframe");
+ frame.src = "page.html";
+ document.body.appendChild(frame);
+ },
+ manifest: { applications: { gecko: { id: ID1 } } },
+ files: {
+ "page.js": function() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ browser.test.sendMessage("received-page", { msg, sender });
+ });
+ // Let them know we're done loading the page.
+ browser.test.sendMessage("page-ready");
+ },
+ "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`,
+ },
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.runtime.onMessageExternal.addListener((msg, sender) => {
+ browser.test.sendMessage("received-external", { msg, sender });
+ });
+ },
+ manifest: { applications: { gecko: { id: ID2 } } },
+ });
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+ await extension1.awaitMessage("page-ready");
+
+ // Check that a message was sent within extension1.
+ async function checkLocalMessage(msg) {
+ let result = await extension1.awaitMessage("received-page");
+ deepEqual(result.msg, msg, "Received internal message");
+ equal(result.sender.id, ID1, "Received correct sender id");
+ }
+
+ // Check that a message was sent from extension1 to extension2.
+ async function checkRemoteMessage(msg) {
+ let result = await extension2.awaitMessage("received-external");
+ deepEqual(result.msg, msg, "Received cross-extension message");
+ equal(result.sender.id, ID1, "Received correct sender id");
+ }
+
+ // sendMessage() takes 3 arguments:
+ // optional extensionID
+ // mandatory message
+ // optional options
+ // Due to this insane design we parse its arguments manually. This
+ // test is meant to cover all the combinations.
+
+ // A single null or undefined argument is allowed, and represents the message
+ extension1.sendMessage(null);
+ await checkLocalMessage(null);
+
+ // With one argument, it must be just the message
+ extension1.sendMessage("message");
+ await checkLocalMessage("message");
+
+ // With two arguments, these cases should be treated as (extensionID, message)
+ extension1.sendMessage(ID2, "message");
+ await checkRemoteMessage("message");
+
+ extension1.sendMessage(ID2, { msg: "message" });
+ await checkRemoteMessage({ msg: "message" });
+
+ // And these should be (message, options)
+ extension1.sendMessage("message", {});
+ await checkLocalMessage("message");
+
+ // or (message, non-callback), pick your poison
+ extension1.sendMessage("message", undefined);
+ await checkLocalMessage("message");
+
+ // With three arguments, we send a cross-extension message
+ extension1.sendMessage(ID2, "message", {});
+ await checkRemoteMessage("message");
+
+ // Even when the last one is null or undefined
+ extension1.sendMessage(ID2, "message", undefined);
+ await checkRemoteMessage("message");
+
+ // The four params case is unambigous, so we allow null as a (non-) callback
+ extension1.sendMessage(ID2, "message", {}, null);
+ await checkRemoteMessage("message");
+
+ await Promise.all([extension1.unload(), extension2.unload()]);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js
new file mode 100644
index 0000000000..a56c2fdc79
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js
@@ -0,0 +1,66 @@
+/* -*- 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_sendMessage_error() {
+ async function background() {
+ let circ = {};
+ circ.circ = circ;
+ let testCases = [
+ // [arguments, expected error string],
+ [[], "runtime.sendMessage's message argument is missing"],
+ [
+ [null, null, null, 42],
+ "runtime.sendMessage's last argument is not a function",
+ ],
+ [[null, null, 1], "runtime.sendMessage's options argument is invalid"],
+ [
+ [1, null, null],
+ "runtime.sendMessage's extensionId argument is invalid",
+ ],
+ [
+ [null, null, null, null, null],
+ "runtime.sendMessage received too many arguments",
+ ],
+
+ // Even when the parameters are accepted, we still expect an error
+ // because there is no onMessage listener.
+ [
+ [null, null, null],
+ "Could not establish connection. Receiving end does not exist.",
+ ],
+
+ // Structured cloning doesn't work with DOM objects
+ [[null, location, null], "The object could not be cloned."],
+ [[null, [circ, location], null], "The object could not be cloned."],
+ ];
+
+ // Repeat all tests with the undefined value instead of null.
+ for (let [args, expectedError] of testCases.slice()) {
+ args = args.map(arg => (arg === null ? undefined : arg));
+ testCases.push([args, expectedError]);
+ }
+
+ for (let [args, expectedError] of testCases) {
+ let description = `runtime.sendMessage(${args.map(String).join(", ")})`;
+
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage(...args),
+ expectedError,
+ `expected error message for ${description}`
+ );
+ }
+
+ browser.test.notifyPass("sendMessage parameter validation");
+ }
+ let extensionData = {
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("sendMessage parameter validation");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js
new file mode 100644
index 0000000000..9827a329e3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js
@@ -0,0 +1,67 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// Regression test for bug 1655624: When there are multiple onMessage receivers
+// that both handle the response asynchronously, destroying the context of one
+// recipient should not prevent the other recipient from sending a reply.
+add_task(async function onMessage_ignores_destroyed_contexts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "startTest") {
+ return;
+ }
+ try {
+ let res = await browser.runtime.sendMessage("msg_from_bg");
+ browser.test.assertEq(0, res, "Result from onMessage");
+ browser.test.notifyPass("handled_onMessage");
+ } catch (e) {
+ browser.test.fail(`Unexpected error: ${e.message} :: ${e.stack}`);
+ browser.test.notifyFail("handled_onMessage");
+ }
+ });
+ },
+ files: {
+ "tab.html": `
+ <!DOCTYPE html><meta charset="utf-8">
+ <script src="tab.js"></script>
+ `,
+ "tab.js": () => {
+ let where = location.search.slice(1);
+ let resolveOnMessage;
+ browser.runtime.onMessage.addListener(async msg => {
+ browser.test.assertEq("msg_from_bg", msg, `onMessage at ${where}`);
+ browser.test.sendMessage(`received:${where}`);
+ return new Promise(resolve => {
+ resolveOnMessage = resolve;
+ });
+ });
+ browser.test.onMessage.addListener(msg => {
+ if (msg === `resolveOnMessage:${where}`) {
+ resolveOnMessage(0);
+ }
+ });
+ },
+ },
+ });
+ await extension.startup();
+ let tabToCloseEarly = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/tab.html?tabToCloseEarly`,
+ { extension }
+ );
+ let tabToRespond = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/tab.html?tabToRespond`,
+ { extension }
+ );
+ extension.sendMessage("startTest");
+ await Promise.all([
+ extension.awaitMessage("received:tabToCloseEarly"),
+ extension.awaitMessage("received:tabToRespond"),
+ ]);
+ await tabToCloseEarly.close();
+ extension.sendMessage("resolveOnMessage:tabToRespond");
+ await extension.awaitFinish("handled_onMessage");
+ await tabToRespond.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js
new file mode 100644
index 0000000000..23d8b05f83
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js
@@ -0,0 +1,93 @@
+/* -*- 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_sendMessage_without_listener() {
+ async function background() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("msg"),
+ "Could not establish connection. Receiving end does not exist.",
+ "Correct error when there are no receivers from background"
+ );
+
+ browser.test.sendMessage("sendMessage-error-bg");
+ }
+ let extensionData = {
+ background,
+ files: {
+ "page.html": `<!doctype><meta charset=utf-8><script src="page.js"></script>`,
+ async "page.js"() {
+ await browser.test.assertRejects(
+ browser.runtime.sendMessage("msg"),
+ "Could not establish connection. Receiving end does not exist.",
+ "Correct error when there are no receivers from extension page"
+ );
+
+ browser.test.notifyPass("sendMessage-error-page");
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("sendMessage-error-bg");
+
+ let url = `moz-extension://${extension.uuid}/page.html`;
+ let page = await ExtensionTestUtils.loadContentPage(url, { extension });
+ await extension.awaitFinish("sendMessage-error-page");
+ await page.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_chrome_sendMessage_without_listener() {
+ function background() {
+ /* globals chrome */
+ browser.test.assertEq(
+ null,
+ chrome.runtime.lastError,
+ "no lastError before call"
+ );
+ let retval = chrome.runtime.sendMessage("msg");
+ browser.test.assertEq(
+ null,
+ chrome.runtime.lastError,
+ "no lastError after call"
+ );
+ browser.test.assertEq(
+ undefined,
+ retval,
+ "return value of chrome.runtime.sendMessage without callback"
+ );
+
+ let isAsyncCall = false;
+ retval = chrome.runtime.sendMessage("msg", reply => {
+ browser.test.assertEq(undefined, reply, "no reply");
+ browser.test.assertTrue(
+ isAsyncCall,
+ "chrome.runtime.sendMessage's callback must be called asynchronously"
+ );
+ browser.test.assertEq(
+ undefined,
+ retval,
+ "return value of chrome.runtime.sendMessage with callback"
+ );
+ browser.test.assertEq(
+ "Could not establish connection. Receiving end does not exist.",
+ chrome.runtime.lastError.message
+ );
+ browser.test.notifyPass("finished chrome.runtime.sendMessage");
+ });
+ isAsyncCall = true;
+ }
+ let extensionData = {
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await extension.awaitFinish("finished chrome.runtime.sendMessage");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js
new file mode 100644
index 0000000000..80641d7be4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js
@@ -0,0 +1,131 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+const WIN = `<html><body>dummy page setting a same-site cookie</body></html>`;
+
+// Small red image.
+const IMG_BYTES = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12" +
+ "P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="
+);
+
+server.registerPathHandler("/same_site_cookies", (request, response) => {
+ // avoid confusing cache behaviors
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ if (request.queryString === "loadWin") {
+ response.write(WIN);
+ return;
+ }
+
+ // using startsWith and discard the math random
+ if (request.queryString.startsWith("loadImage")) {
+ response.setHeader(
+ "Set-Cookie",
+ "myKey=mySameSiteExtensionCookie; samesite=strict",
+ true
+ );
+ response.setHeader("Content-Type", "image/png");
+ response.write(IMG_BYTES);
+ return;
+ }
+
+ if (request.queryString === "loadXHR") {
+ let cookie = "noCookie";
+ if (request.hasHeader("Cookie")) {
+ cookie = request.getHeader("Cookie");
+ }
+ response.setHeader("Content-Type", "text/plain");
+ response.write(cookie);
+ return;
+ }
+
+ // We should never get here, but just in case return something unexpected.
+ response.write("D'oh");
+});
+
+/* Description of the test:
+ * (1) We load an image from mochi.test which sets a same site cookie
+ * (2) We have the web extension perform an XHR request to mochi.test
+ * (3) We verify the web-extension can access the same-site cookie
+ */
+
+add_task(async function test_webRequest_same_site_cookie_access() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/*"],
+ content_scripts: [
+ {
+ matches: ["http://example.com/*"],
+ run_at: "document_end",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "verify-same-site-cookie-moz-extension") {
+ let xhr = new XMLHttpRequest();
+ try {
+ xhr.open(
+ "GET",
+ "http://example.com/same_site_cookies?loadXHR",
+ true
+ );
+ xhr.onload = function() {
+ browser.test.assertEq(
+ "myKey=mySameSiteExtensionCookie",
+ xhr.responseText,
+ "cookie should be accessible from moz-extension context"
+ );
+ browser.test.sendMessage("same-site-cookie-test-done");
+ };
+ xhr.onerror = function() {
+ browser.test.fail("xhr onerror");
+ browser.test.sendMessage("same-site-cookie-test-done");
+ };
+ } catch (e) {
+ browser.test.fail("xhr failure: " + e);
+ }
+ xhr.send();
+ }
+ });
+ },
+
+ files: {
+ "content_script.js": function() {
+ let myImage = document.createElement("img");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ myImage.wrappedJSObject.setAttribute(
+ "src",
+ "http://example.com/same_site_cookies?loadImage" + Math.random()
+ );
+ myImage.onload = function() {
+ browser.test.log("image onload");
+ browser.test.sendMessage("image-loaded-and-same-site-cookie-set");
+ };
+ myImage.onerror = function() {
+ browser.test.log("image onerror");
+ };
+ document.body.appendChild(myImage);
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/same_site_cookies?loadWin"
+ );
+
+ await extension.awaitMessage("image-loaded-and-same-site-cookie-set");
+
+ extension.sendMessage("verify-same-site-cookie-moz-extension");
+ await extension.awaitMessage("same-site-cookie-test-done");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js
new file mode 100644
index 0000000000..df77f8b0dd
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js
@@ -0,0 +1,233 @@
+"use strict";
+
+/**
+ * This test tests various redirection scenarios, and checks whether sameSite
+ * cookies are sent.
+ *
+ * The file has the following tests:
+ * - verify_firstparty_web_behavior - base case, confirms normal web behavior.
+ * - samesite_is_foreign_without_host_permissions
+ * - wildcard_host_permissions_enable_samesite_cookies
+ * - explicit_host_permissions_enable_samesite_cookies
+ * - some_host_permissions_enable_some_samesite_cookies
+ */
+
+// This simulates a common pattern used for sites that require authentication.
+// After logging in, there may be multiple redirects, HTTP and scripted.
+const SITE_START = "start.example.net";
+// set "start" cookies + 302 redirects to found.
+const SITE_FOUND = "found.example.net";
+// set "found" cookies + uses a HTML redirect to redir.
+const SITE_REDIR = "redir.example.net";
+// set "redir" cookies + 302 redirects to final.
+const SITE_FINAL = "final.example.net";
+
+const SITE = "example.net";
+
+const URL_START = `http://${SITE_START}/start`;
+
+const server = createHttpServer({
+ hosts: [SITE_START, SITE_FOUND, SITE_REDIR, SITE_FINAL],
+});
+
+function getCookies(request) {
+ return request.hasHeader("Cookie") ? request.getHeader("Cookie") : "";
+}
+
+function sendCookies(response, prefix, suffix = "") {
+ const cookies = [
+ prefix + "-none=1; sameSite=none; domain=" + SITE + suffix,
+ prefix + "-lax=1; sameSite=lax; domain=" + SITE + suffix,
+ prefix + "-strict=1; sameSite=strict; domain=" + SITE + suffix,
+ ];
+ for (let cookie of cookies) {
+ response.setHeader("Set-Cookie", cookie, true);
+ }
+}
+
+function deleteCookies(response, prefix) {
+ sendCookies(response, prefix, "; expires=Thu, 01 Jan 1970 00:00:00 GMT");
+}
+
+var receivedCookies = [];
+
+server.registerPathHandler("/start", (request, response) => {
+ Assert.equal(request.host, SITE_START);
+ Assert.equal(getCookies(request), "", "No cookies at start of test");
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ sendCookies(response, "start");
+ response.setHeader("Location", `http://${SITE_FOUND}/found`);
+});
+
+server.registerPathHandler("/found", (request, response) => {
+ Assert.equal(request.host, SITE_FOUND);
+ receivedCookies.push(getCookies(request));
+
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ deleteCookies(response, "start");
+ sendCookies(response, "found");
+ response.write(`<script>location = "http://${SITE_REDIR}/redir";</script>`);
+});
+
+server.registerPathHandler("/redir", (request, response) => {
+ Assert.equal(request.host, SITE_REDIR);
+ receivedCookies.push(getCookies(request));
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ deleteCookies(response, "found");
+ sendCookies(response, "redir");
+ response.setHeader("Location", `http://${SITE_FINAL}/final`);
+});
+
+server.registerPathHandler("/final", (request, response) => {
+ Assert.equal(request.host, SITE_FINAL);
+ receivedCookies.push(getCookies(request));
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ deleteCookies(response, "redir");
+ // In test some_host_permissions_enable_some_samesite_cookies, the cookies
+ // from the start haven't been cleared due to the lack of host permissions.
+ // Do that here instead.
+ deleteCookies(response, "start");
+ response.setHeader("Location", "/final_and_clean");
+});
+
+// Should be called before any request is made.
+function promiseFinalResponse() {
+ Assert.deepEqual(receivedCookies, [], "Test starts without observed cookies");
+ return new Promise(resolve => {
+ server.registerPathHandler("/final_and_clean", (request, response) => {
+ Assert.equal(request.host, SITE_FINAL);
+ Assert.equal(getCookies(request), "", "Cookies cleaned up");
+ resolve(receivedCookies.splice(0));
+ });
+ });
+}
+
+// Load the page as a child frame of an extension, for the given permissions.
+async function getCookiesForLoadInExtension({ permissions }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions,
+ },
+ files: {
+ "embedder.html": `<iframe src="${URL_START}"></iframe>`,
+ },
+ });
+ await extension.startup();
+ let cookiesPromise = promiseFinalResponse();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/embedder.html`,
+ { extension }
+ );
+ let cookies = await cookiesPromise;
+ await contentPage.close();
+ await extension.unload();
+ return cookies;
+}
+
+add_task(async function setup() {
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 0);
+ Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true);
+
+ // Test server runs on http, so disable Secure requirement of sameSite=none.
+ Services.prefs.setBoolPref(
+ "network.cookie.sameSite.noneRequiresSecure",
+ false
+ );
+});
+
+// First verify that our expectations match with the actual behavior on the web.
+add_task(async function verify_firstparty_web_behavior() {
+ let cookiesPromise = promiseFinalResponse();
+ let contentPage = await ExtensionTestUtils.loadContentPage(URL_START);
+ let cookies = await cookiesPromise;
+ await contentPage.close();
+ Assert.deepEqual(
+ cookies,
+ // Same expectations as in host_permissions_enable_samesite_cookies
+ [
+ "start-none=1; start-lax=1; start-strict=1",
+ "found-none=1; found-lax=1; found-strict=1",
+ "redir-none=1; redir-lax=1; redir-strict=1",
+ ],
+ "Expected cookies from a first-party load on the web"
+ );
+});
+
+// Verify that an extension without permission behaves like a third-party page.
+add_task(async function samesite_is_foreign_without_host_permissions() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: [],
+ });
+
+ Assert.deepEqual(
+ cookies,
+ ["start-none=1", "found-none=1", "redir-none=1"],
+ "SameSite cookies excluded without permissions"
+ );
+});
+
+// When an extension has permissions for the site, cookies should be included.
+add_task(async function wildcard_host_permissions_enable_samesite_cookies() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: ["*://*.example.net/*"], // = *.SITE
+ });
+
+ Assert.deepEqual(
+ cookies,
+ // Same expectations as in verify_firstparty_web_behavior.
+ [
+ "start-none=1; start-lax=1; start-strict=1",
+ "found-none=1; found-lax=1; found-strict=1",
+ "redir-none=1; redir-lax=1; redir-strict=1",
+ ],
+ "Expected cookies from a load in an extension frame"
+ );
+});
+
+// When an extension has permissions for the site, cookies should be included.
+add_task(async function explicit_host_permissions_enable_samesite_cookies() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: [
+ "*://start.example.net/*",
+ "*://found.example.net/*",
+ "*://redir.example.net/*",
+ "*://final.example.net/*",
+ ],
+ });
+
+ Assert.deepEqual(
+ cookies,
+ // Same expectations as in verify_firstparty_web_behavior.
+ [
+ "start-none=1; start-lax=1; start-strict=1",
+ "found-none=1; found-lax=1; found-strict=1",
+ "redir-none=1; redir-lax=1; redir-strict=1",
+ ],
+ "Expected cookies from a load in an extension frame"
+ );
+});
+
+// When an extension does not have host permissions for all sites, but only
+// some, then same-site cookies are only included in requests with the right
+// permissions.
+add_task(async function some_host_permissions_enable_some_samesite_cookies() {
+ let cookies = await getCookiesForLoadInExtension({
+ permissions: ["*://start.example.net/*", "*://final.example.net/*"],
+ });
+
+ Assert.deepEqual(
+ cookies,
+ [
+ // Missing permission for "found.example.net":
+ "start-none=1",
+ // Missing permission for "redir.example.net":
+ "found-none=1",
+ // "final.example.net" can see cookies from "start.example.net":
+ "start-lax=1; start-strict=1; redir-none=1",
+ ],
+ "Expected some cookies from a load in an extension frame"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js
new file mode 100644
index 0000000000..0a8a5acdef
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js
@@ -0,0 +1,42 @@
+"use strict";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+function contentScript() {
+ window.x = 12;
+ browser.test.assertEq(window.x, 12, "x is 12");
+ browser.test.notifyPass("background test passed");
+}
+
+let extensionData = {
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_idle",
+ },
+ ],
+ },
+
+ files: {
+ "content_script.js": contentScript,
+ },
+};
+
+add_task(async function test_contentscript() {
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+
+ await extension.awaitFinish();
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js
new file mode 100644
index 0000000000..90b615d10e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js
@@ -0,0 +1,79 @@
+"use strict";
+
+AddonTestUtils.init(this);
+
+add_task(async function testEmptySchema() {
+ function background() {
+ browser.test.assertEq(
+ undefined,
+ browser.manifest,
+ "browser.manifest is not defined"
+ );
+ browser.test.assertTrue(
+ !!browser.storage,
+ "browser.storage should be defined"
+ );
+ browser.test.assertEq(
+ undefined,
+ browser.contextMenus,
+ "browser.contextMenus should not be defined"
+ );
+ browser.test.notifyPass("schema");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("schema");
+ await extension.unload();
+});
+
+add_task(async function test_warnings_as_errors() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { unrecognized_property_that_should_be_treated_as_a_warning: 1 },
+ });
+
+ // Tests should be run with extensions.webextensions.warnings-as-errors=true
+ // by default, and prevent extensions with manifest warnings from loading.
+ await Assert.rejects(
+ extension.startup(),
+ /unrecognized_property_that_should_be_treated_as_a_warning/,
+ "extension with invalid manifest should not load if warnings-as-errors=true"
+ );
+ // When ExtensionTestUtils.failOnSchemaWarnings(false) is called, startup is
+ // expected to succeed, as shown by the next "testUnknownProperties" test.
+});
+
+add_task(async function testUnknownProperties() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["unknownPermission"],
+
+ unknown_property: {},
+ },
+
+ background() {},
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ { message: /processing permissions\.0: Value "unknownPermission"/ },
+ {
+ message: /processing unknown_property: An unexpected property was found in the WebExtension manifest/,
+ },
+ ],
+ });
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
new file mode 100644
index 0000000000..8eba1b7e83
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js
@@ -0,0 +1,2097 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon;
+
+const global = this;
+
+let json = [
+ {
+ namespace: "testing",
+
+ properties: {
+ PROP1: { value: 20 },
+ prop2: { type: "string" },
+ prop3: {
+ $ref: "submodule",
+ },
+ prop4: {
+ $ref: "submodule",
+ unsupported: true,
+ },
+ },
+
+ types: [
+ {
+ id: "type1",
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+
+ {
+ id: "type2",
+ type: "object",
+ properties: {
+ prop1: { type: "integer" },
+ prop2: { type: "array", items: { $ref: "type1" } },
+ },
+ },
+
+ {
+ id: "basetype1",
+ type: "object",
+ properties: {
+ prop1: { type: "string" },
+ },
+ },
+
+ {
+ id: "basetype2",
+ choices: [{ type: "integer" }],
+ },
+
+ {
+ $extend: "basetype1",
+ properties: {
+ prop2: { type: "string" },
+ },
+ },
+
+ {
+ $extend: "basetype2",
+ choices: [{ type: "string" }],
+ },
+
+ {
+ id: "basetype3",
+ type: "object",
+ properties: {
+ baseprop: { type: "string" },
+ },
+ },
+
+ {
+ id: "derivedtype1",
+ type: "object",
+ $import: "basetype3",
+ properties: {
+ derivedprop: { type: "string" },
+ },
+ },
+
+ {
+ id: "derivedtype2",
+ type: "object",
+ $import: "basetype3",
+ properties: {
+ derivedprop: { type: "integer" },
+ },
+ },
+
+ {
+ id: "submodule",
+ type: "object",
+ functions: [
+ {
+ name: "sub_foo",
+ type: "function",
+ parameters: [],
+ returns: { type: "integer" },
+ },
+ ],
+ },
+ ],
+
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true, default: 99 },
+ { name: "arg2", type: "boolean", optional: true },
+ ],
+ },
+
+ {
+ name: "bar",
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true },
+ { name: "arg2", type: "boolean" },
+ ],
+ },
+
+ {
+ name: "baz",
+ type: "function",
+ parameters: [
+ {
+ name: "arg1",
+ type: "object",
+ properties: {
+ prop1: { type: "string" },
+ prop2: { type: "integer", optional: true },
+ prop3: { type: "integer", unsupported: true },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "qux",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "type1" }],
+ },
+
+ {
+ name: "quack",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "type2" }],
+ },
+
+ {
+ name: "quora",
+ type: "function",
+ parameters: [{ name: "arg1", type: "function" }],
+ },
+
+ {
+ name: "quileute",
+ type: "function",
+ parameters: [
+ { name: "arg1", type: "integer", optional: true },
+ { name: "arg2", type: "integer" },
+ ],
+ },
+
+ {
+ name: "queets",
+ type: "function",
+ unsupported: true,
+ parameters: [],
+ },
+
+ {
+ name: "quintuplets",
+ type: "function",
+ parameters: [
+ {
+ name: "obj",
+ type: "object",
+ properties: [],
+ additionalProperties: { type: "integer" },
+ },
+ ],
+ },
+
+ {
+ name: "quasar",
+ type: "function",
+ parameters: [
+ {
+ name: "abc",
+ type: "object",
+ properties: {
+ func: {
+ type: "function",
+ parameters: [{ name: "x", type: "integer" }],
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "quosimodo",
+ type: "function",
+ parameters: [
+ {
+ name: "xyz",
+ type: "object",
+ additionalProperties: { type: "any" },
+ },
+ ],
+ },
+
+ {
+ name: "patternprop",
+ type: "function",
+ parameters: [
+ {
+ name: "obj",
+ type: "object",
+ properties: { prop1: { type: "string", pattern: "^\\d+$" } },
+ patternProperties: {
+ "(?i)^prop\\d+$": { type: "string" },
+ "^foo\\d+$": { type: "string" },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "pattern",
+ type: "function",
+ parameters: [
+ { name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$" },
+ ],
+ },
+
+ {
+ name: "format",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ hostname: { type: "string", format: "hostname", optional: true },
+ url: { type: "string", format: "url", optional: true },
+ relativeUrl: {
+ type: "string",
+ format: "relativeUrl",
+ optional: true,
+ },
+ strictRelativeUrl: {
+ type: "string",
+ format: "strictRelativeUrl",
+ optional: true,
+ },
+ imageDataOrStrictRelativeUrl: {
+ type: "string",
+ format: "imageDataOrStrictRelativeUrl",
+ optional: true,
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "formatDate",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ date: { type: "string", format: "date", optional: true },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "deep",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ foo: {
+ type: "object",
+ properties: {
+ bar: {
+ type: "array",
+ items: {
+ type: "object",
+ properties: {
+ baz: {
+ type: "object",
+ properties: {
+ required: { type: "integer" },
+ optional: { type: "string", optional: true },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "errors",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ warn: {
+ type: "string",
+ pattern: "^\\d+$",
+ optional: true,
+ onError: "warn",
+ },
+ ignore: {
+ type: "string",
+ pattern: "^\\d+$",
+ optional: true,
+ onError: "ignore",
+ },
+ default: {
+ type: "string",
+ pattern: "^\\d+$",
+ optional: true,
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "localize",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ foo: { type: "string", preprocess: "localize", optional: true },
+ bar: { type: "string", optional: true },
+ url: {
+ type: "string",
+ preprocess: "localize",
+ format: "url",
+ optional: true,
+ },
+ },
+ },
+ ],
+ },
+
+ {
+ name: "extended1",
+ type: "function",
+ parameters: [{ name: "val", $ref: "basetype1" }],
+ },
+
+ {
+ name: "extended2",
+ type: "function",
+ parameters: [{ name: "val", $ref: "basetype2" }],
+ },
+
+ {
+ name: "callderived1",
+ type: "function",
+ parameters: [{ name: "value", $ref: "derivedtype1" }],
+ },
+
+ {
+ name: "callderived2",
+ type: "function",
+ parameters: [{ name: "value", $ref: "derivedtype2" }],
+ },
+ ],
+
+ events: [
+ {
+ name: "onFoo",
+ type: "function",
+ },
+
+ {
+ name: "onBar",
+ type: "function",
+ extraParameters: [
+ {
+ name: "filter",
+ type: "integer",
+ optional: true,
+ default: 1,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ namespace: "foreign",
+ properties: {
+ foreignRef: { $ref: "testing.submodule" },
+ },
+ },
+ {
+ namespace: "inject",
+ properties: {
+ PROP1: { value: "should inject" },
+ },
+ },
+ {
+ namespace: "do-not-inject",
+ properties: {
+ PROP1: { value: "should not inject" },
+ },
+ },
+];
+
+let tallied = null;
+
+function tally(kind, ns, name, args) {
+ tallied = [kind, ns, name, args];
+}
+
+function verify(...args) {
+ Assert.equal(JSON.stringify(tallied), JSON.stringify(args));
+ tallied = null;
+}
+
+let talliedErrors = [];
+
+function checkErrors(errors) {
+ 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;
+}
+
+let permissions = new Set();
+
+class TallyingAPIImplementation extends SchemaAPIInterface {
+ constructor(namespace, name) {
+ super();
+ this.namespace = namespace;
+ this.name = name;
+ }
+
+ callFunction(args) {
+ tally("call", this.namespace, this.name, args);
+ if (this.name === "sub_foo") {
+ return 13;
+ }
+ }
+
+ callFunctionNoReturn(args) {
+ tally("call", this.namespace, this.name, args);
+ }
+
+ getProperty() {
+ tally("get", this.namespace, this.name);
+ }
+
+ setProperty(value) {
+ tally("set", this.namespace, this.name, value);
+ }
+
+ addListener(listener, args) {
+ tally("addListener", this.namespace, this.name, [listener, args]);
+ }
+
+ removeListener(listener) {
+ tally("removeListener", this.namespace, this.name, [listener]);
+ }
+
+ hasListener(listener) {
+ tally("hasListener", this.namespace, this.name, [listener]);
+ }
+}
+
+let wrapper = {
+ url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
+
+ cloneScope: global,
+
+ checkLoadURL(url) {
+ return !url.startsWith("chrome:");
+ },
+
+ preprocessors: {
+ localize(value, context) {
+ return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`);
+ },
+ },
+
+ logError(message) {
+ talliedErrors.push(message);
+ },
+
+ hasPermission(permission) {
+ return permissions.has(permission);
+ },
+
+ shouldInject(ns, name) {
+ return name != "do-not-inject";
+ },
+
+ getImplementation(namespace, name) {
+ return new TallyingAPIImplementation(namespace, name);
+ },
+};
+
+add_task(async function() {
+ let url = "data:," + JSON.stringify(json);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ tallied = null;
+ Schemas.inject(root, wrapper);
+ Assert.equal(tallied, null);
+
+ Assert.equal(root.testing.PROP1, 20, "simple value property");
+ Assert.equal(root.testing.type1.VALUE1, "value1", "enum type");
+ Assert.equal(root.testing.type1.VALUE2, "value2", "enum type");
+
+ Assert.equal("inject" in root, true, "namespace 'inject' should be injected");
+ Assert.equal(
+ root["do-not-inject"],
+ undefined,
+ "namespace 'do-not-inject' should not be injected"
+ );
+
+ root.testing.foo(11, true);
+ verify("call", "testing", "foo", [11, true]);
+
+ root.testing.foo(true);
+ verify("call", "testing", "foo", [99, true]);
+
+ root.testing.foo(null, true);
+ verify("call", "testing", "foo", [99, true]);
+
+ root.testing.foo(undefined, true);
+ verify("call", "testing", "foo", [99, true]);
+
+ root.testing.foo(11);
+ verify("call", "testing", "foo", [11, null]);
+
+ Assert.throws(
+ () => root.testing.bar(11),
+ /Incorrect argument types/,
+ "should throw without required arg"
+ );
+
+ Assert.throws(
+ () => root.testing.bar(11, true, 10),
+ /Incorrect argument types/,
+ "should throw with too many arguments"
+ );
+
+ root.testing.bar(true);
+ verify("call", "testing", "bar", [null, true]);
+
+ root.testing.baz({ prop1: "hello", prop2: 22 });
+ verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]);
+
+ root.testing.baz({ prop1: "hello" });
+ verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]);
+
+ root.testing.baz({ prop1: "hello", prop2: null });
+ verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]);
+
+ Assert.throws(
+ () => root.testing.baz({ prop2: 12 }),
+ /Property "prop1" is required/,
+ "should throw without required property"
+ );
+
+ Assert.throws(
+ () => root.testing.baz({ prop1: "hi", prop3: 12 }),
+ /Property "prop3" is unsupported by Firefox/,
+ "should throw with unsupported property"
+ );
+
+ Assert.throws(
+ () => root.testing.baz({ prop1: "hi", prop4: 12 }),
+ /Unexpected property "prop4"/,
+ "should throw with unexpected property"
+ );
+
+ Assert.throws(
+ () => root.testing.baz({ prop1: 12 }),
+ /Expected string instead of 12/,
+ "should throw with wrong type"
+ );
+
+ root.testing.qux("value2");
+ verify("call", "testing", "qux", ["value2"]);
+
+ Assert.throws(
+ () => root.testing.qux("value4"),
+ /Invalid enumeration value "value4"/,
+ "should throw for invalid enum value"
+ );
+
+ root.testing.quack({ prop1: 12, prop2: ["value1", "value3"] });
+ verify("call", "testing", "quack", [
+ { prop1: 12, prop2: ["value1", "value3"] },
+ ]);
+
+ Assert.throws(
+ () =>
+ root.testing.quack({ prop1: 12, prop2: ["value1", "value3", "value4"] }),
+ /Invalid enumeration value "value4"/,
+ "should throw for invalid array type"
+ );
+
+ function f() {}
+ root.testing.quora(f);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["call", "testing", "quora"])
+ );
+ Assert.equal(tallied[3][0], f);
+ tallied = null;
+
+ let g = () => 0;
+ root.testing.quora(g);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["call", "testing", "quora"])
+ );
+ Assert.equal(tallied[3][0], g);
+ tallied = null;
+
+ root.testing.quileute(10);
+ verify("call", "testing", "quileute", [null, 10]);
+
+ Assert.throws(
+ () => root.testing.queets(),
+ /queets is not a function/,
+ "should throw for unsupported functions"
+ );
+
+ root.testing.quintuplets({ a: 10, b: 20, c: 30 });
+ verify("call", "testing", "quintuplets", [{ a: 10, b: 20, c: 30 }]);
+
+ Assert.throws(
+ () => root.testing.quintuplets({ a: 10, b: 20, c: 30, d: "hi" }),
+ /Expected integer instead of "hi"/,
+ "should throw for wrong additionalProperties type"
+ );
+
+ root.testing.quasar({ func: f });
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["call", "testing", "quasar"])
+ );
+ Assert.equal(tallied[3][0].func, f);
+ tallied = null;
+
+ root.testing.quosimodo({ a: 10, b: 20, c: 30 });
+ verify("call", "testing", "quosimodo", [{ a: 10, b: 20, c: 30 }]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.quosimodo(10),
+ /Incorrect argument types/,
+ "should throw for wrong type"
+ );
+
+ root.testing.patternprop({
+ prop1: "12",
+ prop2: "42",
+ Prop3: "43",
+ foo1: "x",
+ });
+ verify("call", "testing", "patternprop", [
+ { prop1: "12", prop2: "42", Prop3: "43", foo1: "x" },
+ ]);
+ tallied = null;
+
+ root.testing.patternprop({ prop1: "12" });
+ verify("call", "testing", "patternprop", [{ prop1: "12" }]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", foo1: null }),
+ /Expected string instead of null/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "xx", prop2: "yy" }),
+ /String "xx" must match \/\^\\d\+\$\//,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", prop2: 42 }),
+ /Expected string instead of 42/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", prop2: null }),
+ /Expected string instead of null/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", propx: "42" }),
+ /Unexpected property "propx"/,
+ "should throw for unexpected property"
+ );
+
+ Assert.throws(
+ () => root.testing.patternprop({ prop1: "12", Foo1: "x" }),
+ /Unexpected property "Foo1"/,
+ "should throw for unexpected property"
+ );
+
+ root.testing.pattern("DEADbeef");
+ verify("call", "testing", "pattern", ["DEADbeef"]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.pattern("DEADcow"),
+ /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/,
+ "should throw for non-match"
+ );
+
+ root.testing.format({ hostname: "foo" });
+ verify("call", "testing", "format", [
+ {
+ hostname: "foo",
+ imageDataOrStrictRelativeUrl: null,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+ tallied = null;
+
+ for (let invalid of ["", " ", "http://foo", "foo/bar", "foo.com/", "foo?"]) {
+ Assert.throws(
+ () => root.testing.format({ hostname: invalid }),
+ /Invalid hostname/,
+ "should throw for invalid hostname"
+ );
+ }
+
+ root.testing.format({ url: "http://foo/bar", relativeUrl: "http://foo/bar" });
+ verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: null,
+ relativeUrl: "http://foo/bar",
+ strictRelativeUrl: null,
+ url: "http://foo/bar",
+ },
+ ]);
+ tallied = null;
+
+ root.testing.format({
+ relativeUrl: "foo.html",
+ strictRelativeUrl: "foo.html",
+ });
+ verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: null,
+ relativeUrl: `${wrapper.url}foo.html`,
+ strictRelativeUrl: `${wrapper.url}foo.html`,
+ url: null,
+ },
+ ]);
+ tallied = null;
+
+ root.testing.format({
+ imageDataOrStrictRelativeUrl: "data:image/png;base64,A",
+ });
+ verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: "data:image/png;base64,A",
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+ tallied = null;
+
+ root.testing.format({
+ imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A",
+ });
+ verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A",
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+ tallied = null;
+
+ root.testing.format({ imageDataOrStrictRelativeUrl: "foo.html" });
+ verify("call", "testing", "format", [
+ {
+ hostname: null,
+ imageDataOrStrictRelativeUrl: `${wrapper.url}foo.html`,
+ relativeUrl: null,
+ strictRelativeUrl: null,
+ url: null,
+ },
+ ]);
+
+ tallied = null;
+
+ for (let format of ["url", "relativeUrl"]) {
+ Assert.throws(
+ () => root.testing.format({ [format]: "chrome://foo/content/" }),
+ /Access denied/,
+ "should throw for access denied"
+ );
+ }
+
+ for (let urlString of ["//foo.html", "http://foo/bar.html"]) {
+ Assert.throws(
+ () => root.testing.format({ strictRelativeUrl: urlString }),
+ /must be a relative URL/,
+ "should throw for non-relative URL"
+ );
+ }
+
+ Assert.throws(
+ () =>
+ root.testing.format({
+ imageDataOrStrictRelativeUrl: "data:image/svg+xml;utf8,A",
+ }),
+ /must be a relative or PNG or JPG data:image URL/,
+ "should throw for non-relative or non PNG/JPG data URL"
+ );
+
+ const dates = [
+ "2016-03-04",
+ "2016-03-04T08:00:00Z",
+ "2016-03-04T08:00:00.000Z",
+ "2016-03-04T08:00:00-08:00",
+ "2016-03-04T08:00:00.000-08:00",
+ "2016-03-04T08:00:00+08:00",
+ "2016-03-04T08:00:00.000+08:00",
+ "2016-03-04T08:00:00+0800",
+ "2016-03-04T08:00:00-0800",
+ ];
+ dates.forEach(str => {
+ root.testing.formatDate({ date: str });
+ verify("call", "testing", "formatDate", [{ date: str }]);
+ });
+
+ // Make sure that a trivial change to a valid date invalidates it.
+ dates.forEach(str => {
+ Assert.throws(
+ () => root.testing.formatDate({ date: "0" + str }),
+ /Invalid date string/,
+ "should throw for invalid iso date string"
+ );
+ Assert.throws(
+ () => root.testing.formatDate({ date: str + "0" }),
+ /Invalid date string/,
+ "should throw for invalid iso date string"
+ );
+ });
+
+ const badDates = [
+ "I do not look anything like a date string",
+ "2016-99-99",
+ "2016-03-04T25:00:00Z",
+ ];
+ badDates.forEach(str => {
+ Assert.throws(
+ () => root.testing.formatDate({ date: str }),
+ /Invalid date string/,
+ "should throw for invalid iso date string"
+ );
+ });
+
+ root.testing.deep({
+ foo: { bar: [{ baz: { required: 12, optional: "42" } }] },
+ });
+ verify("call", "testing", "deep", [
+ { foo: { bar: [{ baz: { optional: "42", required: 12 } }] } },
+ ]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.deep({ foo: { bar: [{ baz: { optional: "42" } }] } }),
+ /Type error for parameter arg \(Error processing foo\.bar\.0\.baz: Property "required" is required\) for testing\.deep/,
+ "should throw with the correct object path"
+ );
+
+ Assert.throws(
+ () =>
+ root.testing.deep({
+ foo: { bar: [{ baz: { optional: 42, required: 12 } }] },
+ }),
+ /Type error for parameter arg \(Error processing foo\.bar\.0\.baz\.optional: Expected string instead of 42\) for testing\.deep/,
+ "should throw with the correct object path"
+ );
+
+ talliedErrors.length = 0;
+
+ root.testing.errors({ default: "0123", ignore: "0123", warn: "0123" });
+ verify("call", "testing", "errors", [
+ { default: "0123", ignore: "0123", warn: "0123" },
+ ]);
+ checkErrors([]);
+
+ root.testing.errors({ default: "0123", ignore: "x123", warn: "0123" });
+ verify("call", "testing", "errors", [
+ { default: "0123", ignore: null, warn: "0123" },
+ ]);
+ checkErrors([]);
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ root.testing.errors({ default: "0123", ignore: "0123", warn: "x123" });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ verify("call", "testing", "errors", [
+ { default: "0123", ignore: "0123", warn: null },
+ ]);
+ checkErrors(['String "x123" must match /^\\d+$/']);
+
+ root.testing.onFoo.addListener(f);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["addListener", "testing", "onFoo"])
+ );
+ Assert.equal(tallied[3][0], f);
+ Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([]));
+ tallied = null;
+
+ root.testing.onFoo.removeListener(f);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["removeListener", "testing", "onFoo"])
+ );
+ Assert.equal(tallied[3][0], f);
+ tallied = null;
+
+ root.testing.onFoo.hasListener(f);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["hasListener", "testing", "onFoo"])
+ );
+ Assert.equal(tallied[3][0], f);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.onFoo.addListener(10),
+ /Invalid listener/,
+ "addListener with non-function should throw"
+ );
+
+ root.testing.onBar.addListener(f, 10);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["addListener", "testing", "onBar"])
+ );
+ Assert.equal(tallied[3][0], f);
+ Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([10]));
+ tallied = null;
+
+ root.testing.onBar.addListener(f);
+ Assert.equal(
+ JSON.stringify(tallied.slice(0, -1)),
+ JSON.stringify(["addListener", "testing", "onBar"])
+ );
+ Assert.equal(tallied[3][0], f);
+ Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([1]));
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.onBar.addListener(f, "hi"),
+ /Incorrect argument types/,
+ "addListener with wrong extra parameter should throw"
+ );
+
+ let target = { prop1: 12, prop2: ["value1", "value3"] };
+ let proxy = new Proxy(target, {});
+ Assert.throws(
+ () => root.testing.quack(proxy),
+ /Expected a plain JavaScript object, got a Proxy/,
+ "should throw when passing a Proxy"
+ );
+
+ if (Symbol.toStringTag) {
+ let stringTarget = { prop1: 12, prop2: ["value1", "value3"] };
+ stringTarget[Symbol.toStringTag] = () => "[object Object]";
+ let stringProxy = new Proxy(stringTarget, {});
+ Assert.throws(
+ () => root.testing.quack(stringProxy),
+ /Expected a plain JavaScript object, got a Proxy/,
+ "should throw when passing a Proxy"
+ );
+ }
+
+ root.testing.localize({
+ foo: "__MSG_foo__",
+ bar: "__MSG_foo__",
+ url: "__MSG_http://example.com/__",
+ });
+ verify("call", "testing", "localize", [
+ { bar: "__MSG_foo__", foo: "FOO", url: "http://example.com/" },
+ ]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.localize({ url: "__MSG_/foo/bar__" }),
+ /\/FOO\/BAR is not a valid URL\./,
+ "should throw for invalid URL"
+ );
+
+ root.testing.extended1({ prop1: "foo", prop2: "bar" });
+ verify("call", "testing", "extended1", [{ prop1: "foo", prop2: "bar" }]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.extended1({ prop1: "foo", prop2: 12 }),
+ /Expected string instead of 12/,
+ "should throw for wrong property type"
+ );
+
+ Assert.throws(
+ () => root.testing.extended1({ prop1: "foo" }),
+ /Property "prop2" is required/,
+ "should throw for missing property"
+ );
+
+ Assert.throws(
+ () => root.testing.extended1({ prop1: "foo", prop2: "bar", prop3: "xxx" }),
+ /Unexpected property "prop3"/,
+ "should throw for extra property"
+ );
+
+ root.testing.extended2("foo");
+ verify("call", "testing", "extended2", ["foo"]);
+ tallied = null;
+
+ root.testing.extended2(12);
+ verify("call", "testing", "extended2", [12]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.extended2(true),
+ /Incorrect argument types/,
+ "should throw for wrong argument type"
+ );
+
+ root.testing.prop3.sub_foo();
+ verify("call", "testing.prop3", "sub_foo", []);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.prop4.sub_foo(),
+ /root.testing.prop4 is undefined/,
+ "should throw for unsupported submodule"
+ );
+
+ root.foreign.foreignRef.sub_foo();
+ verify("call", "foreign.foreignRef", "sub_foo", []);
+ tallied = null;
+
+ root.testing.callderived1({ baseprop: "s1", derivedprop: "s2" });
+ verify("call", "testing", "callderived1", [
+ { baseprop: "s1", derivedprop: "s2" },
+ ]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.callderived1({ baseprop: "s1", derivedprop: 42 }),
+ /Error processing derivedprop: Expected string/,
+ "Two different objects may $import the same base object"
+ );
+ Assert.throws(
+ () => root.testing.callderived1({ baseprop: "s1" }),
+ /Property "derivedprop" is required/,
+ "Object using $import has its local properites"
+ );
+ Assert.throws(
+ () => root.testing.callderived1({ derivedprop: "s2" }),
+ /Property "baseprop" is required/,
+ "Object using $import has imported properites"
+ );
+
+ root.testing.callderived2({ baseprop: "s1", derivedprop: 42 });
+ verify("call", "testing", "callderived2", [
+ { baseprop: "s1", derivedprop: 42 },
+ ]);
+ tallied = null;
+
+ Assert.throws(
+ () => root.testing.callderived2({ baseprop: "s1", derivedprop: "s2" }),
+ /Error processing derivedprop: Expected integer/,
+ "Two different objects may $import the same base object"
+ );
+ Assert.throws(
+ () => root.testing.callderived2({ baseprop: "s1" }),
+ /Property "derivedprop" is required/,
+ "Object using $import has its local properites"
+ );
+ Assert.throws(
+ () => root.testing.callderived2({ derivedprop: 42 }),
+ /Property "baseprop" is required/,
+ "Object using $import has imported properites"
+ );
+});
+
+let deprecatedJson = [
+ {
+ namespace: "deprecated",
+
+ properties: {
+ accessor: {
+ type: "string",
+ writable: true,
+ deprecated: "This is not the property you are looking for",
+ },
+ },
+
+ types: [
+ {
+ id: "Type",
+ type: "string",
+ },
+ ],
+
+ functions: [
+ {
+ name: "property",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ properties: {
+ foo: {
+ type: "string",
+ },
+ },
+ additionalProperties: {
+ type: "any",
+ deprecated: "Unknown property",
+ },
+ },
+ ],
+ },
+
+ {
+ name: "value",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "integer",
+ },
+ {
+ type: "string",
+ deprecated: "Please use an integer, not ${value}",
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "choices",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ deprecated: "You have no choices",
+ choices: [
+ {
+ type: "integer",
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "ref",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ $ref: "Type",
+ deprecated: "Deprecated alias",
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "method",
+ type: "function",
+ deprecated: "Do not call this method",
+ parameters: [],
+ },
+ ],
+
+ events: [
+ {
+ name: "onDeprecated",
+ type: "function",
+ deprecated: "This event does not work",
+ },
+ ],
+ },
+];
+
+add_task(async function testDeprecation() {
+ // This whole test expects deprecation warnings.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+
+ let url = "data:," + JSON.stringify(deprecatedJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ talliedErrors.length = 0;
+
+ root.deprecated.property({ foo: "bar", xxx: "any", yyy: "property" });
+ verify("call", "deprecated", "property", [
+ { foo: "bar", xxx: "any", yyy: "property" },
+ ]);
+ checkErrors([
+ "Warning processing xxx: Unknown property",
+ "Warning processing yyy: Unknown property",
+ ]);
+
+ root.deprecated.value(12);
+ verify("call", "deprecated", "value", [12]);
+ checkErrors([]);
+
+ root.deprecated.value("12");
+ verify("call", "deprecated", "value", ["12"]);
+ checkErrors(['Please use an integer, not "12"']);
+
+ root.deprecated.choices(12);
+ verify("call", "deprecated", "choices", [12]);
+ checkErrors(["You have no choices"]);
+
+ root.deprecated.ref("12");
+ verify("call", "deprecated", "ref", ["12"]);
+ checkErrors(["Deprecated alias"]);
+
+ root.deprecated.method();
+ verify("call", "deprecated", "method", []);
+ checkErrors(["Do not call this method"]);
+
+ void root.deprecated.accessor;
+ verify("get", "deprecated", "accessor", null);
+ checkErrors(["This is not the property you are looking for"]);
+
+ root.deprecated.accessor = "x";
+ verify("set", "deprecated", "accessor", "x");
+ checkErrors(["This is not the property you are looking for"]);
+
+ root.deprecated.onDeprecated.addListener(() => {});
+ checkErrors(["This event does not work"]);
+
+ root.deprecated.onDeprecated.removeListener(() => {});
+ checkErrors(["This event does not work"]);
+
+ root.deprecated.onDeprecated.hasListener(() => {});
+ checkErrors(["This event does not work"]);
+
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ Assert.throws(
+ () => root.deprecated.onDeprecated.hasListener(() => {}),
+ /This event does not work/,
+ "Deprecation warning with extensions.webextensions.warnings-as-errors=true"
+ );
+});
+
+let choicesJson = [
+ {
+ namespace: "choices",
+
+ types: [],
+
+ functions: [
+ {
+ name: "meh",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "string",
+ enum: ["foo", "bar", "baz"],
+ },
+ {
+ type: "string",
+ pattern: "florg.*meh",
+ },
+ {
+ type: "integer",
+ minimum: 12,
+ maximum: 42,
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "object",
+ properties: {
+ blurg: {
+ type: "string",
+ unsupported: true,
+ optional: true,
+ },
+ },
+ additionalProperties: {
+ type: "string",
+ },
+ },
+ {
+ type: "string",
+ },
+ {
+ type: "array",
+ minItems: 2,
+ maxItems: 3,
+ items: {
+ type: "integer",
+ },
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ name: "bar",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ choices: [
+ {
+ type: "object",
+ properties: {
+ baz: {
+ type: "string",
+ },
+ },
+ },
+ {
+ type: "array",
+ items: {
+ type: "integer",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function testChoices() {
+ let url = "data:," + JSON.stringify(choicesJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ talliedErrors.length = 0;
+
+ Assert.throws(
+ () => root.choices.meh("frog"),
+ /Value "frog" must either: be one of \["foo", "bar", "baz"\], match the pattern \/florg\.\*meh\/, or be an integer value/
+ );
+
+ Assert.throws(
+ () => root.choices.meh(4),
+ /be a string value, or be at least 12/
+ );
+
+ Assert.throws(
+ () => root.choices.meh(43),
+ /be a string value, or be no greater than 42/
+ );
+
+ Assert.throws(
+ () => root.choices.foo([]),
+ /be an object value, be a string value, or have at least 2 items/
+ );
+
+ Assert.throws(
+ () => root.choices.foo([1, 2, 3, 4]),
+ /be an object value, be a string value, or have at most 3 items/
+ );
+
+ Assert.throws(
+ () => root.choices.foo({ foo: 12 }),
+ /.foo must be a string value, be a string value, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.foo({ blurg: "foo" }),
+ /not contain an unsupported "blurg" property, be a string value, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.bar({}),
+ /contain the required "baz" property, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.bar({ baz: "x", quux: "y" }),
+ /not contain an unexpected "quux" property, or be an array value/
+ );
+
+ Assert.throws(
+ () => root.choices.bar({ baz: "x", quux: "y", foo: "z" }),
+ /not contain the unexpected properties \[foo, quux\], or be an array value/
+ );
+});
+
+let permissionsJson = [
+ {
+ namespace: "noPerms",
+
+ types: [],
+
+ functions: [
+ {
+ name: "noPerms",
+ type: "function",
+ parameters: [],
+ },
+
+ {
+ name: "fooPerm",
+ type: "function",
+ permissions: ["foo"],
+ parameters: [],
+ },
+ ],
+ },
+
+ {
+ namespace: "fooPerm",
+
+ permissions: ["foo"],
+
+ types: [],
+
+ functions: [
+ {
+ name: "noPerms",
+ type: "function",
+ parameters: [],
+ },
+
+ {
+ name: "fooBarPerm",
+ type: "function",
+ permissions: ["foo.bar"],
+ parameters: [],
+ },
+ ],
+ },
+];
+
+add_task(async function testPermissions() {
+ let url = "data:," + JSON.stringify(permissionsJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(typeof root.noPerms, "object", "noPerms namespace should exist");
+ equal(
+ typeof root.noPerms.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+
+ equal(
+ root.noPerms.fooPerm,
+ undefined,
+ "noPerms.fooPerm should not method exist"
+ );
+
+ equal(root.fooPerm, undefined, "fooPerm namespace should not exist");
+
+ info('Add "foo" permission');
+ permissions.add("foo");
+
+ root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(typeof root.noPerms, "object", "noPerms namespace should exist");
+ equal(
+ typeof root.noPerms.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+ equal(
+ typeof root.noPerms.fooPerm,
+ "function",
+ "noPerms.fooPerm method should exist"
+ );
+
+ equal(typeof root.fooPerm, "object", "fooPerm namespace should exist");
+ equal(
+ typeof root.fooPerm.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+
+ equal(
+ root.fooPerm.fooBarPerm,
+ undefined,
+ "fooPerm.fooBarPerm method should not exist"
+ );
+
+ info('Add "foo.bar" permission');
+ permissions.add("foo.bar");
+
+ root = {};
+ Schemas.inject(root, wrapper);
+
+ equal(typeof root.noPerms, "object", "noPerms namespace should exist");
+ equal(
+ typeof root.noPerms.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+ equal(
+ typeof root.noPerms.fooPerm,
+ "function",
+ "noPerms.fooPerm method should exist"
+ );
+
+ equal(typeof root.fooPerm, "object", "fooPerm namespace should exist");
+ equal(
+ typeof root.fooPerm.noPerms,
+ "function",
+ "noPerms.noPerms method should exist"
+ );
+ equal(
+ typeof root.fooPerm.fooBarPerm,
+ "function",
+ "noPerms.fooBarPerm method should exist"
+ );
+});
+
+let nestedNamespaceJson = [
+ {
+ namespace: "nested.namespace",
+ types: [
+ {
+ id: "CustomType",
+ type: "object",
+ events: [
+ {
+ name: "onEvent",
+ type: "function",
+ },
+ ],
+ properties: {
+ url: {
+ type: "string",
+ },
+ },
+ functions: [
+ {
+ name: "functionOnCustomType",
+ type: "function",
+ parameters: [
+ {
+ name: "title",
+ type: "string",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ properties: {
+ instanceOfCustomType: {
+ $ref: "CustomType",
+ },
+ },
+ functions: [
+ {
+ name: "create",
+ type: "function",
+ parameters: [
+ {
+ name: "title",
+ type: "string",
+ },
+ ],
+ },
+ ],
+ },
+];
+
+add_task(async function testNestedNamespace() {
+ let url = "data:," + JSON.stringify(nestedNamespaceJson);
+
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, wrapper);
+
+ talliedErrors.length = 0;
+
+ ok(root.nested, "The root object contains the first namespace level");
+ ok(
+ root.nested.namespace,
+ "The first level object contains the second namespace level"
+ );
+
+ ok(
+ root.nested.namespace.create,
+ "Got the expected function in the nested namespace"
+ );
+ equal(
+ typeof root.nested.namespace.create,
+ "function",
+ "The property is a function as expected"
+ );
+
+ let { instanceOfCustomType } = root.nested.namespace;
+
+ ok(
+ instanceOfCustomType,
+ "Got the expected instance of the CustomType defined in the schema"
+ );
+ ok(
+ instanceOfCustomType.functionOnCustomType,
+ "Got the expected method in the CustomType instance"
+ );
+ ok(
+ instanceOfCustomType.onEvent &&
+ instanceOfCustomType.onEvent.addListener &&
+ typeof instanceOfCustomType.onEvent.addListener == "function",
+ "Got the expected event defined in the CustomType instance"
+ );
+
+ instanceOfCustomType.functionOnCustomType("param_value");
+ verify(
+ "call",
+ "nested.namespace.instanceOfCustomType",
+ "functionOnCustomType",
+ ["param_value"]
+ );
+
+ let fakeListener = () => {};
+ instanceOfCustomType.onEvent.addListener(fakeListener);
+ verify("addListener", "nested.namespace.instanceOfCustomType", "onEvent", [
+ fakeListener,
+ [],
+ ]);
+ instanceOfCustomType.onEvent.removeListener(fakeListener);
+ verify("removeListener", "nested.namespace.instanceOfCustomType", "onEvent", [
+ fakeListener,
+ ]);
+
+ // TODO: test support properties in a SubModuleType defined in the schema,
+ // once implemented, e.g.:
+ // ok("url" in instanceOfCustomType,
+ // "Got the expected property defined in the CustomType instance");
+});
+
+let $importJson = [
+ {
+ namespace: "from_the",
+ $import: "future",
+ },
+ {
+ namespace: "future",
+ properties: {
+ PROP1: { value: "original value" },
+ PROP2: { value: "second original" },
+ },
+ types: [
+ {
+ id: "Colour",
+ type: "string",
+ enum: ["red", "white", "blue"],
+ },
+ ],
+ functions: [
+ {
+ name: "dye",
+ type: "function",
+ parameters: [{ name: "arg", $ref: "Colour" }],
+ },
+ ],
+ },
+ {
+ namespace: "embrace",
+ $import: "future",
+ properties: {
+ PROP2: { value: "overridden value" },
+ },
+ types: [
+ {
+ id: "Colour",
+ type: "string",
+ enum: ["blue", "orange"],
+ },
+ ],
+ },
+];
+
+add_task(async function test_$import() {
+ let url = "data:," + JSON.stringify($importJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ tallied = null;
+ Schemas.inject(root, wrapper);
+ equal(tallied, null);
+
+ equal(root.from_the.PROP1, "original value", "imported property");
+ equal(root.from_the.PROP2, "second original", "second imported property");
+ equal(root.from_the.Colour.RED, "red", "imported enum type");
+ equal(typeof root.from_the.dye, "function", "imported function");
+
+ root.from_the.dye("white");
+ verify("call", "from_the", "dye", ["white"]);
+
+ Assert.throws(
+ () => root.from_the.dye("orange"),
+ /Invalid enumeration value/,
+ "original imported argument type Colour doesn't include 'orange'"
+ );
+
+ equal(root.embrace.PROP1, "original value", "imported property");
+ equal(root.embrace.PROP2, "overridden value", "overridden property");
+ equal(root.embrace.Colour.ORANGE, "orange", "overridden enum type");
+ equal(typeof root.embrace.dye, "function", "imported function");
+
+ root.embrace.dye("orange");
+ verify("call", "embrace", "dye", ["orange"]);
+
+ Assert.throws(
+ () => root.embrace.dye("white"),
+ /Invalid enumeration value/,
+ "overridden argument type Colour doesn't include 'white'"
+ );
+});
+
+add_task(async function testLocalAPIImplementation() {
+ let countGet2 = 0;
+ let countProp3 = 0;
+ let countProp3SubFoo = 0;
+
+ let testingApiObj = {
+ get PROP1() {
+ // PROP1 is a schema-defined constant.
+ throw new Error("Unexpected get PROP1");
+ },
+ get prop2() {
+ ++countGet2;
+ return "prop2 val";
+ },
+ get prop3() {
+ throw new Error("Unexpected get prop3");
+ },
+ set prop3(v) {
+ // prop3 is a submodule, defined as a function, so the API should not pass
+ // through assignment to prop3.
+ throw new Error("Unexpected set prop3");
+ },
+ };
+ let submoduleApiObj = {
+ get sub_foo() {
+ ++countProp3;
+ return () => {
+ return ++countProp3SubFoo;
+ };
+ },
+ };
+
+ let localWrapper = {
+ cloneScope: global,
+ shouldInject(ns, name) {
+ return name == "testing" || ns == "testing" || ns == "testing.prop3";
+ },
+ getImplementation(ns, name) {
+ Assert.ok(ns == "testing" || ns == "testing.prop3");
+ if (ns == "testing.prop3" && name == "sub_foo") {
+ // It is fine to use `null` here because we don't call async functions.
+ return new LocalAPIImplementation(submoduleApiObj, name, null);
+ }
+ // It is fine to use `null` here because we don't call async functions.
+ return new LocalAPIImplementation(testingApiObj, name, null);
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+ Assert.equal(countGet2, 0);
+ Assert.equal(countProp3, 0);
+ Assert.equal(countProp3SubFoo, 0);
+
+ Assert.equal(root.testing.PROP1, 20);
+
+ Assert.equal(root.testing.prop2, "prop2 val");
+ Assert.equal(countGet2, 1);
+
+ Assert.equal(root.testing.prop2, "prop2 val");
+ Assert.equal(countGet2, 2);
+
+ info(JSON.stringify(root.testing));
+ Assert.equal(root.testing.prop3.sub_foo(), 1);
+ Assert.equal(countProp3, 1);
+ Assert.equal(countProp3SubFoo, 1);
+
+ Assert.equal(root.testing.prop3.sub_foo(), 2);
+ Assert.equal(countProp3, 2);
+ Assert.equal(countProp3SubFoo, 2);
+
+ root.testing.prop3.sub_foo = () => {
+ return "overwritten";
+ };
+ Assert.equal(root.testing.prop3.sub_foo(), "overwritten");
+
+ root.testing.prop3 = {
+ sub_foo() {
+ return "overwritten again";
+ },
+ };
+ Assert.equal(root.testing.prop3.sub_foo(), "overwritten again");
+ Assert.equal(countProp3SubFoo, 2);
+});
+
+let defaultsJson = [
+ {
+ namespace: "defaultsJson",
+
+ types: [],
+
+ functions: [
+ {
+ name: "defaultFoo",
+ type: "function",
+ parameters: [
+ {
+ name: "arg",
+ type: "object",
+ optional: true,
+ properties: {
+ prop1: { type: "integer", optional: true },
+ },
+ default: { prop1: 1 },
+ },
+ ],
+ returns: {
+ type: "object",
+ additionalProperties: true,
+ },
+ },
+ ],
+ },
+];
+
+add_task(async function testDefaults() {
+ let url = "data:," + JSON.stringify(defaultsJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let testingApiObj = {
+ defaultFoo: function(arg) {
+ if (Object.keys(arg) != "prop1") {
+ throw new Error(
+ `Received the expected default object, default: ${JSON.stringify(
+ arg
+ )}`
+ );
+ }
+ arg.newProp = 1;
+ return arg;
+ },
+ };
+
+ let localWrapper = {
+ cloneScope: global,
+ shouldInject(ns) {
+ return true;
+ },
+ getImplementation(ns, name) {
+ return new LocalAPIImplementation(testingApiObj, name, null);
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+
+ deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 });
+ deepEqual(root.defaultsJson.defaultFoo({ prop1: 2 }), {
+ prop1: 2,
+ newProp: 1,
+ });
+ deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 });
+});
+
+let returnsJson = [
+ {
+ namespace: "returns",
+ types: [
+ {
+ id: "Widget",
+ type: "object",
+ properties: {
+ size: { type: "integer" },
+ colour: { type: "string", optional: true },
+ },
+ },
+ ],
+ functions: [
+ {
+ name: "complete",
+ type: "function",
+ returns: { $ref: "Widget" },
+ parameters: [],
+ },
+ {
+ name: "optional",
+ type: "function",
+ returns: { $ref: "Widget" },
+ parameters: [],
+ },
+ {
+ name: "invalid",
+ type: "function",
+ returns: { $ref: "Widget" },
+ parameters: [],
+ },
+ ],
+ },
+];
+
+add_task(async function testReturns() {
+ const url = "data:," + JSON.stringify(returnsJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ const apiObject = {
+ complete() {
+ return { size: 3, colour: "orange" };
+ },
+ optional() {
+ return { size: 4 };
+ },
+ invalid() {
+ return {};
+ },
+ };
+
+ const localWrapper = {
+ cloneScope: global,
+ shouldInject(ns) {
+ return true;
+ },
+ getImplementation(ns, name) {
+ return new LocalAPIImplementation(apiObject, name, null);
+ },
+ };
+
+ const root = {};
+ Schemas.inject(root, localWrapper);
+
+ deepEqual(root.returns.complete(), { size: 3, colour: "orange" });
+ deepEqual(
+ root.returns.optional(),
+ { size: 4 },
+ "Missing optional properties is allowed"
+ );
+
+ if (AppConstants.DEBUG) {
+ Assert.throws(
+ () => root.returns.invalid(),
+ /Type error for result value \(Property "size" is required\)/,
+ "Should throw for invalid result in DEBUG builds"
+ );
+ } else {
+ deepEqual(
+ root.returns.invalid(),
+ {},
+ "Doesn't throw for invalid result value in release builds"
+ );
+ }
+});
+
+let booleanEnumJson = [
+ {
+ namespace: "booleanEnum",
+
+ types: [
+ {
+ id: "enumTrue",
+ type: "boolean",
+ enum: [true],
+ },
+ ],
+ functions: [
+ {
+ name: "paramMustBeTrue",
+ type: "function",
+ parameters: [{ name: "arg", $ref: "enumTrue" }],
+ },
+ ],
+ },
+];
+
+add_task(async function testBooleanEnum() {
+ let url = "data:," + JSON.stringify(booleanEnumJson);
+ Schemas._rootSchema = null;
+ await Schemas.load(url);
+
+ let root = {};
+ tallied = null;
+ Schemas.inject(root, wrapper);
+ Assert.equal(tallied, null);
+
+ ok(root.booleanEnum, "namespace exists");
+ root.booleanEnum.paramMustBeTrue(true);
+ verify("call", "booleanEnum", "paramMustBeTrue", [true]);
+ Assert.throws(
+ () => root.booleanEnum.paramMustBeTrue(false),
+ /Type error for parameter arg \(Invalid value false\) for booleanEnum\.paramMustBeTrue\./,
+ "should throw because enum of the type restricts parameter to true"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js
new file mode 100644
index 0000000000..0c90cda51e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js
@@ -0,0 +1,157 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+
+const global = this;
+
+let schemaJson = [
+ {
+ namespace: "noAllowedContexts",
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_zero", "test_one"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_one"] },
+ },
+ },
+ {
+ namespace: "defaultContexts",
+ defaultContexts: ["test_two"],
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_three"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_two"] },
+ },
+ },
+ {
+ namespace: "withAllowedContexts",
+ allowedContexts: ["test_four"],
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_five"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_three"] },
+ },
+ },
+ {
+ namespace: "withAllowedContextsAndDefault",
+ allowedContexts: ["test_six"],
+ defaultContexts: ["test_seven"],
+ properties: {
+ prop1: { type: "object" },
+ prop2: { type: "object", allowedContexts: ["test_eight"] },
+ prop3: { type: "number", value: 1 },
+ prop4: { type: "number", value: 1, allowedContexts: ["numeric_four"] },
+ },
+ },
+ {
+ namespace: "with_submodule",
+ defaultContexts: ["test_nine"],
+ types: [
+ {
+ id: "subtype",
+ type: "object",
+ functions: [
+ {
+ name: "noAllowedContexts",
+ type: "function",
+ parameters: [],
+ },
+ {
+ name: "allowedContexts",
+ allowedContexts: ["test_ten"],
+ type: "function",
+ parameters: [],
+ },
+ ],
+ },
+ ],
+ properties: {
+ prop1: { $ref: "subtype" },
+ prop2: { $ref: "subtype", allowedContexts: ["test_eleven"] },
+ },
+ },
+];
+
+add_task(async function testRestrictions() {
+ let url = "data:," + JSON.stringify(schemaJson);
+ await Schemas.load(url);
+ let results = {};
+ let localWrapper = {
+ cloneScope: global,
+ shouldInject(ns, name, allowedContexts) {
+ name = ns ? ns + "." + name : name;
+ results[name] = allowedContexts.join(",");
+ return true;
+ },
+ getImplementation() {
+ // The actual implementation is not significant for this test.
+ // Let's take this opportunity to see if schema generation is free of
+ // exceptions even when somehow getImplementation does not return an
+ // implementation.
+ },
+ };
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+
+ function verify(path, expected) {
+ let obj = root;
+ for (let thing of path.split(".")) {
+ try {
+ obj = obj[thing];
+ } catch (e) {
+ // Blech.
+ }
+ }
+
+ let result = results[path];
+ equal(result, expected, path);
+ }
+
+ verify("noAllowedContexts", "");
+ verify("noAllowedContexts.prop1", "");
+ verify("noAllowedContexts.prop2", "test_zero,test_one");
+ verify("noAllowedContexts.prop3", "");
+ verify("noAllowedContexts.prop4", "numeric_one");
+
+ verify("defaultContexts", "");
+ verify("defaultContexts.prop1", "test_two");
+ verify("defaultContexts.prop2", "test_three");
+ verify("defaultContexts.prop3", "test_two");
+ verify("defaultContexts.prop4", "numeric_two");
+
+ verify("withAllowedContexts", "test_four");
+ verify("withAllowedContexts.prop1", "");
+ verify("withAllowedContexts.prop2", "test_five");
+ verify("withAllowedContexts.prop3", "");
+ verify("withAllowedContexts.prop4", "numeric_three");
+
+ verify("withAllowedContextsAndDefault", "test_six");
+ verify("withAllowedContextsAndDefault.prop1", "test_seven");
+ verify("withAllowedContextsAndDefault.prop2", "test_eight");
+ verify("withAllowedContextsAndDefault.prop3", "test_seven");
+ verify("withAllowedContextsAndDefault.prop4", "numeric_four");
+
+ verify("with_submodule", "");
+ verify("with_submodule.prop1", "test_nine");
+ verify("with_submodule.prop1.noAllowedContexts", "test_nine");
+ verify("with_submodule.prop1.allowedContexts", "test_ten");
+ verify("with_submodule.prop2", "test_eleven");
+ // Note: test_nine inherits allowed contexts from the namespace, not from
+ // submodule. There is no "defaultContexts" for submodule types to not
+ // complicate things.
+ verify("with_submodule.prop1.noAllowedContexts", "test_nine");
+ verify("with_submodule.prop1.allowedContexts", "test_ten");
+
+ // This is a constant, so it does not matter that getImplementation does not
+ // return an implementation since the API injector should take care of it.
+ equal(root.noAllowedContexts.prop3, 1);
+
+ Assert.throws(
+ () => root.noAllowedContexts.prop1,
+ /undefined/,
+ "Should throw when the implementation is absent."
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js
new file mode 100644
index 0000000000..0ef7b81eaf
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js
@@ -0,0 +1,352 @@
+"use strict";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+
+let { BaseContext, LocalAPIImplementation } = ExtensionCommon;
+
+let schemaJson = [
+ {
+ namespace: "testnamespace",
+ types: [
+ {
+ id: "Widget",
+ type: "object",
+ properties: {
+ size: { type: "integer" },
+ colour: { type: "string", optional: true },
+ },
+ },
+ ],
+ functions: [
+ {
+ name: "one_required",
+ type: "function",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ },
+ ],
+ },
+ {
+ name: "one_optional",
+ type: "function",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ optional: true,
+ },
+ ],
+ },
+ {
+ name: "async_required",
+ type: "function",
+ async: "first",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ },
+ ],
+ },
+ {
+ name: "async_optional",
+ type: "function",
+ async: "first",
+ parameters: [
+ {
+ name: "first",
+ type: "function",
+ parameters: [],
+ optional: true,
+ },
+ ],
+ },
+ {
+ name: "async_result",
+ type: "function",
+ async: "callback",
+ parameters: [
+ {
+ name: "callback",
+ type: "function",
+ parameters: [
+ {
+ name: "widget",
+ $ref: "Widget",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+const global = this;
+class StubContext extends BaseContext {
+ constructor() {
+ let fakeExtension = { id: "test@web.extension" };
+ super("testEnv", fakeExtension);
+ this.sandbox = Cu.Sandbox(global);
+ }
+
+ get cloneScope() {
+ return this.sandbox;
+ }
+
+ get principal() {
+ return Cu.getObjectPrincipal(this.sandbox);
+ }
+}
+
+let context;
+
+function generateAPIs(extraWrapper, apiObj) {
+ context = new StubContext();
+ let localWrapper = {
+ cloneScope: global,
+ shouldInject() {
+ return true;
+ },
+ getImplementation(namespace, name) {
+ return new LocalAPIImplementation(apiObj, name, context);
+ },
+ };
+ Object.assign(localWrapper, extraWrapper);
+
+ let root = {};
+ Schemas.inject(root, localWrapper);
+ return root.testnamespace;
+}
+
+add_task(async function testParameterValidation() {
+ await Schemas.load("data:," + JSON.stringify(schemaJson));
+
+ let testnamespace;
+ function assertThrows(name, ...args) {
+ Assert.throws(
+ () => testnamespace[name](...args),
+ /Incorrect argument types/,
+ `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.`
+ );
+ }
+ function assertNoThrows(name, ...args) {
+ try {
+ testnamespace[name](...args);
+ } catch (e) {
+ info(
+ `testnamespace.${name}(${args
+ .map(String)
+ .join(", ")}) unexpectedly threw.`
+ );
+ throw new Error(e);
+ }
+ }
+ let cb = () => {};
+
+ for (let isChromeCompat of [true, false]) {
+ info(`Testing API validation with isChromeCompat=${isChromeCompat}`);
+ testnamespace = generateAPIs(
+ {
+ isChromeCompat,
+ },
+ {
+ one_required() {},
+ one_optional() {},
+ async_required() {},
+ async_optional() {},
+ }
+ );
+
+ assertThrows("one_required");
+ assertThrows("one_required", null);
+ assertNoThrows("one_required", cb);
+ assertThrows("one_required", cb, null);
+ assertThrows("one_required", cb, cb);
+
+ assertNoThrows("one_optional");
+ assertNoThrows("one_optional", null);
+ assertNoThrows("one_optional", cb);
+ assertThrows("one_optional", cb, null);
+ assertThrows("one_optional", cb, cb);
+
+ // Schema-based validation happens before an async method is called, so
+ // errors should be thrown synchronously.
+
+ // The parameter was declared as required, but there was also an "async"
+ // attribute with the same value as the parameter name, so the callback
+ // parameter is actually optional.
+ assertNoThrows("async_required");
+ assertNoThrows("async_required", null);
+ assertNoThrows("async_required", cb);
+ assertThrows("async_required", cb, null);
+ assertThrows("async_required", cb, cb);
+
+ assertNoThrows("async_optional");
+ assertNoThrows("async_optional", null);
+ assertNoThrows("async_optional", cb);
+ assertThrows("async_optional", cb, null);
+ assertThrows("async_optional", cb, cb);
+ }
+});
+
+add_task(async function testCheckAsyncResults() {
+ await Schemas.load("data:," + JSON.stringify(schemaJson));
+
+ const complete = generateAPIs(
+ {},
+ {
+ async_result: async () => ({ size: 5, colour: "green" }),
+ }
+ );
+
+ const optional = generateAPIs(
+ {},
+ {
+ async_result: async () => ({ size: 6 }),
+ }
+ );
+
+ const invalid = generateAPIs(
+ {},
+ {
+ async_result: async () => ({}),
+ }
+ );
+
+ deepEqual(await complete.async_result(), { size: 5, colour: "green" });
+
+ deepEqual(
+ await optional.async_result(),
+ { size: 6 },
+ "Missing optional properties is allowed"
+ );
+
+ if (AppConstants.DEBUG) {
+ await Assert.rejects(
+ invalid.async_result(),
+ /Type error for widget value \(Property "size" is required\)/,
+ "Should throw for invalid callback argument in DEBUG builds"
+ );
+ } else {
+ deepEqual(
+ await invalid.async_result(),
+ {},
+ "Invalid callback argument doesn't throw in release builds"
+ );
+ }
+});
+
+add_task(async function testAsyncResults() {
+ await Schemas.load("data:," + JSON.stringify(schemaJson));
+ function runWithCallback(func) {
+ info(`Calling testnamespace.${func.name}, expecting callback with result`);
+ return new Promise(resolve => {
+ let result = "uninitialized value";
+ let returnValue = func(reply => {
+ result = reply;
+ resolve(result);
+ });
+ // When a callback is given, the return value must be missing.
+ Assert.equal(returnValue, undefined);
+ // Callback must be called asynchronously.
+ Assert.equal(result, "uninitialized value");
+ });
+ }
+
+ function runFailCallback(func) {
+ info(`Calling testnamespace.${func.name}, expecting callback with error`);
+ return new Promise(resolve => {
+ func(reply => {
+ Assert.equal(reply, undefined);
+ resolve(context.lastError.message); // eslint-disable-line no-undef
+ });
+ });
+ }
+
+ for (let isChromeCompat of [true, false]) {
+ info(`Testing API invocation with isChromeCompat=${isChromeCompat}`);
+ let testnamespace = generateAPIs(
+ {
+ isChromeCompat,
+ },
+ {
+ async_required(cb) {
+ Assert.equal(cb, undefined);
+ return Promise.resolve(1);
+ },
+ async_optional(cb) {
+ Assert.equal(cb, undefined);
+ return Promise.resolve(2);
+ },
+ }
+ );
+ if (!isChromeCompat) {
+ // No promises for chrome.
+ info("testnamespace.async_required should be a Promise");
+ let promise = testnamespace.async_required();
+ Assert.ok(promise instanceof context.cloneScope.Promise);
+ Assert.equal(await promise, 1);
+
+ info("testnamespace.async_optional should be a Promise");
+ promise = testnamespace.async_optional();
+ Assert.ok(promise instanceof context.cloneScope.Promise);
+ Assert.equal(await promise, 2);
+ }
+
+ Assert.equal(await runWithCallback(testnamespace.async_required), 1);
+ Assert.equal(await runWithCallback(testnamespace.async_optional), 2);
+
+ let otherSandbox = Cu.Sandbox(null, {});
+ let errorFactories = [
+ msg => {
+ throw new context.cloneScope.Error(msg);
+ },
+ msg => context.cloneScope.Promise.reject({ message: msg }),
+ msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox),
+ msg =>
+ Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox),
+ ];
+ for (let makeError of errorFactories) {
+ info(`Testing callback/promise with error caused by: ${makeError}`);
+ testnamespace = generateAPIs(
+ {
+ isChromeCompat,
+ },
+ {
+ async_required() {
+ return makeError("ONE");
+ },
+ async_optional() {
+ return makeError("TWO");
+ },
+ }
+ );
+
+ if (!isChromeCompat) {
+ // No promises for chrome.
+ await Assert.rejects(
+ testnamespace.async_required(),
+ /ONE/,
+ "should reject testnamespace.async_required()"
+ );
+ await Assert.rejects(
+ testnamespace.async_optional(),
+ /TWO/,
+ "should reject testnamespace.async_optional()"
+ );
+ }
+
+ Assert.equal(await runFailCallback(testnamespace.async_required), "ONE");
+ Assert.equal(await runFailCallback(testnamespace.async_optional), "TWO");
+ }
+ }
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js
new file mode 100644
index 0000000000..66de5c8aba
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js
@@ -0,0 +1,174 @@
+"use strict";
+
+const { ExtensionManager } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionChild.jsm",
+ null
+);
+
+let experimentAPIs = {
+ userinputtest: {
+ schema: "schema.json",
+ parent: {
+ scopes: ["addon_parent"],
+ script: "parent.js",
+ paths: [["userinputtest"]],
+ },
+ child: {
+ scopes: ["addon_child"],
+ script: "child.js",
+ paths: [["userinputtest", "child"]],
+ },
+ },
+};
+
+let experimentFiles = {
+ "schema.json": JSON.stringify([
+ {
+ namespace: "userinputtest",
+ functions: [
+ {
+ name: "test",
+ type: "function",
+ async: true,
+ requireUserInput: true,
+ parameters: [],
+ },
+ {
+ name: "child",
+ type: "function",
+ async: true,
+ requireUserInput: true,
+ parameters: [],
+ },
+ ],
+ },
+ ]),
+
+ /* globals ExtensionAPI */
+ "parent.js": () => {
+ this.userinputtest = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ userinputtest: {
+ test() {},
+ },
+ };
+ }
+ };
+ },
+
+ "child.js": () => {
+ this.userinputtest = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ userinputtest: {
+ child() {},
+ },
+ };
+ }
+ };
+ },
+};
+
+// Set the "handlingUserInput" flag for the given extension's background page.
+// Returns an RAIIHelper that should be destruct()ed eventually.
+function setHandlingUserInput(extension) {
+ let extensionChild = ExtensionManager.extensions.get(extension.extension.id);
+ let bgwin = null;
+ for (let view of extensionChild.views) {
+ if (view.viewType == "background") {
+ bgwin = view.contentWindow;
+ break;
+ }
+ }
+ notEqual(bgwin, null, "Found background window for the test extension");
+ let winutils = bgwin.windowUtils;
+ return winutils.setHandlingUserInput(true);
+}
+
+// Test that the schema requireUserInput flag works correctly for
+// proxied api implementations.
+add_task(async function test_proxy() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ try {
+ await browser.userinputtest.test();
+ browser.test.sendMessage("result", null);
+ } catch (err) {
+ browser.test.sendMessage("result", err.message);
+ }
+ });
+ },
+ manifest: {
+ permissions: ["experiments.userinputtest"],
+ experiment_apis: experimentAPIs,
+ },
+ files: experimentFiles,
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("test");
+ let result = await extension.awaitMessage("result");
+ ok(
+ /test may only be called from a user input handler/.test(result),
+ `function failed when not called from a user input handler: ${result}`
+ );
+
+ let handle = setHandlingUserInput(extension);
+ extension.sendMessage("test");
+ result = await extension.awaitMessage("result");
+ equal(
+ result,
+ null,
+ "function succeeded when called from a user input handler"
+ );
+ handle.destruct();
+
+ await extension.unload();
+});
+
+// Test that the schema requireUserInput flag works correctly for
+// non-proxied api implementations.
+add_task(async function test_local() {
+ let extension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ background() {
+ browser.test.onMessage.addListener(async () => {
+ try {
+ await browser.userinputtest.child();
+ browser.test.sendMessage("result", null);
+ } catch (err) {
+ browser.test.sendMessage("result", err.message);
+ }
+ });
+ },
+ manifest: {
+ experiment_apis: experimentAPIs,
+ },
+ files: experimentFiles,
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("test");
+ let result = await extension.awaitMessage("result");
+ ok(
+ /child may only be called from a user input handler/.test(result),
+ `function failed when not called from a user input handler: ${result}`
+ );
+
+ let handle = setHandlingUserInput(extension);
+ extension.sendMessage("test");
+ result = await extension.awaitMessage("result");
+ equal(
+ result,
+ null,
+ "function succeeded when called from a user input handler"
+ );
+ handle.destruct();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js
new file mode 100644
index 0000000000..86ce07a5da
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js
@@ -0,0 +1,174 @@
+"use strict";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionAPI } = ExtensionCommon;
+
+add_task(async function() {
+ const schema = [
+ {
+ namespace: "manifest",
+ types: [
+ {
+ $extend: "WebExtensionManifest",
+ properties: {
+ a_manifest_property: {
+ type: "object",
+ optional: true,
+ properties: {
+ nested: {
+ optional: true,
+ type: "any",
+ },
+ },
+ additionalProperties: { $ref: "UnrecognizedProperty" },
+ },
+ },
+ },
+ ],
+ },
+ {
+ namespace: "testManifestPermission",
+ permissions: ["manifest:a_manifest_property"],
+ functions: [
+ {
+ name: "testMethod",
+ type: "function",
+ async: true,
+ parameters: [],
+ permissions: ["manifest:a_manifest_property.nested"],
+ },
+ ],
+ },
+ ];
+
+ class FakeAPI extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ testManifestPermission: {
+ get testProperty() {
+ return "value";
+ },
+ testMethod() {
+ return Promise.resolve("value");
+ },
+ },
+ };
+ }
+ }
+
+ const modules = {
+ testNamespace: {
+ url: URL.createObjectURL(new Blob([FakeAPI.toString()])),
+ schema: `data:,${JSON.stringify(schema)}`,
+ scopes: ["addon_parent", "addon_child"],
+ paths: [["testManifestPermission"]],
+ },
+ };
+
+ Services.catMan.addCategoryEntry(
+ "webextension-modules",
+ "test-manifest-permission",
+ `data:,${JSON.stringify(modules)}`,
+ false,
+ false
+ );
+
+ async function testExtension(extensionDef, assertFn) {
+ let extension = ExtensionTestUtils.loadExtension(extensionDef);
+
+ await extension.startup();
+ await assertFn(extension);
+ await extension.unload();
+ }
+
+ await testExtension(
+ {
+ manifest: {
+ a_manifest_property: {},
+ },
+ background() {
+ // Test hasPermission method implemented in ExtensionChild.jsm.
+ browser.test.assertTrue(
+ "testManifestPermission" in browser,
+ "The API namespace is defined as expected"
+ );
+ browser.test.assertEq(
+ undefined,
+ browser.testManifestPermission &&
+ browser.testManifestPermission.testMethod,
+ "The property with nested manifest property permission should not be available "
+ );
+ browser.test.notifyPass("test-extension-manifest-without-nested-prop");
+ },
+ },
+ async extension => {
+ await extension.awaitFinish(
+ "test-extension-manifest-without-nested-prop"
+ );
+
+ // Test hasPermission method implemented in Extension.jsm.
+ equal(
+ extension.extension.hasPermission("manifest:a_manifest_property"),
+ true,
+ "Got the expected Extension's hasPermission result on existing property"
+ );
+ equal(
+ extension.extension.hasPermission(
+ "manifest:a_manifest_property.nested"
+ ),
+ false,
+ "Got the expected Extension's hasPermission result on existing subproperty"
+ );
+ }
+ );
+
+ await testExtension(
+ {
+ manifest: {
+ a_manifest_property: {
+ nested: {},
+ },
+ },
+ background() {
+ // Test hasPermission method implemented in ExtensionChild.jsm.
+ browser.test.assertTrue(
+ "testManifestPermission" in browser,
+ "The API namespace is defined as expected"
+ );
+ browser.test.assertEq(
+ "function",
+ browser.testManifestPermission &&
+ typeof browser.testManifestPermission.testMethod,
+ "The property with nested manifest property permission should be available "
+ );
+ browser.test.notifyPass("test-extension-manifest-with-nested-prop");
+ },
+ },
+ async extension => {
+ await extension.awaitFinish("test-extension-manifest-with-nested-prop");
+
+ // Test hasPermission method implemented in Extension.jsm.
+ equal(
+ extension.extension.hasPermission("manifest:a_manifest_property"),
+ true,
+ "Got the expected Extension's hasPermission result on existing property"
+ );
+ equal(
+ extension.extension.hasPermission(
+ "manifest:a_manifest_property.nested"
+ ),
+ true,
+ "Got the expected Extension's hasPermission result on existing subproperty"
+ );
+ equal(
+ extension.extension.hasPermission(
+ "manifest:a_manifest_property.unexisting"
+ ),
+ false,
+ "Got the expected Extension's hasPermission result on non existing subproperty"
+ );
+ }
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js
new file mode 100644
index 0000000000..ece69a4106
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js
@@ -0,0 +1,103 @@
+"use strict";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { ExtensionAPI } = ExtensionCommon;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+add_task(async function() {
+ const schema = [
+ {
+ namespace: "privileged",
+ permissions: ["mozillaAddons"],
+ properties: {
+ test: {
+ type: "any",
+ },
+ },
+ },
+ ];
+
+ class API extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ privileged: {
+ test: "hello",
+ },
+ };
+ }
+ }
+
+ const modules = {
+ privileged: {
+ url: URL.createObjectURL(new Blob([API.toString()])),
+ schema: `data:,${JSON.stringify(schema)}`,
+ scopes: ["addon_parent"],
+ paths: [["privileged"]],
+ },
+ };
+
+ Services.catMan.addCategoryEntry(
+ "webextension-modules",
+ "test-privileged",
+ `data:,${JSON.stringify(modules)}`,
+ false,
+ false
+ );
+
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+ );
+ await AddonTestUtils.promiseStartupManager();
+
+ // Try accessing the privileged namespace.
+ async function testOnce() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "privilegedapi@tests.mozilla.org" } },
+ permissions: ["mozillaAddons"],
+ },
+ background() {
+ browser.test.sendMessage(
+ "result",
+ browser.privileged instanceof Object
+ );
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+ let result = await extension.awaitMessage("result");
+ await extension.unload();
+ return result;
+ }
+
+ AddonTestUtils.usePrivilegedSignatures = false;
+ let result = await testOnce();
+ equal(
+ result,
+ false,
+ "Privileged namespace should not be accessible to a regular webextension"
+ );
+
+ AddonTestUtils.usePrivilegedSignatures = true;
+ result = await testOnce();
+ equal(
+ result,
+ true,
+ "Privileged namespace should be accessible to a webextension signed with Mozilla Extensions"
+ );
+
+ await AddonTestUtils.promiseShutdownManager();
+ Services.catMan.deleteCategoryEntry(
+ "webextension-modules",
+ "test-privileged",
+ false
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js
new file mode 100644
index 0000000000..d215338dc9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js
@@ -0,0 +1,507 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+
+let { SchemaAPIInterface } = ExtensionCommon;
+
+const global = this;
+
+let json = [
+ {
+ namespace: "revokableNs",
+
+ permissions: ["revokableNs"],
+
+ properties: {
+ stringProp: {
+ type: "string",
+ writable: true,
+ },
+
+ revokableStringProp: {
+ type: "string",
+ permissions: ["revokableProp"],
+ writable: true,
+ },
+
+ submoduleProp: {
+ $ref: "submodule",
+ },
+
+ revokableSubmoduleProp: {
+ $ref: "submodule",
+ permissions: ["revokableProp"],
+ },
+ },
+
+ types: [
+ {
+ id: "submodule",
+ type: "object",
+ functions: [
+ {
+ name: "sub_foo",
+ type: "function",
+ parameters: [],
+ returns: { type: "integer" },
+ },
+ ],
+ },
+ ],
+
+ functions: [
+ {
+ name: "func",
+ type: "function",
+ parameters: [],
+ },
+
+ {
+ name: "revokableFunc",
+ type: "function",
+ parameters: [],
+ permissions: ["revokableFunc"],
+ },
+ ],
+
+ events: [
+ {
+ name: "onEvent",
+ type: "function",
+ },
+
+ {
+ name: "onRevokableEvent",
+ type: "function",
+ permissions: ["revokableEvent"],
+ },
+ ],
+ },
+];
+
+let recorded = [];
+
+function record(...args) {
+ recorded.push(args);
+}
+
+function verify(expected) {
+ for (let [i, rec] of expected.entries()) {
+ Assert.deepEqual(recorded[i], rec, `Record ${i} matches`);
+ }
+
+ equal(recorded.length, expected.length, "Got expected number of records");
+
+ recorded.length = 0;
+}
+
+registerCleanupFunction(() => {
+ equal(recorded.length, 0, "No unchecked recorded events at shutdown");
+});
+
+let permissions = new Set();
+
+class APIImplementation extends SchemaAPIInterface {
+ constructor(namespace, name) {
+ super();
+ this.namespace = namespace;
+ this.name = name;
+ }
+
+ record(method, args) {
+ record(method, this.namespace, this.name, args);
+ }
+
+ revoke(...args) {
+ this.record("revoke", args);
+ }
+
+ callFunction(...args) {
+ this.record("callFunction", args);
+ if (this.name === "sub_foo") {
+ return 13;
+ }
+ }
+
+ callFunctionNoReturn(...args) {
+ this.record("callFunctionNoReturn", args);
+ }
+
+ getProperty(...args) {
+ this.record("getProperty", args);
+ }
+
+ setProperty(...args) {
+ this.record("setProperty", args);
+ }
+
+ addListener(...args) {
+ this.record("addListener", args);
+ }
+
+ removeListener(...args) {
+ this.record("removeListener", args);
+ }
+
+ hasListener(...args) {
+ this.record("hasListener", args);
+ }
+}
+
+let context = {
+ cloneScope: global,
+
+ permissionsChanged: null,
+
+ setPermissionsChangedCallback(callback) {
+ this.permissionsChanged = callback;
+ },
+
+ hasPermission(permission) {
+ return permissions.has(permission);
+ },
+
+ isPermissionRevokable(permission) {
+ return permission.startsWith("revokable");
+ },
+
+ getImplementation(namespace, name) {
+ return new APIImplementation(namespace, name);
+ },
+
+ shouldInject() {
+ return true;
+ },
+};
+
+function ignoreError(fn) {
+ try {
+ fn();
+ } catch (e) {
+ // Meh.
+ }
+}
+
+add_task(async function() {
+ let url = "data:," + JSON.stringify(json);
+ await Schemas.load(url);
+
+ let root = {};
+ Schemas.inject(root, context);
+ equal(recorded.length, 0, "No recorded events");
+
+ let listener = () => {};
+ let captured = {};
+
+ function checkRecorded() {
+ let possible = [
+ ["revokableNs", ["getProperty", "revokableNs", "stringProp", []]],
+ [
+ "revokableProp",
+ ["getProperty", "revokableNs", "revokableStringProp", []],
+ ],
+
+ [
+ "revokableNs",
+ ["setProperty", "revokableNs", "stringProp", ["stringProp"]],
+ ],
+ [
+ "revokableProp",
+ [
+ "setProperty",
+ "revokableNs",
+ "revokableStringProp",
+ ["revokableStringProp"],
+ ],
+ ],
+
+ ["revokableNs", ["callFunctionNoReturn", "revokableNs", "func", [[]]]],
+ [
+ "revokableFunc",
+ ["callFunctionNoReturn", "revokableNs", "revokableFunc", [[]]],
+ ],
+
+ [
+ "revokableNs",
+ ["callFunction", "revokableNs.submoduleProp", "sub_foo", [[]]],
+ ],
+ [
+ "revokableProp",
+ ["callFunction", "revokableNs.revokableSubmoduleProp", "sub_foo", [[]]],
+ ],
+
+ [
+ "revokableNs",
+ ["addListener", "revokableNs", "onEvent", [listener, []]],
+ ],
+ ["revokableNs", ["removeListener", "revokableNs", "onEvent", [listener]]],
+ ["revokableNs", ["hasListener", "revokableNs", "onEvent", [listener]]],
+
+ [
+ "revokableEvent",
+ ["addListener", "revokableNs", "onRevokableEvent", [listener, []]],
+ ],
+ [
+ "revokableEvent",
+ ["removeListener", "revokableNs", "onRevokableEvent", [listener]],
+ ],
+ [
+ "revokableEvent",
+ ["hasListener", "revokableNs", "onRevokableEvent", [listener]],
+ ],
+ ];
+
+ let expected = [];
+ if (permissions.has("revokableNs")) {
+ for (let [perm, recording] of possible) {
+ if (!perm || permissions.has(perm)) {
+ expected.push(recording);
+ }
+ }
+ }
+
+ verify(expected);
+ }
+
+ function check() {
+ info(`Check normal access (permissions: [${Array.from(permissions)}])`);
+
+ let ns = root.revokableNs;
+
+ void ns.stringProp;
+ void ns.revokableStringProp;
+
+ ns.stringProp = "stringProp";
+ ns.revokableStringProp = "revokableStringProp";
+
+ ns.func();
+
+ if (ns.revokableFunc) {
+ ns.revokableFunc();
+ }
+
+ ns.submoduleProp.sub_foo();
+ if (ns.revokableSubmoduleProp) {
+ ns.revokableSubmoduleProp.sub_foo();
+ }
+
+ ns.onEvent.addListener(listener);
+ ns.onEvent.removeListener(listener);
+ ns.onEvent.hasListener(listener);
+
+ if (ns.onRevokableEvent) {
+ ns.onRevokableEvent.addListener(listener);
+ ns.onRevokableEvent.removeListener(listener);
+ ns.onRevokableEvent.hasListener(listener);
+ }
+
+ checkRecorded();
+ }
+
+ function capture() {
+ info("Capture values");
+
+ let ns = root.revokableNs;
+
+ captured = { ns };
+ captured.revokableStringProp = Object.getOwnPropertyDescriptor(
+ ns,
+ "revokableStringProp"
+ );
+
+ captured.revokableSubmoduleProp = ns.revokableSubmoduleProp;
+ if (ns.revokableSubmoduleProp) {
+ captured.sub_foo = ns.revokableSubmoduleProp.sub_foo;
+ }
+
+ captured.revokableFunc = ns.revokableFunc;
+
+ captured.onRevokableEvent = ns.onRevokableEvent;
+ if (ns.onRevokableEvent) {
+ captured.addListener = ns.onRevokableEvent.addListener;
+ captured.removeListener = ns.onRevokableEvent.removeListener;
+ captured.hasListener = ns.onRevokableEvent.hasListener;
+ }
+ }
+
+ function checkCaptured() {
+ info(
+ `Check captured value access (permissions: [${Array.from(permissions)}])`
+ );
+
+ let { ns } = captured;
+
+ void ns.stringProp;
+ ignoreError(() => captured.revokableStringProp.get());
+ if (!permissions.has("revokableProp")) {
+ void ns.revokableStringProp;
+ }
+
+ ns.stringProp = "stringProp";
+ ignoreError(() => captured.revokableStringProp.set("revokableStringProp"));
+ if (!permissions.has("revokableProp")) {
+ ns.revokableStringProp = "revokableStringProp";
+ }
+
+ ignoreError(() => ns.func());
+ ignoreError(() => captured.revokableFunc());
+ if (!permissions.has("revokableFunc")) {
+ ignoreError(() => ns.revokableFunc());
+ }
+
+ ignoreError(() => ns.submoduleProp.sub_foo());
+
+ ignoreError(() => captured.sub_foo());
+ if (!permissions.has("revokableProp")) {
+ ignoreError(() => captured.revokableSubmoduleProp.sub_foo());
+ ignoreError(() => ns.revokableSubmoduleProp.sub_foo());
+ }
+
+ ignoreError(() => ns.onEvent.addListener(listener));
+ ignoreError(() => ns.onEvent.removeListener(listener));
+ ignoreError(() => ns.onEvent.hasListener(listener));
+
+ ignoreError(() => captured.addListener(listener));
+ ignoreError(() => captured.removeListener(listener));
+ ignoreError(() => captured.hasListener(listener));
+ if (!permissions.has("revokableEvent")) {
+ ignoreError(() => captured.onRevokableEvent.addListener(listener));
+ ignoreError(() => captured.onRevokableEvent.removeListener(listener));
+ ignoreError(() => captured.onRevokableEvent.hasListener(listener));
+
+ ignoreError(() => ns.onRevokableEvent.addListener(listener));
+ ignoreError(() => ns.onRevokableEvent.removeListener(listener));
+ ignoreError(() => ns.onRevokableEvent.hasListener(listener));
+ }
+
+ checkRecorded();
+ }
+
+ permissions.add("revokableNs");
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+
+ check();
+ capture();
+ checkCaptured();
+
+ permissions.delete("revokableProp");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "revokableStringProp", []],
+ ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []],
+ ]);
+
+ check();
+ checkCaptured();
+
+ permissions.delete("revokableFunc");
+ context.permissionsChanged();
+ verify([["revoke", "revokableNs", "revokableFunc", []]]);
+
+ check();
+ checkCaptured();
+
+ permissions.delete("revokableEvent");
+ context.permissionsChanged();
+
+ verify([["revoke", "revokableNs", "onRevokableEvent", []]]);
+
+ check();
+ checkCaptured();
+
+ permissions.delete("revokableNs");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "stringProp", []],
+ ["revoke", "revokableNs", "func", []],
+ ["revoke", "revokableNs.submoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs", "onEvent", []],
+ ]);
+
+ checkCaptured();
+
+ permissions.add("revokableNs");
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+ context.permissionsChanged();
+
+ check();
+ capture();
+ checkCaptured();
+
+ permissions.delete("revokableProp");
+ permissions.delete("revokableFunc");
+ permissions.delete("revokableEvent");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "revokableStringProp", []],
+ ["revoke", "revokableNs", "revokableFunc", []],
+ ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs", "onRevokableEvent", []],
+ ]);
+
+ check();
+ checkCaptured();
+
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+ context.permissionsChanged();
+
+ check();
+ capture();
+ checkCaptured();
+
+ permissions.delete("revokableNs");
+ context.permissionsChanged();
+ verify([
+ ["revoke", "revokableNs", "stringProp", []],
+ ["revoke", "revokableNs", "revokableStringProp", []],
+ ["revoke", "revokableNs", "func", []],
+ ["revoke", "revokableNs", "revokableFunc", []],
+ ["revoke", "revokableNs.submoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []],
+ ["revoke", "revokableNs", "onEvent", []],
+ ["revoke", "revokableNs", "onRevokableEvent", []],
+ ]);
+
+ equal(root.revokableNs, undefined, "Namespace is not defined");
+ checkCaptured();
+});
+
+add_task(async function test_neuter() {
+ context.permissionsChanged = null;
+
+ let root = {};
+ Schemas.inject(root, context);
+ equal(recorded.length, 0, "No recorded events");
+
+ permissions.add("revokableNs");
+ permissions.add("revokableProp");
+ permissions.add("revokableFunc");
+ permissions.add("revokableEvent");
+
+ let ns = root.revokableNs;
+ let { submoduleProp } = ns;
+
+ let lazyGetter = Object.getOwnPropertyDescriptor(submoduleProp, "sub_foo");
+
+ permissions.delete("revokableNs");
+ context.permissionsChanged();
+ verify([]);
+
+ equal(root.revokableNs, undefined, "Should have no revokableNs");
+ equal(ns.submoduleProp, undefined, "Should have no ns.submoduleProp");
+
+ equal(submoduleProp.sub_foo, undefined, "No sub_foo");
+ lazyGetter.get.call(submoduleProp);
+ equal(submoduleProp.sub_foo, undefined, "No sub_foo");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js
new file mode 100644
index 0000000000..ebc881a804
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js
@@ -0,0 +1,242 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { SchemaRoot } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+
+let { SchemaAPIInterface } = ExtensionCommon;
+
+const global = this;
+
+let baseSchemaJSON = [
+ {
+ namespace: "base",
+
+ properties: {
+ PROP1: { value: 42 },
+ },
+
+ types: [
+ {
+ id: "type1",
+ type: "string",
+ enum: ["value1", "value2", "value3"],
+ },
+ ],
+
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [{ name: "arg1", $ref: "type1" }],
+ },
+ ],
+ },
+];
+
+let experimentFooJSON = [
+ {
+ namespace: "experiments.foo",
+ types: [
+ {
+ id: "typeFoo",
+ type: "string",
+ enum: ["foo1", "foo2", "foo3"],
+ },
+ ],
+
+ functions: [
+ {
+ name: "foo",
+ type: "function",
+ parameters: [
+ { name: "arg1", $ref: "typeFoo" },
+ { name: "arg2", $ref: "base.type1" },
+ ],
+ },
+ ],
+ },
+];
+
+let experimentBarJSON = [
+ {
+ namespace: "experiments.bar",
+ types: [
+ {
+ id: "typeBar",
+ type: "string",
+ enum: ["bar1", "bar2", "bar3"],
+ },
+ ],
+
+ functions: [
+ {
+ name: "bar",
+ type: "function",
+ parameters: [
+ { name: "arg1", $ref: "typeBar" },
+ { name: "arg2", $ref: "base.type1" },
+ ],
+ },
+ ],
+ },
+];
+
+let tallied = null;
+
+function tally(kind, ns, name, args) {
+ tallied = [kind, ns, name, args];
+}
+
+function verify(...args) {
+ equal(JSON.stringify(tallied), JSON.stringify(args));
+ tallied = null;
+}
+
+let talliedErrors = [];
+
+let permissions = new Set();
+
+class TallyingAPIImplementation extends SchemaAPIInterface {
+ constructor(namespace, name) {
+ super();
+ this.namespace = namespace;
+ this.name = name;
+ }
+
+ callFunction(args) {
+ tally("call", this.namespace, this.name, args);
+ if (this.name === "sub_foo") {
+ return 13;
+ }
+ }
+
+ callFunctionNoReturn(args) {
+ tally("call", this.namespace, this.name, args);
+ }
+
+ getProperty() {
+ tally("get", this.namespace, this.name);
+ }
+
+ setProperty(value) {
+ tally("set", this.namespace, this.name, value);
+ }
+
+ addListener(listener, args) {
+ tally("addListener", this.namespace, this.name, [listener, args]);
+ }
+
+ removeListener(listener) {
+ tally("removeListener", this.namespace, this.name, [listener]);
+ }
+
+ hasListener(listener) {
+ tally("hasListener", this.namespace, this.name, [listener]);
+ }
+}
+
+let wrapper = {
+ url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/",
+
+ cloneScope: global,
+
+ checkLoadURL(url) {
+ return !url.startsWith("chrome:");
+ },
+
+ preprocessors: {
+ localize(value, context) {
+ return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`);
+ },
+ },
+
+ logError(message) {
+ talliedErrors.push(message);
+ },
+
+ hasPermission(permission) {
+ return permissions.has(permission);
+ },
+
+ shouldInject(ns, name) {
+ return name != "do-not-inject";
+ },
+
+ getImplementation(namespace, name) {
+ return new TallyingAPIImplementation(namespace, name);
+ },
+};
+
+add_task(async function() {
+ let baseSchemas = new Map([["resource://schemas/base.json", baseSchemaJSON]]);
+ let experimentSchemas = new Map([
+ ["resource://experiment-foo/schema.json", experimentFooJSON],
+ ["resource://experiment-bar/schema.json", experimentBarJSON],
+ ]);
+
+ let baseSchema = new SchemaRoot(null, baseSchemas);
+ let schema = new SchemaRoot(baseSchema, experimentSchemas);
+
+ baseSchema.parseSchemas();
+ schema.parseSchemas();
+
+ let root = {};
+ let base = {};
+
+ tallied = null;
+
+ baseSchema.inject(base, wrapper);
+ schema.inject(root, wrapper);
+
+ equal(typeof base.base, "object", "base.base exists");
+ equal(typeof root.base, "object", "root.base exists");
+ equal(typeof base.experiments, "undefined", "base.experiments exists not");
+ equal(typeof root.experiments, "object", "root.experiments exists");
+ equal(typeof root.experiments.foo, "object", "root.experiments.foo exists");
+ equal(typeof root.experiments.bar, "object", "root.experiments.bar exists");
+
+ equal(tallied, null);
+
+ equal(root.base.PROP1, 42, "root.base.PROP1");
+ equal(base.base.PROP1, 42, "root.base.PROP1");
+
+ root.base.foo("value2");
+ verify("call", "base", "foo", ["value2"]);
+
+ base.base.foo("value3");
+ verify("call", "base", "foo", ["value3"]);
+
+ root.experiments.foo.foo("foo2", "value1");
+ verify("call", "experiments.foo", "foo", ["foo2", "value1"]);
+
+ root.experiments.bar.bar("bar2", "value1");
+ verify("call", "experiments.bar", "bar", ["bar2", "value1"]);
+
+ Assert.throws(
+ () => root.base.foo("Meh."),
+ /Type error for parameter arg1/,
+ "root.base.foo()"
+ );
+
+ Assert.throws(
+ () => base.base.foo("Meh."),
+ /Type error for parameter arg1/,
+ "base.base.foo()"
+ );
+
+ Assert.throws(
+ () => root.experiments.foo.foo("Meh."),
+ /Incorrect argument types/,
+ "root.experiments.foo.foo()"
+ );
+
+ Assert.throws(
+ () => root.experiments.bar.bar("Meh."),
+ /Incorrect argument types/,
+ "root.experiments.bar.bar()"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js
new file mode 100644
index 0000000000..626d8de22d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js
@@ -0,0 +1,59 @@
+"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_shadowDOM() {
+ function backgroundScript() {
+ browser.test.assertTrue(
+ "openOrClosedShadowRoot" in document.documentElement,
+ "Should have openOrClosedShadowRoot in Element in background script."
+ );
+ }
+
+ function contentScript() {
+ let host = document.getElementById("host");
+ browser.test.assertTrue(
+ "openOrClosedShadowRoot" in host,
+ "Should have openOrClosedShadowRoot in Element."
+ );
+ let shadowRoot = host.openOrClosedShadowRoot;
+ browser.test.assertEq(
+ shadowRoot.mode,
+ "closed",
+ "Should have closed ShadowRoot."
+ );
+ browser.test.sendMessage("contentScript");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_shadowdom.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ background: backgroundScript,
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_shadowdom.html`
+ );
+ await extension.awaitMessage("contentScript");
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js
new file mode 100644
index 0000000000..3952cefb07
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test attemps to verify that:
+// - SharedWorkers can be created and successfully spawned by web extensions
+// when web-extensions run in their own child process.
+add_task(async function test_spawn_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 worker = new SharedWorker("worker.js");
+ await new Promise(resolve => {
+ worker.port.onmessage = resolve;
+ worker.port.postMessage("bgpage->worker");
+ });
+ browser.test.sendMessage("test-shared-worker:done");
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background,
+ files: {
+ "worker.js": function() {
+ self.onconnect = evt => {
+ const port = evt.ports[0];
+ port.onmessage = () => port.postMessage("worker-reply");
+ };
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("test-shared-worker:done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js
new file mode 100644
index 0000000000..8221219a38
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js
@@ -0,0 +1,42 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+const { GlobalManager } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+);
+
+add_task(async function test_global_manager_shutdown_cleanup() {
+ equal(
+ GlobalManager.initialized,
+ false,
+ "GlobalManager start as not initialized"
+ );
+
+ function background() {
+ browser.test.notifyPass("background page loaded");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("background page loaded");
+
+ equal(
+ GlobalManager.initialized,
+ true,
+ "GlobalManager has been initialized once an extension is started"
+ );
+
+ await extension.unload();
+
+ equal(
+ GlobalManager.initialized,
+ false,
+ "GlobalManager has been uninitialized once all the webextensions have been stopped"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_simple.js b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js
new file mode 100644
index 0000000000..7fd75eb088
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js
@@ -0,0 +1,111 @@
+/* -*- 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_simple() {
+ let extensionData = {
+ manifest: {
+ name: "Simple extension test",
+ version: "1.0",
+ manifest_version: 2,
+ description: "",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.unload();
+});
+
+add_task(async function test_manifest_V3_disabled() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", false);
+ let extensionData = {
+ manifest: {
+ manifest_version: 3,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await Assert.rejects(
+ extension.startup(),
+ /Unsupported manifest version: 3/,
+ "manifest V3 cannot be loaded"
+ );
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
+
+add_task(async function test_manifest_V3_enabled() {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ let extensionData = {
+ manifest: {
+ manifest_version: 3,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ equal(extension.extension.manifest.manifest_version, 3, "manifest V3 loads");
+ await extension.unload();
+ Services.prefs.clearUserPref("extensions.manifestV3.enabled");
+});
+
+add_task(async function test_background() {
+ function background() {
+ browser.test.log("running background script");
+
+ browser.test.onMessage.addListener((x, y) => {
+ browser.test.assertEq(x, 10, "x is 10");
+ browser.test.assertEq(y, 20, "y is 20");
+
+ browser.test.notifyPass("background test passed");
+ });
+
+ browser.test.sendMessage("running", 1);
+ }
+
+ let extensionData = {
+ background,
+ manifest: {
+ name: "Simple extension test",
+ version: "1.0",
+ manifest_version: 2,
+ description: "",
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ let [, x] = await Promise.all([
+ extension.startup(),
+ extension.awaitMessage("running"),
+ ]);
+ equal(x, 1, "got correct value from extension");
+
+ extension.sendMessage(10, 20);
+ await extension.awaitFinish();
+ await extension.unload();
+});
+
+add_task(async function test_extensionTypes() {
+ let extensionData = {
+ background: function() {
+ browser.test.assertEq(
+ typeof browser.extensionTypes,
+ "object",
+ "browser.extensionTypes exists"
+ );
+ browser.test.assertEq(
+ typeof browser.extensionTypes.RunAt,
+ "object",
+ "browser.extensionTypes.RunAt exists"
+ );
+ browser.test.notifyPass("extentionTypes test passed");
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js
new file mode 100644
index 0000000000..df51fa9abf
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js
@@ -0,0 +1,55 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+);
+
+// Tests that startupData is persisted and is available at startup
+add_task(async function test_startupData() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let wrapper = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ });
+ await wrapper.startup();
+
+ let { extension } = wrapper;
+
+ deepEqual(
+ extension.startupData,
+ {},
+ "startupData for a new extension defaults to empty object"
+ );
+
+ const DATA = { test: "i am some startup data" };
+ extension.startupData = DATA;
+ extension.saveStartupData();
+
+ await AddonTestUtils.promiseRestartManager();
+ await wrapper.startupPromise;
+
+ ({ extension } = wrapper);
+ deepEqual(extension.startupData, DATA, "startupData is present on restart");
+
+ const DATA2 = { other: "this is different data" };
+ extension.startupData = DATA2;
+ extension.saveStartupData();
+
+ await AddonTestUtils.promiseRestartManager();
+ await wrapper.startupPromise;
+
+ ({ extension } = wrapper);
+ deepEqual(
+ extension.startupData,
+ DATA2,
+ "updated startupData is present on restart"
+ );
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js
new file mode 100644
index 0000000000..c21458e5a1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js
@@ -0,0 +1,172 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonManager } = ChromeUtils.import(
+ "resource://gre/modules/AddonManager.jsm"
+);
+const { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org";
+
+function makeExtension(opts) {
+ return {
+ useAddonManager: "permanent",
+
+ manifest: {
+ version: opts.version,
+ applications: { gecko: { id: ADDON_ID } },
+
+ name: "__MSG_name__",
+
+ default_locale: "en_US",
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {
+ name: {
+ message: `en-US ${opts.version}`,
+ description: "Name.",
+ },
+ },
+ "_locales/fr/messages.json": {
+ name: {
+ message: `fr ${opts.version}`,
+ description: "Name.",
+ },
+ },
+ },
+
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "get-manifest") {
+ browser.test.sendMessage("manifest", browser.runtime.getManifest());
+ }
+ });
+ },
+ };
+}
+
+add_task(async function() {
+ Preferences.set("extensions.logging.enabled", false);
+ await AddonTestUtils.promiseStartupManager();
+
+ // Install langpacks to get proper locale startup.
+ let langpack = {
+ "manifest.json": {
+ name: "test Language Pack",
+ version: "1.0",
+ manifest_version: 2,
+ applications: {
+ gecko: {
+ id: "@test-langpack",
+ strict_min_version: "42.0",
+ strict_max_version: "42.0",
+ },
+ },
+ langpack_id: "fr",
+ languages: {
+ fr: {
+ chrome_resources: {
+ global: "chrome/fr/locale/fr/global/",
+ },
+ version: "20171001190118",
+ },
+ },
+ sources: {
+ browser: {
+ base_path: "browser/",
+ },
+ },
+ },
+ };
+
+ let [, { addon }] = await Promise.all([
+ TestUtils.topicObserved("webextension-langpack-startup"),
+ AddonTestUtils.promiseInstallXPI(langpack),
+ ]);
+
+ let extension = ExtensionTestUtils.loadExtension(
+ makeExtension({ version: "1.0" })
+ );
+
+ function getManifest() {
+ extension.sendMessage("get-manifest");
+ return extension.awaitMessage("manifest");
+ }
+
+ // At the moment extension language negotiation is tied to Firefox language
+ // negotiation result. That means that to test an extension in `fr`, we need
+ // to mock `fr` being available in Firefox and then request it.
+ //
+ // In the future, we should provide some way for tests to decouple their
+ // language selection from that of Firefox.
+ ok(Services.locale.availableLocales.includes("fr"), "fr locale is avialable");
+
+ await extension.startup();
+
+ equal(extension.version, "1.0", "Expected extension version");
+ let manifest = await getManifest();
+ equal(manifest.name, "en-US 1.0", "Got expected manifest name");
+
+ info("Restart and re-check");
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ equal(extension.version, "1.0", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "en-US 1.0", "Got expected manifest name");
+
+ info("Change locale to 'fr' and restart");
+ Services.locale.requestedLocales = ["fr"];
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ equal(extension.version, "1.0", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "fr 1.0", "Got expected manifest name");
+
+ info("Update to version 1.1");
+ await extension.upgrade(makeExtension({ version: "1.1" }));
+
+ equal(extension.version, "1.1", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "fr 1.1", "Got expected manifest name");
+
+ info("Change locale to 'en-US' and restart");
+ Services.locale.requestedLocales = ["en-US"];
+ await AddonTestUtils.promiseRestartManager();
+ await extension.awaitStartup();
+
+ equal(extension.version, "1.1", "Expected extension version");
+ manifest = await getManifest();
+ equal(manifest.name, "en-US 1.1", "Got expected manifest name");
+
+ info("uninstall locale 'fr'");
+ addon = await AddonManager.getAddonByID("@test-langpack");
+ await addon.uninstall();
+ ok(!Services.locale.availableLocales.includes("fr"), "fr locale is removed");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js
new file mode 100644
index 0000000000..691232479d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js
@@ -0,0 +1,73 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const STARTUP_APIS = ["backgroundPage"];
+
+const STARTUP_MODULES = [
+ "resource://gre/modules/Extension.jsm",
+ "resource://gre/modules/ExtensionCommon.jsm",
+ "resource://gre/modules/ExtensionParent.jsm",
+ // FIXME: This is only loaded at startup for new extension installs.
+ // Otherwise the data comes from the startup cache. We should test for
+ // this.
+ "resource://gre/modules/ExtensionPermissions.jsm",
+ "resource://gre/modules/ExtensionProcessScript.jsm",
+ "resource://gre/modules/ExtensionUtils.jsm",
+ "resource://gre/modules/ExtensionTelemetry.jsm",
+];
+
+if (!Services.prefs.getBoolPref("extensions.webextensions.remote")) {
+ STARTUP_MODULES.push(
+ "resource://gre/modules/ExtensionChild.jsm",
+ "resource://gre/modules/ExtensionPageChild.jsm"
+ );
+}
+
+if (AppConstants.MOZ_APP_NAME == "thunderbird") {
+ STARTUP_MODULES.push(
+ "resource://gre/modules/ExtensionChild.jsm",
+ "resource://gre/modules/ExtensionContent.jsm",
+ "resource://gre/modules/ExtensionPageChild.jsm"
+ );
+}
+
+AddonTestUtils.init(this);
+
+// Tests that only the minimal set of API scripts and modules are loaded at
+// startup for a simple extension.
+add_task(async function test_loaded_scripts() {
+ await ExtensionTestUtils.startAddonManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background() {},
+ manifest: {},
+ });
+
+ await extension.startup();
+
+ const { apiManager } = ExtensionParent;
+
+ const loadedAPIs = Array.from(apiManager.modules.values())
+ .filter(m => m.loaded || m.asyncLoaded)
+ .map(m => m.namespaceName);
+
+ deepEqual(
+ loadedAPIs.sort(),
+ STARTUP_APIS,
+ "No extra APIs should be loaded at startup for a simple extension"
+ );
+
+ let loadedModules = Cu.loadedModules.filter(url =>
+ url.startsWith("resource://gre/modules/Extension")
+ );
+
+ deepEqual(
+ loadedModules.sort(),
+ STARTUP_MODULES.sort(),
+ "No extra extension modules should be loaded at startup for a simple extension"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js
new file mode 100644
index 0000000000..5ebe4c5230
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js
@@ -0,0 +1,64 @@
+"use strict";
+
+function delay(time) {
+ return new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, time);
+ });
+}
+
+const { Extension } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm"
+);
+
+add_task(async function test_startup_request_handler() {
+ const ID = "request-startup@xpcshell.mozilla.org";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: ID } },
+ },
+
+ files: {
+ "meh.txt": "Meh.",
+ },
+ });
+
+ let ready = false;
+ let resolvePromise;
+ let promise = new Promise(resolve => {
+ resolvePromise = resolve;
+ });
+ promise.then(() => {
+ ready = true;
+ });
+
+ let origInitLocale = Extension.prototype.initLocale;
+ Extension.prototype.initLocale = async function initLocale() {
+ await promise;
+ return origInitLocale.call(this);
+ };
+
+ let startupPromise = extension.startup();
+
+ await delay(0);
+ let policy = WebExtensionPolicy.getByID(ID);
+ let url = policy.getURL("meh.txt");
+
+ let resp = ExtensionTestUtils.fetch(url, url);
+ resp.then(() => {
+ ok(ready, "Shouldn't get response before extension is ready");
+ });
+
+ await delay(2000);
+
+ resolvePromise();
+ await startupPromise;
+
+ let body = await resp;
+ equal(body, "Meh.", "Got the correct response");
+
+ await extension.unload();
+
+ Extension.prototype.initLocale = origInitLocale;
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js
new file mode 100644
index 0000000000..b677110a47
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js
@@ -0,0 +1,39 @@
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /WebExtension context not found/
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// The storage API in content scripts should behave identical to the storage API
+// in background pages.
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contentscript_storage_local_file_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () =>
+ test_contentscript_storage("local")
+ );
+});
+
+add_task(async function test_contentscript_storage_local_idb_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_contentscript_storage("local")
+ );
+});
+
+add_task(async function test_contentscript_storage_local_idb_no_bytes_in_use() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_contentscript_storage_area_no_bytes_in_use("local")
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js
new file mode 100644
index 0000000000..6b1695417d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false);
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /WebExtension context not found/
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// The storage API in content scripts should behave identical to the storage API
+// in background pages.
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contentscript_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage("sync")
+ );
+});
+
+add_task(async function test_contentscript_bytes_in_use_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage_area_with_bytes_in_use("sync", true)
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js
new file mode 100644
index 0000000000..92ec405520
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js
@@ -0,0 +1,31 @@
+"use strict";
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true);
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /WebExtension context not found/
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// The storage API in content scripts should behave identical to the storage API
+// in background pages.
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_contentscript_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage("sync")
+ );
+});
+
+add_task(async function test_contentscript_storage_no_bytes_in_use() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_contentscript_storage_area_with_bytes_in_use("sync", false)
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
new file mode 100644
index 0000000000..90d4740bf9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js
@@ -0,0 +1,787 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+// This test file verifies various scenarios related to the data migration
+// from the JSONFile backend to the IDB backend.
+
+AddonTestUtils.init(this);
+
+// Create appInfo before importing any other jsm file, to prevent
+// Services.appinfo to be cached before an appInfo.version is
+// actually defined (which prevent failures to be triggered when
+// the test run in a non nightly build).
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const { getTrimmedString } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+const { ExtensionStorage } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorage.jsm"
+);
+const { ExtensionStorageIDB } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+const { TelemetryController } = ChromeUtils.import(
+ "resource://gre/modules/TelemetryController.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.import(
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ OS: "resource://gre/modules/osfile.jsm",
+});
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+const {
+ IDB_MIGRATED_PREF_BRANCH,
+ IDB_MIGRATE_RESULT_HISTOGRAM,
+} = ExtensionStorageIDB;
+const CATEGORIES = ["success", "failure"];
+const EVENT_CATEGORY = "extensions.data";
+const EVENT_OBJECT = "storageLocal";
+const EVENT_METHOD = "migrateResult";
+const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
+const TELEMETRY_EVENTS_FILTER = {
+ category: "extensions.data",
+ method: "migrateResult",
+ object: "storageLocal",
+};
+
+async function createExtensionJSONFileWithData(extensionId, data) {
+ await ExtensionStorage.set(extensionId, data);
+ const jsonFile = await ExtensionStorage.getFile(extensionId);
+ await jsonFile._save();
+ const oldStorageFilename = ExtensionStorage.getStorageFile(extensionId);
+ equal(
+ await OS.File.exists(oldStorageFilename),
+ true,
+ "The old json file has been created"
+ );
+
+ return { jsonFile, oldStorageFilename };
+}
+
+function clearMigrationHistogram() {
+ const histogram = Services.telemetry.getHistogramById(
+ IDB_MIGRATE_RESULT_HISTOGRAM
+ );
+ histogram.clear();
+ equal(
+ histogram.snapshot().sum,
+ 0,
+ `No data recorded for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}`
+ );
+}
+
+function assertMigrationHistogramCount(category, expectedCount) {
+ const histogram = Services.telemetry.getHistogramById(
+ IDB_MIGRATE_RESULT_HISTOGRAM
+ );
+
+ equal(
+ histogram.snapshot().values[CATEGORIES.indexOf(category)],
+ expectedCount,
+ `Got the expected count on category "${category}" for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}`
+ );
+}
+
+function assertTelemetryEvents(expectedEvents) {
+ TelemetryTestUtils.assertEvents(expectedEvents, {
+ category: EVENT_CATEGORY,
+ method: EVENT_METHOD,
+ object: EVENT_OBJECT,
+ });
+}
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true);
+
+ await promiseStartupManager();
+
+ // Telemetry test setup needed to ensure that the builtin events are defined
+ // and they can be collected and verified.
+ await TelemetryController.testSetup();
+
+ // This is actually only needed on Android, because it does not properly support unified telemetry
+ // and so, if not enabled explicitly here, it would make these tests to fail when running on a
+ // non-Nightly build.
+ const oldCanRecordBase = Services.telemetry.canRecordBase;
+ Services.telemetry.canRecordBase = true;
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordBase = oldCanRecordBase;
+ });
+
+ // Clear any telemetry events collected so far.
+ Services.telemetry.clearEvents();
+});
+
+// Test that for newly installed extension the IDB backend is enabled without
+// any data migration.
+add_task(async function test_no_migration_for_newly_installed_extensions() {
+ const EXTENSION_ID = "test-no-data-migration@mochi.test";
+
+ await createExtensionJSONFileWithData(EXTENSION_ID, {
+ test_old_data: "test_old_value",
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id: EXTENSION_ID } },
+ },
+ async background() {
+ const data = await browser.storage.local.get();
+ browser.test.assertEq(
+ Object.keys(data).length,
+ 0,
+ "Expect the storage.local store to be empty"
+ );
+ browser.test.sendMessage("test-stored-data:done");
+ },
+ });
+
+ await extension.startup();
+ equal(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ true,
+ "The newly installed test extension is marked as migrated"
+ );
+ await extension.awaitMessage("test-stored-data:done");
+ await extension.unload();
+
+ // Verify that no data migration have been needed on the newly installed
+ // extension, by asserting that no telemetry events has been collected.
+ await TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTER);
+});
+
+// Test that the data migration is still running for a newly installed extension
+// if keepStorageOnUninstall is true.
+add_task(async function test_data_migration_on_keep_storage_on_uninstall() {
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+
+ // Store some fake data in the storage.local file backend before starting the extension.
+ const EXTENSION_ID = "new-extension-on-keep-storage-on-uninstall@mochi.test";
+ await createExtensionJSONFileWithData(EXTENSION_ID, {
+ test_key_string: "test_value",
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id: EXTENSION_ID } },
+ },
+ async background() {
+ const storedData = await browser.storage.local.get();
+ browser.test.assertEq(
+ "test_value",
+ storedData.test_key_string,
+ "Got the expected data after the storage.local data migration"
+ );
+ browser.test.sendMessage("storage-local-data-migrated");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("storage-local-data-migrated");
+ equal(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ true,
+ "The newly installed test extension is marked as migrated"
+ );
+ await extension.unload();
+
+ // Verify that the expected telemetry has been recorded.
+ await TelemetryTestUtils.assertEvents(
+ [
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTER
+ );
+
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+});
+
+// Test that the old data is migrated successfully to the new storage backend
+// and that the original JSONFile has been renamed.
+add_task(async function test_storage_local_data_migration() {
+ const EXTENSION_ID = "extension-to-be-migrated@mozilla.org";
+
+ // Keep the extension storage and the uuid on uninstall, to verify that no telemetry events
+ // are being sent for an already migrated extension.
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
+
+ const data = {
+ test_key_string: "test_value1",
+ test_key_number: 1000,
+ test_nested_data: {
+ nested_key: true,
+ },
+ };
+
+ // Store some fake data in the storage.local file backend before starting the extension.
+ const { oldStorageFilename } = await createExtensionJSONFileWithData(
+ EXTENSION_ID,
+ data
+ );
+
+ async function background() {
+ const storedData = await browser.storage.local.get();
+
+ browser.test.assertEq(
+ "test_value1",
+ storedData.test_key_string,
+ "Got the expected data after the storage.local data migration"
+ );
+ browser.test.assertEq(
+ 1000,
+ storedData.test_key_number,
+ "Got the expected data after the storage.local data migration"
+ );
+ browser.test.assertEq(
+ true,
+ storedData.test_nested_data.nested_key,
+ "Got the expected data after the storage.local data migration"
+ );
+
+ browser.test.sendMessage("storage-local-data-migrated");
+ }
+
+ clearMigrationHistogram();
+
+ let extensionDefinition = {
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionDefinition);
+
+ // Install the extension while the storage.local IDB backend is disabled.
+ Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, false);
+ await extension.startup();
+
+ ok(
+ !ExtensionStorageIDB.isMigratedExtension(extension),
+ "The test extension should be using the JSONFile backend"
+ );
+
+ // Enabled the storage.local IDB backend and upgrade the extension.
+ Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true);
+ await extension.upgrade({
+ ...extensionDefinition,
+ background,
+ });
+
+ await extension.awaitMessage("storage-local-data-migrated");
+
+ ok(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ "The test extension should be using the IndexedDB backend"
+ );
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+
+ equal(
+ await idbConn.isEmpty(extension.extension),
+ false,
+ "Data stored in the ExtensionStorageIDB backend as expected"
+ );
+
+ equal(
+ await OS.File.exists(oldStorageFilename),
+ false,
+ "The old json storage file name should not exist anymore"
+ );
+
+ equal(
+ await OS.File.exists(`${oldStorageFilename}.migrated`),
+ true,
+ "The old json storage file name should have been renamed as .migrated"
+ );
+
+ equal(
+ Services.prefs.getBoolPref(
+ `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`,
+ false
+ ),
+ true,
+ `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected`
+ );
+
+ assertMigrationHistogramCount("success", 1);
+ assertMigrationHistogramCount("failure", 0);
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ]);
+
+ equal(
+ Services.prefs.getBoolPref(
+ `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`,
+ false
+ ),
+ true,
+ `${IDB_MIGRATED_PREF_BRANCH} should still be true on keepStorageOnUninstall=true`
+ );
+
+ // Upgrade the extension and check that no telemetry events are being sent
+ // for an already migrated extension.
+ await extension.upgrade({
+ ...extensionDefinition,
+ background,
+ });
+
+ await extension.awaitMessage("storage-local-data-migrated");
+
+ // The histogram values are unmodified.
+ assertMigrationHistogramCount("success", 1);
+ assertMigrationHistogramCount("failure", 0);
+
+ // No new telemetry events recorded for the extension.
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+ const filterByCategory = ([timestamp, category]) =>
+ category === EVENT_CATEGORY;
+
+ ok(
+ !snapshot.parent || snapshot.parent.filter(filterByCategory).length === 0,
+ "No telemetry events should be recorded for an already migrated extension"
+ );
+
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, false);
+
+ await extension.unload();
+
+ equal(
+ Services.prefs.getPrefType(`${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`),
+ Services.prefs.PREF_INVALID,
+ `Got the ${IDB_MIGRATED_PREF_BRANCH} preference has been cleared on addon uninstall`
+ );
+});
+
+// Test that the extensionId included in the telemetry event is being trimmed down to 80 chars
+// as expected.
+add_task(async function test_extensionId_trimmed_in_telemetry_event() {
+ // Generated extensionId in email-like format, longer than 80 chars.
+ const EXTENSION_ID = `long.extension.id@${Array(80)
+ .fill("a")
+ .join("")}`;
+
+ const data = { test_key_string: "test_value" };
+
+ // Store some fake data in the storage.local file backend before starting the extension.
+ await createExtensionJSONFileWithData(EXTENSION_ID, data);
+
+ async function background() {
+ const storedData = await browser.storage.local.get("test_key_string");
+
+ browser.test.assertEq(
+ "test_value",
+ storedData.test_key_string,
+ "Got the expected data after the storage.local data migration"
+ );
+
+ browser.test.sendMessage("storage-local-data-migrated");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("storage-local-data-migrated");
+
+ const expectedTrimmedExtensionId = getTrimmedString(EXTENSION_ID);
+
+ equal(
+ expectedTrimmedExtensionId.length,
+ 80,
+ "The trimmed version of the extensionId should be 80 chars long"
+ );
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: expectedTrimmedExtensionId,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ]);
+
+ await extension.unload();
+});
+
+// Test that if the old JSONFile data file is corrupted and the old data
+// can't be successfully migrated to the new storage backend, then:
+// - the new storage backend for that extension is still initialized and enabled
+// - any new data is being stored in the new backend
+// - the old file is being renamed (with the `.corrupted` suffix that JSONFile.jsm
+// adds when it fails to load the data file) and still available on disk.
+add_task(async function test_storage_local_corrupted_data_migration() {
+ const EXTENSION_ID = "extension-corrupted-data-migration@mozilla.org";
+
+ const invalidData = `{"test_key_string": "test_value1"`;
+ const oldStorageFilename = ExtensionStorage.getStorageFile(EXTENSION_ID);
+
+ const profileDir = OS.Constants.Path.profileDir;
+ await OS.File.makeDir(
+ OS.Path.join(profileDir, "browser-extension-data", EXTENSION_ID),
+ { from: profileDir, ignoreExisting: true }
+ );
+
+ // Write the json file with some invalid data.
+ await OS.File.writeAtomic(oldStorageFilename, invalidData, { flush: true });
+ equal(
+ await OS.File.read(oldStorageFilename, { encoding: "utf-8" }),
+ invalidData,
+ "The old json file has been overwritten with invalid data"
+ );
+
+ async function background() {
+ const storedData = await browser.storage.local.get();
+
+ browser.test.assertEq(
+ Object.keys(storedData).length,
+ 0,
+ "No data should be found on invalid data migration"
+ );
+
+ await browser.storage.local.set({
+ test_key_string_on_IDBBackend: "expected-value",
+ });
+
+ browser.test.sendMessage("storage-local-data-migrated-and-set");
+ }
+
+ clearMigrationHistogram();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("storage-local-data-migrated-and-set");
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+
+ equal(
+ await idbConn.isEmpty(extension.extension),
+ false,
+ "Data stored in the ExtensionStorageIDB backend as expected"
+ );
+
+ equal(
+ await OS.File.exists(`${oldStorageFilename}.corrupt`),
+ true,
+ "The old json storage should still be available if failed to be read"
+ );
+
+ // The extension is still migrated successfully to the new backend if the file from the
+ // original json file was corrupted.
+
+ equal(
+ Services.prefs.getBoolPref(
+ `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`,
+ false
+ ),
+ true,
+ `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected`
+ );
+
+ assertMigrationHistogramCount("success", 1);
+ assertMigrationHistogramCount("failure", 0);
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "IndexedDB",
+ data_migrated: "y",
+ has_jsonfile: "y",
+ has_olddata: "n",
+ },
+ },
+ ]);
+
+ await extension.unload();
+});
+
+// Test that if the data migration fails to store the old data into the IndexedDB backend
+// then the expected telemetry histogram is being updated.
+add_task(async function test_storage_local_data_migration_failure() {
+ const EXTENSION_ID = "extension-data-migration-failure@mozilla.org";
+
+ // Create the file under the expected directory tree.
+ const {
+ jsonFile,
+ oldStorageFilename,
+ } = await createExtensionJSONFileWithData(EXTENSION_ID, {});
+
+ // Store a fake invalid value which is going to fail to be saved into IndexedDB
+ // (because it can't be cloned and it is going to raise a DataCloneError), which
+ // will trigger a data migration failure that we expect to increment the related
+ // telemetry histogram.
+ jsonFile.data.set("fake_invalid_key", new Error());
+
+ async function background() {
+ await browser.storage.local.set({
+ test_key_string_on_JSONFileBackend: "expected-value",
+ });
+ browser.test.sendMessage("storage-local-data-migrated-and-set");
+ }
+
+ clearMigrationHistogram();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("storage-local-data-migrated-and-set");
+
+ const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(
+ extension.extension
+ );
+
+ const idbConn = await ExtensionStorageIDB.open(storagePrincipal);
+ equal(
+ await idbConn.isEmpty(extension.extension),
+ true,
+ "No data stored in the ExtensionStorageIDB backend as expected"
+ );
+ equal(
+ await OS.File.exists(oldStorageFilename),
+ true,
+ "The old json storage should still be available if failed to be read"
+ );
+
+ await extension.unload();
+
+ assertTelemetryEvents([
+ {
+ method: "migrateResult",
+ value: EXTENSION_ID,
+ extra: {
+ backend: "JSONFile",
+ data_migrated: "n",
+ error_name: "DataCloneError",
+ has_jsonfile: "y",
+ has_olddata: "y",
+ },
+ },
+ ]);
+
+ assertMigrationHistogramCount("success", 0);
+ assertMigrationHistogramCount("failure", 1);
+});
+
+add_task(async function test_migration_aborted_on_shutdown() {
+ const EXTENSION_ID = "test-migration-aborted-on-shutdown@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ applications: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+
+ equal(
+ extension.extension.hasShutdown,
+ false,
+ "The extension is still running"
+ );
+
+ await extension.unload();
+ equal(extension.extension.hasShutdown, true, "The extension has shutdown");
+
+ // Trigger a data migration after the extension has been unloaded.
+ const result = await ExtensionStorageIDB.selectBackend({
+ extension: extension.extension,
+ });
+ Assert.deepEqual(
+ result,
+ { backendEnabled: false },
+ "Expect migration to have been aborted"
+ );
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ value: EXTENSION_ID,
+ extra: {
+ backend: "JSONFile",
+ error_name: "DataMigrationAbortedError",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTER
+ );
+});
+
+add_task(async function test_storage_local_data_migration_clear_pref() {
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+ Services.prefs.clearUserPref(LEAVE_UUID_PREF);
+ Services.prefs.clearUserPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF);
+ await promiseShutdownManager();
+ await TelemetryController.testShutdown();
+});
+
+add_task(async function setup_quota_manager_testing_prefs() {
+ Services.prefs.setBoolPref("dom.quotaManager.testing", true);
+ Services.prefs.setIntPref(
+ "dom.quotaManager.temporaryStorage.fixedLimit",
+ 100
+ );
+ await promiseQuotaManagerServiceReset();
+});
+
+add_task(
+ // TODO: temporarily disabled because it currently perma-fails on
+ // android builds (Bug 1564871)
+ { skip_if: () => AppConstants.platform === "android" },
+ // eslint-disable-next-line no-use-before-define
+ test_quota_exceeded_while_migrating_data
+);
+async function test_quota_exceeded_while_migrating_data() {
+ const EXT_ID = "test-data-migration-stuck@mochi.test";
+ const dataSize = 1000 * 1024;
+
+ await createExtensionJSONFileWithData(EXT_ID, {
+ data: new Array(dataSize).fill("x").join(""),
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id: EXT_ID } },
+ },
+ background() {
+ browser.test.onMessage.addListener(async (msg, dataSize) => {
+ if (msg !== "verify-stored-data") {
+ return;
+ }
+ const res = await browser.storage.local.get();
+ browser.test.assertEq(
+ res.data && res.data.length,
+ dataSize,
+ "Got the expected data"
+ );
+ browser.test.sendMessage("verify-stored-data:done");
+ });
+
+ browser.test.sendMessage("bg-page:ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bg-page:ready");
+
+ extension.sendMessage("verify-stored-data", dataSize);
+ await extension.awaitMessage("verify-stored-data:done");
+
+ await ok(
+ !ExtensionStorageIDB.isMigratedExtension(extension),
+ "The extension falls back to the JSONFile backend because of the migration failure"
+ );
+ await extension.unload();
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ value: EXT_ID,
+ extra: {
+ backend: "JSONFile",
+ error_name: "QuotaExceededError",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTER
+ );
+
+ Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit");
+ await promiseQuotaManagerServiceClear();
+ Services.prefs.clearUserPref("dom.quotaManager.testing");
+}
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js
new file mode 100644
index 0000000000..a74528db7d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js
@@ -0,0 +1,73 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionStorageIDB",
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_local_cache_invalidation() {
+ function background(checkGet) {
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "set-initial") {
+ await browser.storage.local.set({
+ "test-prop1": "value1",
+ "test-prop2": "value2",
+ });
+ browser.test.sendMessage("set-initial-done");
+ } else if (msg === "check") {
+ await checkGet("local", "test-prop1", "value1");
+ await checkGet("local", "test-prop2", "value2");
+ browser.test.sendMessage("check-done");
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+ background: `(${background})(${checkGetImpl})`,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ extension.sendMessage("set-initial");
+ await extension.awaitMessage("set-initial-done");
+
+ Services.obs.notifyObservers(null, "extension-invalidate-storage-cache");
+
+ extension.sendMessage("check");
+ await extension.awaitMessage("check-done");
+
+ await extension.unload();
+});
+
+add_task(function test_storage_local_file_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () =>
+ test_background_page_storage("local")
+ );
+});
+
+add_task(function test_storage_local_idb_backend() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_background_page_storage("local")
+ );
+});
+
+add_task(function test_storage_local_idb_bytes_in_use() {
+ return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () =>
+ test_background_storage_area_no_bytes_in_use("local")
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js
new file mode 100644
index 0000000000..b35e4240c4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js
@@ -0,0 +1,170 @@
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ MockRegistry: "resource://testing-common/MockRegistry.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+});
+
+const MANIFEST = {
+ name: "test-storage-managed@mozilla.com",
+ description: "",
+ type: "storage",
+ data: {
+ null: null,
+ str: "hello",
+ obj: {
+ a: [2, 3],
+ b: true,
+ },
+ },
+};
+
+AddonTestUtils.init(this);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+
+ let tmpDir = FileUtils.getDir("TmpD", ["native-manifests"]);
+ tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let dirProvider = {
+ getFile(property) {
+ if (property.endsWith("NativeManifests")) {
+ return tmpDir.clone();
+ }
+ },
+ };
+ Services.dirsvc.registerProvider(dirProvider);
+
+ let typeSlug =
+ AppConstants.platform === "linux" ? "managed-storage" : "ManagedStorage";
+ OS.File.makeDir(OS.Path.join(tmpDir.path, typeSlug));
+
+ let path = OS.Path.join(tmpDir.path, typeSlug, `${MANIFEST.name}.json`);
+ await OS.File.writeAtomic(path, JSON.stringify(MANIFEST));
+
+ let registry;
+ if (AppConstants.platform === "win") {
+ registry = new MockRegistry();
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `Software\\\Mozilla\\\ManagedStorage\\${MANIFEST.name}`,
+ "",
+ path
+ );
+ }
+
+ registerCleanupFunction(() => {
+ Services.dirsvc.unregisterProvider(dirProvider);
+ tmpDir.remove(true);
+ if (registry) {
+ registry.shutdown();
+ }
+ });
+});
+
+add_task(async function test_storage_managed() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: MANIFEST.name } },
+ permissions: ["storage"],
+ },
+
+ async background() {
+ await browser.test.assertRejects(
+ browser.storage.managed.set({ a: 1 }),
+ /storage.managed is read-only/,
+ "browser.storage.managed.set() rejects because it's read only"
+ );
+
+ await browser.test.assertRejects(
+ browser.storage.managed.remove("str"),
+ /storage.managed is read-only/,
+ "browser.storage.managed.remove() rejects because it's read only"
+ );
+
+ await browser.test.assertRejects(
+ browser.storage.managed.clear(),
+ /storage.managed is read-only/,
+ "browser.storage.managed.clear() rejects because it's read only"
+ );
+
+ browser.test.sendMessage(
+ "results",
+ await Promise.all([
+ browser.storage.managed.get(),
+ browser.storage.managed.get("str"),
+ browser.storage.managed.get(["null", "obj"]),
+ browser.storage.managed.get({ str: "a", num: 2 }),
+ ])
+ );
+ },
+ });
+
+ await extension.startup();
+ deepEqual(await extension.awaitMessage("results"), [
+ MANIFEST.data,
+ { str: "hello" },
+ { null: null, obj: MANIFEST.data.obj },
+ { str: "hello", num: 2 },
+ ]);
+ await extension.unload();
+});
+
+add_task(async function test_storage_managed_from_content_script() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: MANIFEST.name } },
+ permissions: ["storage"],
+ content_scripts: [
+ {
+ js: ["contentscript.js"],
+ matches: ["*://*/*"],
+ run_at: "document_end",
+ },
+ ],
+ },
+
+ files: {
+ "contentscript.js": async function() {
+ browser.test.sendMessage(
+ "results",
+ await browser.storage.managed.get()
+ );
+ },
+ },
+ });
+
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ deepEqual(await extension.awaitMessage("results"), MANIFEST.data);
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_manifest_not_found() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+
+ async background() {
+ await browser.test.assertRejects(
+ browser.storage.managed.get({ a: 1 }),
+ /Managed storage manifest not found/,
+ "browser.storage.managed.get() rejects when without manifest"
+ );
+
+ browser.test.notifyPass();
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js
new file mode 100644
index 0000000000..d99956671d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js
@@ -0,0 +1,55 @@
+"use strict";
+
+const PREF_DISABLE_SECURITY =
+ "security.turn_off_all_security_so_that_" +
+ "viruses_can_take_over_this_computer";
+
+const { EnterprisePolicyTesting } = ChromeUtils.import(
+ "resource://testing-common/EnterprisePolicyTesting.jsm"
+);
+
+// Setting PREF_DISABLE_SECURITY tells the policy engine that we are in testing
+// mode and enables restarting the policy engine without restarting the browser.
+Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_DISABLE_SECURITY);
+});
+
+// Load policy engine
+Services.policies; // eslint-disable-line no-unused-expressions
+
+AddonTestUtils.init(this);
+
+add_task(async function test_storage_managed_policy() {
+ await ExtensionTestUtils.startAddonManager();
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ "3rdparty": {
+ Extensions: {
+ "test-storage-managed-policy@mozilla.com": {
+ string: "value",
+ },
+ },
+ },
+ },
+ });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: {
+ gecko: { id: "test-storage-managed-policy@mozilla.com" },
+ },
+ permissions: ["storage"],
+ },
+
+ async background() {
+ let str = await browser.storage.managed.get("string");
+ browser.test.sendMessage("results", str);
+ },
+ });
+
+ await extension.startup();
+ deepEqual(await extension.awaitMessage("results"), { string: "value" });
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js
new file mode 100644
index 0000000000..b9dc8a0212
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js
@@ -0,0 +1,82 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ExtensionStorageIDB",
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+
+const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ // Ensure that the IDB backend is enabled.
+ Services.prefs.setBoolPref("ExtensionStorageIDB.BACKEND_ENABLED_PREF", true);
+
+ Services.prefs.setBoolPref("dom.quotaManager.testing", true);
+ Services.prefs.setIntPref(
+ "dom.quotaManager.temporaryStorage.fixedLimit",
+ 100
+ );
+ await promiseQuotaManagerServiceReset();
+
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_storage_local_set_quota_exceeded_error() {
+ const EXT_ID = "test-quota-exceeded@mochi.test";
+
+ const extensionDef = {
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id: EXT_ID } },
+ },
+ async background() {
+ const data = new Array(1000 * 1024).fill("x").join("");
+ await browser.test.assertRejects(
+ browser.storage.local.set({ data }),
+ /QuotaExceededError/,
+ "Got a rejection with the expected error message"
+ );
+ browser.test.sendMessage("data-stored");
+ },
+ };
+
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+ Services.prefs.clearUserPref(LEAVE_UUID_PREF);
+ });
+
+ const extension = ExtensionTestUtils.loadExtension(extensionDef);
+
+ // Run test on a test extension being migrated to the IDB backend.
+ await extension.startup();
+ await extension.awaitMessage("data-stored");
+
+ ok(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ "The extension has been successfully migrated to the IDB backend"
+ );
+ await extension.unload();
+
+ // Run again on a test extension already already migrated to the IDB backend.
+ const extensionUpdated = ExtensionTestUtils.loadExtension(extensionDef);
+ await extensionUpdated.startup();
+ ok(
+ ExtensionStorageIDB.isMigratedExtension(extension),
+ "The extension has been successfully migrated to the IDB backend"
+ );
+ await extensionUpdated.awaitMessage("data-stored");
+
+ await extensionUpdated.unload();
+
+ Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit");
+ await promiseQuotaManagerServiceClear();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js
new file mode 100644
index 0000000000..38d1de29fa
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js
@@ -0,0 +1,106 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "Sanitizer",
+ "resource:///modules/Sanitizer.jsm"
+);
+
+async function test_sanitize_offlineApps(storageHelpersScript) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ background: {
+ scripts: ["storageHelpers.js", "background.js"],
+ },
+ },
+ files: {
+ "storageHelpers.js": storageHelpersScript,
+ "background.js": function() {
+ browser.test.onMessage.addListener(async (msg, args) => {
+ let result = {};
+ switch (msg) {
+ case "set-storage-data":
+ await window.testWriteKey(...args);
+ break;
+ case "get-storage-data":
+ const value = await window.testReadKey(args[0]);
+ browser.test.assertEq(args[1], value, "Got the expected value");
+ break;
+ default:
+ browser.test.fail(`Unexpected test message received: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`, result);
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+
+ extension.sendMessage("set-storage-data", ["aKey", "aValue"]);
+ await extension.awaitMessage("set-storage-data:done");
+
+ await extension.sendMessage("get-storage-data", ["aKey", "aValue"]);
+ await extension.awaitMessage("get-storage-data:done");
+
+ info("Verify the extension data not cleared by offlineApps Sanitizer");
+ await Sanitizer.sanitize(["offlineApps"]);
+ await extension.sendMessage("get-storage-data", ["aKey", "aValue"]);
+ await extension.awaitMessage("get-storage-data:done");
+
+ await extension.unload();
+}
+
+add_task(async function test_sanitize_offlineApps_extension_indexedDB() {
+ await test_sanitize_offlineApps(function indexedDBStorageHelpers() {
+ const getIDBStore = () =>
+ new Promise(resolve => {
+ let dbreq = window.indexedDB.open("TestDB");
+ dbreq.onupgradeneeded = () =>
+ dbreq.result.createObjectStore("TestStore");
+ dbreq.onsuccess = () => resolve(dbreq.result);
+ });
+
+ // Export writeKey and readKey storage test helpers.
+ window.testWriteKey = (k, v) =>
+ getIDBStore().then(db => {
+ const tx = db.transaction("TestStore", "readwrite");
+ const store = tx.objectStore("TestStore");
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = evt => resolve(evt.target.result);
+ tx.onerror = evt => reject(evt.target.error);
+ store.add(v, k);
+ });
+ });
+ window.testReadKey = k =>
+ getIDBStore().then(db => {
+ const tx = db.transaction("TestStore");
+ const store = tx.objectStore("TestStore");
+ return new Promise((resolve, reject) => {
+ const req = store.get(k);
+ tx.oncomplete = evt => resolve(req.result);
+ tx.onerror = evt => reject(evt.target.error);
+ });
+ });
+ });
+});
+
+add_task(
+ {
+ // Skip this test if LSNG is not enabled (because this test is only
+ // going to pass when nextgen local storage is being used).
+ skip_if: () => !Services.prefs.getBoolPref("dom.storage.next_gen"),
+ },
+ async function test_sanitize_offlineApps_extension_localStorage() {
+ await test_sanitize_offlineApps(function indexedDBStorageHelpers() {
+ // Export writeKey and readKey storage test helpers.
+ window.testWriteKey = (k, v) => window.localStorage.setItem(k, v);
+ window.testReadKey = k => window.localStorage.getItem(k);
+ });
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
new file mode 100644
index 0000000000..ad821c5a07
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js
@@ -0,0 +1,29 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false);
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(test_config_flag_needed);
+
+add_task(test_sync_reloading_extensions_works);
+
+add_task(function test_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_page_storage("sync")
+ );
+});
+
+add_task(test_storage_sync_requires_real_id);
+
+add_task(function test_bytes_in_use() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_storage_area_with_bytes_in_use("sync", true)
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js
new file mode 100644
index 0000000000..db7091db8d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js
@@ -0,0 +1,2290 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This is a kinto-specific test...
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true);
+
+do_get_profile(); // so we can use FxAccounts
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+const { CommonUtils } = ChromeUtils.import(
+ "resource://services-common/utils.js"
+);
+const {
+ cleanUpForContext,
+ CollectionKeyEncryptionRemoteTransformer,
+ CryptoCollection,
+ ExtensionStorageSync,
+ idToKey,
+ keyToId,
+ KeyRingEncryptionRemoteTransformer,
+} = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageSyncKinto.jsm",
+ null
+);
+const { BulkKeyBundle } = ChromeUtils.import(
+ "resource://services-sync/keys.js"
+);
+const { FxAccountsKeys } = ChromeUtils.import(
+ "resource://gre/modules/FxAccountsKeys.jsm"
+);
+const { Utils } = ChromeUtils.import("resource://services-sync/util.js");
+
+const { createAppInfo, promiseStartupManager } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "69");
+
+function handleCannedResponse(cannedResponse, request, response) {
+ response.setStatusLine(
+ null,
+ cannedResponse.status.status,
+ cannedResponse.status.statusText
+ );
+ // send the headers
+ for (let headerLine of cannedResponse.sampleHeaders) {
+ let headerElements = headerLine.split(":");
+ response.setHeader(headerElements[0], headerElements[1].trimLeft());
+ }
+ response.setHeader("Date", new Date().toUTCString());
+
+ response.write(cannedResponse.responseBody);
+}
+
+function collectionPath(collectionId) {
+ return `/buckets/default/collections/${collectionId}`;
+}
+
+function collectionRecordsPath(collectionId) {
+ return `/buckets/default/collections/${collectionId}/records`;
+}
+
+class KintoServer {
+ constructor() {
+ // Set up an HTTP Server
+ this.httpServer = new HttpServer();
+ this.httpServer.start(-1);
+
+ // Set<Object> corresponding to records that might be served.
+ // The format of these objects is defined in the documentation for #addRecord.
+ this.records = [];
+
+ // Collections that we have set up access to (see `installCollection`).
+ this.collections = new Set();
+
+ // ETag to serve with responses
+ this.etag = 1;
+
+ this.port = this.httpServer.identity.primaryPort;
+
+ // POST requests we receive from the client go here
+ this.posts = [];
+ // DELETEd buckets will go here.
+ this.deletedBuckets = [];
+ // Anything in here will force the next POST to generate a conflict
+ this.conflicts = [];
+ // If this is true, reject the next request with a 401
+ this.rejectNextAuthResponse = false;
+ this.failedAuths = [];
+
+ this.installConfigPath();
+ this.installBatchPath();
+ this.installCatchAll();
+ }
+
+ clearPosts() {
+ this.posts = [];
+ }
+
+ getPosts() {
+ return this.posts;
+ }
+
+ getDeletedBuckets() {
+ return this.deletedBuckets;
+ }
+
+ rejectNextAuthWith(response) {
+ this.rejectNextAuthResponse = response;
+ }
+
+ checkAuth(request, response) {
+ equal(request.getHeader("Authorization"), "Bearer some-access-token");
+
+ if (this.rejectNextAuthResponse) {
+ response.setStatusLine(null, 401, "Unauthorized");
+ response.write(this.rejectNextAuthResponse);
+ this.rejectNextAuthResponse = false;
+ this.failedAuths.push(request);
+ return true;
+ }
+ return false;
+ }
+
+ installConfigPath() {
+ const configPath = "/v1/";
+ const responseBody = JSON.stringify({
+ settings: { batch_max_requests: 25 },
+ url: `http://localhost:${this.port}/v1/`,
+ documentation: "https://kinto.readthedocs.org/",
+ version: "1.5.1",
+ commit: "cbc6f58",
+ hello: "kinto",
+ });
+ const configResponse = {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: responseBody,
+ };
+
+ function handleGetConfig(request, response) {
+ if (request.method != "GET") {
+ dump(`ARGH, got ${request.method}\n`);
+ }
+ return handleCannedResponse(configResponse, request, response);
+ }
+
+ this.httpServer.registerPathHandler(configPath, handleGetConfig);
+ }
+
+ installBatchPath() {
+ const batchPath = "/v1/batch";
+
+ function handlePost(request, response) {
+ if (this.checkAuth(request, response)) {
+ return;
+ }
+
+ let bodyStr = CommonUtils.readBytesFromInputStream(
+ request.bodyInputStream
+ );
+ let body = JSON.parse(bodyStr);
+ let defaults = body.defaults;
+ for (let req of body.requests) {
+ let headers = Object.assign(
+ {},
+ (defaults && defaults.headers) || {},
+ req.headers
+ );
+ this.posts.push(Object.assign({}, req, { headers }));
+ }
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-Type", "application/json; charset=UTF-8");
+ response.setHeader("Date", new Date().toUTCString());
+
+ let postResponse = {
+ responses: body.requests.map(req => {
+ let oneBody;
+ if (req.method == "DELETE") {
+ let id = req.path.match(
+ /^\/buckets\/default\/collections\/.+\/records\/(.+)$/
+ )[1];
+ oneBody = {
+ data: {
+ deleted: true,
+ id: id,
+ last_modified: this.etag,
+ },
+ };
+ } else {
+ oneBody = {
+ data: Object.assign({}, req.body.data, {
+ last_modified: this.etag,
+ }),
+ permissions: [],
+ };
+ }
+
+ return {
+ path: req.path,
+ status: 201, // FIXME -- only for new posts??
+ headers: { ETag: 3000 }, // FIXME???
+ body: oneBody,
+ };
+ }),
+ };
+
+ if (this.conflicts.length) {
+ const nextConflict = this.conflicts.shift();
+ if (!nextConflict.transient) {
+ this.records.push(nextConflict);
+ }
+ const { data } = nextConflict;
+ postResponse = {
+ responses: body.requests.map(req => {
+ return {
+ path: req.path,
+ status: 412,
+ headers: { ETag: this.etag }, // is this correct??
+ body: {
+ details: {
+ existing: data,
+ },
+ },
+ };
+ }),
+ };
+ }
+
+ response.write(JSON.stringify(postResponse));
+
+ // "sampleHeaders": [
+ // "Access-Control-Allow-Origin: *",
+ // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ // "Server: waitress",
+ // "Etag: \"4000\""
+ // ],
+ }
+
+ this.httpServer.registerPathHandler(batchPath, handlePost.bind(this));
+ }
+
+ installCatchAll() {
+ this.httpServer.registerPathHandler("/", (request, response) => {
+ dump(
+ `got request: ${request.method}:${request.path}?${request.queryString}\n`
+ );
+ dump(
+ `${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n`
+ );
+ });
+ }
+
+ /**
+ * Add a record to those that can be served by this server.
+ *
+ * @param {Object} properties An object describing the record that
+ * should be served. The properties of this object are:
+ * - collectionId {string} This record should only be served if a
+ * request is for this collection.
+ * - predicate {Function} If present, this record should only be served if the
+ * predicate returns true. The predicate will be called with
+ * {request: Request, response: Response, since: number, server: KintoServer}.
+ * - data {string} The record to serve.
+ * - conflict {boolean} If present and true, this record is added to
+ * "conflicts" and won't be served, but will cause a conflict on
+ * the next push.
+ */
+ addRecord(properties) {
+ if (!properties.conflict) {
+ this.records.push(properties);
+ } else {
+ this.conflicts.push(properties);
+ }
+
+ this.installCollection(properties.collectionId);
+ }
+
+ /**
+ * Tell the server to set up a route for this collection.
+ *
+ * This will automatically be called for any collection to which you `addRecord`.
+ *
+ * @param {string} collectionId the collection whose route we
+ * should set up.
+ */
+ installCollection(collectionId) {
+ if (this.collections.has(collectionId)) {
+ return;
+ }
+ this.collections.add(collectionId);
+ const remoteCollectionPath =
+ "/v1" + collectionPath(encodeURIComponent(collectionId));
+ this.httpServer.registerPathHandler(
+ remoteCollectionPath,
+ this.handleGetCollection.bind(this, collectionId)
+ );
+ const remoteRecordsPath =
+ "/v1" + collectionRecordsPath(encodeURIComponent(collectionId));
+ this.httpServer.registerPathHandler(
+ remoteRecordsPath,
+ this.handleGetRecords.bind(this, collectionId)
+ );
+ }
+
+ handleGetCollection(collectionId, request, response) {
+ if (this.checkAuth(request, response)) {
+ return;
+ }
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-Type", "application/json; charset=UTF-8");
+ response.setHeader("Date", new Date().toUTCString());
+ response.write(
+ JSON.stringify({
+ data: {
+ id: collectionId,
+ },
+ })
+ );
+ }
+
+ handleGetRecords(collectionId, request, response) {
+ if (this.checkAuth(request, response)) {
+ return;
+ }
+
+ if (request.method != "GET") {
+ do_throw(`only GET is supported on ${request.path}`);
+ }
+
+ let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/);
+ let since = sinceMatch && parseInt(sinceMatch[2], 10);
+
+ response.setStatusLine(null, 200, "OK");
+ response.setHeader("Content-Type", "application/json; charset=UTF-8");
+ response.setHeader("Date", new Date().toUTCString());
+ response.setHeader("ETag", this.etag.toString());
+
+ const records = this.records
+ .filter(properties => {
+ if (properties.collectionId != collectionId) {
+ return false;
+ }
+
+ if (properties.predicate) {
+ const predAllowed = properties.predicate({
+ request: request,
+ response: response,
+ since: since,
+ server: this,
+ });
+ if (!predAllowed) {
+ return false;
+ }
+ }
+
+ return true;
+ })
+ .map(properties => properties.data);
+
+ const body = JSON.stringify({
+ data: records,
+ });
+ response.write(body);
+ }
+
+ installDeleteBucket() {
+ this.httpServer.registerPrefixHandler(
+ "/v1/buckets/",
+ (request, response) => {
+ if (request.method != "DELETE") {
+ dump(
+ `got a non-delete action on bucket: ${request.method} ${request.path}\n`
+ );
+ return;
+ }
+
+ const noPrefix = request.path.slice("/v1/buckets/".length);
+ const [bucket, afterBucket] = noPrefix.split("/", 1);
+ if (afterBucket && afterBucket != "") {
+ dump(
+ `got a delete for a non-bucket: ${request.method} ${request.path}\n`
+ );
+ }
+
+ this.deletedBuckets.push(bucket);
+ // Fake like this actually deletes the records.
+ this.records = [];
+
+ response.write(
+ JSON.stringify({
+ data: {
+ deleted: true,
+ last_modified: 1475161309026,
+ id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME
+ },
+ })
+ );
+ }
+ );
+ }
+
+ // Utility function to install a keyring at the start of a test.
+ async installKeyRing(fxaService, keysData, salts, etag, properties) {
+ const keysRecord = {
+ id: "keys",
+ keys: keysData,
+ salts: salts,
+ last_modified: etag,
+ };
+ this.etag = etag;
+ const transformer = new KeyRingEncryptionRemoteTransformer(fxaService);
+ return this.encryptAndAddRecord(
+ transformer,
+ Object.assign({}, properties, {
+ collectionId: "storage-sync-crypto",
+ data: keysRecord,
+ })
+ );
+ }
+
+ encryptAndAddRecord(transformer, properties) {
+ return transformer.encode(properties.data).then(encrypted => {
+ this.addRecord(Object.assign({}, properties, { data: encrypted }));
+ });
+ }
+
+ stop() {
+ this.httpServer.stop(() => {});
+ }
+}
+
+/**
+ * Predicate that represents a record appearing at some time.
+ * Requests with "_since" before this time should see this record,
+ * unless the server itself isn't at this time yet (etag is before
+ * this time).
+ *
+ * Requests with _since after this time shouldn't see this record any
+ * more, since it hasn't changed after this time.
+ *
+ * @param {int} startTime the etag at which time this record should
+ * start being available (and thus, the predicate should start
+ * returning true)
+ * @returns {Function}
+ */
+function appearsAt(startTime) {
+ return function({ since, server }) {
+ return since < startTime && startTime < server.etag;
+ };
+}
+
+// Run a block of code with access to a KintoServer.
+async function withServer(f) {
+ let server = new KintoServer();
+ // Point the sync.storage client to use the test server we've just started.
+ Services.prefs.setCharPref(
+ "webextensions.storage.sync.serverURL",
+ `http://localhost:${server.port}/v1`
+ );
+ try {
+ await f(server);
+ } finally {
+ server.stop();
+ }
+}
+
+// Run a block of code with access to both a sync context and a
+// KintoServer. This is meant as a workaround for eslint's refusal to
+// let me have 5 nested callbacks.
+async function withContextAndServer(f) {
+ await withSyncContext(async function(context) {
+ await withServer(async function(server) {
+ await f(context, server);
+ });
+ });
+}
+
+// Run a block of code with fxa mocked out to return a specific user.
+// Calls the given function with an ExtensionStorageSync instance that
+// was constructed using a mocked FxAccounts instance.
+async function withSignedInUser(user, f) {
+ let fxaServiceMock = {
+ getSignedInUser() {
+ return Promise.resolve({ uid: user.uid });
+ },
+ getOAuthToken() {
+ return Promise.resolve("some-access-token");
+ },
+ checkAccountStatus() {
+ return Promise.resolve(true);
+ },
+ removeCachedOAuthToken() {
+ return Promise.resolve();
+ },
+ keys: {
+ getKeyForScope(scope) {
+ return Promise.resolve({ ...user.scopedKeys[scope] });
+ },
+ kidAsHex(jwk) {
+ return new FxAccountsKeys({}).kidAsHex(jwk);
+ },
+ },
+ };
+
+ let telemetryMock = {
+ _calls: [],
+ _histograms: {},
+ scalarSet(name, value) {
+ this._calls.push({ method: "scalarSet", name, value });
+ },
+ keyedScalarSet(name, key, value) {
+ this._calls.push({ method: "keyedScalarSet", name, key, value });
+ },
+ getKeyedHistogramById(name) {
+ let self = this;
+ return {
+ add(key, value) {
+ if (!self._histograms[name]) {
+ self._histograms[name] = [];
+ }
+ self._histograms[name].push(value);
+ },
+ };
+ },
+ };
+ let extensionStorageSync = new ExtensionStorageSync(
+ fxaServiceMock,
+ telemetryMock
+ );
+ await f(extensionStorageSync, fxaServiceMock);
+}
+
+// Some assertions that make it easier to write tests about what was
+// posted and when.
+
+// Assert that a post in a batch was made with the correct access token.
+// This should be true of all requests, so this is usually called from
+// another assertion.
+function assertAuthenticatedPost(post) {
+ equal(post.headers.Authorization, "Bearer some-access-token");
+}
+
+// Assert that this post was made with the correct request headers to
+// create a new resource while protecting against someone else
+// creating it at the same time (in other words, "If-None-Match: *").
+// Also calls assertAuthenticatedPost(post).
+function assertPostedNewRecord(post) {
+ assertAuthenticatedPost(post);
+ equal(post.headers["If-None-Match"], "*");
+}
+
+// Assert that this post was made with the correct request headers to
+// update an existing resource while protecting against concurrent
+// modification (in other words, `If-Match: "${etag}"`).
+// Also calls assertAuthenticatedPost(post).
+function assertPostedUpdatedRecord(post, since) {
+ assertAuthenticatedPost(post);
+ equal(post.headers["If-Match"], `"${since}"`);
+}
+
+// Assert that this post was an encrypted keyring, and produce the
+// decrypted body. Sanity check the body while we're here.
+const assertPostedEncryptedKeys = async function(fxaService, post) {
+ equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys");
+
+ let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode(
+ post.body.data
+ );
+ ok(body.keys, `keys object should be present in decoded body`);
+ ok(body.keys.default, `keys object should have a default key`);
+ ok(body.salts, `salts object should be present in decoded body`);
+ return body;
+};
+
+// assertEqual, but for keyring[extensionId] == key.
+function assertKeyRingKey(keyRing, extensionId, expectedKey, message) {
+ if (!message) {
+ message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`;
+ }
+ ok(
+ keyRing.hasKeysFor([extensionId]),
+ `expected keyring to have a key for ${extensionId}\n`
+ );
+ deepEqual(
+ keyRing.keyForCollection(extensionId).keyPairB64,
+ expectedKey.keyPairB64,
+ message
+ );
+}
+
+// Assert that this post was posted for a given extension.
+const assertExtensionRecord = async function(fxaService, post, extension, key) {
+ const extensionId = extension.id;
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const hashedId =
+ "id-" +
+ (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId));
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ const transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ equal(
+ post.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "decrypted data should be posted to path corresponding to its key"
+ );
+ let decoded = await transformer.decode(post.body.data);
+ equal(
+ decoded.key,
+ key,
+ "decrypted data should have a key attribute corresponding to the extension data key"
+ );
+ return decoded;
+};
+
+// Tests using this ID will share keys in local storage, so be careful.
+const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}";
+const defaultExtension = { id: defaultExtensionId };
+
+const loggedInUser = {
+ uid: "0123456789abcdef0123456789abcdef",
+ scopedKeys: {
+ "sync:addon_storage": {
+ kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA",
+ k:
+ "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ",
+ kty: "oct",
+ },
+ },
+ oauthTokens: {
+ "sync:addon_storage": {
+ token: "some-access-token",
+ },
+ },
+};
+
+function uuid() {
+ const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService(
+ Ci.nsIUUIDGenerator
+ );
+ return uuidgen.generateUUID().toString();
+}
+
+add_task(async function test_setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function test_single_initialization() {
+ // Grab access to this via the backstage pass to check if we're calling openConnection too often.
+ const { FirefoxAdapter } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageSyncKinto.jsm",
+ null
+ );
+ const origOpenConnection = FirefoxAdapter.openConnection;
+ let callCount = 0;
+ FirefoxAdapter.openConnection = function(...args) {
+ ++callCount;
+ return origOpenConnection.apply(this, args);
+ };
+ function background() {
+ let promises = ["foo", "bar", "baz", "quux"].map(key =>
+ browser.storage.sync.get(key)
+ );
+ Promise.all(promises).then(() =>
+ browser.test.notifyPass("initialize once")
+ );
+ }
+ try {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["storage"],
+ },
+ background: `(${background})()`,
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("initialize once");
+ await extension.unload();
+ equal(
+ callCount,
+ 1,
+ "Initialized FirefoxAdapter connection and Kinto exactly once"
+ );
+ } finally {
+ FirefoxAdapter.openConnection = origOpenConnection;
+ }
+});
+
+add_task(async function test_key_to_id() {
+ equal(keyToId("foo"), "key-foo");
+ equal(keyToId("my-new-key"), "key-my_2D_new_2D_key");
+ equal(keyToId(""), "key-");
+ equal(keyToId("™"), "key-_2122_");
+ equal(keyToId("\b"), "key-_8_");
+ equal(keyToId("abc\ndef"), "key-abc_A_def");
+ equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string");
+
+ const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"];
+ for (let key of KEYS) {
+ equal(idToKey(keyToId(key)), key);
+ }
+
+ equal(idToKey("hi"), null);
+ equal(idToKey("-key-hi"), null);
+ equal(idToKey("key--abcd"), null);
+ equal(idToKey("key-%"), null);
+ equal(idToKey("key-_HI"), null);
+ equal(idToKey("key-_HI_"), null);
+ equal(idToKey("key-"), "");
+ equal(idToKey("key-1"), "1");
+ equal(idToKey("key-_2D_"), "-");
+});
+
+add_task(async function test_extension_id_to_collection_id() {
+ const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}";
+ // FIXME: this doesn't actually require the signed in user, but the
+ // extensionIdToCollectionId method exists on CryptoCollection,
+ // which needs an fxaService to be instantiated.
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ // Fake a static keyring since the server doesn't exist.
+ const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo=";
+ const cryptoCollection = new CryptoCollection(fxaService);
+ await cryptoCollection._setSalt(extensionId, salt);
+
+ equal(
+ await cryptoCollection.extensionIdToCollectionId(extensionId),
+ "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo"
+ );
+ });
+});
+
+add_task(async function ensureCanSync_clearAll() {
+ // A test extension that will not have any active context around
+ // but it is returned from a call to AddonManager.getExtensionsByType.
+ const extensionId = "test-wipe-on-enabled-and-synced@mochi.test";
+ const testExtension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id: extensionId } },
+ },
+ });
+
+ await testExtension.startup();
+
+ // Retrieve the Extension class instance from the test extension.
+ const { extension } = testExtension;
+
+ // Another test extension that will have an active extension context.
+ const extensionId2 = "test-wipe-on-active-context@mochi.test";
+ const extension2 = { id: extensionId2 };
+
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ async function assertSetAndGetData(extension, data) {
+ await extensionStorageSync.set(extension, data, context);
+ let storedData = await extensionStorageSync.get(
+ extension,
+ Object.keys(data),
+ context
+ );
+ const extId = extensionId;
+ deepEqual(storedData, data, `${extId} should get back the data we set`);
+ }
+
+ async function assertDataCleared(extension, keys) {
+ const storedData = await extensionStorageSync.get(
+ extension,
+ keys,
+ context
+ );
+ deepEqual(storedData, {}, `${extension.id} should have lost the data`);
+ }
+
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ let newKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionId2,
+ ]);
+ ok(
+ newKeys.hasKeysFor([extensionId]),
+ `key isn't present for ${extensionId}`
+ );
+ ok(
+ newKeys.hasKeysFor([extensionId2]),
+ `key isn't present for ${extensionId2}`
+ );
+
+ let posts = server.getPosts();
+ equal(posts.length, 1);
+ assertPostedNewRecord(posts[0]);
+
+ await assertSetAndGetData(extension, { "my-key": 1 });
+ await assertSetAndGetData(extension2, { "my-key": 2 });
+
+ // Call cleanup for the first extension, to double check it has
+ // been wiped out even without an active extension context.
+ cleanUpForContext(extension, context);
+
+ // clear everything.
+ await extensionStorageSync.clearAll();
+
+ // Assert that the data is gone for both the extensions.
+ await assertDataCleared(extension, ["my-key"]);
+ await assertDataCleared(extension2, ["my-key"]);
+
+ // should have been no posts caused by the clear.
+ posts = server.getPosts();
+ equal(posts.length, 1);
+ });
+ });
+
+ await testExtension.unload();
+});
+
+add_task(async function ensureCanSync_posts_new_keys() {
+ const extensionId = uuid();
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ let newKeys = await extensionStorageSync.ensureCanSync([extensionId]);
+ ok(
+ newKeys.hasKeysFor([extensionId]),
+ `key isn't present for ${extensionId}`
+ );
+
+ let posts = server.getPosts();
+ equal(posts.length, 1);
+ const post = posts[0];
+ assertPostedNewRecord(post);
+ const body = await assertPostedEncryptedKeys(fxaService, post);
+ const oldSalt = body.salts[extensionId];
+ ok(
+ body.keys.collections[extensionId],
+ `keys object should have a key for ${extensionId}`
+ );
+ ok(oldSalt, `salts object should have a salt for ${extensionId}`);
+
+ // Try adding another key to make sure that the first post was
+ // OK, even on a new profile.
+ await extensionStorageSync.cryptoCollection._clear();
+ server.clearPosts();
+ // Restore the first posted keyring, but add a last_modified date
+ const firstPostedKeyring = Object.assign({}, post.body.data, {
+ last_modified: server.etag,
+ });
+ server.addRecord({
+ data: firstPostedKeyring,
+ collectionId: "storage-sync-crypto",
+ predicate: appearsAt(250),
+ });
+ const extensionId2 = uuid();
+ newKeys = await extensionStorageSync.ensureCanSync([extensionId2]);
+ ok(
+ newKeys.hasKeysFor([extensionId]),
+ `didn't forget key for ${extensionId}`
+ );
+ ok(
+ newKeys.hasKeysFor([extensionId2]),
+ `new key generated for ${extensionId2}`
+ );
+
+ posts = server.getPosts();
+ equal(posts.length, 1);
+ const newPost = posts[posts.length - 1];
+ const newBody = await assertPostedEncryptedKeys(fxaService, newPost);
+ ok(
+ newBody.keys.collections[extensionId],
+ `keys object should have a key for ${extensionId}`
+ );
+ ok(
+ newBody.keys.collections[extensionId2],
+ `keys object should have a key for ${extensionId2}`
+ );
+ ok(
+ newBody.salts[extensionId],
+ `salts object should have a key for ${extensionId}`
+ );
+ ok(
+ newBody.salts[extensionId2],
+ `salts object should have a key for ${extensionId2}`
+ );
+ equal(
+ oldSalt,
+ newBody.salts[extensionId],
+ `old salt should be preserved in post`
+ );
+ });
+ });
+});
+
+add_task(async function ensureCanSync_pulls_key() {
+ // ensureCanSync is implemented by adding a key to our local record
+ // and doing a sync. This means that if the same key exists
+ // remotely, we get a "conflict". Ensure that we handle this
+ // correctly -- we keep the server key (since presumably it's
+ // already been used to encrypt records) and we don't wipe out other
+ // collections' keys.
+ const extensionId = uuid();
+ const extensionId2 = uuid();
+ const extensionOnlyKey = uuid();
+ const extensionOnlySalt = uuid();
+ const DEFAULT_KEY = new BulkKeyBundle("[default]");
+ await DEFAULT_KEY.generateRandom();
+ const RANDOM_KEY = new BulkKeyBundle(extensionId);
+ await RANDOM_KEY.generateRandom();
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ // FIXME: generating a random salt probably shouldn't require a CryptoCollection?
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const RANDOM_SALT = cryptoCollection.getNewSalt();
+ await extensionStorageSync.cryptoCollection._clear();
+ const keysData = {
+ default: DEFAULT_KEY.keyPairB64,
+ collections: {
+ [extensionId]: RANDOM_KEY.keyPairB64,
+ },
+ };
+ const saltData = {
+ [extensionId]: RANDOM_SALT,
+ };
+ await server.installKeyRing(fxaService, keysData, saltData, 950, {
+ predicate: appearsAt(900),
+ });
+
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY);
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 0,
+ "ensureCanSync shouldn't push when the server keyring has the right key"
+ );
+
+ // Another client generates a key for extensionId2
+ const newKey = new BulkKeyBundle(extensionId2);
+ await newKey.generateRandom();
+ keysData.collections[extensionId2] = newKey.keyPairB64;
+ saltData[extensionId2] = cryptoCollection.getNewSalt();
+ await server.installKeyRing(fxaService, keysData, saltData, 1050, {
+ predicate: appearsAt(1000),
+ });
+
+ let newCollectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionId2,
+ ]);
+ assertKeyRingKey(newCollectionKeys, extensionId2, newKey);
+ assertKeyRingKey(
+ newCollectionKeys,
+ extensionId,
+ RANDOM_KEY,
+ `ensureCanSync shouldn't lose the old key for ${extensionId}`
+ );
+
+ posts = server.getPosts();
+ equal(posts.length, 0, "ensureCanSync shouldn't push when updating keys");
+
+ // Another client generates a key, but not a salt, for extensionOnlyKey
+ const onlyKey = new BulkKeyBundle(extensionOnlyKey);
+ await onlyKey.generateRandom();
+ keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64;
+ await server.installKeyRing(fxaService, keysData, saltData, 1150, {
+ predicate: appearsAt(1100),
+ });
+
+ let withNewKey = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionOnlyKey,
+ ]);
+ dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`);
+ assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey);
+ assertKeyRingKey(
+ withNewKey,
+ extensionId,
+ RANDOM_KEY,
+ `ensureCanSync shouldn't lose the old key for ${extensionId}`
+ );
+
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 1,
+ "ensureCanSync should push when generating a new salt"
+ );
+ const withNewKeyRecord = await assertPostedEncryptedKeys(
+ fxaService,
+ posts[0]
+ );
+ // We don't a priori know what the new salt is
+ dump(`${JSON.stringify(withNewKeyRecord)}\n`);
+ ok(
+ withNewKeyRecord.salts[extensionOnlyKey],
+ `ensureCanSync should generate a salt for an extension that only had a key`
+ );
+
+ // Another client generates a key, but not a salt, for extensionOnlyKey
+ const newSalt = cryptoCollection.getNewSalt();
+ saltData[extensionOnlySalt] = newSalt;
+ await server.installKeyRing(fxaService, keysData, saltData, 1250, {
+ predicate: appearsAt(1200),
+ });
+
+ let withOnlySaltKey = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ extensionOnlySalt,
+ ]);
+ assertKeyRingKey(
+ withOnlySaltKey,
+ extensionId,
+ RANDOM_KEY,
+ `ensureCanSync shouldn't lose the old key for ${extensionId}`
+ );
+ // We don't a priori know what the new key is
+ ok(
+ withOnlySaltKey.hasKeysFor([extensionOnlySalt]),
+ `ensureCanSync generated a key for an extension that only had a salt`
+ );
+
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "ensureCanSync should push when generating a new key"
+ );
+ const withNewSaltRecord = await assertPostedEncryptedKeys(
+ fxaService,
+ posts[1]
+ );
+ equal(
+ withNewSaltRecord.salts[extensionOnlySalt],
+ newSalt,
+ "ensureCanSync should keep the existing salt when generating only a key"
+ );
+ });
+ });
+});
+
+add_task(async function ensureCanSync_handles_conflicts() {
+ // Syncing is done through a pull followed by a push of any merged
+ // changes. Accordingly, the only way to have a "true" conflict --
+ // i.e. with the server rejecting a change -- is if
+ // someone pushes changes between our pull and our push. Ensure that
+ // if this happens, we still behave sensibly (keep the remote key).
+ const extensionId = uuid();
+ const DEFAULT_KEY = new BulkKeyBundle("[default]");
+ await DEFAULT_KEY.generateRandom();
+ const RANDOM_KEY = new BulkKeyBundle(extensionId);
+ await RANDOM_KEY.generateRandom();
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ // FIXME: generating salts probably shouldn't rely on a CryptoCollection
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const RANDOM_SALT = cryptoCollection.getNewSalt();
+ const keysData = {
+ default: DEFAULT_KEY.keyPairB64,
+ collections: {
+ [extensionId]: RANDOM_KEY.keyPairB64,
+ },
+ };
+ const saltData = {
+ [extensionId]: RANDOM_SALT,
+ };
+ await server.installKeyRing(fxaService, keysData, saltData, 765, {
+ conflict: true,
+ });
+
+ await extensionStorageSync.cryptoCollection._clear();
+
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ assertKeyRingKey(
+ collectionKeys,
+ extensionId,
+ RANDOM_KEY,
+ `syncing keyring should keep the server key for ${extensionId}`
+ );
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 1,
+ "syncing keyring should have tried to post a keyring"
+ );
+ const failedPost = posts[0];
+ assertPostedNewRecord(failedPost);
+ let body = await assertPostedEncryptedKeys(fxaService, failedPost);
+ // This key will be the one the client generated locally, so
+ // we don't know what its value will be
+ ok(
+ body.keys.collections[extensionId],
+ `decrypted failed post should have a key for ${extensionId}`
+ );
+ notEqual(
+ body.keys.collections[extensionId],
+ RANDOM_KEY.keyPairB64,
+ `decrypted failed post should have a randomly-generated key for ${extensionId}`
+ );
+ });
+ });
+});
+
+add_task(async function ensureCanSync_handles_deleted_conflicts() {
+ // A keyring can be deleted, and this changes the format of the 412
+ // Conflict response from the Kinto server. Make sure we handle it correctly.
+ const extensionId = uuid();
+ const extensionId2 = uuid();
+ await withContextAndServer(async function(context, server) {
+ server.installCollection("storage-sync-crypto");
+ server.installDeleteBucket();
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ server.etag = 700;
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Generate keys that we can check for later.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ const extensionKey = collectionKeys.keyForCollection(extensionId);
+ server.clearPosts();
+
+ // This is the response that the Kinto server return when the
+ // keyring has been deleted.
+ server.addRecord({
+ collectionId: "storage-sync-crypto",
+ conflict: true,
+ transient: true,
+ data: null,
+ etag: 765,
+ });
+
+ // Try to add a new extension to trigger a sync of the keyring.
+ let collectionKeys2 = await extensionStorageSync.ensureCanSync([
+ extensionId2,
+ ]);
+
+ assertKeyRingKey(
+ collectionKeys2,
+ extensionId,
+ extensionKey,
+ `syncing keyring should keep our local key for ${extensionId}`
+ );
+
+ deepEqual(
+ server.getDeletedBuckets(),
+ ["default"],
+ "Kinto server should have been wiped when keyring was thrown away"
+ );
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "syncing keyring should have tried to post a keyring twice"
+ );
+ // The first post got a conflict.
+ const failedPost = posts[0];
+ assertPostedUpdatedRecord(failedPost, 700);
+ let body = await assertPostedEncryptedKeys(fxaService, failedPost);
+
+ deepEqual(
+ body.keys.collections[extensionId],
+ extensionKey.keyPairB64,
+ `decrypted failed post should have the key for ${extensionId}`
+ );
+
+ // The second post was after the wipe, and succeeded.
+ const afterWipePost = posts[1];
+ assertPostedNewRecord(afterWipePost);
+ let afterWipeBody = await assertPostedEncryptedKeys(
+ fxaService,
+ afterWipePost
+ );
+
+ deepEqual(
+ afterWipeBody.keys.collections[extensionId],
+ extensionKey.keyPairB64,
+ `decrypted new post should have preserved the key for ${extensionId}`
+ );
+ });
+ });
+});
+
+add_task(async function ensureCanSync_handles_flushes() {
+ // See Bug 1359879 and Bug 1350088. One of the ways that 1359879 presents is
+ // as 1350088. This seems to be the symptom that results when the user had
+ // two devices, one of which was not syncing at the time the keyring was
+ // lost. Ensure we can recover for these users as well.
+ const extensionId = uuid();
+ const extensionId2 = uuid();
+ await withContextAndServer(async function(context, server) {
+ server.installCollection("storage-sync-crypto");
+ server.installDeleteBucket();
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ server.etag = 700;
+ // Generate keys that we can check for later.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ const extensionKey = collectionKeys.keyForCollection(extensionId);
+ server.clearPosts();
+
+ // last_modified is new, but there is no data.
+ server.etag = 800;
+
+ // Try to add a new extension to trigger a sync of the keyring.
+ let collectionKeys2 = await extensionStorageSync.ensureCanSync([
+ extensionId2,
+ ]);
+
+ assertKeyRingKey(
+ collectionKeys2,
+ extensionId,
+ extensionKey,
+ `syncing keyring should keep our local key for ${extensionId}`
+ );
+
+ deepEqual(
+ server.getDeletedBuckets(),
+ ["default"],
+ "Kinto server should have been wiped when keyring was thrown away"
+ );
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 1,
+ "syncing keyring should have tried to post a keyring once"
+ );
+
+ const post = posts[0];
+ assertPostedNewRecord(post);
+ let postBody = await assertPostedEncryptedKeys(fxaService, post);
+
+ deepEqual(
+ postBody.keys.collections[extensionId],
+ extensionKey.keyPairB64,
+ `decrypted new post should have preserved the key for ${extensionId}`
+ );
+ });
+ });
+});
+
+add_task(async function checkSyncKeyRing_reuploads_keys() {
+ // Verify that when keys are present, they are reuploaded with the
+ // new kbHash when we call touchKeys().
+ const extensionId = uuid();
+ let extensionKey, extensionSalt;
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ server.installCollection("storage-sync-crypto");
+ server.etag = 765;
+
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Do an `ensureCanSync` to generate some keys.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ ok(
+ collectionKeys.hasKeysFor([extensionId]),
+ `ensureCanSync should return a keyring that has a key for ${extensionId}`
+ );
+ extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
+ equal(
+ server.getPosts().length,
+ 1,
+ "generating a key that doesn't exist on the server should post it"
+ );
+ const body = await assertPostedEncryptedKeys(
+ fxaService,
+ server.getPosts()[0]
+ );
+ extensionSalt = body.salts[extensionId];
+ });
+
+ // The user changes their password. This is their new kbHash, with
+ // the last character changed.
+ const newUser = Object.assign({}, loggedInUser, {
+ scopedKeys: {
+ "sync:addon_storage": {
+ kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE",
+ k:
+ "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA",
+ kty: "oct",
+ },
+ },
+ });
+ let postedKeys;
+ await withSignedInUser(newUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ await extensionStorageSync.checkSyncKeyRing();
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "when kBHash changes, checkSyncKeyRing should post the keyring reencrypted with the new kBHash"
+ );
+ postedKeys = posts[1];
+ assertPostedUpdatedRecord(postedKeys, 765);
+
+ let body = await assertPostedEncryptedKeys(fxaService, postedKeys);
+ deepEqual(
+ body.keys.collections[extensionId],
+ extensionKey,
+ `the posted keyring should have the same key for ${extensionId} as the old one`
+ );
+ deepEqual(
+ body.salts[extensionId],
+ extensionSalt,
+ `the posted keyring should have the same salt for ${extensionId} as the old one`
+ );
+ });
+
+ // Verify that with the old kBHash, we can't decrypt the record.
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ let error;
+ try {
+ await new KeyRingEncryptionRemoteTransformer(fxaService).decode(
+ postedKeys.body.data
+ );
+ } catch (e) {
+ error = e;
+ }
+ ok(error, "decrypting the keyring with the old kBHash should fail");
+ ok(
+ Utils.isHMACMismatch(error) ||
+ KeyRingEncryptionRemoteTransformer.isOutdatedKB(error),
+ "decrypting the keyring with the old kBHash should throw an HMAC mismatch"
+ );
+ });
+ });
+});
+
+add_task(async function checkSyncKeyRing_overwrites_on_conflict() {
+ // If there is already a record on the server that was encrypted
+ // with a different kbHash, we wipe the server, clear sync state, and
+ // overwrite it with our keys.
+ const extensionId = uuid();
+ let extensionKey;
+ await withSyncContext(async function(context) {
+ await withServer(async function(server) {
+ // The old device has this kbHash, which is very similar to the
+ // current kbHash but with the last character changed.
+ const oldUser = Object.assign({}, loggedInUser, {
+ scopedKeys: {
+ "sync:addon_storage": {
+ kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE",
+ k:
+ "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA",
+ kty: "oct",
+ },
+ },
+ });
+ server.installDeleteBucket();
+ await withSignedInUser(oldUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ await server.installKeyRing(fxaService, {}, {}, 765);
+ });
+
+ // Now we have this new user with a different kbHash.
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Do an `ensureCanSync` to generate some keys.
+ // This will try to sync, notice that the record is
+ // undecryptable, and clear the server.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ ok(
+ collectionKeys.hasKeysFor([extensionId]),
+ `ensureCanSync should always return a keyring with a key for ${extensionId}`
+ );
+ extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64;
+
+ deepEqual(
+ server.getDeletedBuckets(),
+ ["default"],
+ "Kinto server should have been wiped when keyring was thrown away"
+ );
+
+ let posts = server.getPosts();
+ equal(posts.length, 1, "new keyring should have been uploaded");
+ const postedKeys = posts[0];
+ // The POST was to an empty server, so etag shouldn't be respected
+ equal(
+ postedKeys.headers.Authorization,
+ "Bearer some-access-token",
+ "keyring upload should be authorized"
+ );
+ equal(
+ postedKeys.headers["If-None-Match"],
+ "*",
+ "keyring upload should be to empty Kinto server"
+ );
+ equal(
+ postedKeys.path,
+ collectionRecordsPath("storage-sync-crypto") + "/keys",
+ "keyring upload should be to keyring path"
+ );
+
+ let body = await new KeyRingEncryptionRemoteTransformer(
+ fxaService
+ ).decode(postedKeys.body.data);
+ ok(body.uuid, "new keyring should have a UUID");
+ equal(typeof body.uuid, "string", "keyring UUIDs should be strings");
+ notEqual(
+ body.uuid,
+ "abcd",
+ "new keyring should not have the same UUID as previous keyring"
+ );
+ ok(body.keys, "new keyring should have a keys attribute");
+ ok(body.keys.default, "new keyring should have a default key");
+ // We should keep the extension key that was in our uploaded version.
+ deepEqual(
+ extensionKey,
+ body.keys.collections[extensionId],
+ "ensureCanSync should have returned keyring with the same key that was uploaded"
+ );
+
+ // This should be a no-op; the keys were uploaded as part of ensurekeysfor
+ await extensionStorageSync.checkSyncKeyRing();
+ equal(
+ server.getPosts().length,
+ 1,
+ "checkSyncKeyRing should not need to post keys after they were reuploaded"
+ );
+ });
+ });
+ });
+});
+
+add_task(async function checkSyncKeyRing_flushes_on_uuid_change() {
+ // If we can decrypt the record, but the UUID has changed, that
+ // means another client has wiped the server and reuploaded a
+ // keyring, so reset sync state and reupload everything.
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withSyncContext(async function(context) {
+ await withServer(async function(server) {
+ server.installCollection("storage-sync-crypto");
+ server.installDeleteBucket();
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const transformer = new KeyRingEncryptionRemoteTransformer(fxaService);
+ await extensionStorageSync.cryptoCollection._clear();
+
+ // Do an `ensureCanSync` to get access to keys and salt.
+ let collectionKeys = await extensionStorageSync.ensureCanSync([
+ extensionId,
+ ]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ server.installCollection(collectionId);
+
+ ok(
+ collectionKeys.hasKeysFor([extensionId]),
+ `ensureCanSync should always return a keyring that has a key for ${extensionId}`
+ );
+ const extensionKey = collectionKeys.keyForCollection(extensionId)
+ .keyPairB64;
+
+ // Set something to make sure that it gets re-uploaded when
+ // uuid changes.
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+ await extensionStorageSync.syncAll();
+
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "should have posted a new keyring and an extension datum"
+ );
+ const postedKeys = posts[0];
+ equal(
+ postedKeys.path,
+ collectionRecordsPath("storage-sync-crypto") + "/keys",
+ "should have posted keyring to /keys"
+ );
+
+ let body = await transformer.decode(postedKeys.body.data);
+ ok(body.uuid, "keyring should have a UUID");
+ ok(body.keys, "keyring should have a keys attribute");
+ ok(body.keys.default, "keyring should have a default key");
+ ok(
+ body.salts[extensionId],
+ `keyring should have a salt for ${extensionId}`
+ );
+ const extensionSalt = body.salts[extensionId];
+ deepEqual(
+ extensionKey,
+ body.keys.collections[extensionId],
+ "new keyring should have the same key that we uploaded"
+ );
+
+ // Another client comes along and replaces the UUID.
+ // In real life, this would mean changing the keys too, but
+ // this test verifies that just changing the UUID is enough.
+ const newKeyRingData = Object.assign({}, body, {
+ uuid: "abcd",
+ // Technically, last_modified should be served outside the
+ // object, but the transformer will pass it through in
+ // either direction, so this is OK.
+ last_modified: 765,
+ });
+ server.etag = 1000;
+ await server.encryptAndAddRecord(transformer, {
+ collectionId: "storage-sync-crypto",
+ data: newKeyRingData,
+ predicate: appearsAt(800),
+ });
+
+ // Fake adding another extension just so that the keyring will
+ // really get synced.
+ const newExtension = uuid();
+ const newKeyRing = await extensionStorageSync.ensureCanSync([
+ newExtension,
+ ]);
+
+ // This should have detected the UUID change and flushed everything.
+ // The keyring should, however, be the same, since we just
+ // changed the UUID of the previously POSTed one.
+ deepEqual(
+ newKeyRing.keyForCollection(extensionId).keyPairB64,
+ extensionKey,
+ "ensureCanSync should have pulled down a new keyring with the same keys"
+ );
+
+ // Syncing should reupload the data for the extension.
+ await extensionStorageSync.syncAll();
+ posts = server.getPosts();
+ equal(
+ posts.length,
+ 4,
+ "should have posted keyring for new extension and reuploaded extension data"
+ );
+
+ const finalKeyRingPost = posts[2];
+ const reuploadedPost = posts[3];
+
+ equal(
+ finalKeyRingPost.path,
+ collectionRecordsPath("storage-sync-crypto") + "/keys",
+ "keyring for new extension should have been posted to /keys"
+ );
+ let finalKeyRing = await transformer.decode(finalKeyRingPost.body.data);
+ equal(
+ finalKeyRing.uuid,
+ "abcd",
+ "newly uploaded keyring should preserve UUID from replacement keyring"
+ );
+ deepEqual(
+ finalKeyRing.salts[extensionId],
+ extensionSalt,
+ "newly uploaded keyring should preserve salts from existing salts"
+ );
+
+ // Confirm that the data got reuploaded
+ let reuploadedData = await assertExtensionRecord(
+ fxaService,
+ reuploadedPost,
+ extension,
+ "my-key"
+ );
+ equal(
+ reuploadedData.data,
+ 5,
+ "extension data should have a data attribute corresponding to the extension data value"
+ );
+ });
+ });
+ });
+});
+
+add_task(async function test_storage_sync_pulls_changes() {
+ const extensionId = defaultExtensionId;
+ const extension = defaultExtension;
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ let calls = [];
+ await extensionStorageSync.addOnChangedListener(
+ extension,
+ function() {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+ server.etag = 900;
+
+ await extensionStorageSync.syncAll();
+ const remoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue,
+ 6,
+ "ExtensionStorageSync.get() returns value retrieved from sync"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener");
+ deepEqual(calls[0][0], { "remote-key": { newValue: 6 } });
+ calls = [];
+
+ // Syncing again doesn't do anything
+ await extensionStorageSync.syncAll();
+
+ equal(
+ calls.length,
+ 0,
+ "syncing again shouldn't call on-changed listener"
+ );
+
+ // Updating the server causes us to pull down the new value
+ server.etag = 1000;
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 7,
+ },
+ predicate: appearsAt(950),
+ });
+
+ await extensionStorageSync.syncAll();
+ const remoteValue2 = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue2,
+ 7,
+ "ExtensionStorageSync.get() returns value updated from sync"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener on update");
+ deepEqual(calls[0][0], { "remote-key": { oldValue: 6, newValue: 7 } });
+ });
+ });
+});
+
+// Tests that an enabled extension which have been synced before it is going
+// to be synced on ExtensionStorageSync.syncAll even if there is no active
+// context that is currently using the API.
+add_task(async function test_storage_sync_on_no_active_context() {
+ const extensionId = "sync@mochi.test";
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id: extensionId } },
+ },
+ files: {
+ "ext-page.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <script src="ext-page.js"></script>
+ </head>
+ </html>
+ `,
+ "ext-page.js": function() {
+ const { browser } = this;
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "get-sync-data") {
+ browser.test.sendMessage(
+ "get-sync-data:done",
+ await browser.storage.sync.get(["remote-key"])
+ );
+ }
+ });
+ },
+ },
+ });
+
+ await extension.startup();
+
+ await withServer(async server => {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+
+ server.etag = 1000;
+ await extensionStorageSync.syncAll();
+ });
+ });
+
+ const extPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/ext-page.html`,
+ { extension }
+ );
+
+ await extension.sendMessage("get-sync-data");
+ const res = await extension.awaitMessage("get-sync-data:done");
+ Assert.deepEqual(res, { "remote-key": 6 }, "Got the expected sync data");
+
+ await extPage.close();
+
+ await extension.unload();
+});
+
+add_task(async function test_storage_sync_pushes_changes() {
+ // FIXME: This test relies on the fact that previous tests pushed
+ // keys and salts for the default extension ID
+ const extension = defaultExtension;
+ const extensionId = defaultExtensionId;
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ server.installCollection(collectionId);
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+
+ // install this AFTER we set the key to 5...
+ let calls = [];
+ extensionStorageSync.addOnChangedListener(
+ extension,
+ function() {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.syncAll();
+ const localValue = (
+ await extensionStorageSync.get(extension, "my-key", context)
+ )["my-key"];
+ equal(
+ localValue,
+ 5,
+ "pushing an ExtensionStorageSync value shouldn't change local value"
+ );
+ const hashedId =
+ "id-" +
+ (await cryptoCollection.hashWithExtensionSalt(
+ "key-my_2D_key",
+ extensionId
+ ));
+
+ let posts = server.getPosts();
+ // FIXME: Keys were pushed in a previous test
+ equal(
+ posts.length,
+ 1,
+ "pushing a value should cause a post to the server"
+ );
+ const post = posts[0];
+ assertPostedNewRecord(post);
+ equal(
+ post.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "pushing a value should have a path corresponding to its id"
+ );
+
+ const encrypted = post.body.data;
+ ok(
+ encrypted.ciphertext,
+ "pushing a value should post an encrypted record"
+ );
+ ok(!encrypted.data, "pushing a value should not have any plaintext data");
+ equal(
+ encrypted.id,
+ hashedId,
+ "pushing a value should use a kinto-friendly record ID"
+ );
+
+ const record = await assertExtensionRecord(
+ fxaService,
+ post,
+ extension,
+ "my-key"
+ );
+ equal(
+ record.data,
+ 5,
+ "when decrypted, a pushed value should have a data field corresponding to its storage.sync value"
+ );
+ equal(
+ record.id,
+ "key-my_2D_key",
+ "when decrypted, a pushed value should have an id field corresponding to its record ID"
+ );
+
+ equal(
+ calls.length,
+ 0,
+ "pushing a value shouldn't call the on-changed listener"
+ );
+
+ await extensionStorageSync.set(extension, { "my-key": 6 }, context);
+ await extensionStorageSync.syncAll();
+
+ // Doesn't push keys because keys were pushed by a previous test.
+ posts = server.getPosts();
+ equal(posts.length, 2, "updating a value should trigger another push");
+ const updatePost = posts[1];
+ assertPostedUpdatedRecord(updatePost, 1000);
+ equal(
+ updatePost.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "pushing an updated value should go to the same path"
+ );
+
+ const updateEncrypted = updatePost.body.data;
+ ok(
+ updateEncrypted.ciphertext,
+ "pushing an updated value should still be encrypted"
+ );
+ ok(
+ !updateEncrypted.data,
+ "pushing an updated value should not have any plaintext visible"
+ );
+ equal(
+ updateEncrypted.id,
+ hashedId,
+ "pushing an updated value should maintain the same ID"
+ );
+ });
+ });
+});
+
+add_task(async function test_storage_sync_retries_failed_auth() {
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ // Put a remote record just to verify that eventually we succeeded
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+ server.etag = 900;
+
+ // This is a typical response from a production stack if your
+ // bearer token is bad.
+ server.rejectNextAuthWith(
+ '{"code": 401, "errno": 104, "error": "Unauthorized", "message": "Please authenticate yourself to use this endpoint"}'
+ );
+ await extensionStorageSync.syncAll();
+
+ equal(server.failedAuths.length, 1, "an auth was failed");
+
+ const remoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue,
+ 6,
+ "ExtensionStorageSync.get() returns value retrieved from sync"
+ );
+
+ // Try again with an emptier JSON body to make sure this still
+ // works with a less-cooperative server.
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 7,
+ },
+ predicate: appearsAt(950),
+ });
+ server.etag = 1000;
+ // Need to write a JSON response.
+ // kinto.js 9.0.2 doesn't throw unless there's json.
+ // See https://github.com/Kinto/kinto-http.js/issues/192.
+ server.rejectNextAuthWith("{}");
+
+ await extensionStorageSync.syncAll();
+
+ equal(server.failedAuths.length, 2, "an auth was failed");
+
+ const newRemoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ newRemoteValue,
+ 7,
+ "ExtensionStorageSync.get() returns value retrieved from sync"
+ );
+ });
+ });
+});
+
+add_task(async function test_storage_sync_pulls_conflicts() {
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.ensureCanSync([extensionId]);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+ let transformer = new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 6,
+ },
+ predicate: appearsAt(850),
+ });
+ server.etag = 900;
+
+ await extensionStorageSync.set(extension, { "remote-key": 8 }, context);
+
+ let calls = [];
+ await extensionStorageSync.addOnChangedListener(
+ extension,
+ function() {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.syncAll();
+ const remoteValue = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(remoteValue, 8, "locally set value overrides remote value");
+
+ equal(calls.length, 1, "conflicts manifest in on-changed listener");
+ deepEqual(calls[0][0], { "remote-key": { newValue: 8 } });
+ calls = [];
+
+ // Syncing again doesn't do anything
+ await extensionStorageSync.syncAll();
+
+ equal(
+ calls.length,
+ 0,
+ "syncing again shouldn't call on-changed listener"
+ );
+
+ // Updating the server causes us to pull down the new value
+ server.etag = 1000;
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-remote_2D_key",
+ key: "remote-key",
+ data: 7,
+ },
+ predicate: appearsAt(950),
+ });
+
+ await extensionStorageSync.syncAll();
+ const remoteValue2 = (
+ await extensionStorageSync.get(extension, "remote-key", context)
+ )["remote-key"];
+ equal(
+ remoteValue2,
+ 7,
+ "conflicts do not prevent retrieval of new values"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener on update");
+ deepEqual(calls[0][0], { "remote-key": { oldValue: 8, newValue: 7 } });
+ });
+ });
+});
+
+add_task(async function test_storage_sync_pulls_deletes() {
+ const extension = defaultExtension;
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ defaultExtensionId
+ );
+ server.installCollection(collectionId);
+ server.installCollection("storage-sync-crypto");
+
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+ await extensionStorageSync.syncAll();
+ server.clearPosts();
+
+ let calls = [];
+ await extensionStorageSync.addOnChangedListener(
+ extension,
+ function() {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ const transformer = new CollectionKeyEncryptionRemoteTransformer(
+ new CryptoCollection(fxaService),
+ await cryptoCollection.getKeyRing(),
+ extension.id
+ );
+ await server.encryptAndAddRecord(transformer, {
+ collectionId,
+ data: {
+ id: "key-my_2D_key",
+ data: 6,
+ _status: "deleted",
+ },
+ });
+
+ await extensionStorageSync.syncAll();
+ const remoteValues = await extensionStorageSync.get(
+ extension,
+ "my-key",
+ context
+ );
+ ok(
+ !remoteValues["my-key"],
+ "ExtensionStorageSync.get() shows value was deleted by sync"
+ );
+
+ equal(
+ server.getPosts().length,
+ 0,
+ "pulling the delete shouldn't cause posts"
+ );
+
+ equal(calls.length, 1, "syncing calls on-changed listener");
+ deepEqual(calls[0][0], { "my-key": { oldValue: 5 } });
+ calls = [];
+
+ // Syncing again doesn't do anything
+ await extensionStorageSync.syncAll();
+
+ equal(
+ calls.length,
+ 0,
+ "syncing again shouldn't call on-changed listener"
+ );
+ });
+ });
+});
+
+add_task(async function test_storage_sync_pushes_deletes() {
+ const extensionId = uuid();
+ const extension = { id: extensionId };
+ await withContextAndServer(async function(context, server) {
+ await withSignedInUser(loggedInUser, async function(
+ extensionStorageSync,
+ fxaService
+ ) {
+ const cryptoCollection = new CryptoCollection(fxaService);
+ await cryptoCollection._clear();
+ await cryptoCollection._setSalt(
+ extensionId,
+ cryptoCollection.getNewSalt()
+ );
+ const collectionId = await cryptoCollection.extensionIdToCollectionId(
+ extensionId
+ );
+
+ server.installCollection(collectionId);
+ server.installCollection("storage-sync-crypto");
+ server.etag = 1000;
+
+ await extensionStorageSync.set(extension, { "my-key": 5 }, context);
+
+ let calls = [];
+ extensionStorageSync.addOnChangedListener(
+ extension,
+ function() {
+ calls.push(arguments);
+ },
+ context
+ );
+
+ await extensionStorageSync.syncAll();
+ let posts = server.getPosts();
+ equal(
+ posts.length,
+ 2,
+ "pushing a non-deleted value should post keys and post the value to the server"
+ );
+
+ await extensionStorageSync.remove(extension, ["my-key"], context);
+ equal(
+ calls.length,
+ 1,
+ "deleting a value should call the on-changed listener"
+ );
+
+ await extensionStorageSync.syncAll();
+ equal(
+ calls.length,
+ 1,
+ "pushing a deleted value shouldn't call the on-changed listener"
+ );
+
+ // Doesn't push keys because keys were pushed by a previous test.
+ const hashedId =
+ "id-" +
+ (await cryptoCollection.hashWithExtensionSalt(
+ "key-my_2D_key",
+ extensionId
+ ));
+ posts = server.getPosts();
+ equal(posts.length, 3, "deleting a value should trigger another push");
+ const post = posts[2];
+ assertPostedUpdatedRecord(post, 1000);
+ equal(
+ post.path,
+ `${collectionRecordsPath(collectionId)}/${hashedId}`,
+ "pushing a deleted value should go to the same path"
+ );
+ ok(post.method, "PUT");
+ ok(
+ post.body.data.ciphertext,
+ "deleting a value should have an encrypted body"
+ );
+ const decoded = await new CollectionKeyEncryptionRemoteTransformer(
+ cryptoCollection,
+ await cryptoCollection.getKeyRing(),
+ extensionId
+ ).decode(post.body.data);
+ equal(decoded._status, "deleted");
+ // Ideally, we'd check that decoded.deleted is not true, because
+ // the encrypted record shouldn't have it, but the decoder will
+ // add it when it sees _status == deleted
+ });
+ });
+});
+
+// Some sync tests shared between implementations.
+add_task(test_config_flag_needed);
+
+add_task(test_sync_reloading_extensions_works);
+
+add_task(function test_storage_sync() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_page_storage("sync")
+ );
+});
+
+add_task(test_storage_sync_requires_real_id);
+
+add_task(function test_storage_sync_with_bytes_in_use() {
+ return runWithPrefs([[STORAGE_SYNC_PREF, true]], () =>
+ test_background_storage_area_with_bytes_in_use("sync", false)
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js
new file mode 100644
index 0000000000..8c4137b078
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This is a kinto-specific test...
+Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true);
+
+const { EncryptionRemoteTransformer } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageSyncKinto.jsm",
+ null
+);
+const { CryptoUtils } = ChromeUtils.import(
+ "resource://services-crypto/utils.js"
+);
+const { Utils } = ChromeUtils.import("resource://services-sync/util.js");
+
+/**
+ * Like Assert.throws, but for generators.
+ *
+ * @param {string | Object | function} constraint
+ * What to use to check the exception.
+ * @param {function} f
+ * The function to call.
+ */
+async function throwsGen(constraint, f) {
+ let threw = false;
+ let exception;
+ try {
+ await f();
+ } catch (e) {
+ threw = true;
+ exception = e;
+ }
+
+ ok(threw, "did not throw an exception");
+
+ const debuggingMessage = `got ${exception}, expected ${constraint}`;
+
+ if (typeof constraint === "function") {
+ ok(constraint(exception), debuggingMessage);
+ } else {
+ let message = exception;
+ if (typeof exception === "object") {
+ message = exception.message;
+ }
+ ok(constraint === message, debuggingMessage);
+ }
+}
+
+/**
+ * An EncryptionRemoteTransformer that uses a fixed key bundle,
+ * suitable for testing.
+ */
+class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
+ constructor(keyBundle) {
+ super();
+ this.keyBundle = keyBundle;
+ }
+
+ getKeys() {
+ return Promise.resolve(this.keyBundle);
+ }
+}
+const BORING_KB =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+let transformer;
+add_task(async function setup() {
+ const STRETCHED_KEY = await CryptoUtils.hkdfLegacy(
+ BORING_KB,
+ undefined,
+ `testing storage.sync encryption`,
+ 2 * 32
+ );
+ const KEY_BUNDLE = {
+ sha256HMACHasher: Utils.makeHMACHasher(
+ Ci.nsICryptoHMAC.SHA256,
+ Utils.makeHMACKey(STRETCHED_KEY.slice(0, 32))
+ ),
+ encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)),
+ };
+ transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE);
+});
+
+add_task(async function test_encryption_transformer_roundtrip() {
+ const POSSIBLE_DATAS = [
+ "string",
+ 2, // number
+ [1, 2, 3], // array
+ { key: "value" }, // object
+ ];
+
+ for (let data of POSSIBLE_DATAS) {
+ const record = { data, id: "key-some_2D_key", key: "some-key" };
+
+ deepEqual(
+ record,
+ await transformer.decode(await transformer.encode(record))
+ );
+ }
+});
+
+add_task(async function test_refuses_to_decrypt_tampered() {
+ const encryptedRecord = await transformer.encode({
+ data: [1, 2, 3],
+ id: "key-some_2D_key",
+ key: "some-key",
+ });
+ const tamperedHMAC = Object.assign({}, encryptedRecord, {
+ hmac: "0000000000000000000000000000000000000000000000000000000000000001",
+ });
+ await throwsGen(Utils.isHMACMismatch, async function() {
+ await transformer.decode(tamperedHMAC);
+ });
+
+ const tamperedIV = Object.assign({}, encryptedRecord, {
+ IV: "aaaaaaaaaaaaaaaaaaaaaa==",
+ });
+ await throwsGen(Utils.isHMACMismatch, async function() {
+ await transformer.decode(tamperedIV);
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js
new file mode 100644
index 0000000000..7d7f70ee14
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js
@@ -0,0 +1,245 @@
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+
+async function test_multiple_pages() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ function awaitMessage(expectedMsg, api = "test") {
+ return new Promise(resolve => {
+ browser[api].onMessage.addListener(function listener(msg) {
+ if (msg === expectedMsg) {
+ browser[api].onMessage.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ let tabReady = awaitMessage("tab-ready", "runtime");
+
+ try {
+ let storage = browser.storage.local;
+
+ browser.test.sendMessage(
+ "load-page",
+ browser.runtime.getURL("tab.html")
+ );
+ await awaitMessage("page-loaded");
+ await tabReady;
+
+ let result = await storage.get("key");
+ browser.test.assertEq(undefined, result.key, "Key should be undefined");
+
+ await browser.runtime.sendMessage("tab-set-key");
+
+ result = await storage.get("key");
+ browser.test.assertEq(
+ JSON.stringify({ foo: { bar: "baz" } }),
+ JSON.stringify(result.key),
+ "Key should be set to the value from the tab"
+ );
+
+ browser.test.sendMessage("remove-page");
+ await awaitMessage("page-removed");
+
+ result = await storage.get("key");
+ browser.test.assertEq(
+ JSON.stringify({ foo: { bar: "baz" } }),
+ JSON.stringify(result.key),
+ "Key should still be set to the value from the tab"
+ );
+
+ browser.test.notifyPass("storage-multiple");
+ } catch (e) {
+ browser.test.fail(`Error: ${e} :: ${e.stack}`);
+ browser.test.notifyFail("storage-multiple");
+ }
+ },
+
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab.js"></script>
+ </head>
+ </html>`,
+
+ "tab.js"() {
+ browser.test.log("tab");
+ browser.runtime.onMessage.addListener(msg => {
+ if (msg == "tab-set-key") {
+ return browser.storage.local.set({ key: { foo: { bar: "baz" } } });
+ }
+ });
+
+ browser.runtime.sendMessage("tab-ready");
+ },
+ },
+
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ let contentPage;
+ extension.onMessage("load-page", async url => {
+ contentPage = await ExtensionTestUtils.loadContentPage(url, { extension });
+ extension.sendMessage("page-loaded");
+ });
+ extension.onMessage("remove-page", async url => {
+ await contentPage.close();
+ extension.sendMessage("page-removed");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("storage-multiple");
+ await extension.unload();
+}
+
+add_task(async function test_storage_local_file_backend_from_tab() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ test_multiple_pages
+ );
+});
+
+add_task(async function test_storage_local_idb_backend_from_tab() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
+ test_multiple_pages
+ );
+});
+
+async function test_storage_local_call_from_destroying_context() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let numberOfChanges = 0;
+ browser.storage.onChanged.addListener((changes, areaName) => {
+ if (areaName !== "local") {
+ browser.test.fail(
+ `Received unexpected storage changes for "${areaName}"`
+ );
+ }
+
+ numberOfChanges++;
+ });
+
+ browser.test.onMessage.addListener(async ({ msg, values }) => {
+ switch (msg) {
+ case "storage-set": {
+ await browser.storage.local.set(values);
+ browser.test.sendMessage("storage-set:done");
+ break;
+ }
+ case "storage-get": {
+ const res = await browser.storage.local.get();
+ browser.test.sendMessage("storage-get:done", res);
+ break;
+ }
+ case "storage-changes": {
+ browser.test.sendMessage("storage-changes-count", numberOfChanges);
+ break;
+ }
+ default:
+ browser.test.fail(`Received unexpected message: ${msg}`);
+ }
+ });
+
+ browser.test.sendMessage(
+ "ext-page-url",
+ browser.runtime.getURL("tab.html")
+ );
+ },
+ files: {
+ "tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <script src="tab.js"></script>
+ </head>
+ </html>`,
+
+ "tab.js"() {
+ browser.test.log("Extension tab - calling storage.local API method");
+ // Call the storage.local API from a tab that is going to be quickly closed.
+ browser.storage.local.set({
+ "test-key-from-destroying-context": "testvalue2",
+ });
+ // Navigate away from the extension page, so that the storage.local API call will be unable
+ // to send the call to the caller context (because it has been destroyed in the meantime).
+ window.location = "about:blank";
+ },
+ },
+ manifest: {
+ permissions: ["storage"],
+ },
+ });
+
+ await extension.startup();
+ const url = await extension.awaitMessage("ext-page-url");
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ extension,
+ });
+ let expectedBackgroundPageData = {
+ "test-key-from-background-page": "test-value",
+ };
+ let expectedTabData = { "test-key-from-destroying-context": "testvalue2" };
+
+ info(
+ "Call storage.local.set from the background page and wait it to be completed"
+ );
+ extension.sendMessage({
+ msg: "storage-set",
+ values: expectedBackgroundPageData,
+ });
+ await extension.awaitMessage("storage-set:done");
+
+ info(
+ "Call storage.local.get from the background page and wait it to be completed"
+ );
+ extension.sendMessage({ msg: "storage-get" });
+ let res = await extension.awaitMessage("storage-get:done");
+
+ Assert.deepEqual(
+ res,
+ {
+ ...expectedBackgroundPageData,
+ ...expectedTabData,
+ },
+ "Got the expected data set in the storage.local backend"
+ );
+
+ extension.sendMessage({ msg: "storage-changes" });
+ equal(
+ await extension.awaitMessage("storage-changes-count"),
+ 2,
+ "Got the expected number of storage.onChanged event received"
+ );
+
+ contentPage.close();
+
+ await extension.unload();
+}
+
+add_task(
+ async function test_storage_local_file_backend_destroyed_context_promise() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ test_storage_local_call_from_destroying_context
+ );
+ }
+);
+
+add_task(
+ async function test_storage_local_idb_backend_destroyed_context_promise() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]],
+ test_storage_local_call_from_destroying_context
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js
new file mode 100644
index 0000000000..f4a7574337
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js
@@ -0,0 +1,369 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { ExtensionStorageIDB } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionStorageIDB.jsm"
+);
+const { getTrimmedString } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionTelemetry.jsm"
+);
+const { TelemetryController } = ChromeUtils.import(
+ "resource://gre/modules/TelemetryController.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.import(
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+const HISTOGRAM_JSON_IDS = [
+ "WEBEXT_STORAGE_LOCAL_SET_MS",
+ "WEBEXT_STORAGE_LOCAL_GET_MS",
+];
+const KEYED_HISTOGRAM_JSON_IDS = [
+ "WEBEXT_STORAGE_LOCAL_SET_MS_BY_ADDONID",
+ "WEBEXT_STORAGE_LOCAL_GET_MS_BY_ADDONID",
+];
+
+const HISTOGRAM_IDB_IDS = [
+ "WEBEXT_STORAGE_LOCAL_IDB_SET_MS",
+ "WEBEXT_STORAGE_LOCAL_IDB_GET_MS",
+];
+const KEYED_HISTOGRAM_IDB_IDS = [
+ "WEBEXT_STORAGE_LOCAL_IDB_SET_MS_BY_ADDONID",
+ "WEBEXT_STORAGE_LOCAL_IDB_GET_MS_BY_ADDONID",
+];
+
+const HISTOGRAM_IDS = [].concat(HISTOGRAM_JSON_IDS, HISTOGRAM_IDB_IDS);
+const KEYED_HISTOGRAM_IDS = [].concat(
+ KEYED_HISTOGRAM_JSON_IDS,
+ KEYED_HISTOGRAM_IDB_IDS
+);
+
+const EXTENSION_ID1 = "@test-extension1";
+const EXTENSION_ID2 = "@test-extension2";
+
+async function test_telemetry_background() {
+ const expectedEmptyHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? HISTOGRAM_JSON_IDS
+ : HISTOGRAM_IDB_IDS;
+ const expectedEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? KEYED_HISTOGRAM_JSON_IDS
+ : KEYED_HISTOGRAM_IDB_IDS;
+
+ const expectedNonEmptyHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? HISTOGRAM_IDB_IDS
+ : HISTOGRAM_JSON_IDS;
+ const expectedNonEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled
+ ? KEYED_HISTOGRAM_IDB_IDS
+ : KEYED_HISTOGRAM_JSON_IDS;
+
+ const server = createHttpServer();
+ server.registerDirectory("/data/", do_get_file("data"));
+
+ const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+ async function contentScript() {
+ await browser.storage.local.set({ a: "b" });
+ await browser.storage.local.get("a");
+ browser.test.sendMessage("contentDone");
+ }
+
+ let baseManifest = {
+ permissions: ["storage"],
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ };
+
+ let baseExtInfo = {
+ async background() {
+ await browser.storage.local.set({ a: "b" });
+ await browser.storage.local.get("a");
+ browser.test.sendMessage("backgroundDone");
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ ...baseExtInfo,
+ manifest: {
+ ...baseManifest,
+ applications: {
+ gecko: { id: EXTENSION_ID1 },
+ },
+ },
+ });
+ let extension2 = ExtensionTestUtils.loadExtension({
+ ...baseExtInfo,
+ manifest: {
+ ...baseManifest,
+ applications: {
+ gecko: { id: EXTENSION_ID2 },
+ },
+ },
+ });
+
+ clearHistograms();
+
+ let process = IS_OOP ? "extension" : "parent";
+ let snapshots = getSnapshots(process);
+ let keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of HISTOGRAM_IDS) {
+ ok(!(id in snapshots), `No data recorded for histogram: ${id}.`);
+ }
+
+ for (let id of KEYED_HISTOGRAM_IDS) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id] || {}),
+ [],
+ `No data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension1.startup();
+ await extension1.awaitMessage("backgroundDone");
+ for (let id of expectedNonEmptyHistograms) {
+ await promiseTelemetryRecorded(id, process, 1);
+ }
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID1, 1);
+ }
+
+ // Telemetry from extension1's background page should be recorded.
+ snapshots = getSnapshots(process);
+ keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of expectedNonEmptyHistograms) {
+ equal(
+ valueSum(snapshots[id].values),
+ 1,
+ `Data recorded for histogram: ${id}.`
+ );
+ }
+
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id]),
+ [EXTENSION_ID1],
+ `Data recorded for histogram: ${id}.`
+ );
+ equal(
+ valueSum(keyedSnapshots[id][EXTENSION_ID1].values),
+ 1,
+ `Data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension2.startup();
+ await extension2.awaitMessage("backgroundDone");
+
+ for (let id of expectedNonEmptyHistograms) {
+ await promiseTelemetryRecorded(id, process, 2);
+ }
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID2, 1);
+ }
+
+ // Telemetry from extension2's background page should be recorded.
+ snapshots = getSnapshots(process);
+ keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of expectedNonEmptyHistograms) {
+ equal(
+ valueSum(snapshots[id].values),
+ 2,
+ `Additional data recorded for histogram: ${id}.`
+ );
+ }
+
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id]).sort(),
+ [EXTENSION_ID1, EXTENSION_ID2],
+ `Additional data recorded for histogram: ${id}.`
+ );
+ equal(
+ valueSum(keyedSnapshots[id][EXTENSION_ID2].values),
+ 1,
+ `Additional data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension2.unload();
+
+ // Run a content script.
+ process = IS_OOP ? "content" : "parent";
+ let expectedCount = IS_OOP ? 1 : 3;
+ let expectedKeyedCount = IS_OOP ? 1 : 2;
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/file_sample.html`
+ );
+ await extension1.awaitMessage("contentDone");
+
+ for (let id of expectedNonEmptyHistograms) {
+ await promiseTelemetryRecorded(id, process, expectedCount);
+ }
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ await promiseKeyedTelemetryRecorded(
+ id,
+ process,
+ EXTENSION_ID1,
+ expectedKeyedCount
+ );
+ }
+
+ // Telemetry from extension1's content script should be recorded.
+ snapshots = getSnapshots(process);
+ keyedSnapshots = getKeyedSnapshots(process);
+
+ for (let id of expectedNonEmptyHistograms) {
+ equal(
+ valueSum(snapshots[id].values),
+ expectedCount,
+ `Data recorded in content script for histogram: ${id}.`
+ );
+ }
+
+ for (let id of expectedNonEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id]).sort(),
+ IS_OOP ? [EXTENSION_ID1] : [EXTENSION_ID1, EXTENSION_ID2],
+ `Additional data recorded for histogram: ${id}.`
+ );
+ equal(
+ valueSum(keyedSnapshots[id][EXTENSION_ID1].values),
+ expectedKeyedCount,
+ `Additional data recorded for histogram: ${id}.`
+ );
+ }
+
+ await extension1.unload();
+
+ // Telemetry for histograms that we expect to be empty.
+ for (let id of expectedEmptyHistograms) {
+ ok(!(id in snapshots), `No data recorded for histogram: ${id}.`);
+ }
+
+ for (let id of expectedEmptyKeyedHistograms) {
+ Assert.deepEqual(
+ Object.keys(keyedSnapshots[id] || {}),
+ [],
+ `No data recorded for histogram: ${id}.`
+ );
+ }
+
+ await contentPage.close();
+}
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ // Telemetry test setup needed to ensure that the builtin events are defined
+ // and they can be collected and verified.
+ await TelemetryController.testSetup();
+
+ // This is actually only needed on Android, because it does not properly support unified telemetry
+ // and so, if not enabled explicitly here, it would make these tests to fail when running on a
+ // non-Nightly build.
+ const oldCanRecordBase = Services.telemetry.canRecordBase;
+ Services.telemetry.canRecordBase = true;
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordBase = oldCanRecordBase;
+ });
+});
+
+add_task(function test_telemetry_background_file_backend() {
+ return runWithPrefs(
+ [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]],
+ test_telemetry_background
+ );
+});
+
+add_task(function test_telemetry_background_idb_backend() {
+ return runWithPrefs(
+ [
+ [ExtensionStorageIDB.BACKEND_ENABLED_PREF, true],
+ // Set the migrated preference for the two test extension, because the
+ // first storage.local call fallbacks to run in the parent process when we
+ // don't know which is the selected backend during the extension startup
+ // and so we can't choose the telemetry histogram to use.
+ [
+ `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID1}`,
+ true,
+ ],
+ [
+ `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID2}`,
+ true,
+ ],
+ ],
+ test_telemetry_background
+ );
+});
+
+// This test verifies that we do record the expected telemetry event when we
+// normalize the error message for an unexpected error (an error raised internally
+// by the QuotaManager and/or IndexedDB, which it is being normalized into the generic
+// "An unexpected error occurred" error message).
+add_task(async function test_telemetry_storage_local_unexpected_error() {
+ // Clear any telemetry events collected so far.
+ Services.telemetry.clearEvents();
+
+ const methods = ["clear", "get", "remove", "set"];
+ const veryLongErrorName = `VeryLongErrorName${Array(200)
+ .fill(0)
+ .join("")}`;
+ const otherError = new Error("an error recorded as OtherError");
+
+ const recordedErrors = [
+ new DOMException("error message", "UnexpectedDOMException"),
+ new DOMException("error message", veryLongErrorName),
+ otherError,
+ ];
+
+ // We expect the following errors to not be recorded in telemetry (because they
+ // are raised on scenarios that we already expect).
+ const nonRecordedErrors = [
+ new DOMException("error message", "QuotaExceededError"),
+ new DOMException("error message", "DataCloneError"),
+ ];
+
+ const expectedEvents = [];
+
+ const errors = [].concat(recordedErrors, nonRecordedErrors);
+
+ for (let i = 0; i < errors.length; i++) {
+ const error = errors[i];
+ const storageMethod = methods[i] || "set";
+ ExtensionStorageIDB.normalizeStorageError({
+ error: errors[i],
+ extensionId: EXTENSION_ID1,
+ storageMethod,
+ });
+
+ if (recordedErrors.includes(error)) {
+ let error_name =
+ error === otherError ? "OtherError" : getTrimmedString(error.name);
+
+ expectedEvents.push({
+ value: EXTENSION_ID1,
+ object: storageMethod,
+ extra: { error_name },
+ });
+ }
+ }
+
+ await TelemetryTestUtils.assertEvents(expectedEvents, {
+ category: "extensions.data",
+ method: "storageLocalError",
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js
new file mode 100644
index 0000000000..ccbfddf6d2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js
@@ -0,0 +1,98 @@
+"use strict";
+
+add_task(async function test_extension_page_tabs_create_reload_and_close() {
+ let events = [];
+ {
+ let { Management } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm",
+ null
+ );
+ 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.sendMessage("tab-url", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`,
+ "page.js"() {
+ browser.test.sendMessage("extension page loaded", document.URL);
+ },
+ },
+ });
+
+ await extension.startup();
+ let tabURL = await extension.awaitMessage("tab-url");
+ events.splice(0);
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(tabURL, {
+ extension,
+ });
+ let extensionPageURL = await extension.awaitMessage("extension page loaded");
+ equal(extensionPageURL, tabURL, "Loaded the expected URL");
+
+ let contextEvents = events.splice(0);
+ equal(contextEvents.length, 1, "ExtensionContext change for opening a tab");
+ equal(contextEvents[0].eventType, "load", "create ExtensionContext for tab");
+ equal(
+ contextEvents[0].url,
+ extensionPageURL,
+ "ExtensionContext URL after tab creation should be tab URL"
+ );
+
+ await contentPage.spawn(null, () => {
+ this.content.location.reload();
+ });
+ let extensionPageURL2 = await extension.awaitMessage("extension page loaded");
+
+ equal(
+ extensionPageURL,
+ extensionPageURL2,
+ "The tab's URL is expected to not change after a page reload"
+ );
+
+ contextEvents = events.splice(0);
+ equal(contextEvents.length, 2, "ExtensionContext change after tab reload");
+ equal(contextEvents[0].eventType, "unload", "unload old ExtensionContext");
+ equal(
+ contextEvents[0].url,
+ extensionPageURL,
+ "ExtensionContext URL before reload should be tab URL"
+ );
+ equal(
+ contextEvents[1].eventType,
+ "load",
+ "create new ExtensionContext for tab"
+ );
+ equal(
+ contextEvents[1].url,
+ extensionPageURL2,
+ "ExtensionContext URL after reload should be tab URL"
+ );
+
+ await contentPage.close();
+
+ contextEvents = events.splice(0);
+ equal(contextEvents.length, 1, "ExtensionContext after closing tab");
+ equal(contextEvents[0].eventType, "unload", "unload tab's ExtensionContext");
+ equal(
+ contextEvents[0].url,
+ extensionPageURL2,
+ "ExtensionContext URL at closing tab should be tab URL"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js
new file mode 100644
index 0000000000..8aa22f5a10
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js
@@ -0,0 +1,870 @@
+"use strict";
+
+const { TelemetryArchive } = ChromeUtils.import(
+ "resource://gre/modules/TelemetryArchive.jsm"
+);
+const { TelemetryUtils } = ChromeUtils.import(
+ "resource://gre/modules/TelemetryUtils.jsm"
+);
+const { TelemetryTestUtils } = ChromeUtils.import(
+ "resource://testing-common/TelemetryTestUtils.jsm"
+);
+
+const { TelemetryArchiveTesting } = ChromeUtils.import(
+ "resource://testing-common/TelemetryArchiveTesting.jsm"
+);
+
+const { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+
+// All tests run privileged unless otherwise specified not to.
+function createExtension(
+ backgroundScript,
+ permissions,
+ isPrivileged = true,
+ telemetry
+) {
+ let extensionData = {
+ background: backgroundScript,
+ manifest: { permissions, telemetry },
+ isPrivileged,
+ };
+
+ return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+async function run(test) {
+ let extension = createExtension(
+ test.backgroundScript,
+ test.permissions || ["telemetry"],
+ test.isPrivileged,
+ test.telemetry
+ );
+ await extension.startup();
+ await extension.awaitFinish(test.doneSignal);
+ await extension.unload();
+}
+
+// Currently unsupported on Android: blocked on 1220177.
+// See 1280234 c67 for discussion.
+if (AppConstants.MOZ_BUILD_APP === "browser") {
+ add_task(async function test_telemetry_without_telemetry_permission() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertTrue(
+ !browser.telemetry,
+ "'telemetry' permission is required"
+ );
+ browser.test.notifyPass("telemetry_permission");
+ },
+ permissions: [],
+ doneSignal: "telemetry_permission",
+ isPrivileged: false,
+ });
+ });
+
+ add_task(
+ async function test_telemetry_without_telemetry_permission_privileged() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertTrue(
+ !browser.telemetry,
+ "'telemetry' permission is required"
+ );
+ browser.test.notifyPass("telemetry_permission");
+ },
+ permissions: [],
+ doneSignal: "telemetry_permission",
+ });
+ }
+ );
+
+ add_task(async function test_telemetry_scalar_add() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarAdd(
+ "telemetry.test.unsigned_int_kind",
+ 1
+ );
+ browser.test.notifyPass("scalar_add");
+ },
+ doneSignal: "scalar_add",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.unsigned_int_kind",
+ 1
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_add_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarAdd("telemetry.test.does_not_exist", 1);
+ browser.test.notifyPass("scalar_add_unknown_name");
+ },
+ doneSignal: "scalar_add_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown scalar is incremented"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_add_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", {}),
+ /Incorrect argument types for telemetry.scalarAdd/,
+ "The second 'value' argument to scalarAdd must be an integer, string, or boolean"
+ );
+ browser.test.notifyPass("scalar_add_illegal_value");
+ },
+ doneSignal: "scalar_add_illegal_value",
+ });
+ });
+
+ add_task(async function test_telemetry_scalar_add_invalid_keyed_scalar() {
+ let { messages } = await promiseConsoleOutput(async function() {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ 1
+ );
+ browser.test.notifyPass("scalar_add_invalid_keyed_scalar");
+ },
+ doneSignal: "scalar_add_invalid_keyed_scalar",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("Attempting to manage a keyed scalar as a scalar")
+ ),
+ "Telemetry should warn if a scalarAdd is called for a keyed scalar"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true);
+ browser.test.notifyPass("scalar_set");
+ },
+ doneSignal: "scalar_set",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.boolean_kind",
+ true
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function() {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSet(
+ "telemetry.test.does_not_exist",
+ true
+ );
+ browser.test.notifyPass("scalar_set_unknown_name");
+ },
+ doneSignal: "scalar_set_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown scalar is set"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_maximum() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSetMaximum(
+ "telemetry.test.unsigned_int_kind",
+ 123
+ );
+ browser.test.notifyPass("scalar_set_maximum");
+ },
+ doneSignal: "scalar_set_maximum",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.unsigned_int_kind",
+ 123
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_maximum_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function() {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.scalarSetMaximum(
+ "telemetry.test.does_not_exist",
+ 1
+ );
+ browser.test.notifyPass("scalar_set_maximum_unknown_name");
+ },
+ doneSignal: "scalar_set_maximum_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown scalar is set"
+ );
+ });
+
+ add_task(async function test_telemetry_scalar_set_maximum_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.scalarSetMaximum(
+ "telemetry.test.unsigned_int_kind",
+ "string"
+ ),
+ /Incorrect argument types for telemetry.scalarSetMaximum/,
+ "The second 'value' argument to scalarSetMaximum must be a scalar"
+ );
+ browser.test.notifyPass("scalar_set_maximum_illegal_value");
+ },
+ doneSignal: "scalar_set_maximum_illegal_value",
+ });
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add");
+ },
+ doneSignal: "keyed_scalar_add",
+ });
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 1
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.does_not_exist",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add_unknown_name");
+ },
+ doneSignal: "keyed_scalar_add_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown keyed scalar is incremented"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.keyedScalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ {}
+ ),
+ /Incorrect argument types for telemetry.keyedScalarAdd/,
+ "The second 'value' argument to keyedScalarAdd must be an integer, string, or boolean"
+ );
+ browser.test.notifyPass("keyed_scalar_add_illegal_value");
+ },
+ doneSignal: "keyed_scalar_add_illegal_value",
+ });
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_invalid_scalar() {
+ let { messages } = await promiseConsoleOutput(async function() {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.unsigned_int_kind",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add_invalid_scalar");
+ },
+ doneSignal: "keyed_scalar_add_invalid_scalar",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes(
+ "Attempting to manage a keyed scalar as a scalar (or vice-versa)"
+ )
+ ),
+ "Telemetry should warn if a scalar is incremented as a keyed scalar"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_add_long_key() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarAdd(
+ "telemetry.test.keyed_unsigned_int",
+ "X".repeat(73),
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_add_long_key");
+ },
+ doneSignal: "keyed_scalar_add_long_key",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("The key length must be limited to 72 characters.")
+ ),
+ "Telemetry should warn if keyed scalar's key is too long"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSet(
+ "telemetry.test.keyed_boolean_kind",
+ "foo",
+ true
+ );
+ browser.test.notifyPass("keyed_scalar_set");
+ },
+ doneSignal: "keyed_scalar_set",
+ });
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "telemetry.test.keyed_boolean_kind",
+ "foo",
+ true
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function() {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSet(
+ "telemetry.test.does_not_exist",
+ "foo",
+ true
+ );
+ browser.test.notifyPass("keyed_scalar_set_unknown_name");
+ },
+ doneSignal: "keyed_scalar_set_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown keyed scalar is incremented"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set_long_key() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSet(
+ "telemetry.test.keyed_unsigned_int",
+ "X".repeat(73),
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_set_long_key");
+ },
+ doneSignal: "keyed_scalar_set_long_key",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("The key length must be limited to 72 characters")
+ ),
+ "Telemetry should warn if keyed scalar's key is too long"
+ );
+ });
+
+ add_task(async function test_telemetry_keyed_scalar_set_maximum() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 123
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum");
+ },
+ doneSignal: "keyed_scalar_set_maximum",
+ });
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ 123
+ );
+ });
+
+ add_task(
+ async function test_telemetry_keyed_scalar_set_maximum_unknown_name() {
+ let { messages } = await promiseConsoleOutput(async function() {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.does_not_exist",
+ "foo",
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum_unknown_name");
+ },
+ doneSignal: "keyed_scalar_set_maximum_unknown_name",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) => message.includes("Unknown scalar")),
+ "Telemetry should warn if an unknown keyed scalar is set"
+ );
+ }
+ );
+
+ add_task(
+ async function test_telemetry_keyed_scalar_set_maximum_illegal_value() {
+ await run({
+ backgroundScript: () => {
+ browser.test.assertThrows(
+ () =>
+ browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.keyed_unsigned_int",
+ "foo",
+ "string"
+ ),
+ /Incorrect argument types for telemetry.keyedScalarSetMaximum/,
+ "The third 'value' argument to keyedScalarSetMaximum must be a scalar"
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum_illegal_value");
+ },
+ doneSignal: "keyed_scalar_set_maximum_illegal_value",
+ });
+ }
+ );
+
+ add_task(async function test_telemetry_keyed_scalar_set_maximum_long_key() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.keyedScalarSetMaximum(
+ "telemetry.test.keyed_unsigned_int",
+ "X".repeat(73),
+ 1
+ );
+ browser.test.notifyPass("keyed_scalar_set_maximum_long_key");
+ },
+ doneSignal: "keyed_scalar_set_maximum_long_key",
+ });
+ });
+ Assert.ok(
+ messages.find(({ message }) =>
+ message.includes("The key length must be limited to 72 characters")
+ ),
+ "Telemetry should warn if keyed scalar's key is too long"
+ );
+ });
+
+ add_task(async function test_telemetry_record_event() {
+ Services.telemetry.clearEvents();
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", true);
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.recordEvent(
+ "telemetry.test",
+ "test1",
+ "object1"
+ );
+ browser.test.notifyPass("record_event_ok");
+ },
+ doneSignal: "record_event_ok",
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "telemetry.test",
+ method: "test1",
+ object: "object1",
+ },
+ ],
+ { category: "telemetry.test" }
+ );
+
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", false);
+ Services.telemetry.clearEvents();
+ });
+
+ // Bug 1536877
+ add_task(async function test_telemetry_record_event_value_must_be_string() {
+ Services.telemetry.clearEvents();
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", true);
+
+ await run({
+ backgroundScript: async () => {
+ try {
+ await browser.telemetry.recordEvent(
+ "telemetry.test",
+ "test1",
+ "object1",
+ "value1"
+ );
+ browser.test.notifyPass("record_event_string_value");
+ } catch (ex) {
+ browser.test.fail(
+ `Unexpected exception raised during record_event_value_must_be_string: ${ex}`
+ );
+ browser.test.notifyPass("record_event_string_value");
+ throw ex;
+ }
+ },
+ doneSignal: "record_event_string_value",
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "telemetry.test",
+ method: "test1",
+ object: "object1",
+ value: "value1",
+ },
+ ],
+ { category: "telemetry.test" }
+ );
+
+ Services.telemetry.setEventRecordingEnabled("telemetry.test", false);
+ Services.telemetry.clearEvents();
+ });
+
+ add_task(async function test_telemetry_register_scalars_string() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_string: {
+ kind: browser.telemetry.ScalarType.STRING,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ browser.test.notifyPass("register_scalars_string");
+ },
+ doneSignal: "register_scalars_string",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent", false, true),
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ });
+
+ add_task(async function test_telemetry_register_scalars_multiple() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_string: {
+ kind: browser.telemetry.ScalarType.STRING,
+ keyed: false,
+ record_on_release: true,
+ },
+ webext_string_too: {
+ kind: browser.telemetry.ScalarType.STRING,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_string_too",
+ "world"
+ );
+ browser.test.notifyPass("register_scalars_multiple");
+ },
+ doneSignal: "register_scalars_multiple",
+ });
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "telemetry.test.dynamic.webext_string",
+ "hello"
+ );
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "telemetry.test.dynamic.webext_string_too",
+ "world"
+ );
+ });
+
+ add_task(async function test_telemetry_register_scalars_boolean() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_boolean: {
+ kind: browser.telemetry.ScalarType.BOOLEAN,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_boolean",
+ true
+ );
+ browser.test.notifyPass("register_scalars_boolean");
+ },
+ doneSignal: "register_scalars_boolean",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("dynamic", false, true),
+ "telemetry.test.dynamic.webext_boolean",
+ true
+ );
+ });
+
+ add_task(async function test_telemetry_register_scalars_count() {
+ Services.telemetry.clearScalars();
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerScalars("telemetry.test.dynamic", {
+ webext_count: {
+ kind: browser.telemetry.ScalarType.COUNT,
+ keyed: false,
+ record_on_release: true,
+ },
+ });
+ await browser.telemetry.scalarSet(
+ "telemetry.test.dynamic.webext_count",
+ 123
+ );
+ browser.test.notifyPass("register_scalars_count");
+ },
+ doneSignal: "register_scalars_count",
+ });
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("dynamic", false, true),
+ "telemetry.test.dynamic.webext_count",
+ 123
+ );
+ });
+
+ add_task(async function test_telemetry_register_events() {
+ Services.telemetry.clearEvents();
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.registerEvents("telemetry.test.dynamic", {
+ test1: {
+ methods: ["test1"],
+ objects: ["object1"],
+ extra_keys: [],
+ },
+ });
+ await browser.telemetry.recordEvent(
+ "telemetry.test.dynamic",
+ "test1",
+ "object1"
+ );
+ browser.test.notifyPass("register_events");
+ },
+ doneSignal: "register_events",
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ category: "telemetry.test.dynamic",
+ method: "test1",
+ object: "object1",
+ },
+ ],
+ { category: "telemetry.test.dynamic" },
+ { process: "dynamic" }
+ );
+ });
+
+ add_task(async function test_telemetry_submit_ping() {
+ let archiveTester = new TelemetryArchiveTesting.Checker();
+ await archiveTester.promiseInit();
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.submitPing("webext-test", {}, {});
+ browser.test.notifyPass("submit_ping");
+ },
+ doneSignal: "submit_ping",
+ });
+
+ await TestUtils.waitForCondition(
+ () => archiveTester.promiseFindPing("webext-test", []),
+ "Failed to find the webext-test ping"
+ );
+ });
+
+ add_task(async function test_telemetry_submit_encrypted_ping() {
+ await run({
+ backgroundScript: async () => {
+ try {
+ await browser.telemetry.submitEncryptedPing(
+ { payload: "encrypted-webext-test" },
+ {
+ schemaName: "schema-name",
+ schemaVersion: 123,
+ }
+ );
+ browser.test.fail(
+ "Expected exception without required manifest entries set."
+ );
+ } catch (e) {
+ browser.test.assertTrue(
+ e,
+ /Encrypted telemetry pings require ping_type and public_key to be set in manifest./
+ );
+ browser.test.notifyPass("submit_encrypted_ping_fail");
+ }
+ },
+ doneSignal: "submit_encrypted_ping_fail",
+ });
+
+ const telemetryManifestEntries = {
+ ping_type: "encrypted-webext-ping",
+ schemaNamespace: "schema-namespace",
+ public_key: {
+ id: "pioneer-dev-20200423",
+ key: {
+ crv: "P-256",
+ kty: "EC",
+ x: "Qqihp7EryDN2-qQ-zuDPDpy5mJD5soFBDZmzPWTmjwk",
+ y: "PiEQVUlywi2bEsA3_5D0VFrCHClCyUlLW52ajYs-5uc",
+ },
+ },
+ };
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.submitEncryptedPing(
+ {
+ payload: "encrypted-webext-test",
+ },
+ {
+ schemaName: "schema-name",
+ schemaVersion: 123,
+ }
+ );
+ browser.test.notifyPass("submit_encrypted_ping_pass");
+ },
+ permissions: ["telemetry"],
+ doneSignal: "submit_encrypted_ping_pass",
+ isPrivileged: true,
+ telemetry: telemetryManifestEntries,
+ });
+
+ telemetryManifestEntries.pioneer_id = true;
+ telemetryManifestEntries.study_name = "test123";
+ Services.prefs.setStringPref("toolkit.telemetry.pioneerId", "test123");
+
+ await run({
+ backgroundScript: async () => {
+ await browser.telemetry.submitEncryptedPing(
+ { payload: "encrypted-webext-test" },
+ {
+ schemaName: "schema-name",
+ schemaVersion: 123,
+ }
+ );
+ browser.test.notifyPass("submit_encrypted_ping_pass");
+ },
+ permissions: ["telemetry"],
+ doneSignal: "submit_encrypted_ping_pass",
+ isPrivileged: true,
+ telemetry: telemetryManifestEntries,
+ });
+
+ let pings;
+ await TestUtils.waitForCondition(async function() {
+ pings = await TelemetryArchive.promiseArchivedPingList();
+ return pings.length >= 3;
+ }, "Wait until we have at least 3 pings in the telemetry archive");
+
+ equal(pings.length, 3);
+ equal(pings[1].type, "encrypted-webext-ping");
+ equal(pings[2].type, "encrypted-webext-ping");
+ });
+
+ add_task(async function test_telemetry_can_upload_enabled() {
+ Services.prefs.setBoolPref(
+ TelemetryUtils.Preferences.FhrUploadEnabled,
+ true
+ );
+
+ await run({
+ backgroundScript: async () => {
+ const result = await browser.telemetry.canUpload();
+ browser.test.assertTrue(result);
+ browser.test.notifyPass("can_upload_enabled");
+ },
+ doneSignal: "can_upload_enabled",
+ });
+
+ Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled);
+ });
+
+ add_task(async function test_telemetry_can_upload_disabled() {
+ Services.prefs.setBoolPref(
+ TelemetryUtils.Preferences.FhrUploadEnabled,
+ false
+ );
+
+ await run({
+ backgroundScript: async () => {
+ const result = await browser.telemetry.canUpload();
+ browser.test.assertFalse(result);
+ browser.test.notifyPass("can_upload_disabled");
+ },
+ doneSignal: "can_upload_disabled",
+ });
+
+ Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled);
+ });
+}
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js
new file mode 100644
index 0000000000..81e07d9a9b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js
@@ -0,0 +1,55 @@
+/* -*- 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/browser/browser_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");
+}
+
+AddonTestUtils.init(this);
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_MockExtension() {
+ let ext = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {},
+ });
+
+ Assert.equal(ext.constructor.name, "InstallableWrapper", "expected class");
+ Assert.ok(!ext.id, "Extension ID is initially unavailable");
+ Assert.ok(!ext.uuid, "Extension UUID is initially unavailable");
+ await checkExtensionStartupAndUnload(ext);
+ // When useAddonManager is set, AOMExtensionWrapper clears the ID upon unload.
+ // TODO: Fix AOMExtensionWrapper to not clear the ID after unload, and move
+ // this assertion inside |checkExtensionStartupAndUnload| (since then the
+ // behavior will be consistent across all test types).
+ Assert.ok(!ext.id, "Extension ID is cleared after unload");
+});
+
+add_task(async function test_generated_Extension() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {},
+ });
+
+ Assert.equal(ext.constructor.name, "ExtensionWrapper", "expected class");
+ // Without "useAddonManager", an Extension is generated and their IDs are
+ // immediately available.
+ Assert.ok(ext.id, "Extension ID is initially available");
+ Assert.ok(ext.uuid, "Extension UUID is initially available");
+ await checkExtensionStartupAndUnload(ext);
+ Assert.ok(ext.id, "Extension ID exists after unload");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js
new file mode 100644
index 0000000000..1752b5a2b5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js
@@ -0,0 +1,64 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+
+// Automatically start the background page after restarting the AddonManager.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ false
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+const TEST_ADDON_ID = "@some-permanent-test-addon";
+
+// Load a permanent extension that eventually unloads the extension immediately
+// after add-on startup, to set the stage as a regression test for bug 1575190.
+add_task(async function setup_wrapper() {
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ applications: { gecko: { id: TEST_ADDON_ID } },
+ },
+ background() {
+ browser.test.sendMessage("started_up");
+ },
+ });
+
+ await AddonTestUtils.promiseStartupManager();
+ await extension.startup();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // Check message because it is expected to be received while `startup()` was
+ // pending resolution.
+ info("Awaiting expected started_up message 1");
+ await extension.awaitMessage("started_up");
+
+ // Load AddonManager, and unload the extension as soon as it has started.
+ await AddonTestUtils.promiseStartupManager();
+ await extension.unload();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // Confirm that the extension has started when promiseStartupManager returned.
+ info("Awaiting expected started_up message 2");
+ await extension.awaitMessage("started_up");
+});
+
+// Check that the add-on from the previous test has indeed been uninstalled.
+add_task(async function restart_addon_manager_after_extension_unload() {
+ await AddonTestUtils.promiseStartupManager();
+ let addon = await AddonManager.getAddonByID(TEST_ADDON_ID);
+ equal(addon, null, "Test add-on should have been removed");
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js
new file mode 100644
index 0000000000..f509ae1749
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js
@@ -0,0 +1,20 @@
+"use strict";
+
+/**
+ * This test is asserting that moz-extension: URLs are recognized as trustworthy local origins
+ */
+
+add_task(
+ function test_isOriginPotentiallyTrustworthnsIContentSecurityManagery() {
+ let uri = NetUtil.newURI("moz-extension://foobar/something.html");
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ Assert.equal(
+ principal.isOriginPotentiallyTrustworthy,
+ true,
+ "it is potentially trustworthy"
+ );
+ }
+);
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js
new file mode 100644
index 0000000000..9c515520e1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js
@@ -0,0 +1,63 @@
+"use strict";
+
+// This test expects and checks warnings for unknown permissions.
+ExtensionTestUtils.failOnSchemaWarnings(false);
+
+add_task(async function test_unknown_permissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "activeTab",
+ "fooUnknownPermission",
+ "http://*/",
+ "chrome://favicon/",
+ ],
+ optional_permissions: ["chrome://favicon/", "https://example.com/"],
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(() => extension.startup());
+
+ const { WebExtensionPolicy } = Cu.getGlobalForObject(
+ ChromeUtils.import("resource://gre/modules/Extension.jsm", {})
+ );
+
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ Assert.deepEqual(Array.from(policy.permissions).sort(), [
+ "activeTab",
+ "http://*/*",
+ ]);
+
+ Assert.deepEqual(extension.extension.manifest.optional_permissions, [
+ "https://example.com/",
+ ]);
+
+ ok(
+ messages.some(message =>
+ /Error processing permissions\.1: Value "fooUnknownPermission" must/.test(
+ message
+ )
+ ),
+ 'Got expected error for "fooUnknownPermission"'
+ );
+
+ ok(
+ messages.some(message =>
+ /Error processing permissions\.3: Value "chrome:\/\/favicon\/" must/.test(
+ message
+ )
+ ),
+ 'Got expected error for "chrome://favicon/"'
+ );
+
+ ok(
+ messages.some(message =>
+ /Error processing optional_permissions\.0: Value "chrome:\/\/favicon\/" must/.test(
+ message
+ )
+ ),
+ "Got expected error from optional_permissions"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js
new file mode 100644
index 0000000000..6a9125c9e4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js
@@ -0,0 +1,213 @@
+"use strict";
+
+const {
+ createAppInfo,
+ promiseStartupManager,
+ promiseRestartManager,
+ promiseWebExtensionStartup,
+} = AddonTestUtils;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+const STORAGE_SITE_PERMISSIONS = [
+ "WebExtensions-unlimitedStorage",
+ "indexedDB",
+ "persistent-storage",
+];
+
+function checkSitePermissions(principal, expectedPermAction, assertMessage) {
+ for (const permName of STORAGE_SITE_PERMISSIONS) {
+ const actualPermAction = Services.perms.testPermissionFromPrincipal(
+ principal,
+ permName
+ );
+
+ equal(
+ actualPermAction,
+ expectedPermAction,
+ `The extension "${permName}" SitePermission ${assertMessage} as expected`
+ );
+ }
+}
+
+add_task(async function test_unlimitedStorage_restored_on_app_startup() {
+ const id = "test-unlimitedStorage-removed-on-app-shutdown@mozilla";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["unlimitedStorage"],
+ applications: {
+ gecko: { id },
+ },
+ },
+
+ useAddonManager: "permanent",
+ });
+
+ await promiseStartupManager();
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+ const principal = policy.extension.principal;
+
+ checkSitePermissions(
+ principal,
+ Services.perms.ALLOW_ACTION,
+ "has been allowed"
+ );
+
+ // Remove site permissions as it would happen if Firefox is shutting down
+ // with the "clear site permissions" setting.
+
+ Services.perms.removeFromPrincipal(
+ principal,
+ "WebExtensions-unlimitedStorage"
+ );
+ Services.perms.removeFromPrincipal(principal, "indexedDB");
+ Services.perms.removeFromPrincipal(principal, "persistent-storage");
+
+ checkSitePermissions(principal, Services.perms.UNKNOWN_ACTION, "is not set");
+
+ const onceExtensionStarted = promiseWebExtensionStartup(id);
+ await promiseRestartManager();
+ await onceExtensionStarted;
+
+ // The site permissions should have been granted again.
+ checkSitePermissions(
+ principal,
+ Services.perms.ALLOW_ACTION,
+ "has been allowed"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_unlimitedStorage_removed_on_update() {
+ const id = "test-unlimitedStorage-removed-on-update@mozilla";
+
+ function background() {
+ browser.test.onMessage.addListener(async msg => {
+ switch (msg) {
+ case "set-storage":
+ browser.test.log(`storing data in storage.local`);
+ await browser.storage.local.set({ akey: "somevalue" });
+ browser.test.log(`data stored in storage.local successfully`);
+ break;
+ case "has-storage": {
+ browser.test.log(`checking data stored in storage.local`);
+ const data = await browser.storage.local.get(["akey"]);
+ browser.test.assertEq(
+ data.akey,
+ "somevalue",
+ "Got storage.local data"
+ );
+ break;
+ }
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["unlimitedStorage", "storage"],
+ applications: { gecko: { id } },
+ version: "1",
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ const policy = WebExtensionPolicy.getByID(extension.id);
+ const principal = policy.extension.principal;
+
+ checkSitePermissions(
+ principal,
+ Services.perms.ALLOW_ACTION,
+ "has been allowed"
+ );
+
+ extension.sendMessage("set-storage");
+ await extension.awaitMessage("set-storage:done");
+ extension.sendMessage("has-storage");
+ await extension.awaitMessage("has-storage:done");
+
+ // Simulate an update which do not require the unlimitedStorage permission.
+ await extension.upgrade({
+ background,
+ manifest: {
+ permissions: ["storage"],
+ applications: { gecko: { id } },
+ version: "2",
+ },
+ useAddonManager: "permanent",
+ });
+
+ const newPolicy = WebExtensionPolicy.getByID(extension.id);
+ const newPrincipal = newPolicy.extension.principal;
+
+ equal(
+ principal.spec,
+ newPrincipal.spec,
+ "upgraded extension has the expected principal"
+ );
+
+ checkSitePermissions(
+ principal,
+ Services.perms.UNKNOWN_ACTION,
+ "has been cleared"
+ );
+
+ // Verify that the previously stored data has not been
+ // removed as a side effect of removing the unlimitedStorage
+ // permission.
+ extension.sendMessage("has-storage");
+ await extension.awaitMessage("has-storage:done");
+
+ await extension.unload();
+});
+
+add_task(async function test_unlimitedStorage_origin_attributes() {
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", true);
+
+ const id = "test-unlimitedStorage-origin-attributes@mozilla";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["unlimitedStorage"],
+ applications: { gecko: { id } },
+ },
+ });
+
+ await extension.startup();
+
+ let policy = WebExtensionPolicy.getByID(extension.id);
+ let principal = policy.extension.principal;
+
+ ok(
+ !principal.firstPartyDomain,
+ "extension principal has no firstPartyDomain"
+ );
+
+ let perm = Services.perms.testExactPermissionFromPrincipal(
+ principal,
+ "persistent-storage"
+ );
+ equal(
+ perm,
+ Services.perms.ALLOW_ACTION,
+ "Should have the correct permission without OAs"
+ );
+
+ await extension.unload();
+
+ Services.prefs.clearUserPref("privacy.firstparty.isolate");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js
new file mode 100644
index 0000000000..058e8b7371
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js
@@ -0,0 +1,230 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+// Background and content script for testSendMessage_*
+function sendMessage_background(delayedNotifyPass) {
+ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
+ browser.test.assertEq("from frame", msg, "Expected message from frame");
+ sendResponse("msg from back"); // Should not throw or anything like that.
+ delayedNotifyPass("Received sendMessage from closing frame");
+ });
+}
+function sendMessage_contentScript(testType) {
+ browser.runtime.sendMessage("from frame", reply => {
+ // The frame has been removed, so we should not get this callback!
+ browser.test.fail(`Unexpected reply: ${reply}`);
+ });
+ if (testType == "frame") {
+ frameElement.remove();
+ } else {
+ browser.test.sendMessage("close-window");
+ }
+}
+
+// Background and content script for testConnect_*
+function connect_background(delayedNotifyPass) {
+ browser.runtime.onConnect.addListener(port => {
+ browser.test.assertEq("port from frame", port.name);
+
+ let disconnected = false;
+ let hasMessage = false;
+ port.onDisconnect.addListener(() => {
+ browser.test.assertFalse(disconnected, "onDisconnect should fire once");
+ disconnected = true;
+ browser.test.assertTrue(
+ hasMessage,
+ "Expected onMessage before onDisconnect"
+ );
+ browser.test.assertEq(
+ null,
+ port.error,
+ "The port is implicitly closed without errors when the other context unloads"
+ );
+ delayedNotifyPass("Received onDisconnect from closing frame");
+ });
+ port.onMessage.addListener(msg => {
+ browser.test.assertFalse(hasMessage, "onMessage should fire once");
+ hasMessage = true;
+ browser.test.assertFalse(
+ disconnected,
+ "Should get message before disconnect"
+ );
+ browser.test.assertEq("from frame", msg, "Expected message from frame");
+ });
+
+ port.postMessage("reply to closing frame");
+ });
+}
+function connect_contentScript(testType) {
+ let isUnloading = false;
+ addEventListener(
+ "pagehide",
+ () => {
+ isUnloading = true;
+ },
+ { once: true }
+ );
+
+ let port = browser.runtime.connect({ name: "port from frame" });
+ port.onMessage.addListener(msg => {
+ // The background page sends a reply as soon as we call runtime.connect().
+ // It is possible that the reply reaches this frame before the
+ // window.close() request has been processed.
+ if (!isUnloading) {
+ browser.test.log(
+ `Ignorting unexpected reply ("${msg}") because the page is not being unloaded.`
+ );
+ return;
+ }
+
+ // The frame has been removed, so we should not get a reply.
+ browser.test.fail(`Unexpected reply: ${msg}`);
+ });
+ port.postMessage("from frame");
+
+ // Removing the frame or window should disconnect the port.
+ if (testType == "frame") {
+ frameElement.remove();
+ } else {
+ browser.test.sendMessage("close-window");
+ }
+}
+
+// `testType` is "window" or "frame".
+function createTestExtension(testType, backgroundScript, contentScript) {
+ // Make a roundtrip between the background page and the test runner (which is
+ // in the same process as the content script) to make sure that we record a
+ // failure in case the content script's sendMessage or onMessage handlers are
+ // called even after the frame or window was removed.
+ function delayedNotifyPass(msg) {
+ browser.test.onMessage.addListener((type, echoMsg) => {
+ if (type == "pong") {
+ browser.test.assertEq(msg, echoMsg, "Echoed reply should be the same");
+ browser.test.notifyPass(msg);
+ }
+ });
+ browser.test.log("Starting ping-pong to flush messages...");
+ browser.test.sendMessage("ping", msg);
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `(${backgroundScript})(${delayedNotifyPass});`,
+ manifest: {
+ content_scripts: [
+ {
+ js: ["contentscript.js"],
+ all_frames: testType == "frame",
+ matches: ["http://example.com/data/file_sample.html"],
+ },
+ ],
+ },
+ files: {
+ "contentscript.js": `(${contentScript})("${testType}");`,
+ },
+ });
+ extension.awaitMessage("ping").then(msg => {
+ extension.sendMessage("pong", msg);
+ });
+ return extension;
+}
+
+add_task(async function testSendMessage_and_remove_frame() {
+ let extension = createTestExtension(
+ "frame",
+ sendMessage_background,
+ sendMessage_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ await contentPage.spawn(null, () => {
+ let { document } = this.content;
+ let frame = document.createElement("iframe");
+ frame.src = "/data/file_sample.html";
+ document.body.appendChild(frame);
+ });
+
+ await extension.awaitFinish("Received sendMessage from closing frame");
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function testConnect_and_remove_frame() {
+ let extension = createTestExtension(
+ "frame",
+ connect_background,
+ connect_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+
+ await contentPage.spawn(null, () => {
+ let { document } = this.content;
+ let frame = document.createElement("iframe");
+ frame.src = "/data/file_sample.html";
+ document.body.appendChild(frame);
+ });
+
+ await extension.awaitFinish("Received onDisconnect from closing frame");
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function testSendMessage_and_remove_window() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // We can't rely on this timing on Android.
+ return;
+ }
+
+ let extension = createTestExtension(
+ "window",
+ sendMessage_background,
+ sendMessage_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("close-window");
+ await contentPage.close();
+
+ await extension.awaitFinish("Received sendMessage from closing frame");
+ await extension.unload();
+});
+
+add_task(async function testConnect_and_remove_window() {
+ if (AppConstants.MOZ_BUILD_APP !== "browser") {
+ // We can't rely on this timing on Android.
+ return;
+ }
+
+ let extension = createTestExtension(
+ "window",
+ connect_background,
+ connect_contentScript
+ );
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("close-window");
+ await contentPage.close();
+
+ await extension.awaitFinish("Received onDisconnect from closing frame");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
new file mode 100644
index 0000000000..78114d9de4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js
@@ -0,0 +1,671 @@
+"use strict";
+
+const PROCESS_COUNT_PREF = "dom.ipc.processCount";
+
+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`;
+
+add_task(async function setup_test_environment() {
+ if (ExtensionTestUtils.remoteContentScripts) {
+ // Start with one content process so that we can increase the number
+ // later and test the behavior of a fresh content process.
+ Services.prefs.setIntPref(PROCESS_COUNT_PREF, 1);
+ }
+
+ // Grant the optional permissions requested.
+ function permissionObserver(subject, topic, data) {
+ if (topic == "webextension-optional-permission-prompt") {
+ let { resolve } = subject.wrappedJSObject;
+ resolve(true);
+ }
+ }
+ Services.obs.addObserver(
+ permissionObserver,
+ "webextension-optional-permission-prompt"
+ );
+ registerCleanupFunction(() => {
+ Services.obs.removeObserver(
+ permissionObserver,
+ "webextension-optional-permission-prompt"
+ );
+ });
+});
+
+// Test that there is no userScripts API namespace when the manifest doesn't include a user_scripts
+// property.
+add_task(async function test_userScripts_manifest_property_required() {
+ function background() {
+ browser.test.assertEq(
+ undefined,
+ browser.userScripts,
+ "userScripts API namespace should be undefined in the extension page"
+ );
+ browser.test.sendMessage("background-page:done");
+ }
+
+ async function contentScript() {
+ browser.test.assertEq(
+ undefined,
+ browser.userScripts,
+ "userScripts API namespace should be undefined in the content script"
+ );
+ browser.test.sendMessage("content-script:done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("background-page:done");
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ await extension.awaitMessage("content-script:done");
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+// Test that userScripts can only matches origins that are subsumed by the extension permissions,
+// and that more origins can be allowed by requesting an optional permission.
+add_task(async function test_userScripts_matches_denied() {
+ async function background() {
+ async function registerUserScriptWithMatches(matches) {
+ const scripts = await browser.userScripts.register({
+ js: [{ code: "" }],
+ matches,
+ });
+ await scripts.unregister();
+ }
+
+ // These matches are supposed to be denied until the extension has been granted the
+ // <all_urls> origin permission.
+ const testMatches = [
+ "<all_urls>",
+ "file://*/*",
+ "https://localhost/*",
+ "http://example.com/*",
+ ];
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "test-denied-matches") {
+ for (let testMatch of testMatches) {
+ await browser.test.assertRejects(
+ registerUserScriptWithMatches([testMatch]),
+ /Permission denied to register a user script for/,
+ "Got the expected rejection when the extension permission does not subsume the userScript matches"
+ );
+ }
+ } else if (msg === "grant-all-urls") {
+ await browser.permissions.request({ origins: ["<all_urls>"] });
+ } else if (msg === "test-allowed-matches") {
+ for (let testMatch of testMatches) {
+ try {
+ await registerUserScriptWithMatches([testMatch]);
+ } catch (err) {
+ browser.test.fail(
+ `Unexpected rejection ${err} on matching ${JSON.stringify(
+ testMatch
+ )}`
+ );
+ }
+ }
+ } else {
+ browser.test.fail(`Received an unexpected ${msg} test message`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`);
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*"],
+ optional_permissions: ["<all_urls>"],
+ user_scripts: {},
+ },
+ background,
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-ready");
+
+ // Test that the matches not subsumed by the extension permissions are being denied.
+ extension.sendMessage("test-denied-matches");
+ await extension.awaitMessage("test-denied-matches:done");
+
+ // Grant the optional <all_urls> permission.
+ await withHandlingUserInput(extension, async () => {
+ extension.sendMessage("grant-all-urls");
+ await extension.awaitMessage("grant-all-urls:done");
+ });
+
+ // Test that all the matches are now subsumed by the extension permissions.
+ extension.sendMessage("test-allowed-matches");
+ await extension.awaitMessage("test-allowed-matches:done");
+
+ await extension.unload();
+});
+
+// Test that userScripts sandboxes:
+// - can be registered/unregistered from an extension page (and they are registered on both new and
+// existing processes).
+// - have no WebExtensions APIs available
+// - are able to access the target window and document
+add_task(async function test_userScripts_no_webext_apis() {
+ async function background() {
+ const matches = ["http://localhost/*/file_sample.html*"];
+
+ const sharedCode = {
+ code: 'console.log("js code shared by multiple userScripts");',
+ };
+
+ const userScriptOptions = {
+ js: [
+ sharedCode,
+ {
+ code: `
+ window.addEventListener("load", () => {
+ const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
+ document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces);
+ }, {once: true});
+ `,
+ },
+ ],
+ runAt: "document_start",
+ matches,
+ scriptMetadata: {
+ name: "test-user-script",
+ arrayProperty: ["el1"],
+ objectProperty: { nestedProp: "nestedValue" },
+ nullProperty: null,
+ },
+ };
+
+ let script = await browser.userScripts.register(userScriptOptions);
+
+ // Unregister and then register the same js code again, to verify that the last registered
+ // userScript doesn't get assigned a revoked blob url (otherwise Extensioncontent.jsm
+ // ScriptCache raises an error because it fails to compile the revoked blob url and the user
+ // script will never be loaded).
+ script.unregister();
+ script = await browser.userScripts.register(userScriptOptions);
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg !== "register-new-script") {
+ return;
+ }
+
+ await script.unregister();
+ await browser.userScripts.register({
+ ...userScriptOptions,
+ scriptMetadata: { name: "test-new-script" },
+ js: [
+ sharedCode,
+ {
+ code: `
+ window.addEventListener("load", () => {
+ const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
+ document.body.innerHTML = "new userScript loaded - " + JSON.stringify(webextAPINamespaces);
+ }, {once: true});
+ `,
+ },
+ ],
+ });
+
+ browser.test.sendMessage("script-registered");
+ });
+
+ const scriptToRemove = await browser.userScripts.register({
+ js: [
+ sharedCode,
+ {
+ code: `
+ window.addEventListener("load", () => {
+ document.body.innerHTML = "unexpected unregistered userScript loaded";
+ }, {once: true});
+ `,
+ },
+ ],
+ runAt: "document_start",
+ matches,
+ scriptMetadata: {
+ name: "user-script-to-remove",
+ },
+ });
+
+ browser.test.assertTrue(
+ "unregister" in script,
+ "Got an unregister method on the userScript API object"
+ );
+
+ // Remove the last registered user script.
+ await scriptToRemove.unregister();
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ user_scripts: {},
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-ready");
+
+ let url = `${BASE_URL}/file_sample.html?testpage=1`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ url,
+ ExtensionTestUtils.remoteContentScripts ? { remote: true } : undefined
+ );
+ let result = await contentPage.spawn(undefined, async () => {
+ return {
+ textContent: this.content.document.body.textContent,
+ url: this.content.location.href,
+ readyState: this.content.document.readyState,
+ };
+ });
+ Assert.deepEqual(
+ result,
+ {
+ textContent: "userScript loaded - undefined",
+ url,
+ readyState: "complete",
+ },
+ "The userScript executed on the expected url and no access to the WebExtensions APIs"
+ );
+
+ // If the tests is running with "remote content process" mode, test that the userScript
+ // are being correctly registered in newly created processes (received as part of the sharedData).
+ if (ExtensionTestUtils.remoteContentScripts) {
+ info(
+ "Test content script are correctly created on a newly created process"
+ );
+
+ await extension.sendMessage("register-new-script");
+ await extension.awaitMessage("script-registered");
+
+ // Update the process count preference, so that we can test that the newly registered user script
+ // is propagated as expected into the newly created process.
+ Services.prefs.setIntPref(PROCESS_COUNT_PREF, 2);
+
+ const url2 = `${BASE_URL}/file_sample.html?testpage=2`;
+ let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, {
+ remote: true,
+ });
+ let result2 = await contentPage2.spawn(undefined, async () => {
+ return {
+ textContent: this.content.document.body.textContent,
+ url: this.content.location.href,
+ readyState: this.content.document.readyState,
+ };
+ });
+ Assert.deepEqual(
+ result2,
+ {
+ textContent: "new userScript loaded - undefined",
+ url: url2,
+ readyState: "complete",
+ },
+ "The userScript executed on the expected url and no access to the WebExtensions APIs"
+ );
+
+ await contentPage2.close();
+ }
+
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// This test verify that a cached script is still able to catch the document
+// while it is still loading (when we do not block the document parsing as
+// we do for a non cached script).
+add_task(async function test_cached_userScript_on_document_start() {
+ function apiScript() {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ sendTestMessage(name, params) {
+ return browser.test.sendMessage(name, params);
+ },
+ });
+ });
+ }
+
+ async function background() {
+ function userScript() {
+ this.sendTestMessage("user-script-loaded", {
+ url: window.location.href,
+ documentReadyState: document.readyState,
+ });
+ }
+
+ await browser.userScripts.register({
+ js: [
+ {
+ code: `(${userScript})();`,
+ },
+ ],
+ runAt: "document_start",
+ matches: ["http://localhost/*/file_sample.html"],
+ });
+
+ browser.test.sendMessage("user-script-registered");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ user_scripts: {
+ api_script: "api-script.js",
+ // The following is an unexpected manifest property, that we expect to be ignored and
+ // to not prevent the test extension from being installed and run as expected.
+ unexpected_manifest_key: "test-unexpected-key",
+ },
+ },
+ background,
+ files: {
+ "api-script.js": apiScript,
+ },
+ });
+
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.awaitMessage("user-script-registered");
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ let msg = await extension.awaitMessage("user-script-loaded");
+ Assert.deepEqual(
+ msg,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a non cached user script"
+ );
+
+ // Reload the page and check that the cached content script is still able to
+ // run on document_start.
+ await contentPage.loadURL(url);
+
+ let msgFromCached = await extension.awaitMessage("user-script-loaded");
+ Assert.deepEqual(
+ msgFromCached,
+ {
+ url,
+ documentReadyState: "loading",
+ },
+ "Got the expected url and document.readyState from a cached user script"
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_userScripts_pref_disabled() {
+ async function run_userScript_on_pref_disabled_test() {
+ async function background() {
+ let promise = (async () => {
+ await browser.userScripts.register({
+ js: [
+ {
+ code:
+ "throw new Error('This userScripts should not be registered')",
+ },
+ ],
+ runAt: "document_start",
+ matches: ["<all_urls>"],
+ });
+ })();
+
+ await browser.test.assertRejects(
+ promise,
+ /userScripts APIs are currently experimental/,
+ "Got the expected error from userScripts.register when the userScripts API is disabled"
+ );
+
+ browser.test.sendMessage("background-page:done");
+ }
+
+ async function contentScript() {
+ let promise = (async () => {
+ browser.userScripts.onBeforeScript.addListener(() => {});
+ })();
+ await browser.test.assertRejects(
+ promise,
+ /userScripts APIs are currently experimental/,
+ "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled"
+ );
+
+ browser.test.sendMessage("content-script:done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: { api_script: "" },
+ content_scripts: [
+ {
+ matches: ["http://*/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ },
+ files: {
+ "content_script.js": contentScript,
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-page:done");
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ await extension.awaitMessage("content-script:done");
+
+ await extension.unload();
+ await contentPage.close();
+ }
+
+ await runWithPrefs(
+ [["extensions.webextensions.userScripts.enabled", false]],
+ run_userScript_on_pref_disabled_test
+ );
+});
+
+// This test verify that userScripts.onBeforeScript API Event is not available without
+// a "user_scripts.api_script" property in the manifest.
+add_task(async function test_user_script_api_script_required() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://localhost/*/file_sample.html"],
+ js: ["content_script.js"],
+ run_at: "document_start",
+ },
+ ],
+ user_scripts: {},
+ },
+ files: {
+ "content_script.js": function() {
+ browser.test.assertEq(
+ undefined,
+ browser.userScripts && browser.userScripts.onBeforeScript,
+ "Got an undefined onBeforeScript property as expected"
+ );
+ browser.test.sendMessage("no-onBeforeScript:done");
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+
+ await extension.awaitMessage("no-onBeforeScript:done");
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_scriptMetaData() {
+ function getTestCases(isUserScriptsRegister) {
+ return [
+ // When scriptMetadata is not set (or undefined), it is treated as if it were null.
+ // In the API script, the metadata is then expected to be null.
+ isUserScriptsRegister ? undefined : null,
+
+ // Falsey
+ null,
+ "",
+ false,
+ 0,
+
+ // Truthy
+ true,
+ 1,
+ "non-empty string",
+
+ // Objects
+ ["some array with value"],
+ { "some object": "with value" },
+ ];
+ }
+
+ async function background(pageUrl) {
+ for (let scriptMetadata of getTestCases(true)) {
+ await browser.userScripts.register({
+ js: [{ file: "userscript.js" }],
+ runAt: "document_end",
+ allFrames: true,
+ matches: ["http://localhost/*/file_sample.html"],
+ scriptMetadata,
+ });
+ }
+
+ let f = document.createElement("iframe");
+ f.src = pageUrl;
+ document.body.append(f);
+ browser.test.sendMessage("background-page:done");
+ }
+
+ function apiScript() {
+ let testCases = getTestCases(false);
+ let i = 0;
+
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ checkMetadata() {
+ let expectation = testCases[i];
+ let metadata = script.metadata;
+ if (typeof expectation === "object" && expectation !== null) {
+ // Non-primitive values cannot be compared with assertEq,
+ // so serialize both and just verify that they are equal.
+ expectation = JSON.stringify(expectation);
+ metadata = JSON.stringify(script.metadata);
+ }
+
+ browser.test.assertEq(
+ expectation,
+ metadata,
+ `Expected metadata at call ${i}`
+ );
+ if (++i === testCases.length) {
+ browser.test.sendMessage("apiscript:done");
+ }
+ },
+ });
+ });
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `${getTestCases};(${background})("${BASE_URL}/file_sample.html")`,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: {
+ api_script: "apiscript.js",
+ },
+ },
+ files: {
+ "apiscript.js": `${getTestCases};(${apiScript})()`,
+ "userscript.js": "checkMetadata();",
+ },
+ });
+
+ await extension.startup();
+
+ await extension.awaitMessage("background-page:done");
+ await extension.awaitMessage("apiscript:done");
+
+ await extension.unload();
+});
+
+add_task(async function test_userScriptOptions_js_property_required() {
+ function background() {
+ const userScriptOptions = {
+ runAt: "document_start",
+ matches: ["http://*/*/file_sample.html"],
+ };
+
+ browser.test.assertThrows(
+ () => browser.userScripts.register(userScriptOptions),
+ /Type error for parameter userScriptOptions \(Property \"js\" is required\)/,
+ "Got the expected error from userScripts.register when js property is missing"
+ );
+
+ browser.test.sendMessage("done");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: {},
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js
new file mode 100644
index 0000000000..72e8a51c7f
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js
@@ -0,0 +1,1108 @@
+"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`;
+
+// A small utility function used to test the expected behaviors of the userScripts API method
+// wrapper.
+async function test_userScript_APIMethod({
+ apiScript,
+ userScript,
+ userScriptMetadata,
+ testFn,
+ runtimeMessageListener,
+}) {
+ async function backgroundScript(
+ userScriptFn,
+ scriptMetadata,
+ messageListener
+ ) {
+ await browser.userScripts.register({
+ js: [
+ {
+ code: `(${userScriptFn})();`,
+ },
+ ],
+ runAt: "document_end",
+ matches: ["http://localhost/*/file_sample.html"],
+ scriptMetadata,
+ });
+
+ if (messageListener) {
+ browser.runtime.onMessage.addListener(messageListener);
+ }
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function notifyFinish(failureReason) {
+ browser.test.assertEq(
+ undefined,
+ failureReason,
+ "should be completed without errors"
+ );
+ browser.test.sendMessage("test_userScript_APIMethod:done");
+ }
+
+ function assertTrue(val, message) {
+ browser.test.assertTrue(val, message);
+ if (!val) {
+ browser.test.sendMessage("test_userScript_APIMethod:done");
+ throw message;
+ }
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://localhost/*/file_sample.html"],
+ user_scripts: {
+ api_script: "api-script.js",
+ },
+ },
+ // Defines a background script that receives all the needed test parameters.
+ background: `
+ const metadata = ${JSON.stringify(userScriptMetadata)};
+ (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener})
+ `,
+ files: {
+ "api-script.js": `(${apiScript})({
+ assertTrue: ${assertTrue},
+ notifyFinish: ${notifyFinish}
+ })`,
+ },
+ });
+
+ // Load a page in a content process, register the user script and then load a
+ // new page in the existing content process.
+ let url = `${BASE_URL}/file_sample.html`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`);
+
+ await extension.startup();
+ await extension.awaitMessage("background-ready");
+ await contentPage.loadURL(url);
+
+ // Run any additional test-specific assertions.
+ if (testFn) {
+ await testFn({ extension, contentPage, url });
+ }
+
+ await extension.awaitMessage("test_userScript_APIMethod:done");
+
+ await extension.unload();
+ await contentPage.close();
+}
+
+add_task(async function test_apiScript_exports_simple_sync_method() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ const scriptMetadata = script.metadata;
+
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(
+ stringParam,
+ numberParam,
+ boolParam,
+ nullParam,
+ undefinedParam,
+ arrayParam
+ ) {
+ browser.test.assertEq(
+ "test-user-script-exported-apis",
+ scriptMetadata.name,
+ "Got the expected value for a string scriptMetadata property"
+ );
+ browser.test.assertEq(
+ null,
+ scriptMetadata.nullProperty,
+ "Got the expected value for a null scriptMetadata property"
+ );
+ browser.test.assertTrue(
+ scriptMetadata.arrayProperty &&
+ scriptMetadata.arrayProperty.length === 1 &&
+ scriptMetadata.arrayProperty[0] === "el1",
+ "Got the expected value for an array scriptMetadata property"
+ );
+ browser.test.assertTrue(
+ scriptMetadata.objectProperty &&
+ scriptMetadata.objectProperty.nestedProp === "nestedValue",
+ "Got the expected value for an object scriptMetadata property"
+ );
+
+ browser.test.assertEq(
+ "param1",
+ stringParam,
+ "Got the expected string parameter value"
+ );
+ browser.test.assertEq(
+ 123,
+ numberParam,
+ "Got the expected number parameter value"
+ );
+ browser.test.assertEq(
+ true,
+ boolParam,
+ "Got the expected boolean parameter value"
+ );
+ browser.test.assertEq(
+ null,
+ nullParam,
+ "Got the expected null parameter value"
+ );
+ browser.test.assertEq(
+ undefined,
+ undefinedParam,
+ "Got the expected undefined parameter value"
+ );
+
+ browser.test.assertEq(
+ 3,
+ arrayParam.length,
+ "Got the expected length on the array param"
+ );
+ browser.test.assertTrue(
+ arrayParam.includes(1),
+ "Got the expected result when calling arrayParam.includes"
+ );
+
+ return "returned_value";
+ },
+ });
+ });
+ }
+
+ function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ // Redefine the includes method on the Array prototype, to explicitly verify that the method
+ // redefined in the userScript is not used when accessing arrayParam.includes from the API script.
+ // eslint-disable-next-line no-extend-native
+ Array.prototype.includes = () => {
+ throw new Error("Unexpected prototype leakage");
+ };
+ const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor
+ const result = testAPIMethod(
+ "param1",
+ 123,
+ true,
+ null,
+ undefined,
+ arrayParam
+ );
+
+ assertTrue(
+ result === "returned_value",
+ `userScript got an unexpected result value: ${result}`
+ );
+
+ notifyFinish();
+ }
+
+ const userScriptMetadata = {
+ name: "test-user-script-exported-apis",
+ arrayProperty: ["el1"],
+ objectProperty: { nestedProp: "nestedValue" },
+ nullProperty: null,
+ };
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ userScriptMetadata,
+ });
+});
+
+add_task(async function test_apiScript_async_method() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(param, cb, cb2, objWithCb) {
+ browser.test.assertEq(
+ "function",
+ typeof cb,
+ "Got a callback function parameter"
+ );
+ browser.test.assertTrue(
+ cb === cb2,
+ "Got the same cloned function for the same function parameter"
+ );
+
+ browser.runtime.sendMessage(param).then(bgPageRes => {
+ const cbResult = cb(script.export(bgPageRes));
+ browser.test.sendMessage("user-script-callback-return", cbResult);
+ });
+
+ return "resolved_value";
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ // Redefine Promise to verify that it doesn't break the WebExtensions internals
+ // that are going to use them.
+ const { Promise } = this;
+ Promise.resolve = function() {
+ throw new Error("Promise.resolve poisoning");
+ };
+ this.Promise = function() {
+ throw new Error("Promise constructor poisoning");
+ };
+
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ const cb = cbParam => {
+ return `callback param: ${JSON.stringify(cbParam)}`;
+ };
+ const cb2 = cb;
+ const asyncAPIResult = await testAPIMethod("param3", cb, cb2);
+
+ assertTrue(
+ asyncAPIResult === "resolved_value",
+ `userScript got an unexpected resolved value: ${asyncAPIResult}`
+ );
+
+ notifyFinish();
+ }
+
+ async function runtimeMessageListener(param) {
+ if (param !== "param3") {
+ browser.test.fail(`Got an unexpected message: ${param}`);
+ }
+
+ return { bgPageReply: true };
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ runtimeMessageListener,
+ async testFn({ extension }) {
+ const res = await extension.awaitMessage("user-script-callback-return");
+ equal(
+ res,
+ `callback param: ${JSON.stringify({ bgPageReply: true })}`,
+ "Got the expected userScript callback return value"
+ );
+ },
+ });
+});
+
+add_task(async function test_apiScript_method_with_webpage_objects_params() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(windowParam, documentParam) {
+ browser.test.assertEq(
+ window,
+ windowParam,
+ "Got a reference to the native window as first param"
+ );
+ browser.test.assertEq(
+ window.document,
+ documentParam,
+ "Got a reference to the native document as second param"
+ );
+
+ // Return an uncloneable webpage object, which checks that if the returned object is from a principal
+ // that is subsumed by the userScript sandbox principal, it is returned without being cloned.
+ return windowParam;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ const result = testAPIMethod(window, document);
+
+ // We expect the returned value to be the uncloneable window object.
+ assertTrue(
+ result === window,
+ `userScript got an unexpected returned value: ${result}`
+ );
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(async function test_apiScript_method_got_param_with_methods() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ const scriptGlobal = script.global;
+ const ScriptFunction = scriptGlobal.Function;
+
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(objWithMethods) {
+ browser.test.assertEq(
+ "objPropertyValue",
+ objWithMethods && objWithMethods.objProperty,
+ "Got the expected property on the object passed as a parameter"
+ );
+ browser.test.assertEq(
+ undefined,
+ typeof objWithMethods && objWithMethods.objMethod,
+ "XrayWrapper should deny access to a callable property"
+ );
+
+ browser.test.assertTrue(
+ objWithMethods &&
+ objWithMethods.wrappedJSObject &&
+ objWithMethods.wrappedJSObject.objMethod instanceof
+ ScriptFunction.wrappedJSObject,
+ "The callable property is accessible on the wrappedJSObject"
+ );
+
+ browser.test.assertEq(
+ "objMethodResult: p1",
+ objWithMethods &&
+ objWithMethods.wrappedJSObject &&
+ objWithMethods.wrappedJSObject.objMethod("p1"),
+ "Got the expected result when calling the method on the wrappedJSObject"
+ );
+ return true;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ let result = testAPIMethod({
+ objProperty: "objPropertyValue",
+ objMethod(param) {
+ return `objMethodResult: ${param}`;
+ },
+ });
+
+ assertTrue(
+ result === true,
+ `userScript got an unexpected returned value: ${result}`
+ );
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(async function test_apiScript_method_throws_errors() {
+ function apiScript({ notifyFinish }) {
+ let proxyTrapsCount = 0;
+
+ browser.userScripts.onBeforeScript.addListener(script => {
+ const scriptGlobals = {
+ Error: script.global.Error,
+ TypeError: script.global.TypeError,
+ Proxy: script.global.Proxy,
+ };
+
+ script.defineGlobals({
+ notifyFinish,
+ testAPIMethod(errorTestName, returnRejectedPromise) {
+ let err;
+
+ switch (errorTestName) {
+ case "apiScriptError":
+ err = new Error(`${errorTestName} message`);
+ break;
+ case "apiScriptThrowsPlainString":
+ err = `${errorTestName} message`;
+ break;
+ case "apiScriptThrowsNull":
+ err = null;
+ break;
+ case "userScriptError":
+ err = new scriptGlobals.Error(`${errorTestName} message`);
+ break;
+ case "userScriptTypeError":
+ err = new scriptGlobals.TypeError(`${errorTestName} message`);
+ break;
+ case "userScriptProxyObject":
+ let proxyTarget = script.export({
+ name: "ProxyObject",
+ message: "ProxyObject message",
+ });
+ let proxyHandlers = script.export({
+ get(target, prop) {
+ proxyTrapsCount++;
+ switch (prop) {
+ case "name":
+ return "ProxyObjectGetName";
+ case "message":
+ return "ProxyObjectGetMessage";
+ }
+ return undefined;
+ },
+ getPrototypeOf() {
+ proxyTrapsCount++;
+ return scriptGlobals.TypeError;
+ },
+ });
+ err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers);
+ break;
+ default:
+ browser.test.fail(`Unknown ${errorTestName} error testname`);
+ return undefined;
+ }
+
+ if (returnRejectedPromise) {
+ return Promise.reject(err);
+ }
+
+ throw err;
+ },
+ assertNoProxyTrapTriggered() {
+ browser.test.assertEq(
+ 0,
+ proxyTrapsCount,
+ "Proxy traps should not be triggered"
+ );
+ },
+ resetProxyTrapCounter() {
+ proxyTrapsCount = 0;
+ },
+ sendResults(results) {
+ browser.test.sendMessage("test-results", results);
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertNoProxyTrapTriggered,
+ notifyFinish,
+ resetProxyTrapCounter,
+ sendResults,
+ testAPIMethod,
+ } = this;
+
+ let apiThrowResults = {};
+ let apiThrowTestCases = [
+ "apiScriptError",
+ "apiScriptThrowsPlainString",
+ "apiScriptThrowsNull",
+ "userScriptError",
+ "userScriptTypeError",
+ "userScriptProxyObject",
+ ];
+ for (let errorTestName of apiThrowTestCases) {
+ try {
+ testAPIMethod(errorTestName);
+ } catch (err) {
+ // We expect that no proxy traps have been triggered by the WebExtensions internals.
+ if (errorTestName === "userScriptProxyObject") {
+ assertNoProxyTrapTriggered();
+ }
+
+ if (err instanceof Error) {
+ apiThrowResults[errorTestName] = {
+ name: err.name,
+ message: err.message,
+ };
+ } else {
+ apiThrowResults[errorTestName] = {
+ name: err && err.name,
+ message: err && err.message,
+ typeOf: typeof err,
+ value: err,
+ };
+ }
+ }
+ }
+
+ sendResults(apiThrowResults);
+
+ resetProxyTrapCounter();
+
+ let apiRejectsResults = {};
+ for (let errorTestName of apiThrowTestCases) {
+ try {
+ await testAPIMethod(errorTestName, true);
+ } catch (err) {
+ // We expect that no proxy traps have been triggered by the WebExtensions internals.
+ if (errorTestName === "userScriptProxyObject") {
+ assertNoProxyTrapTriggered();
+ }
+
+ if (err instanceof Error) {
+ apiRejectsResults[errorTestName] = {
+ name: err.name,
+ message: err.message,
+ };
+ } else {
+ apiRejectsResults[errorTestName] = {
+ name: err && err.name,
+ message: err && err.message,
+ typeOf: typeof err,
+ value: err,
+ };
+ }
+ }
+ }
+
+ sendResults(apiRejectsResults);
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ async testFn({ extension }) {
+ const expectedResults = {
+ // Any error not explicitly raised as a userScript objects or error instance is
+ // expected to be turned into a generic error message.
+ apiScriptError: {
+ name: "Error",
+ message: "An unexpected apiScript error occurred",
+ },
+
+ // When the api script throws a primitive value, we expect to receive it unmodified on
+ // the userScript side.
+ apiScriptThrowsPlainString: {
+ typeOf: "string",
+ value: "apiScriptThrowsPlainString message",
+ name: undefined,
+ message: undefined,
+ },
+ apiScriptThrowsNull: {
+ typeOf: "object",
+ value: null,
+ name: undefined,
+ message: undefined,
+ },
+
+ // Error messages that the apiScript has explicitly created as userScript's Error
+ // global instances are expected to be passing through unmodified.
+ userScriptError: { name: "Error", message: "userScriptError message" },
+ userScriptTypeError: {
+ name: "TypeError",
+ message: "userScriptTypeError message",
+ },
+
+ // Error raised from the apiScript as userScript proxy objects are expected to
+ // be passing through unmodified.
+ userScriptProxyObject: {
+ typeOf: "object",
+ name: "ProxyObjectGetName",
+ message: "ProxyObjectGetMessage",
+ },
+ };
+
+ info(
+ "Checking results from errors raised from an apiScript exported function"
+ );
+
+ const apiThrowResults = await extension.awaitMessage("test-results");
+
+ for (let [key, expected] of Object.entries(expectedResults)) {
+ Assert.deepEqual(
+ apiThrowResults[key],
+ expected,
+ `Got the expected error object for test case "${key}"`
+ );
+ }
+
+ Assert.deepEqual(
+ Object.keys(expectedResults).sort(),
+ Object.keys(apiThrowResults).sort(),
+ "the expected and actual test case names matches"
+ );
+
+ info(
+ "Checking expected results from errors raised from an apiScript exported function"
+ );
+
+ // Verify expected results from rejected promises returned from an apiScript exported function.
+ const apiThrowRejections = await extension.awaitMessage("test-results");
+
+ for (let [key, expected] of Object.entries(expectedResults)) {
+ Assert.deepEqual(
+ apiThrowRejections[key],
+ expected,
+ `Got the expected rejected object for test case "${key}"`
+ );
+ }
+
+ Assert.deepEqual(
+ Object.keys(expectedResults).sort(),
+ Object.keys(apiThrowRejections).sort(),
+ "the expected and actual test case names matches"
+ );
+ },
+ });
+});
+
+add_task(
+ async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(...args) {
+ // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object
+ // is supposed to be Object.prototype.
+ browser.test.assertEq(
+ script.global.Object.prototype,
+ Object.getPrototypeOf(args[0]),
+ "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap"
+ );
+
+ browser.test.assertTrue(
+ Array.isArray(args[0]),
+ "Got an array object for the XrayWrapped proxy object param"
+ );
+ browser.test.assertEq(
+ undefined,
+ args[0].length,
+ "XrayWrappers deny access to the length property"
+ );
+ browser.test.assertEq(
+ undefined,
+ args[0][0],
+ "Got the expected item in the array object"
+ );
+ return true;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ let proxy = new Proxy(["expectedArrayValue"], {
+ getPrototypeOf() {
+ throw new Error("Proxy's getPrototypeOf trap");
+ },
+ get(target, prop, receiver) {
+ throw new Error("Proxy's get trap");
+ },
+ });
+
+ let result = testAPIMethod(proxy);
+
+ assertTrue(
+ result,
+ `userScript got an unexpected returned value: ${result}`
+ );
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+ }
+);
+
+add_task(async function test_apiScript_method_return_proxy_object() {
+ function apiScript(sharedTestAPIMethods) {
+ let proxyTrapsCount = 0;
+ let scriptTrapsCount = 0;
+
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethodError() {
+ return new Proxy(["expectedArrayValue"], {
+ getPrototypeOf(target) {
+ proxyTrapsCount++;
+ return Object.getPrototypeOf(target);
+ },
+ });
+ },
+ testAPIMethodOk() {
+ return new script.global.Proxy(
+ script.export(["expectedArrayValue"]),
+ script.export({
+ getPrototypeOf(target) {
+ scriptTrapsCount++;
+ return script.global.Object.getPrototypeOf(target);
+ },
+ })
+ );
+ },
+ assertNoProxyTrapTriggered() {
+ browser.test.assertEq(
+ 0,
+ proxyTrapsCount,
+ "Proxy traps should not be triggered"
+ );
+ },
+ assertScriptProxyTrapsCount(expected) {
+ browser.test.assertEq(
+ expected,
+ scriptTrapsCount,
+ "Script Proxy traps should have been triggered"
+ );
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ assertNoProxyTrapTriggered,
+ assertScriptProxyTrapsCount,
+ notifyFinish,
+ testAPIMethodError,
+ testAPIMethodOk,
+ } = this;
+
+ let error;
+ try {
+ let result = testAPIMethodError();
+ notifyFinish(
+ `Unexpected returned value while expecting error: ${result}`
+ );
+ return;
+ } catch (err) {
+ error = err;
+ }
+
+ assertTrue(
+ error &&
+ error.message.includes("Return value not accessible to the userScript"),
+ `Got an unexpected error message: ${error}`
+ );
+
+ error = undefined;
+ try {
+ let result = testAPIMethodOk();
+ assertScriptProxyTrapsCount(0);
+ if (!(result instanceof Array)) {
+ notifyFinish(`Got an unexpected result: ${result}`);
+ return;
+ }
+ assertScriptProxyTrapsCount(1);
+ } catch (err) {
+ error = err;
+ }
+
+ assertTrue(!error, `Got an unexpected error: ${error}`);
+
+ assertNoProxyTrapTriggered();
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(async function test_apiScript_returns_functions() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIReturnsFunction() {
+ // Return a function with provides the same kind of behavior
+ // of the API methods exported as globals.
+ return script.export(() => window);
+ },
+ testAPIReturnsObjWithMethod() {
+ return script.export({
+ getWindow() {
+ return window;
+ },
+ });
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ notifyFinish,
+ testAPIReturnsFunction,
+ testAPIReturnsObjWithMethod,
+ } = this;
+
+ let resultFn = testAPIReturnsFunction();
+ assertTrue(
+ typeof resultFn === "function",
+ `userScript got an unexpected returned value: ${typeof resultFn}`
+ );
+
+ let fnRes = resultFn();
+ assertTrue(
+ fnRes === window,
+ `Got an unexpected value from the returned function: ${fnRes}`
+ );
+
+ let resultObj = testAPIReturnsObjWithMethod();
+ let actualTypeof = resultObj && typeof resultObj.getWindow;
+ assertTrue(
+ actualTypeof === "function",
+ `Returned object does not have the expected getWindow method: ${actualTypeof}`
+ );
+
+ let methodRes = resultObj.getWindow();
+ assertTrue(
+ methodRes === window,
+ `Got an unexpected value from the returned method: ${methodRes}`
+ );
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(
+ async function test_apiScript_method_clone_non_subsumed_returned_values() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethodReturnOk() {
+ return script.export({
+ objKey1: {
+ nestedProp: "nestedvalue",
+ },
+ window,
+ });
+ },
+ testAPIMethodExplicitlyClonedError() {
+ let result = script.export({ apiScopeObject: undefined });
+
+ browser.test.assertThrows(
+ () => {
+ result.apiScopeObject = { disallowedProp: "disallowedValue" };
+ },
+ /Not allowed to define cross-origin object as property on .* XrayWrapper/,
+ "Assigning a property to a xRayWrapper is expected to throw"
+ );
+
+ // Let the exception to be raised, so that we check that the actual underlying
+ // error message is not leaking in the userScript (replaced by the generic
+ // "An unexpected apiScript error occurred" error message).
+ result.apiScopeObject = { disallowedProp: "disallowedValue" };
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ notifyFinish,
+ testAPIMethodReturnOk,
+ testAPIMethodExplicitlyClonedError,
+ } = this;
+
+ let result = testAPIMethodReturnOk();
+
+ assertTrue(
+ result &&
+ "objKey1" in result &&
+ result.objKey1.nestedProp === "nestedvalue",
+ `userScript got an unexpected returned value: ${result}`
+ );
+
+ assertTrue(
+ result.window === window,
+ `userScript should have access to the window property: ${result.window}`
+ );
+
+ let error;
+ try {
+ result = testAPIMethodExplicitlyClonedError();
+ notifyFinish(
+ `Unexpected returned value while expecting error: ${result}`
+ );
+ return;
+ } catch (err) {
+ error = err;
+ }
+
+ // We expect the generic "unexpected apiScript error occurred" to be raised to the
+ // userScript code.
+ assertTrue(
+ error &&
+ error.message.includes("An unexpected apiScript error occurred"),
+ `Got an unexpected error message: ${error}`
+ );
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+ }
+);
+
+add_task(async function test_apiScript_method_export_primitive_types() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod(typeToExport) {
+ switch (typeToExport) {
+ case "boolean":
+ return script.export(true);
+ case "number":
+ return script.export(123);
+ case "string":
+ return script.export("a string");
+ case "symbol":
+ return script.export(Symbol("a symbol"));
+ }
+ return undefined;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ let v = testAPIMethod("boolean");
+ assertTrue(v === true, `Should export a boolean`);
+
+ v = testAPIMethod("number");
+ assertTrue(v === 123, `Should export a number`);
+
+ v = testAPIMethod("string");
+ assertTrue(v === "a string", `Should export a string`);
+
+ v = testAPIMethod("symbol");
+ assertTrue(typeof v === "symbol", `Should export a symbol`);
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
+
+add_task(
+ async function test_apiScript_method_avoid_unnecessary_params_cloning() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethodReturnsParam(param) {
+ return param;
+ },
+ testAPIMethodReturnsUnwrappedParam(param) {
+ return param.wrappedJSObject;
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const {
+ assertTrue,
+ notifyFinish,
+ testAPIMethodReturnsParam,
+ testAPIMethodReturnsUnwrappedParam,
+ } = this;
+
+ let obj = {};
+
+ let result = testAPIMethodReturnsParam(obj);
+
+ assertTrue(
+ result === obj,
+ `Expect returned value to be strictly equal to the API method parameter`
+ );
+
+ result = testAPIMethodReturnsUnwrappedParam(obj);
+
+ assertTrue(
+ result === obj,
+ `Expect returned value to be strictly equal to the unwrapped API method parameter`
+ );
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+ }
+);
+
+add_task(async function test_apiScript_method_export_sparse_arrays() {
+ function apiScript(sharedTestAPIMethods) {
+ browser.userScripts.onBeforeScript.addListener(script => {
+ script.defineGlobals({
+ ...sharedTestAPIMethods,
+ testAPIMethod() {
+ const sparseArray = [];
+ sparseArray[3] = "third-element";
+ sparseArray[5] = "fifth-element";
+ return script.export(sparseArray);
+ },
+ });
+ });
+ }
+
+ async function userScript() {
+ const { assertTrue, notifyFinish, testAPIMethod } = this;
+
+ const result = testAPIMethod(window, document);
+
+ // We expect the returned value to be the uncloneable window object.
+ assertTrue(
+ result && result.length === 6,
+ `the returned value should be an array of the expected length: ${result}`
+ );
+ assertTrue(
+ result[3] === "third-element",
+ `the third array element should have the expected value: ${result[3]}`
+ );
+ assertTrue(
+ result[5] === "fifth-element",
+ `the fifth array element should have the expected value: ${result[5]}`
+ );
+ assertTrue(
+ result[0] === undefined,
+ `the first array element should have the expected value: ${result[0]}`
+ );
+ assertTrue(!("0" in result), "Holey array should still be holey");
+
+ notifyFinish();
+ }
+
+ await test_userScript_APIMethod({
+ userScript,
+ apiScript,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js
new file mode 100644
index 0000000000..08d61d1e85
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js
@@ -0,0 +1,175 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const HISTOGRAM = "WEBEXT_USER_SCRIPT_INJECTION_MS";
+const HISTOGRAM_KEYED = "WEBEXT_USER_SCRIPT_INJECTION_MS_BY_ADDONID";
+
+const server = createHttpServer();
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;
+
+add_task(async function test_userScripts_telemetry() {
+ function apiScript() {
+ browser.userScripts.onBeforeScript.addListener(userScript => {
+ const scriptMetadata = userScript.metadata;
+
+ userScript.defineGlobals({
+ US_test_sendMessage(msg, data) {
+ browser.test.sendMessage(msg, { data, scriptMetadata });
+ },
+ });
+ });
+ }
+
+ async function background() {
+ const code = `
+ US_test_sendMessage("userScript-run", {location: window.location.href});
+ `;
+ await browser.userScripts.register({
+ js: [{ code }],
+ matches: ["http://*/*/file_sample.html"],
+ runAt: "document_end",
+ scriptMetadata: {
+ name: "test-user-script-telemetry",
+ },
+ });
+
+ browser.test.sendMessage("userScript-registered");
+ }
+
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ let testExtensionDef = {
+ manifest: {
+ permissions: ["http://*/*/file_sample.html"],
+ user_scripts: {
+ api_script: "api-script.js",
+ },
+ },
+ background,
+ files: {
+ "api-script.js": apiScript,
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(testExtensionDef);
+ let extension2 = ExtensionTestUtils.loadExtension(testExtensionDef);
+ let contentPage = await ExtensionTestUtils.loadContentPage("about:blank");
+
+ clearHistograms();
+
+ let process = IS_OOP ? "content" : "parent";
+ ok(
+ !(HISTOGRAM in getSnapshots(process)),
+ `No data recorded for histogram: ${HISTOGRAM}.`
+ );
+ ok(
+ !(HISTOGRAM_KEYED in getKeyedSnapshots(process)),
+ `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.`
+ );
+
+ await extension.startup();
+ await extension.awaitMessage("userScript-registered");
+
+ let extensionId = extension.extension.id;
+
+ ok(
+ !(HISTOGRAM in getSnapshots(process)),
+ `No data recorded for histogram after startup: ${HISTOGRAM}.`
+ );
+ ok(
+ !(HISTOGRAM_KEYED in getKeyedSnapshots(process)),
+ `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.`
+ );
+
+ let url = `${BASE_URL}/file_sample.html`;
+ contentPage.loadURL(url);
+ const res = await extension.awaitMessage("userScript-run");
+ Assert.deepEqual(
+ res,
+ {
+ data: { location: url },
+ scriptMetadata: { name: "test-user-script-telemetry" },
+ },
+ "The userScript has been executed on the content page as expected"
+ );
+
+ await promiseTelemetryRecorded(HISTOGRAM, process, 1);
+ await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1);
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+
+ await contentPage.close();
+ await extension.unload();
+
+ await extension2.startup();
+ await extension2.awaitMessage("userScript-registered");
+ let extensionId2 = extension2.extension.id;
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 1,
+ `No data recorded for histogram after startup: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+ ok(
+ !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]),
+ `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.`
+ );
+
+ contentPage = await ExtensionTestUtils.loadContentPage(url);
+ const res2 = await extension2.awaitMessage("userScript-run");
+ Assert.deepEqual(
+ res2,
+ {
+ data: { location: url },
+ scriptMetadata: { name: "test-user-script-telemetry" },
+ },
+ "The userScript has been executed on the content page as expected"
+ );
+
+ await promiseTelemetryRecorded(HISTOGRAM, process, 2);
+ await promiseKeyedTelemetryRecorded(
+ HISTOGRAM_KEYED,
+ process,
+ extensionId2,
+ 1
+ );
+
+ equal(
+ valueSum(getSnapshots(process)[HISTOGRAM].values),
+ 2,
+ `Data recorded for histogram: ${HISTOGRAM}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values),
+ 1,
+ `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.`
+ );
+ equal(
+ valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values),
+ 1,
+ `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.`
+ );
+
+ await contentPage.close();
+ await extension2.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js
new file mode 100644
index 0000000000..c616d162a5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js
@@ -0,0 +1,425 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+
+// Save seen realms for cache checking.
+let realms = new Set([]);
+
+server.registerPathHandler("/authenticate.sjs", (request, response) => {
+ let url = new URL(`${BASE_URL}${request.path}?${request.queryString}`);
+ let realm = url.searchParams.get("realm") || "mochitest";
+ let proxy_realm = url.searchParams.get("proxy_realm");
+
+ function checkAuthorization(authorization) {
+ let expected_user = url.searchParams.get("user");
+ if (!expected_user) {
+ return true;
+ }
+ let expected_pass = url.searchParams.get("pass");
+ let actual_user, actual_pass;
+ let authHeader = request.getHeader("Authorization");
+ let match = /Basic (.+)/.exec(authHeader);
+ if (match.length != 2) {
+ throw new Error("Couldn't parse auth header: " + authHeader);
+ }
+ let userpass = atob(match[1]); // no atob() :-(
+ match = /(.*):(.*)/.exec(userpass);
+ if (match.length != 3) {
+ throw new Error("Couldn't decode auth header: " + userpass);
+ }
+ actual_user = match[1];
+ actual_pass = match[2];
+ return expected_user === actual_user && expected_pass === actual_pass;
+ }
+
+ response.setHeader("Content-Type", "text/plain; charset=UTF-8", false);
+ if (proxy_realm && !request.hasHeader("Proxy-Authorization")) {
+ // We're not testing anything that requires checking the proxy auth user/password.
+ response.setStatusLine("1.0", 407, "Proxy authentication required");
+ response.setHeader(
+ "Proxy-Authenticate",
+ `basic realm="${proxy_realm}"`,
+ true
+ );
+ response.write("proxy auth required");
+ } else if (
+ !(
+ realms.has(realm) &&
+ request.hasHeader("Authorization") &&
+ checkAuthorization()
+ )
+ ) {
+ realms.add(realm);
+ response.setStatusLine(request.httpVersion, 401, "Authentication required");
+ response.setHeader("WWW-Authenticate", `basic realm="${realm}"`, true);
+ response.write("auth required");
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok, got authorization");
+ }
+});
+
+function getExtension(bgConfig) {
+ function background(config) {
+ let path = config.path;
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.log(
+ `onBeforeRequest called with ${details.requestId} ${details.url}`
+ );
+ browser.test.sendMessage("onBeforeRequest");
+ return (
+ config.onBeforeRequest.hasOwnProperty("result") &&
+ config.onBeforeRequest.result
+ );
+ },
+ { urls: [path] },
+ config.onBeforeRequest.hasOwnProperty("extra")
+ ? config.onBeforeRequest.extra
+ : []
+ );
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.log(
+ `onAuthRequired called with ${details.requestId} ${details.url}`
+ );
+ browser.test.assertEq(
+ config.realm,
+ details.realm,
+ "providing www authorization"
+ );
+ browser.test.sendMessage("onAuthRequired");
+ return (
+ config.onAuthRequired.hasOwnProperty("result") &&
+ config.onAuthRequired.result
+ );
+ },
+ { urls: [path] },
+ config.onAuthRequired.hasOwnProperty("extra")
+ ? config.onAuthRequired.extra
+ : []
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(
+ `onCompleted called with ${details.requestId} ${details.url}`
+ );
+ browser.test.sendMessage("onCompleted");
+ },
+ { urls: [path] }
+ );
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(
+ `onErrorOccurred called with ${JSON.stringify(details)}`
+ );
+ browser.test.sendMessage("onErrorOccurred");
+ },
+ { urls: [path] }
+ );
+ }
+
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", bgConfig.path],
+ },
+ background: `(${background})(${JSON.stringify(bgConfig)})`,
+ });
+}
+
+add_task(async function test_webRequest_auth() {
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ result: {
+ authCredentials: {
+ username: "testuser",
+ password: "testpass",
+ },
+ },
+ },
+ };
+
+ let extension = getExtension(config);
+ await extension.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ]);
+ await contentPage.close();
+
+ // Second time around to test cached credentials
+ contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onCompleted"),
+ ]);
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_webRequest_auth_cancelled() {
+ // Test that any auth listener can cancel.
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ result: {
+ authCredentials: {
+ username: "testuser",
+ password: "testpass",
+ },
+ },
+ },
+ };
+
+ let ex1 = getExtension(config);
+ config.onAuthRequired.result = { cancel: true };
+ let ex2 = getExtension(config);
+ await ex1.startup();
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired"),
+ ex1.awaitMessage("onErrorOccurred"),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired"),
+ ex2.awaitMessage("onErrorOccurred"),
+ ]);
+
+ await contentPage.close();
+ await ex1.unload();
+ await ex2.unload();
+});
+
+add_task(async function test_webRequest_auth_nonblocking() {
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ result: {
+ authCredentials: {
+ username: "testuser",
+ password: "testpass",
+ },
+ },
+ },
+ };
+
+ let ex1 = getExtension(config);
+ // non-blocking ext tries to cancel but cannot.
+ delete config.onBeforeRequest.extra;
+ delete config.onAuthRequired.extra;
+ config.onAuthRequired.result = { cancel: true };
+ let ex2 = getExtension(config);
+ await ex1.startup();
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ]);
+
+ await contentPage.close();
+ Services.obs.notifyObservers(null, "net:clear-active-logins");
+ await ex1.unload();
+ await ex2.unload();
+});
+
+add_task(async function test_webRequest_auth_blocking_noreturn() {
+ // The first listener is blocking but doesn't return anything. The second
+ // listener cancels the request.
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ },
+ };
+
+ let ex1 = getExtension(config);
+ config.onAuthRequired.result = { cancel: true };
+ let ex2 = getExtension(config);
+ await ex1.startup();
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired"),
+ ex1.awaitMessage("onErrorOccurred"),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired"),
+ ex2.awaitMessage("onErrorOccurred"),
+ ]);
+
+ await contentPage.close();
+ await ex1.unload();
+ await ex2.unload();
+});
+
+add_task(async function test_webRequest_duelingAuth() {
+ let config = {
+ path: `${BASE_URL}/*`,
+ realm: `webRequest_auth${Math.random()}`,
+ onBeforeRequest: {
+ extra: ["blocking"],
+ },
+ onAuthRequired: {
+ extra: ["blocking"],
+ },
+ };
+ let exNone = getExtension(config);
+ await exNone.startup();
+
+ let authCredentials = {
+ username: `testuser_da1${Math.random()}`,
+ password: `testpass_da1${Math.random()}`,
+ };
+ config.onAuthRequired.result = { authCredentials };
+ let ex1 = getExtension(config);
+ await ex1.startup();
+
+ config.onAuthRequired.result = {};
+ let exEmpty = getExtension(config);
+ await exEmpty.startup();
+
+ config.onAuthRequired.result = {
+ authCredentials: {
+ username: `testuser_da2${Math.random()}`,
+ password: `testpass_da2${Math.random()}`,
+ },
+ };
+ let ex2 = getExtension(config);
+ await ex2.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}&user=${authCredentials.username}&pass=${authCredentials.password}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+ await Promise.all([
+ exNone.awaitMessage("onBeforeRequest"),
+ exNone.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ exNone.awaitMessage("onBeforeRequest"),
+ exNone.awaitMessage("onCompleted"),
+ ]);
+ }),
+ exEmpty.awaitMessage("onBeforeRequest"),
+ exEmpty.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ exEmpty.awaitMessage("onBeforeRequest"),
+ exEmpty.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex1.awaitMessage("onBeforeRequest"),
+ ex1.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onAuthRequired").then(() => {
+ return Promise.all([
+ ex2.awaitMessage("onBeforeRequest"),
+ ex2.awaitMessage("onCompleted"),
+ ]);
+ }),
+ ]);
+
+ await Promise.all([
+ await contentPage.close(),
+ exNone.unload(),
+ exEmpty.unload(),
+ ex1.unload(),
+ ex2.unload(),
+ ]);
+});
+
+add_task(async function test_webRequest_auth_proxy() {
+ function background(permissionPath) {
+ let proxyOk = false;
+ browser.webRequest.onAuthRequired.addListener(
+ details => {
+ browser.test.log(
+ `handlingExt onAuthRequired called with ${details.requestId} ${details.url}`
+ );
+ if (details.isProxy) {
+ browser.test.succeed("providing proxy authorization");
+ proxyOk = true;
+ return { authCredentials: { username: "puser", password: "ppass" } };
+ }
+ browser.test.assertTrue(
+ proxyOk,
+ "providing www authorization after proxy auth"
+ );
+ browser.test.sendMessage("done");
+ return { authCredentials: { username: "auser", password: "apass" } };
+ },
+ { urls: [permissionPath] },
+ ["blocking"]
+ );
+ }
+
+ let handlingExt = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/*`],
+ },
+ background: `(${background})("${BASE_URL}/*")`,
+ });
+
+ await handlingExt.startup();
+
+ let requestUrl = `${BASE_URL}/authenticate.sjs?realm=webRequest_auth${Math.random()}&proxy_realm=proxy_auth${Math.random()}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl);
+
+ await handlingExt.awaitMessage("done");
+ await contentPage.close();
+ await handlingExt.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js
new file mode 100644
index 0000000000..c18c75a580
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js
@@ -0,0 +1,311 @@
+"use strict";
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/data/file_sample.html";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+server.registerPathHandler("/status", (request, response) => {
+ let IfNoneMatch = request.hasHeader("If-None-Match")
+ ? request.getHeader("If-None-Match")
+ : "";
+
+ switch (IfNoneMatch) {
+ case "1234567890":
+ response.setStatusLine("1.1", 304, "Not Modified");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Etag", "1234567890", false);
+ break;
+ case "":
+ response.setStatusLine("1.1", 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Etag", "1234567890", false);
+ response.write("ok");
+ break;
+ default:
+ throw new Error(`Unexpected If-None-Match: ${IfNoneMatch}`);
+ }
+});
+
+// This test initialises a cache entry with a CSP header, then
+// loads the cached entry and replaces the CSP header with
+// a new one. We test in onResponseStarted that the header
+// is what we expect.
+add_task(async function test_replaceResponseHeaders() {
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ function replaceHeader(headers, newHeader) {
+ headers = headers.filter(header => header.name !== newHeader.name);
+ headers.push(newHeader);
+ return headers;
+ }
+ let testHeaders = [
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src 'none'",
+ },
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src https:",
+ },
+ ];
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ if (!details.fromCache) {
+ // Add a CSP header on the initial request
+ details.responseHeaders.push(testHeaders[0]);
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ }
+ // Test that the header added during the initial request is
+ // now in the cached response.
+ let header = details.responseHeaders.filter(header => {
+ browser.test.log(`header ${header.name} = ${header.value}`);
+ return header.name == "Content-Security-Policy";
+ });
+ browser.test.assertEq(
+ header[0].value,
+ testHeaders[0].value,
+ "pre-cached header exists"
+ );
+ // Replace the cached value so we can test overriding the header that was cached.
+ return {
+ responseHeaders: replaceHeader(
+ details.responseHeaders,
+ testHeaders[1]
+ ),
+ };
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onResponseStarted.addListener(
+ details => {
+ let needle = details.fromCache ? testHeaders[1] : testHeaders[0];
+ let header = details.responseHeaders.filter(header => {
+ browser.test.log(`header ${header.name} = ${header.value}`);
+ return header.name == needle.name && header.value == needle.value;
+ });
+ browser.test.assertEq(
+ header.length,
+ 1,
+ "header exists with correct value"
+ );
+ if (details.fromCache) {
+ browser.test.sendMessage("from-cache");
+ }
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["responseHeaders"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`;
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await extension.awaitMessage("from-cache");
+
+ await extension.unload();
+});
+
+// This test initialises a cache entry with a CSP header, then
+// loads the cached entry and adds a second CSP header. We also
+// test that the browser has the CSP entries we expect.
+add_task(async function test_addCSPHeaders() {
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let testHeaders = [
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src 'none'",
+ },
+ {
+ name: "Content-Security-Policy",
+ value: "object-src 'none'; script-src https:",
+ },
+ ];
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ if (!details.fromCache) {
+ details.responseHeaders.push(testHeaders[0]);
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ }
+ browser.test.log("cached request received");
+ details.responseHeaders.push(testHeaders[1]);
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ let { name, value } = testHeaders[0];
+ if (details.fromCache) {
+ value = `${value}, ${testHeaders[1].value}`;
+ }
+ let header = details.responseHeaders.filter(header => {
+ browser.test.log(`header ${header.name} = ${header.value}`);
+ return header.name == name && header.value == value;
+ });
+ browser.test.assertEq(
+ header.length,
+ 1,
+ "header exists with correct value"
+ );
+ if (details.fromCache) {
+ browser.test.sendMessage("from-cache");
+ }
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["responseHeaders"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`;
+ let contentPage = await ExtensionTestUtils.loadContentPage(url);
+ equal(contentPage.browser.csp.policyCount, 1, "expected 1 policy");
+ equal(
+ contentPage.browser.csp.getPolicy(0),
+ "object-src 'none'; script-src 'none'",
+ "expected policy"
+ );
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(url);
+ equal(contentPage.browser.csp.policyCount, 2, "expected 2 policies");
+ equal(
+ contentPage.browser.csp.getPolicy(0),
+ "object-src 'none'; script-src 'none'",
+ "expected first policy"
+ );
+ equal(
+ contentPage.browser.csp.getPolicy(1),
+ "object-src 'none'; script-src https:",
+ "expected second policy"
+ );
+
+ await extension.awaitMessage("from-cache");
+ await contentPage.close();
+
+ await extension.unload();
+});
+
+// This test verifies that a content type changed during
+// onHeadersReceived is cached. We initialize the cache,
+// then load against a url that will specifically return
+// a 304 status code.
+add_task(async function test_addContentTypeHeaders() {
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`);
+ },
+ {
+ urls: ["http://example.com/status*"],
+ },
+ ["blocking", "requestHeaders"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ browser.test.log(`onHeadersReceived ${JSON.stringify(details)}\n`);
+ if (!details.fromCache) {
+ browser.test.sendMessage("statusCode", details.statusCode);
+ const mime = details.responseHeaders.find(header => {
+ return header.value && header.name === "content-type";
+ });
+ if (mime) {
+ mime.value = "text/plain";
+ } else {
+ details.responseHeaders.push({
+ name: "content-type",
+ value: "text/plain",
+ });
+ }
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ }
+ },
+ {
+ urls: ["http://example.com/status*"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ browser.test.log(`onCompleted ${JSON.stringify(details)}\n`);
+ const mime = details.responseHeaders.find(header => {
+ return header.value && header.name === "content-type";
+ });
+ browser.test.sendMessage("contentType", mime.value);
+ },
+ {
+ urls: ["http://example.com/status*"],
+ },
+ ["responseHeaders"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/status`
+ );
+ equal(await extension.awaitMessage("statusCode"), "200", "status OK");
+ equal(
+ await extension.awaitMessage("contentType"),
+ "text/plain",
+ "plain text header"
+ );
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/status`);
+ equal(await extension.awaitMessage("statusCode"), "304", "not modified");
+ equal(
+ await extension.awaitMessage("contentType"),
+ "text/plain",
+ "plain text header"
+ );
+ await contentPage.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js
new file mode 100644
index 0000000000..de9ed535b3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js
@@ -0,0 +1,69 @@
+"use strict";
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_cancel_with_reason() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {
+ applications: { gecko: { id: "cancel@test" } },
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return { cancel: true };
+ },
+ { urls: ["*://*/*"] },
+ ["blocking"]
+ );
+ },
+ });
+ await ext.startup();
+
+ let data = await new Promise(resolve => {
+ let ssm = Services.scriptSecurityManager;
+
+ let channel = NetUtil.newChannel({
+ uri: `${gServerUrl}/dummy`,
+ loadingPrincipal: ssm.createContentPrincipalFromOrigin(
+ "http://localhost"
+ ),
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ });
+
+ channel.asyncOpen({
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request) {},
+
+ onStopRequest(request, statusCode) {
+ let properties = request.QueryInterface(Ci.nsIPropertyBag);
+ let id = properties.getProperty("cancelledByExtension");
+ let reason = request.loadInfo.requestBlockingReason;
+ resolve({ reason, id });
+ },
+
+ onDataAvailable() {},
+ });
+ });
+
+ Assert.equal(
+ Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST,
+ data.reason,
+ "extension cancelled request"
+ );
+ Assert.equal(
+ ext.id,
+ data.id,
+ "extension id attached to channel property bag"
+ );
+ await ext.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js
new file mode 100644
index 0000000000..75acb39000
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js
@@ -0,0 +1,43 @@
+"use strict";
+
+// Test for Bug 1579911: Check that download requests created by the
+// downloads.download API can be observed by extensions.
+add_task(async function testDownload() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "downloads",
+ "https://example.com/*",
+ ],
+ },
+ background: async function() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("request_intercepted");
+ return { cancel: true };
+ },
+ {
+ urls: ["https://example.com/downloadtest"],
+ },
+ ["blocking"]
+ );
+
+ browser.downloads.onChanged.addListener(delta => {
+ browser.test.assertEq(delta.state.current, "interrupted");
+ browser.test.sendMessage("done");
+ });
+
+ await browser.downloads.download({
+ url: "https://example.com/downloadtest",
+ filename: "example.txt",
+ });
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("request_intercepted");
+ await extension.awaitMessage("done");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js
new file mode 100644
index 0000000000..27f4ff01e8
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js
@@ -0,0 +1,523 @@
+"use strict";
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+const HOSTS = new Set(["example.com", "example.org", "example.net"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const FETCH_ORIGIN = "http://example.com/dummy";
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/redirect", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", params.get("redirect_uri"));
+ response.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+server.registerPathHandler("/redirect301", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ response.setStatusLine(request.httpVersion, 301, "Moved Permanently");
+ response.setHeader("Location", params.get("redirect_uri"));
+ response.setHeader("Access-Control-Allow-Origin", "*");
+});
+
+server.registerPathHandler("/script302.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", "http://example.com/script.js");
+});
+
+server.registerPathHandler("/script.js", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/javascript");
+ response.write(String.raw`console.log("HELLO!");`);
+});
+
+server.registerPathHandler("/302.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ response.write(String.raw`
+ <script type="application/javascript" src="http://example.com/script302.js"></script>
+ `);
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.write("ok");
+});
+
+server.registerPathHandler("/dummy.xhtml", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/xhtml+xml");
+ response.write(String.raw`<?xml version="1.0"?>
+ <html xml:lang="en" xmlns="http://www.w3.org/1999/xhtml">
+ <head/>
+ <body/>
+ </html>
+ `);
+});
+
+server.registerPathHandler("/lorem.html.gz", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ "Content-Type: text/html; charset=utf-8",
+ false
+ );
+ response.setHeader("Content-Encoding", "gzip", false);
+
+ let data = await OS.File.read(do_get_file("data/lorem.html.gz").path);
+ response.write(String.fromCharCode(...new Uint8Array(data)));
+
+ response.finish();
+});
+
+// Test re-encoding the data stream for bug 1590898.
+add_task(async function test_stream_encoding_data() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ request => {
+ let filter = browser.webRequest.filterResponseData(request.requestId);
+ let decoder = new TextDecoder("utf-8");
+ let encoder = new TextEncoder();
+
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ filter.disconnect();
+ };
+ },
+ {
+ urls: ["http://example.com/lorem.html.gz"],
+ types: ["main_frame"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/lorem.html.gz"
+ );
+
+ let content = await contentPage.spawn(null, () => {
+ return this.content.document.body.textContent;
+ });
+
+ ok(
+ content.includes("Lorem ipsum dolor sit amet"),
+ `expected content received`
+ );
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+// Tests that the stream filter request is added to the document's load
+// group, and blocks an XML document's load event until after the filter
+// stops sending data.
+add_task(async function test_xml_document_loadgroup_blocking() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ request => {
+ let filter = browser.webRequest.filterResponseData(request.requestId);
+
+ let data = [];
+ filter.ondata = event => {
+ data.push(event.data);
+ };
+ filter.onstop = async () => {
+ browser.test.sendMessage("phase", "original-onstop");
+
+ // Make a few trips through the event loop.
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+
+ for (let buffer of data) {
+ filter.write(buffer);
+ }
+ browser.test.sendMessage("phase", "filter-onstop");
+ filter.close();
+ };
+ },
+ {
+ urls: ["http://example.com/dummy.xhtml"],
+ },
+ ["blocking"]
+ );
+ },
+
+ files: {
+ "content_script.js"() {
+ browser.test.sendMessage("phase", "content-script-start");
+ window.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ browser.test.sendMessage("phase", "content-script-domload");
+ },
+ { once: true }
+ );
+ window.addEventListener(
+ "load",
+ () => {
+ browser.test.sendMessage("phase", "content-script-load");
+ },
+ { once: true }
+ );
+ },
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+
+ content_scripts: [
+ {
+ matches: ["http://example.com/dummy.xhtml"],
+ run_at: "document_start",
+ js: ["content_script.js"],
+ },
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ const EXPECTED = [
+ "original-onstop",
+ "filter-onstop",
+ "content-script-start",
+ "content-script-domload",
+ "content-script-load",
+ ];
+
+ let done = new Promise(resolve => {
+ let phases = [];
+ extension.onMessage("phase", phase => {
+ phases.push(phase);
+ if (phases.length === EXPECTED.length) {
+ resolve(phases);
+ }
+ });
+ });
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy.xhtml"
+ );
+
+ deepEqual(await done, EXPECTED, "Things happened, and in the right order");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_filter_content_fetch() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let pending = [];
+
+ browser.webRequest.onBeforeRequest.addListener(
+ data => {
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ let url = new URL(data.url);
+
+ if (url.searchParams.get("redirect_uri")) {
+ pending.push(
+ new Promise(resolve => {
+ filter.onerror = resolve;
+ }).then(() => {
+ browser.test.assertEq(
+ "Channel redirected",
+ filter.error,
+ "Got correct error for redirected filter"
+ );
+ })
+ );
+ }
+
+ filter.onstart = () => {
+ filter.write(new TextEncoder().encode(data.url));
+ };
+ filter.ondata = event => {
+ let str = new TextDecoder().decode(event.data);
+ browser.test.assertEq(
+ "ok",
+ str,
+ `Got unfiltered data for ${data.url}`
+ );
+ };
+ filter.onstop = () => {
+ filter.close();
+ };
+ },
+ {
+ urls: ["<all_urls>"],
+ },
+ ["blocking"]
+ );
+
+ browser.test.onMessage.addListener(async msg => {
+ if (msg === "done") {
+ await Promise.all(pending);
+ browser.test.notifyPass("stream-filter");
+ }
+ });
+ },
+
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/",
+ "http://example.org/",
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ let results = [
+ ["http://example.com/dummy", "http://example.com/dummy"],
+ ["http://example.org/dummy", "http://example.org/dummy"],
+ ["http://example.net/dummy", "ok"],
+ [
+ "http://example.com/redirect?redirect_uri=http://example.com/dummy",
+ "http://example.com/dummy",
+ ],
+ [
+ "http://example.com/redirect?redirect_uri=http://example.org/dummy",
+ "http://example.org/dummy",
+ ],
+ ["http://example.com/redirect?redirect_uri=http://example.net/dummy", "ok"],
+ [
+ "http://example.net/redirect?redirect_uri=http://example.com/dummy",
+ "http://example.com/dummy",
+ ],
+ ].map(async ([url, expectedResponse]) => {
+ let text = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ equal(text, expectedResponse, `Expected response for ${url}`);
+ });
+
+ await Promise.all(results);
+
+ extension.sendMessage("done");
+ await extension.awaitFinish("stream-filter");
+ await extension.unload();
+});
+
+add_task(async function test_filter_301() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ data => {
+ if (data.statusCode !== 200) {
+ return;
+ }
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ filter.onstop = () => {
+ filter.close();
+ browser.test.notifyPass("stream-filter");
+ };
+ filter.onerror = () => {
+ browser.test.fail(`unexpected ${filter.error}`);
+ };
+ },
+ {
+ urls: ["<all_urls>"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://example.com/",
+ "http://example.org/",
+ ],
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/redirect301?redirect_uri=http://example.org/dummy"
+ );
+
+ await extension.awaitFinish("stream-filter");
+
+ await contentPage.close();
+ await extension.unload();
+});
+
+add_task(async function test_filter_302() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ browser.test.sendMessage("filter-created");
+
+ filter.ondata = event => {
+ const script = "forceError();";
+ filter.write(
+ new Uint8Array(new TextEncoder("utf-8").encode(script))
+ );
+ filter.close();
+ browser.test.sendMessage("filter-ondata");
+ };
+
+ filter.onerror = () => {
+ browser.test.assertEq(filter.error, "Channel redirected");
+ browser.test.sendMessage("filter-redirect");
+ };
+ },
+ {
+ urls: ["http://example.com/*.js"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/302.html"
+ );
+
+ await extension.awaitMessage("filter-created");
+ await extension.awaitMessage("filter-redirect");
+ await extension.awaitMessage("filter-created");
+ await extension.awaitMessage("filter-ondata");
+ await contentPage.close();
+ });
+ AddonTestUtils.checkMessages(messages, {
+ expected: [{ message: /forceError is not defined/ }],
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_alternate_cached_data() {
+ Services.prefs.setBoolPref("dom.script_loader.bytecode_cache.enabled", true);
+ Services.prefs.setIntPref("dom.script_loader.bytecode_cache.strategy", -1);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ let decoder = new TextDecoder("utf-8");
+ let encoder = new TextEncoder();
+
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ filter.disconnect();
+ browser.test.assertTrue(
+ str.startsWith(`"use strict";`),
+ "ondata received decoded data"
+ );
+ browser.test.sendMessage("onBeforeRequest");
+ };
+
+ filter.onerror = () => {
+ // onBeforeRequest will always beat the cache race, so we should always
+ // get valid data in ondata.
+ browser.test.fail("error-received", filter.error);
+ };
+ },
+ {
+ urls: ["http://example.com/data/file_script_good.js"],
+ },
+ ["blocking"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ let decoder = new TextDecoder("utf-8");
+ let encoder = new TextEncoder();
+
+ // Because cache is always a race, intermittently we will succesfully
+ // beat the cache, in which case we pass in ondata. If cache wins,
+ // we pass in onerror.
+ // Running the test with --verify hits this cache race issue, as well
+ // it seems that the cache primarily looses on linux1804.
+ let gotone = false;
+ filter.ondata = event => {
+ browser.test.assertFalse(gotone, "cache lost the race");
+ gotone = true;
+ let str = decoder.decode(event.data, { stream: true });
+ filter.write(encoder.encode(str));
+ filter.disconnect();
+ browser.test.assertTrue(
+ str.startsWith(`"use strict";`),
+ "ondata received decoded data"
+ );
+ browser.test.sendMessage("onHeadersReceived");
+ };
+
+ filter.onerror = () => {
+ browser.test.assertFalse(gotone, "cache won the race");
+ gotone = true;
+ browser.test.assertEq(
+ filter.error,
+ "Channel is delivering cached alt-data"
+ );
+ browser.test.sendMessage("onHeadersReceived");
+ };
+ },
+ {
+ urls: ["http://example.com/data/file_script_bad.js"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/*"],
+ },
+ });
+
+ // Prime the cache so we have the script byte-cached.
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_script.html"
+ );
+ await contentPage.close();
+
+ await extension.startup();
+
+ let page_cached = await await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_script.html"
+ );
+ await Promise.all([
+ extension.awaitMessage("onBeforeRequest"),
+ extension.awaitMessage("onHeadersReceived"),
+ ]);
+ await page_cached.close();
+ await extension.unload();
+
+ Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.enabled");
+ Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.strategy");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js
new file mode 100644
index 0000000000..643a375ff0
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js
@@ -0,0 +1,85 @@
+"use strict";
+
+AddonTestUtils.init(this);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (request, response) => {
+ response.setHeader("Content-Tpe", "text/plain", false);
+ response.write("OK");
+});
+
+add_task(async function test_all_webRequest_ResourceTypes() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"],
+ },
+ background() {
+ browser.test.onMessage.addListener(async msg => {
+ browser.webRequest[msg.event].addListener(
+ () => {},
+ { urls: ["*://example.com/*"], ...msg.filter },
+ ["blocking"]
+ );
+ // Call an API method implemented in the parent process to
+ // be sure that the webRequest listener has been registered
+ // in the parent process as well.
+ await browser.runtime.getBrowserInfo();
+ browser.test.sendMessage(`webRequest-listener-registered`);
+ });
+ },
+ });
+
+ await extension.startup();
+
+ const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+ const webRequestSchema = Schemas.privilegedSchemaJSON
+ .get("chrome://extensions/content/schemas/web_request.json")
+ .deserialize({});
+ const ResourceType = webRequestSchema[1].types.filter(
+ type => type.id == "ResourceType"
+ )[0];
+ ok(
+ ResourceType && ResourceType.enum,
+ "Found ResourceType in the web_request.json schema"
+ );
+ info(
+ "Register webRequest.onBeforeRequest event listener for all supported ResourceType"
+ );
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ extension.sendMessage({
+ event: "onBeforeRequest",
+ filter: {
+ // Verify that the resourceType not supported is going to be ignored
+ // and all the ones supported does not trigger a ChannelWrapper.matches
+ // exception once the listener is being triggered.
+ types: [].concat(ResourceType.enum, "not-supported-resource-type"),
+ },
+ });
+ await extension.awaitMessage("webRequest-listener-registered");
+ ExtensionTestUtils.failOnSchemaWarnings();
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/dummy",
+ "http://example.com"
+ );
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ { message: /Warning processing types: .* "not-supported-resource-type"/ },
+ ],
+ forbidden: [{ message: /JavaScript Error: "ChannelWrapper.matches/ }],
+ });
+ info("No ChannelWrapper.matches errors have been logged");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js
new file mode 100644
index 0000000000..af0d8594f4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+
+add_task(async function test_invalid_urls_in_webRequest_filter() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "https://example.com/*"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(() => {}, {
+ urls: ["htt:/example.com/*"],
+ types: ["main_frame"],
+ });
+ },
+ });
+ let { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+ await extension.unload();
+ });
+ AddonTestUtils.checkMessages(
+ messages,
+ {
+ expected: [
+ {
+ message: /ExtensionError: Invalid url pattern: htt:\/example.com\/*/,
+ },
+ ],
+ },
+ true
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js
new file mode 100644
index 0000000000..b63d14cd16
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/HELLO", (req, res) => {
+ res.write("BYE");
+});
+
+add_task(async function request_from_extension_page() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["http://example.com/", "webRequest", "webRequestBlocking"],
+ },
+ files: {
+ "tab.html": `<!DOCTYPE html><script src="tab.js"></script>`,
+ "tab.js": async function() {
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ let { responseHeaders } = details;
+ responseHeaders.push({
+ name: "X-Added-by-Test",
+ value: "TheValue",
+ });
+ return { responseHeaders };
+ },
+ {
+ urls: ["http://example.com/HELLO"],
+ },
+ ["blocking", "responseHeaders"]
+ );
+
+ // Ensure that listener is registered (workaround for bug 1300234).
+ await browser.runtime.getPlatformInfo();
+
+ let response = await fetch("http://example.com/HELLO");
+ browser.test.assertEq(
+ "TheValue",
+ response.headers.get("X-added-by-test"),
+ "expected response header from webRequest listener"
+ );
+ browser.test.assertEq(
+ await response.text(),
+ "BYE",
+ "Expected response from server"
+ );
+ browser.test.sendMessage("done");
+ },
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `moz-extension://${extension.uuid}/tab.html`,
+ { extension }
+ );
+ await extension.awaitMessage("done");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js
new file mode 100644
index 0000000000..425d83560d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js
@@ -0,0 +1,99 @@
+"use strict";
+
+const HOSTS = new Set(["example.com", "example.org"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/dummy";
+
+server.registerPathHandler("/return_headers.sjs", (request, response) => {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ for (let { data: header } of request.headers) {
+ headers[header] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+function getExtension(permission = "<all_urls>") {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", permission],
+ },
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ details.requestHeaders.push({ name: "Host", value: "example.org" });
+ return { requestHeaders: details.requestHeaders };
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+ },
+ });
+}
+
+add_task(async function test_host_header_accepted() {
+ let extension = getExtension();
+ await extension.startup();
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(headers.host, "example.org", "Host header was set on request");
+
+ await extension.unload();
+});
+
+add_task(async function test_host_header_denied() {
+ let extension = getExtension(`${BASE_URL}/`);
+
+ await extension.startup();
+
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(headers.host, "example.com", "Host header was not set on request");
+
+ await extension.unload();
+});
+
+add_task(async function test_host_header_restricted() {
+ Services.prefs.setCharPref(
+ "extensions.webextensions.restrictedDomains",
+ "example.org"
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("extensions.webextensions.restrictedDomains");
+ });
+
+ let extension = getExtension();
+
+ await extension.startup();
+
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(headers.host, "example.com", "Host header was not set on request");
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js
new file mode 100644
index 0000000000..cc84791aaf
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js
@@ -0,0 +1,81 @@
+"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("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_incognito_webrequest_access() {
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ let pb_extension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertTrue(details.incognito, "incognito flag is set");
+ },
+ { urls: ["<all_urls>"], incognito: true },
+ ["blocking"]
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set"
+ );
+ browser.test.notifyPass("webRequest.spanning");
+ },
+ { urls: ["<all_urls>"], incognito: false },
+ ["blocking"]
+ );
+ },
+ });
+ await pb_extension.startup();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set"
+ );
+ browser.test.notifyPass("webRequest");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ },
+ });
+ // Load non-incognito extension to check that private requests are invisible to it.
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { privateBrowsing: true }
+ );
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitFinish("webRequest");
+ await pb_extension.awaitFinish("webRequest.spanning");
+ await contentPage.close();
+
+ await pb_extension.unload();
+ await extension.unload();
+
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js
new file mode 100644
index 0000000000..06dd0f54ef
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js
@@ -0,0 +1,214 @@
+/* -*- 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.net", "example.com"],
+});
+server.registerDirectory("/data/", do_get_file("data"));
+
+const pageContent = `<!DOCTYPE html>
+ <script id="script1" src="/data/file_script_good.js"></script>
+ <script id="script3" src="//example.com/data/file_script_bad.js"></script>
+ <img id="img1" src='/data/file_image_good.png'>
+ <img id="img3" src='//example.com/data/file_image_good.png'>
+`;
+
+server.registerPathHandler("/", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html");
+ if (request.queryString) {
+ response.setHeader(
+ "Content-Security-Policy",
+ decodeURIComponent(request.queryString)
+ );
+ }
+ response.write(pageContent);
+});
+
+let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://example.net/*"],
+ },
+ background() {
+ let csp_value = undefined;
+ browser.test.onMessage.addListener(function(msg, expectedCount) {
+ csp_value = msg;
+ browser.test.sendMessage("csp-set");
+ });
+ browser.webRequest.onHeadersReceived.addListener(
+ e => {
+ browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`);
+ if (csp_value === undefined) {
+ browser.test.assertTrue(false, "extension called before CSP was set");
+ }
+ if (csp_value !== null) {
+ e.responseHeaders = e.responseHeaders.filter(
+ i => i.name.toLowerCase() != "content-security-policy"
+ );
+ if (csp_value !== "") {
+ e.responseHeaders.push({
+ name: "Content-Security-Policy",
+ value: csp_value,
+ });
+ }
+ }
+ return { responseHeaders: e.responseHeaders };
+ },
+ { urls: ["*://example.net/*"] },
+ ["blocking", "responseHeaders"]
+ );
+ },
+};
+
+/**
+ * Test a combination of Content Security Policies against first/third party images/scripts.
+ * @param {string} site_csp The CSP to be sent by the site, or null.
+ * @param {string} ext1_csp The CSP to be sent by the first extension,
+ * "" to remove the header, or null to not modify it.
+ * @param {string} ext2_csp The CSP to be sent by the first extension,
+ * "" to remove the header, or null to not modify it.
+ * @param {Object} expect Object containing information which resources are expected to be loaded.
+ * @param {Object} expect.img1_loaded image from a first party origin.
+ * @param {Object} expect.img3_loaded image from a third party origin.
+ * @param {Object} expect.script1_loaded script from a first party origin.
+ * @param {Object} expect.script3_loaded script from a third party origin.
+ */
+async function test_csp(site_csp, ext1_csp, ext2_csp, expect) {
+ let extension1 = await ExtensionTestUtils.loadExtension(extensionData);
+ let extension2 = await ExtensionTestUtils.loadExtension(extensionData);
+ await extension1.startup();
+ await extension2.startup();
+ extension1.sendMessage(ext1_csp);
+ extension2.sendMessage(ext2_csp);
+ await extension1.awaitMessage("csp-set");
+ await extension2.awaitMessage("csp-set");
+
+ let csp_value = encodeURIComponent(site_csp || "");
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `http://example.net/?${csp_value}`
+ );
+ let results = await contentPage.spawn(null, async () => {
+ let img1 = this.content.document.getElementById("img1");
+ let img3 = this.content.document.getElementById("img3");
+ return {
+ img1_loaded: img1.complete && img1.naturalWidth > 0,
+ img3_loaded: img3.complete && img3.naturalWidth > 0,
+ // Note: "good" and "bad" are just placeholders; they don't mean anything.
+ script1_loaded: !!this.content.document.getElementById("good"),
+ script3_loaded: !!this.content.document.getElementById("bad"),
+ };
+ });
+
+ await contentPage.close();
+ await extension1.unload();
+ await extension2.unload();
+
+ let action = {
+ true: "loaded",
+ false: "blocked",
+ };
+
+ info(`test_csp: From "${site_csp}" to "${ext1_csp}" to "${ext2_csp}"`);
+
+ equal(
+ expect.img1_loaded,
+ results.img1_loaded,
+ `expected first party image to be ${action[expect.img1_loaded]}`
+ );
+ equal(
+ expect.img3_loaded,
+ results.img3_loaded,
+ `expected third party image to be ${action[expect.img3_loaded]}`
+ );
+ equal(
+ expect.script1_loaded,
+ results.script1_loaded,
+ `expected first party script to be ${action[expect.script1_loaded]}`
+ );
+ equal(
+ expect.script3_loaded,
+ results.script3_loaded,
+ `expected third party script to be ${action[expect.script3_loaded]}`
+ );
+}
+
+add_task(async function test_webRequest_mergecsp() {
+ await test_csp("default-src *", "script-src 'none'", null, {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: false,
+ script3_loaded: false,
+ });
+ await test_csp(null, "script-src 'none'", null, {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: false,
+ script3_loaded: false,
+ });
+ await test_csp("default-src *", "script-src 'none'", "img-src 'none'", {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ });
+ await test_csp(null, "script-src 'none'", "img-src 'none'", {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: false,
+ script3_loaded: false,
+ });
+ await test_csp(
+ "default-src *",
+ "img-src example.com",
+ "img-src example.org",
+ {
+ img1_loaded: false,
+ img3_loaded: false,
+ script1_loaded: true,
+ script3_loaded: true,
+ }
+ );
+});
+
+add_task(async function test_remove_and_replace_csp() {
+ // CSP removed, CSP added.
+ await test_csp("img-src 'self'", "", "img-src example.com", {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ });
+
+ // CSP removed, CSP added.
+ await test_csp("default-src 'none'", "", "img-src example.com", {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ });
+
+ // CSP replaced - regression test for bug 1635781.
+ await test_csp("default-src 'none'", "img-src example.com", null, {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ });
+
+ // CSP unchanged, CSP replaced - regression test for bug 1635781.
+ await test_csp("default-src 'none'", null, "img-src example.com", {
+ img1_loaded: false,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ });
+
+ // CSP replaced, CSP removed.
+ await test_csp("default-src 'none'", "img-src example.com", "", {
+ img1_loaded: true,
+ img3_loaded: true,
+ script1_loaded: true,
+ script3_loaded: true,
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js
new file mode 100644
index 0000000000..530deaa1a7
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js
@@ -0,0 +1,154 @@
+"use strict";
+
+const PREF_DISABLE_SECURITY =
+ "security.turn_off_all_security_so_that_" +
+ "viruses_can_take_over_this_computer";
+
+const HOSTS = new Set(["example.com", "example.org"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.write("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_permissions() {
+ function background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ if (details.url.includes("_original")) {
+ let redirectUrl = details.url
+ .replace("example.org", "example.com")
+ .replace("_original", "_redirected");
+ return { redirectUrl };
+ }
+ return {};
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ }
+
+ let extensionData = {
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background,
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ const frameScript = () => {
+ const messageListener = {
+ async receiveMessage({ target, messageName, recipient, data, name }) {
+ /* globals content */
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ doc.body.appendChild(iframe);
+
+ let promise = new Promise(resolve => {
+ let listener = event => {
+ content.removeEventListener("message", listener);
+ resolve(event.data);
+ };
+ content.addEventListener("message", listener);
+ });
+
+ iframe.setAttribute(
+ "src",
+ "http://example.com/data/file_WebRequest_permission_original.html"
+ );
+ let result = await promise;
+ doc.body.removeChild(iframe);
+ return result;
+ },
+ };
+
+ const { MessageChannel } = ChromeUtils.import(
+ "resource://gre/modules/MessageChannel.jsm"
+ );
+ MessageChannel.addListener(this, "Test:Check", messageListener);
+ };
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy`
+ );
+ await contentPage.loadFrameScript(frameScript);
+
+ let results = await contentPage.sendMessage("Test:Check", {});
+ equal(
+ results.page,
+ "redirected",
+ "Regular webRequest redirect works on an unprivileged page"
+ );
+ equal(
+ results.script,
+ "redirected",
+ "Regular webRequest redirect works from an unprivileged page"
+ );
+
+ Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true);
+ Services.prefs.setBoolPref("extensions.webapi.testing", true);
+ Services.prefs.setBoolPref("extensions.webapi.testing.http", true);
+
+ results = await contentPage.sendMessage("Test:Check", {});
+ equal(
+ results.page,
+ "original",
+ "webRequest redirect fails on a privileged page"
+ );
+ equal(
+ results.script,
+ "original",
+ "webRequest redirect fails from a privileged page"
+ );
+
+ await extension.unload();
+ await contentPage.close();
+});
+
+add_task(async function test_no_webRequestBlocking_error() {
+ function background() {
+ const expectedError =
+ "Using webRequest.addListener with the blocking option " +
+ "requires the 'webRequestBlocking' permission.";
+
+ const blockingEvents = [
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onHeadersReceived",
+ "onAuthRequired",
+ ];
+
+ for (let eventName of blockingEvents) {
+ browser.test.assertThrows(
+ () => {
+ browser.webRequest[eventName].addListener(
+ details => {},
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ },
+ expectedError,
+ `Got the expected exception for a blocking webRequest.${eventName} listener`
+ );
+ }
+ }
+
+ const extensionData = {
+ manifest: { permissions: ["webRequest", "<all_urls>"] },
+ background,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionData);
+
+ await extension.startup();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js
new file mode 100644
index 0000000000..f8d329c85b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js
@@ -0,0 +1,129 @@
+"use strict";
+
+// StreamFilters should be closed upon a redirect.
+//
+// Some redirects are already tested in other tests:
+// - test_ext_webRequest_filterResponseData.js tests fetch requests.
+// - test_ext_webRequest_viewsource_StreamFilter.js tests view-source documents.
+//
+// Usually, redirects are caught in StreamFilterParent::OnStartRequest, but due
+// to the fact that AttachStreamFilter is deferred for document requests, OSR is
+// not called and the cleanup is triggered from nsHttpChannel::ReleaseListeners.
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+
+server.registerPathHandler("/redir", (request, response) => {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", "/target");
+});
+server.registerPathHandler("/target", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+server.registerPathHandler("/RedirectToRedir.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
+ response.write("<script>location.href='http://example.com/redir';</script>");
+});
+server.registerPathHandler("/iframeWithRedir.html", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
+ response.write("<iframe src='http://example.com/redir'></iframe>");
+});
+
+function loadRedirectCatcherExtension() {
+ return ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://*/*"],
+ },
+ background() {
+ const closeCounts = {};
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let expectedError = "Channel redirected";
+ if (details.type === "main_frame" || details.type === "sub_frame") {
+ // Message differs for the reason stated at the top of this file.
+ // TODO bug 1683862: Make error message more accurate.
+ expectedError = "Invalid request ID";
+ }
+
+ closeCounts[details.requestId] = 0;
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstart = () => {
+ filter.disconnect();
+ browser.test.fail("Unexpected filter.onstart");
+ };
+ filter.onerror = function() {
+ closeCounts[details.requestId]++;
+ browser.test.assertEq(expectedError, filter.error, "filter.error");
+ };
+ },
+ { urls: ["*://*/redir"] },
+ ["blocking"]
+ );
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ // filter.onerror from the redirect request should be called before
+ // webRequest.onCompleted of the redirection target. Regression test
+ // for bug 1683189.
+ browser.test.assertEq(
+ 1,
+ closeCounts[details.requestId],
+ "filter from initial, redirected request should have been closed"
+ );
+ browser.test.log("Intentionally canceling view-source request");
+ browser.test.sendMessage("req_end", details.type);
+ },
+ { urls: ["*://*/target"] }
+ );
+ },
+ });
+}
+
+add_task(async function redirect_document() {
+ let extension = loadRedirectCatcherExtension();
+ await extension.startup();
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/redir"
+ );
+ equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc");
+ await contentPage.close();
+ }
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/iframeWithRedir.html"
+ );
+ equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc");
+ await contentPage.close();
+ }
+
+ await extension.unload();
+});
+
+// Cross-origin redirect = process switch.
+add_task(async function redirect_document_cross_origin() {
+ let extension = loadRedirectCatcherExtension();
+ await extension.startup();
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/RedirectToRedir.html"
+ );
+ equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc");
+ await contentPage.close();
+ }
+
+ {
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.org/iframeWithRedir.html"
+ );
+ equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc");
+ await contentPage.close();
+ }
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js
new file mode 100644
index 0000000000..e390e3348e
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js
@@ -0,0 +1,47 @@
+"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": '<!DOCTYPE html><script src="test.js"></script>',
+ "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 contentPage = await ExtensionTestUtils.loadContentPage(
+ "https://example.com/redir"
+ );
+
+ await extension.awaitMessage("test_done");
+
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js
new file mode 100644
index 0000000000..69238fb057
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const server = createHttpServer();
+const gServerUrl = `http://localhost:${server.identity.primaryPort}`;
+
+const EXTENSION_DATA = {
+ manifest: {
+ name: "Simple extension test",
+ version: "1.0",
+ manifest_version: 2,
+ description: "",
+
+ permissions: ["webRequest", "<all_urls>"],
+ },
+
+ async background() {
+ browser.test.log("background script running");
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ async details => {
+ browser.test.assertTrue(details.requestSize == 0, "no requestSize");
+ browser.test.assertTrue(details.responseSize == 0, "no responseSize");
+ browser.test.log(`details.requestSize: ${details.requestSize}`);
+ browser.test.log(`details.responseSize: ${details.responseSize}`);
+ browser.test.sendMessage("check");
+ },
+ { urls: ["*://*/*"] }
+ );
+
+ browser.webRequest.onCompleted.addListener(
+ async details => {
+ browser.test.assertTrue(details.requestSize > 100, "have requestSize");
+ browser.test.assertTrue(
+ details.responseSize > 100,
+ "have responseSize"
+ );
+ browser.test.log(`details.requestSize: ${details.requestSize}`);
+ browser.test.log(`details.responseSize: ${details.responseSize}`);
+ browser.test.sendMessage("done");
+ },
+ { urls: ["*://*/*"] }
+ );
+ },
+};
+
+add_task(async function test_request_response_size() {
+ let ext = ExtensionTestUtils.loadExtension(EXTENSION_DATA);
+ await ext.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${gServerUrl}/dummy`
+ );
+ await ext.awaitMessage("check");
+ await ext.awaitMessage("done");
+ await contentPage.close();
+ await ext.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js
new file mode 100644
index 0000000000..d3715684f9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js
@@ -0,0 +1,765 @@
+"use strict";
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* eslint-disable no-shadow */
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { ExtensionTestCommon } = ChromeUtils.import(
+ "resource://testing-common/ExtensionTestCommon.jsm"
+);
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/data/file_sample.html";
+
+const SEQUENTIAL = false;
+
+const PARTS = [
+ `<!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title></title>
+ </head>
+ <body>`,
+ "Lorem ipsum dolor sit amet, <br>",
+ "consectetur adipiscing elit, <br>",
+ "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>",
+ "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>",
+ "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>",
+ "Excepteur sint occaecat cupidatat non proident, <br>",
+ "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>",
+ `
+ </body>
+ </html>`,
+].map(part => `${part}\n`);
+
+const TIMEOUT = AppConstants.DEBUG ? 4000 : 800;
+
+function delay(timeout = TIMEOUT) {
+ return new Promise(resolve => setTimeout(resolve, timeout));
+}
+
+server.registerPathHandler("/slow_response.sjs", async (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) {
+ try {
+ response.write(part);
+ } catch (e) {
+ // This fails if we attempt to write data after the connection has
+ // been closed.
+ break;
+ }
+ await delay();
+ }
+
+ response.finish();
+});
+
+server.registerPathHandler("/lorem.html.gz", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ "Content-Type: text/html; charset=utf-8",
+ false
+ );
+ response.setHeader("Content-Encoding", "gzip", false);
+
+ let data = await OS.File.read(do_get_file("data/lorem.html.gz").path);
+ response.write(String.fromCharCode(...new Uint8Array(data)));
+
+ response.finish();
+});
+
+server.registerPathHandler("/multipart", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"',
+ false
+ );
+
+ response.write("--testingtesting\n");
+ response.write(PARTS.join(""));
+ response.write("--testingtesting--\n");
+
+ response.finish();
+});
+
+server.registerPathHandler("/multipart2", async (request, response) => {
+ response.processAsync();
+
+ response.setHeader(
+ "Content-Type",
+ 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"',
+ false
+ );
+
+ response.write("--testingtesting\n");
+ response.write(PARTS.join(""));
+ response.write("--testingtesting\n");
+ response.write(PARTS.join(""));
+ response.write("--testingtesting--\n");
+
+ response.finish();
+});
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+const TASKS = [
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let decoder = new TextDecoder("utf-8");
+
+ browser.test.assertEq(
+ "uninitialized",
+ filter.status,
+ `(${num}): Got expected initial status`
+ );
+
+ filter.onstart = event => {
+ browser.test.assertEq(
+ "transferringdata",
+ filter.status,
+ `(${num}): Got expected onStart status`
+ );
+ };
+
+ filter.onstop = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected onStop event while disconnected`
+ );
+ };
+
+ let n = 0;
+ filter.ondata = async event => {
+ let str = decoder.decode(event.data, { stream: true });
+
+ if (n < 3) {
+ browser.test.assertEq(
+ JSON.stringify(PARTS[n]),
+ JSON.stringify(str),
+ `(${num}): Got expected part`
+ );
+ }
+ n++;
+
+ filter.write(event.data);
+
+ if (n == 3) {
+ filter.suspend();
+
+ browser.test.assertEq(
+ "suspended",
+ filter.status,
+ `(${num}): Got expected suspended status`
+ );
+
+ let fail = () => {
+ browser.test.fail(
+ `(${num}): Got unexpected data event while suspended`
+ );
+ };
+ filter.addEventListener("data", fail);
+
+ await delay(TIMEOUT * 3);
+
+ browser.test.assertEq(
+ "suspended",
+ filter.status,
+ `(${num}): Got expected suspended status`
+ );
+
+ filter.removeEventListener("data", fail);
+ filter.resume();
+ browser.test.assertEq(
+ "transferringdata",
+ filter.status,
+ `(${num}): Got expected resumed status`
+ );
+ } else if (n > 4) {
+ filter.disconnect();
+
+ filter.addEventListener("data", () => {
+ browser.test.fail(
+ `(${num}): Got unexpected data event while disconnected`
+ );
+ });
+
+ browser.test.assertEq(
+ "disconnected",
+ filter.status,
+ `(${num}): Got expected disconnected status`
+ );
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let decoder = new TextDecoder("utf-8");
+
+ filter.onstop = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected onStop event while disconnected`
+ );
+ };
+
+ let n = 0;
+ filter.ondata = async event => {
+ let str = decoder.decode(event.data, { stream: true });
+
+ if (n < 3) {
+ browser.test.assertEq(
+ JSON.stringify(PARTS[n]),
+ JSON.stringify(str),
+ `(${num}): Got expected part`
+ );
+ }
+ n++;
+
+ filter.write(event.data);
+
+ if (n == 3) {
+ filter.suspend();
+
+ await delay(TIMEOUT * 3);
+
+ filter.disconnect();
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let encoder = new TextEncoder("utf-8");
+
+ filter.onstop = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected onStop event while disconnected`
+ );
+ };
+
+ let n = 0;
+ filter.ondata = async event => {
+ n++;
+
+ filter.write(event.data);
+
+ function checkState(state) {
+ browser.test.assertEq(
+ state,
+ filter.status,
+ `(${num}): Got expected status`
+ );
+ }
+ if (n == 3) {
+ filter.resume();
+ checkState("transferringdata");
+ filter.suspend();
+ checkState("suspended");
+ filter.suspend();
+ checkState("suspended");
+ filter.resume();
+ checkState("transferringdata");
+ filter.suspend();
+ checkState("suspended");
+
+ await delay(TIMEOUT * 3);
+
+ checkState("suspended");
+ filter.disconnect();
+ checkState("disconnected");
+
+ for (let method of ["suspend", "resume", "close"]) {
+ browser.test.assertThrows(
+ () => {
+ filter[method]();
+ },
+ /.*/,
+ `(${num}): ${method}() should throw while disconnected`
+ );
+ }
+
+ browser.test.assertThrows(
+ () => {
+ filter.write(encoder.encode("Foo bar"));
+ },
+ /.*/,
+ `(${num}): write() should throw while disconnected`
+ );
+
+ filter.disconnect();
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "slow_response.sjs",
+ task(filter, resolve, num) {
+ let encoder = new TextEncoder("utf-8");
+ let decoder = new TextDecoder("utf-8");
+
+ filter.onstop = event => {
+ browser.test.fail(`(${num}): Got unexpected onStop event while closed`);
+ };
+
+ browser.test.assertThrows(
+ () => {
+ filter.write(encoder.encode("Foo bar"));
+ },
+ /.*/,
+ `(${num}): write() should throw prior to connection`
+ );
+
+ let n = 0;
+ filter.ondata = async event => {
+ n++;
+
+ filter.write(event.data);
+
+ browser.test.log(
+ `(${num}): Got part ${n}: ${JSON.stringify(
+ decoder.decode(event.data)
+ )}`
+ );
+
+ function checkState(state) {
+ browser.test.assertEq(
+ state,
+ filter.status,
+ `(${num}): Got expected status`
+ );
+ }
+ if (n == 3) {
+ filter.close();
+
+ checkState("closed");
+
+ for (let method of ["suspend", "resume", "disconnect"]) {
+ browser.test.assertThrows(
+ () => {
+ filter[method]();
+ },
+ /.*/,
+ `(${num}): ${method}() should throw while closed`
+ );
+ }
+
+ browser.test.assertThrows(
+ () => {
+ filter.write(encoder.encode("Foo bar"));
+ },
+ /.*/,
+ `(${num}): write() should throw while closed`
+ );
+
+ filter.close();
+
+ resolve();
+ }
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.slice(0, 3).join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "lorem.html.gz",
+ task(filter, resolve, num) {
+ let response = "";
+ let decoder = new TextDecoder("utf-8");
+
+ filter.onstart = event => {
+ browser.test.log(`(${num}): Request start`);
+ };
+
+ filter.onstop = event => {
+ browser.test.assertEq(
+ "finishedtransferringdata",
+ filter.status,
+ `(${num}): Got expected onStop status`
+ );
+
+ filter.close();
+ browser.test.assertEq(
+ "closed",
+ filter.status,
+ `Got expected closed status`
+ );
+
+ browser.test.assertEq(
+ JSON.stringify(PARTS.join("")),
+ JSON.stringify(response),
+ `(${num}): Got expected response`
+ );
+
+ resolve();
+ };
+
+ filter.ondata = event => {
+ let str = decoder.decode(event.data, { stream: true });
+ response += str;
+
+ filter.write(event.data);
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(response, PARTS.join(""), "Got expected final HTML");
+ },
+ },
+ {
+ url: "multipart",
+ task(filter, resolve, num) {
+ filter.onstart = event => {
+ browser.test.log(`(${num}): Request start`);
+ };
+
+ filter.onstop = event => {
+ filter.disconnect();
+ resolve();
+ };
+
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(
+ response,
+ "--testingtesting\n" + PARTS.join("") + "--testingtesting--\n",
+ "Got expected final HTML"
+ );
+ },
+ },
+ {
+ url: "multipart2",
+ task(filter, resolve, num) {
+ filter.onstart = event => {
+ browser.test.log(`(${num}): Request start`);
+ };
+
+ filter.onstop = event => {
+ filter.disconnect();
+ resolve();
+ };
+
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+
+ filter.onerror = event => {
+ browser.test.fail(
+ `(${num}): Got unexpected error event: ${filter.error}`
+ );
+ };
+ },
+ verify(response) {
+ equal(
+ response,
+ "--testingtesting\n" +
+ PARTS.join("") +
+ "--testingtesting\n" +
+ PARTS.join("") +
+ "--testingtesting--\n",
+ "Got expected final HTML"
+ );
+ },
+ },
+];
+
+function serializeTest(test, num) {
+ let url = `${test.url}?test_num=${num}`;
+ let task = ExtensionTestCommon.serializeFunction(test.task);
+
+ return `{url: ${JSON.stringify(url)}, task: ${task}}`;
+}
+
+add_task(async function() {
+ function background(TASKS) {
+ async function runTest(test, num, details) {
+ browser.test.log(`Running test #${num}: ${details.url}`);
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+
+ try {
+ await new Promise(resolve => {
+ test.task(filter, resolve, num, details);
+ });
+ } catch (e) {
+ browser.test.fail(
+ `Task #${num} threw an unexpected exception: ${e} :: ${e.stack}`
+ );
+ }
+
+ browser.test.log(`Finished test #${num}: ${details.url}`);
+ browser.test.sendMessage(`finished-${num}`);
+ }
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ for (let [num, test] of TASKS.entries()) {
+ if (details.url.endsWith(test.url)) {
+ runTest(test, num, details);
+ break;
+ }
+ }
+ },
+ {
+ urls: ["http://example.com/*?test_num=*"],
+ },
+ ["blocking"]
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background: `
+ const PARTS = ${JSON.stringify(PARTS)};
+ const TIMEOUT = ${TIMEOUT};
+
+ ${delay}
+
+ (${background})([${TASKS.map(serializeTest)}])
+ `,
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ async function runTest(test, num) {
+ let url = `${BASE_URL}/${test.url}?test_num=${num}`;
+
+ let body = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+
+ await extension.awaitMessage(`finished-${num}`);
+
+ info(`Verifying test #${num}: ${url}`);
+ await test.verify(body);
+ }
+
+ if (SEQUENTIAL) {
+ for (let [num, test] of TASKS.entries()) {
+ await runTest(test, num);
+ }
+ } else {
+ await Promise.all(TASKS.map(runTest));
+ }
+
+ await extension.unload();
+});
+
+// Test that registering a listener for a cached response does not cause a crash.
+add_task(async function test_cachedResponse() {
+ if (AppConstants.platform === "android") {
+ return;
+ }
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ data => {
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ filter.onstop = event => {
+ filter.close();
+ };
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+
+ if (data.fromCache) {
+ browser.test.sendMessage("from-cache");
+ }
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?r=*"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`;
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, url);
+ await extension.awaitMessage("from-cache");
+
+ await extension.unload();
+});
+
+// Test that finishing transferring data doesn't overwrite an existing closing/closed state.
+add_task(async function test_late_close() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ data => {
+ let filter = browser.webRequest.filterResponseData(data.requestId);
+
+ filter.onstop = event => {
+ browser.test.fail("Should not receive onstop after close()");
+ browser.test.assertEq(
+ "closed",
+ filter.status,
+ "Filter status should still be 'closed'"
+ );
+ browser.test.assertThrows(() => {
+ filter.close();
+ });
+ };
+ filter.ondata = event => {
+ filter.write(event.data);
+ filter.close();
+
+ browser.test.sendMessage(`done-${data.url}`);
+ };
+ },
+ {
+ urls: ["http://example.com/*/file_sample.html?*"],
+ },
+ ["blocking"]
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+
+ // This issue involves a race, so several requests in parallel to increase
+ // the chances of triggering it.
+ let urls = [];
+ for (let i = 0; i < 32; i++) {
+ urls.push(`${BASE_URL}/data/file_sample.html?r=${Math.random()}`);
+ }
+
+ await Promise.all(
+ urls.map(url => ExtensionTestUtils.fetch(FETCH_ORIGIN, url))
+ );
+ await Promise.all(urls.map(url => extension.awaitMessage(`done-${url}`)));
+
+ await extension.unload();
+});
+
+add_task(async function test_permissions() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.assertEq(
+ undefined,
+ browser.webRequest.filterResponseData,
+ "filterResponseData is undefined without blocking permissions"
+ );
+ },
+
+ manifest: {
+ permissions: ["webRequest", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.unload();
+});
+
+add_task(async function test_invalidId() {
+ let extension = ExtensionTestUtils.loadExtension({
+ async background() {
+ let filter = browser.webRequest.filterResponseData("34159628");
+
+ await new Promise(resolve => {
+ filter.onerror = resolve;
+ });
+
+ browser.test.assertEq(
+ "Invalid request ID",
+ filter.error,
+ "Got expected error"
+ );
+
+ browser.test.notifyPass("invalid-request-id");
+ },
+
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("invalid-request-id");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js
new file mode 100644
index 0000000000..e40bc4f8b4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js
@@ -0,0 +1,308 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+server.registerPathHandler(
+ "/file_webrequestblocking_set_cookie.html",
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Set-Cookie", "reqcookie=reqvalue", false);
+ response.write("<!DOCTYPE html><html></html>");
+ }
+);
+
+add_task(async function test_modifying_cookies_from_onHeadersReceived() {
+ async function background() {
+ /**
+ * Check that all the cookies described by `prefixes` are in the cookie jar.
+ *
+ * @param {Array.string} prefixes
+ * Zero or more prefixes, describing cookies that are expected to be set
+ * in the current cookie jar. Each prefix describes both a cookie
+ * name and corresponding value. For example, if the string "ext"
+ * is passed as an argument, then this function expects to see
+ * a cookie called "extcookie" and corresponding value of "extvalue".
+ */
+ async function checkCookies(prefixes) {
+ const numPrefixes = prefixes.length;
+ const currentCookies = await browser.cookies.getAll({});
+ browser.test.assertEq(
+ numPrefixes,
+ currentCookies.length,
+ `${numPrefixes} cookies were set`
+ );
+
+ for (let cookiePrefix of prefixes) {
+ let cookieName = `${cookiePrefix}cookie`;
+ let expectedCookieValue = `${cookiePrefix}value`;
+ let fetchedCookie = await browser.cookies.getAll({ name: cookieName });
+ browser.test.assertEq(
+ 1,
+ fetchedCookie.length,
+ `Found 1 cookie with name "${cookieName}"`
+ );
+ browser.test.assertEq(
+ expectedCookieValue,
+ fetchedCookie[0] && fetchedCookie[0].value,
+ `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"`
+ );
+ }
+ }
+
+ function awaitMessage(expectedMsg) {
+ return new Promise(resolve => {
+ browser.test.onMessage.addListener(function listener(msg) {
+ if (msg === expectedMsg) {
+ browser.test.onMessage.removeListener(listener);
+ resolve();
+ }
+ });
+ });
+ }
+
+ /**
+ * Opens the given test file as a content page.
+ *
+ * @param {string} filename
+ * The name of a html file relative to the test server root.
+ *
+ * @returns {Promise}
+ */
+ function openContentPage(filename) {
+ let promise = awaitMessage("url-loaded");
+ browser.test.sendMessage(
+ "load-url",
+ `http://example.com/${filename}?nocache=${Math.random()}`
+ );
+ return promise;
+ }
+
+ /**
+ * Tests that expected cookies are in the cookie jar after opening a file.
+ *
+ * @param {string} filename
+ * The name of a html file in the
+ * "toolkit/components/extensions/test/mochitest" directory.
+ * @param {?Array.string} prefixes
+ * Zero or more prefixes, describing cookies that are expected to be set
+ * in the current cookie jar. Each prefix describes both a cookie
+ * name and corresponding value. For example, if the string "ext"
+ * is passed as an argument, then this function expects to see
+ * a cookie called "extcookie" and corresponding value of "extvalue".
+ * If undefined, then no checks are automatically performed, and the
+ * caller should provide a callback to perform the checks.
+ * @param {?Function} callback
+ * An optional async callback function that, if provided, will be called
+ * with an object that contains windowId and tabId parameters.
+ * Callers can use this callback to apply extra tests about the state of
+ * the cookie jar, or to query the state of the opened page.
+ */
+ async function testCookiesWithFile(filename, prefixes, callback) {
+ await browser.browsingData.removeCookies({});
+ await openContentPage(filename);
+
+ if (prefixes !== undefined) {
+ await checkCookies(prefixes);
+ }
+
+ if (callback !== undefined) {
+ await callback();
+ }
+ let promise = awaitMessage("url-unloaded");
+ browser.test.sendMessage("unload-url");
+ await promise;
+ }
+
+ const filter = {
+ urls: ["<all_urls>"],
+ types: ["main_frame", "sub_frame"],
+ };
+
+ const headersReceivedInfoSpec = ["blocking", "responseHeaders"];
+
+ const onHeadersReceived = details => {
+ details.responseHeaders.push({
+ name: "Set-Cookie",
+ value: "extcookie=extvalue",
+ });
+
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ onHeadersReceived,
+ filter,
+ headersReceivedInfoSpec
+ );
+
+ // First, perform a request that should not set any cookies, and check
+ // that the cookie the extension sets is the only cookie in the
+ // cookie jar.
+ await testCookiesWithFile("data/file_sample.html", ["ext"]);
+
+ // Next, perform a request that will set on cookie (reqcookie=reqvalue)
+ // and check that two cookies wind up in the cookie jar (the request
+ // set cookie, and the extension set cookie).
+ await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [
+ "ext",
+ "req",
+ ]);
+
+ // Third, register another onHeadersReceived handler that also
+ // sets a cookie (thirdcookie=thirdvalue), to make sure modifications from
+ // multiple onHeadersReceived listeners are merged correctly.
+ const thirdOnHeadersRecievedListener = details => {
+ details.responseHeaders.push({
+ name: "Set-Cookie",
+ value: "thirdcookie=thirdvalue",
+ });
+
+ browser.test.log(JSON.stringify(details.responseHeaders));
+
+ return {
+ responseHeaders: details.responseHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ thirdOnHeadersRecievedListener,
+ filter,
+ headersReceivedInfoSpec
+ );
+ await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [
+ "ext",
+ "req",
+ "third",
+ ]);
+ browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived);
+ browser.webRequest.onHeadersReceived.removeListener(
+ thirdOnHeadersRecievedListener
+ );
+
+ // Fourth, test to make sure that extensions can remove cookies
+ // using onHeadersReceived too, by 1. making a request that
+ // sets a cookie (reqcookie=reqvalue), 2. having the extension remove
+ // that cookie by removing that header, and 3. adding a new cookie
+ // (extcookie=extvalue).
+ const fourthOnHeadersRecievedListener = details => {
+ // Remove the cookie set by the request (reqcookie=reqvalue).
+ const newHeaders = details.responseHeaders.filter(
+ cookie => cookie.name !== "set-cookie"
+ );
+
+ // And then add a new cookie in its place (extcookie=extvalue).
+ newHeaders.push({
+ name: "Set-Cookie",
+ value: "extcookie=extvalue",
+ });
+
+ return {
+ responseHeaders: newHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ fourthOnHeadersRecievedListener,
+ filter,
+ headersReceivedInfoSpec
+ );
+ await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [
+ "ext",
+ ]);
+ browser.webRequest.onHeadersReceived.removeListener(
+ fourthOnHeadersRecievedListener
+ );
+
+ // Fifth, check that extensions are able to overwrite headers set by
+ // pages. In this test, make a request that will set "reqcookie=reqvalue",
+ // and add a listener that sets "reqcookie=changedvalue". Check
+ // to make sure that the cookie jar contains "reqcookie=changedvalue"
+ // and not "reqcookie=reqvalue".
+ const fifthOnHeadersRecievedListener = details => {
+ // Remove the cookie set by the request (reqcookie=reqvalue).
+ const newHeaders = details.responseHeaders.filter(
+ cookie => cookie.name !== "set-cookie"
+ );
+
+ // And then add a new cookie in its place (reqcookie=changedvalue).
+ newHeaders.push({
+ name: "Set-Cookie",
+ value: "reqcookie=changedvalue",
+ });
+
+ return {
+ responseHeaders: newHeaders,
+ };
+ };
+ browser.webRequest.onHeadersReceived.addListener(
+ fifthOnHeadersRecievedListener,
+ filter,
+ headersReceivedInfoSpec
+ );
+
+ await testCookiesWithFile(
+ "file_webrequestblocking_set_cookie.html",
+ undefined,
+ async () => {
+ const currentCookies = await browser.cookies.getAll({});
+ browser.test.assertEq(1, currentCookies.length, `1 cookie was set`);
+
+ const cookieName = "reqcookie";
+ const expectedCookieValue = "changedvalue";
+ const fetchedCookie = await browser.cookies.getAll({
+ name: cookieName,
+ });
+
+ browser.test.assertEq(
+ 1,
+ fetchedCookie.length,
+ `Found 1 cookie with name "${cookieName}"`
+ );
+ browser.test.assertEq(
+ expectedCookieValue,
+ fetchedCookie[0] && fetchedCookie[0].value,
+ `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"`
+ );
+ }
+ );
+ browser.webRequest.onHeadersReceived.removeListener(
+ fifthOnHeadersRecievedListener
+ );
+
+ browser.test.notifyPass("cookie modifying extension");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: [
+ "browsingData",
+ "cookies",
+ "webNavigation",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ ],
+ },
+ background,
+ });
+
+ let contentPage = null;
+ extension.onMessage("load-url", async url => {
+ ok(!contentPage, "Should have no content page to unload");
+ contentPage = await ExtensionTestUtils.loadContentPage(url);
+ extension.sendMessage("url-loaded");
+ });
+ extension.onMessage("unload-url", async () => {
+ await contentPage.close();
+ contentPage = null;
+ extension.sendMessage("url-unloaded");
+ });
+
+ await extension.startup();
+ await extension.awaitFinish("cookie modifying extension");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js
new file mode 100644
index 0000000000..0528d97298
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js
@@ -0,0 +1,603 @@
+"use strict";
+
+// Delay loading until createAppInfo is called and setup.
+ChromeUtils.defineModuleGetter(
+ this,
+ "AddonManager",
+ "resource://gre/modules/AddonManager.jsm"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+// The app and platform version here should be >= of the version set in the extensions.webExtensionsMinPlatformVersion preference,
+// otherwise test_persistent_listener_after_staged_update will fail because no compatible updates will be found.
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+let {
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+);
+
+function trackEvents(wrapper) {
+ let events = new Map();
+ for (let event of ["background-page-event", "start-background-page"]) {
+ events.set(event, false);
+ wrapper.extension.once(event, () => events.set(event, true));
+ }
+ return events;
+}
+
+async function testPersistentRequestStartup(extension, events, expect) {
+ equal(
+ events.get("background-page-event"),
+ expect.background,
+ "Should have gotten a background page event"
+ );
+ equal(
+ events.get("start-background-page"),
+ false,
+ "Background page should not be started"
+ );
+
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ await ExtensionParent.browserPaintedPromise;
+
+ equal(
+ events.get("start-background-page"),
+ expect.delayedStart,
+ "Should have gotten start-background-page event"
+ );
+
+ if (expect.request) {
+ await extension.awaitMessage("got-request");
+ ok(true, "Background page loaded and received webRequest event");
+ }
+}
+
+// Test that a non-blocking listener during startup does not immediately
+// start the background page, but the event is queued until the background
+// page is started.
+add_task(async function test_1() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["webRequest", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ },
+ });
+
+ await extension.startup();
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let events = trackEvents(extension);
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: true,
+ delayedStart: true,
+ request: true,
+ });
+
+ await extension.unload();
+
+ await promiseShutdownManager();
+});
+
+// Tests that filters are handled properly: if we have a blocking listener
+// with a filter, a request that does not match the filter does not get
+// suspended and does not start the background page.
+add_task(async function test_2() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: [
+ "webRequest",
+ "webRequestBlocking",
+ "http://test1.example.com/",
+ ],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.fail("Listener should not have been called");
+ },
+ { urls: ["http://test1.example.com/*"] },
+ ["blocking"]
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let events = trackEvents(extension);
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: false,
+ delayedStart: false,
+ request: false,
+ });
+
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ await extension.awaitMessage("ready");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// Tests that moving permission to optional retains permission and that the
+// persistent listeners are used as expected.
+add_task(async function test_persistent_listener_after_sideload_upgrade() {
+ let id = "permission-sideload-upgrade@test";
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id } },
+ permissions: ["webRequest", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ },
+ };
+ let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData);
+
+ let extension = ExtensionTestUtils.expectExtension(id);
+ await AddonTestUtils.manuallyInstall(xpi);
+ await promiseStartupManager();
+ await extension.awaitStartup();
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("got-request");
+
+ await promiseShutdownManager();
+
+ // Prepare a sideload update for the extension.
+ extensionData.manifest.version = "2.0";
+ extensionData.manifest.permissions = ["http://example.com/"];
+ extensionData.manifest.optional_permissions = ["webRequest"];
+ xpi = AddonTestUtils.createTempWebExtensionFile(extensionData);
+ await AddonTestUtils.manuallyInstall(xpi);
+
+ ExtensionParent._resetStartupPromises();
+ await promiseStartupManager();
+ await extension.awaitStartup();
+ let events = trackEvents(extension);
+
+ // Verify webRequest permission.
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(policy.hasPermission("webRequest"), "addon webRequest permission added");
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: true,
+ delayedStart: true,
+ request: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// Utility to install builtin addon
+async function installBuiltinExtension(extensionData) {
+ let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData);
+
+ // The built-in location requires a resource: URL that maps to a
+ // jar: or file: URL. This would typically be something bundled
+ // into omni.ja but for testing we just use a temp file.
+ let base = Services.io.newURI(`jar:file:${xpi.path}!/`);
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+ resProto.setSubstitution("ext-test", base);
+ return AddonManager.installBuiltinAddon("resource://ext-test/");
+}
+
+function promisePostponeInstall(install) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onInstallFailed: () => {
+ install.removeListener(listener);
+ reject(new Error("extension installation should not have failed"));
+ },
+ onInstallEnded: () => {
+ install.removeListener(listener);
+ reject(
+ new Error(
+ `extension installation should not have ended for ${install.addon.id}`
+ )
+ );
+ },
+ onInstallPostponed: () => {
+ install.removeListener(listener);
+ resolve();
+ },
+ };
+
+ install.addListener(listener);
+ install.install();
+ });
+}
+
+// Tests that moving permission to optional retains permission and that the
+// persistent listeners are used as expected.
+add_task(
+ async function test_persistent_listener_after_builtin_location_upgrade() {
+ let id = "permission-builtin-upgrade@test";
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: { gecko: { id } },
+ permissions: ["webRequest", "http://example.com/"],
+ },
+
+ async background() {
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.sendMessage("postponed");
+ });
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ },
+ };
+ await promiseStartupManager();
+ // If we use an extension wrapper via ExtensionTestUtils.expectExtension
+ // it will continue to handle messages even after the update, resulting
+ // in errors when it receives additional messages without any awaitMessage.
+ let promiseExtension = AddonTestUtils.promiseWebExtensionStartup(id);
+ await installBuiltinExtension(extensionData);
+ let extv1 = await promiseExtension;
+
+ // Prepare an update for the extension.
+ extensionData.manifest.version = "2.0";
+ let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData);
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ // Install the update and wait for the onUpdateAvailable event to complete.
+ let promiseUpdate = new Promise(resolve =>
+ extv1.once("test-message", (kind, msg) => {
+ if (msg == "postponed") {
+ resolve();
+ }
+ })
+ );
+ await Promise.all([promisePostponeInstall(install), promiseUpdate]);
+ await promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ ExtensionParent._resetStartupPromises();
+ let extension = ExtensionTestUtils.expectExtension(id);
+ await promiseStartupManager();
+ await extension.awaitStartup();
+ let events = trackEvents(extension);
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: true,
+ delayedStart: true,
+ request: true,
+ });
+
+ await extension.unload();
+
+ // remove the builtin addon which will have restarted now.
+ let addon = await AddonManager.getAddonByID(id);
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+ }
+);
+
+// Tests that moving permission to optional during a staged upgrade retains permission
+// and that the persistent listeners are used as expected.
+add_task(async function test_persistent_listener_after_staged_upgrade() {
+ AddonManager.checkUpdateSecurity = false;
+ let id = "persistent-staged-upgrade@test";
+
+ // register an update file.
+ AddonTestUtils.registerJSON(server, "/test_update.json", {
+ addons: {
+ "persistent-staged-upgrade@test": {
+ updates: [
+ {
+ version: "2.0",
+ update_link:
+ "http://example.com/addons/test_settings_staged_restart.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "2.0",
+ applications: {
+ gecko: { id, update_url: `http://example.com/test_update.json` },
+ },
+ permissions: ["http://example.com/"],
+ optional_permissions: ["webRequest"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ // Force a staged updated.
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ if (details && details.version) {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.sendMessage("delay");
+ }
+ });
+ },
+ };
+
+ // Prepare the update first.
+ server.registerFile(
+ `/addons/test_settings_staged_restart.xpi`,
+ AddonTestUtils.createTempWebExtensionFile(extensionData)
+ );
+
+ // Prepare the extension that will be updated.
+ extensionData.manifest.version = "1.0";
+ extensionData.manifest.permissions = ["webRequest", "http://example.com/"];
+ delete extensionData.manifest.optional_permissions;
+
+ await promiseStartupManager();
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("got-request");
+ ok(true, "Initial version received webRequest event");
+
+ let addon = await AddonManager.getAddonByID(id);
+ Assert.equal(addon.version, "1.0", "1.0 is loaded");
+
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ Assert.ok(install, `install is available ${update.error}`);
+
+ await AddonTestUtils.promiseCompleteAllInstalls([install]);
+
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "update is staged for install"
+ );
+ await extension.awaitMessage("delay");
+
+ await promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ ExtensionParent._resetStartupPromises();
+ await promiseStartupManager();
+ await extension.awaitStartup();
+ let events = trackEvents(extension);
+
+ // Verify webRequest permission.
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(policy.hasPermission("webRequest"), "addon webRequest permission added");
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: true,
+ delayedStart: true,
+ request: true,
+ });
+
+ await extension.unload();
+ await promiseShutdownManager();
+ AddonManager.checkUpdateSecurity = true;
+});
+
+// Tests that removing the permission releases the persistent listener.
+add_task(async function test_persistent_listener_after_permission_removal() {
+ let id = "persistent-staged-remove@test";
+
+ // register an update file.
+ AddonTestUtils.registerJSON(server, "/test_remove.json", {
+ addons: {
+ "persistent-staged-remove@test": {
+ updates: [
+ {
+ version: "2.0",
+ update_link:
+ "http://example.com/addons/test_settings_staged_remove.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "2.0",
+ applications: {
+ gecko: { id, update_url: `http://example.com/test_remove.json` },
+ },
+ permissions: ["tabs", "http://example.com/"],
+ },
+
+ background() {
+ browser.test.sendMessage("loaded");
+ },
+ };
+
+ // Prepare the update first.
+ server.registerFile(
+ `/addons/test_settings_staged_remove.xpi`,
+ AddonTestUtils.createTempWebExtensionFile(extensionData)
+ );
+
+ await promiseStartupManager();
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ applications: {
+ gecko: { id, update_url: `http://example.com/test_remove.json` },
+ },
+ permissions: ["webRequest", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.sendMessage("got-request");
+ },
+ { urls: ["http://example.com/data/file_sample.html"] }
+ );
+ // Force a staged updated.
+ browser.runtime.onUpdateAvailable.addListener(async details => {
+ if (details && details.version) {
+ // This should be the version of the pending update.
+ browser.test.assertEq("2.0", details.version, "correct version");
+ browser.test.sendMessage("delay");
+ }
+ });
+ },
+ });
+
+ await extension.startup();
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+ await extension.awaitMessage("got-request");
+ ok(true, "Initial version received webRequest event");
+
+ let addon = await AddonManager.getAddonByID(id);
+ Assert.equal(addon.version, "1.0", "1.0 is loaded");
+
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ Assert.ok(install, `install is available ${update.error}`);
+
+ await AddonTestUtils.promiseCompleteAllInstalls([install]);
+
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "update is staged for install"
+ );
+ await extension.awaitMessage("delay");
+
+ await promiseShutdownManager();
+
+ // restarting allows upgrade to proceed
+ await promiseStartupManager();
+ let events = trackEvents(extension);
+ await extension.awaitStartup();
+
+ // Verify webRequest permission.
+ let policy = WebExtensionPolicy.getByID(id);
+ ok(
+ !policy.hasPermission("webRequest"),
+ "addon webRequest permission removed"
+ );
+
+ await ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ await testPersistentRequestStartup(extension, events, {
+ background: false,
+ delayedStart: false,
+ request: false,
+ });
+
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+
+ await extension.awaitMessage("loaded");
+ ok(true, "Background page loaded");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js
new file mode 100644
index 0000000000..c8c18fcf19
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js
@@ -0,0 +1,84 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "43"
+);
+
+let {
+ promiseRestartManager,
+ promiseShutdownManager,
+ promiseStartupManager,
+} = AddonTestUtils;
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+Services.prefs.setBoolPref(
+ "extensions.webextensions.background-delayed-startup",
+ true
+);
+
+// Test that a blocking listener that uses filterResponseData() works
+// properly (i.e., that the delayed call to registerTraceableChannel
+// works properly).
+add_task(async function test_StreamFilter_at_restart() {
+ const DATA = `<!DOCTYPE html>
+<html>
+<body>
+ <h1>This is a modified page</h1>
+</body>
+</html>`;
+
+ function background(data) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstop = () => {
+ let encoded = new TextEncoder("utf-8").encode(data);
+ filter.write(encoded);
+ filter.close();
+ };
+ },
+ { urls: ["http://example.com/data/file_sample.html"] },
+ ["blocking"]
+ );
+ }
+
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+
+ background: `(${background})(${uneval(DATA)})`,
+ });
+
+ await extension.startup();
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ let dataPromise = ExtensionTestUtils.fetch(
+ "http://example.com/",
+ "http://example.com/data/file_sample.html"
+ );
+
+ Services.obs.notifyObservers(null, "browser-delayed-startup-finished");
+ let data = await dataPromise;
+
+ equal(
+ data,
+ DATA,
+ "Stream filter was properly installed for a load during startup"
+ );
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js
new file mode 100644
index 0000000000..296bee3685
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js
@@ -0,0 +1,49 @@
+"use strict";
+
+const BASE = "http://example.com/data/";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_stylesheet_cache() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ const SHEET_URI = "http://example.com/data/file_stylesheet_cache.css";
+ let firstFound = false;
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ details.url,
+ firstFound ? SHEET_URI + "?2" : SHEET_URI
+ );
+ firstFound = true;
+ browser.test.sendMessage("stylesheet found");
+ },
+ { urls: ["<all_urls>"], types: ["stylesheet"] },
+ ["blocking"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ let cp = await ExtensionTestUtils.loadContentPage(
+ BASE + "file_stylesheet_cache.html"
+ );
+
+ await extension.awaitMessage("stylesheet found");
+
+ // Need to use the same ContentPage so that the remote process the page ends
+ // up in is the same.
+ await cp.loadURL(BASE + "file_stylesheet_cache_2.html");
+
+ await extension.awaitMessage("stylesheet found");
+
+ await cp.close();
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js
new file mode 100644
index 0000000000..48505c9a1b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js
@@ -0,0 +1,294 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+const BASE_URL = "http://example.com";
+const FETCH_ORIGIN = "http://example.com/dummy";
+
+server.registerPathHandler("/return_headers.sjs", (request, response) => {
+ response.setHeader("Content-Type", "text/plain", false);
+
+ let headers = {};
+ for (let { data: header } of request.headers) {
+ headers[header.toLowerCase()] = request.getHeader(header);
+ }
+
+ response.write(JSON.stringify(headers));
+});
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_suspend() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`],
+ },
+
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ // Make sure that returning undefined or a promise that resolves to
+ // undefined does not break later handlers.
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ return Promise.resolve();
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ let requestHeaders = details.requestHeaders.concat({
+ name: "Foo",
+ value: "Bar",
+ });
+
+ return new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, 500);
+ }).then(() => {
+ return { requestHeaders };
+ });
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking", "requestHeaders"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ let headers = JSON.parse(
+ await ExtensionTestUtils.fetch(
+ FETCH_ORIGIN,
+ `${BASE_URL}/return_headers.sjs`
+ )
+ );
+
+ equal(
+ headers.foo,
+ "Bar",
+ "Request header was correctly set on suspended request"
+ );
+
+ await extension.unload();
+});
+
+// Test that requests that were canceled while suspended for a blocking
+// listener are correctly resumed.
+add_task(async function test_error_resume() {
+ let observer = channel => {
+ if (
+ channel instanceof Ci.nsIHttpChannel &&
+ channel.URI.spec === "http://example.com/dummy"
+ ) {
+ Services.obs.removeObserver(observer, "http-on-before-connect");
+
+ // Wait until the next tick to make sure this runs after WebRequest observers.
+ Promise.resolve().then(() => {
+ channel.cancel(Cr.NS_BINDING_ABORTED);
+ });
+ }
+ };
+
+ Services.obs.addObserver(observer, "http-on-before-connect");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`],
+ },
+
+ background() {
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ details => {
+ browser.test.log(`onBeforeSendHeaders({url: ${details.url}})`);
+
+ if (details.url === "http://example.com/dummy") {
+ browser.test.sendMessage("got-before-send-headers");
+ }
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.log(`onErrorOccurred({url: ${details.url}})`);
+
+ if (details.url === "http://example.com/dummy") {
+ browser.test.sendMessage("got-error-occurred");
+ }
+ },
+ { urls: ["<all_urls>"] }
+ );
+ },
+ });
+
+ await extension.startup();
+
+ try {
+ await ExtensionTestUtils.fetch(FETCH_ORIGIN, `${BASE_URL}/dummy`);
+ ok(false, "Fetch should have failed.");
+ } catch (e) {
+ ok(true, "Got expected error.");
+ }
+
+ await extension.awaitMessage("got-before-send-headers");
+ await extension.awaitMessage("got-error-occurred");
+
+ // Wait for the next tick so the onErrorRecurred response can be
+ // processed before shutting down the extension.
+ await new Promise(resolve => executeSoon(resolve));
+
+ await extension.unload();
+});
+
+// Test that response header modifications take effect before onStartRequest fires.
+add_task(async function test_set_responseHeaders() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "http://example.com/"],
+ },
+
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ details => {
+ browser.test.log(`onHeadersReceived({url: ${details.url}})`);
+
+ details.responseHeaders.push({ name: "foo", value: "bar" });
+
+ return { responseHeaders: details.responseHeaders };
+ },
+ { urls: ["http://example.com/?modify_headers"] },
+ ["blocking", "responseHeaders"]
+ );
+ },
+ });
+
+ await extension.startup();
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ let resolveHeaderPromise;
+ let headerPromise = new Promise(resolve => {
+ resolveHeaderPromise = resolve;
+ });
+ {
+ const { Services } = ChromeUtils.import(
+ "resource://gre/modules/Services.jsm"
+ );
+
+ let ssm = Services.scriptSecurityManager;
+
+ let channel = NetUtil.newChannel({
+ uri: "http://example.com/?modify_headers",
+ loadingPrincipal: ssm.createContentPrincipalFromOrigin(
+ "http://example.com"
+ ),
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST,
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ });
+
+ channel.asyncOpen({
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onStartRequest(request) {
+ request.QueryInterface(Ci.nsIHttpChannel);
+
+ try {
+ resolveHeaderPromise(request.getResponseHeader("foo"));
+ } catch (e) {
+ resolveHeaderPromise(null);
+ }
+ request.cancel(Cr.NS_BINDING_ABORTED);
+ },
+
+ onStopRequest() {},
+
+ onDataAvailable() {
+ throw new Components.Exception("", Cr.NS_ERROR_FAILURE);
+ },
+ });
+ }
+
+ let headerValue = await headerPromise;
+ equal(headerValue, "bar", "Expected Foo header value");
+
+ await extension.unload();
+});
+
+// Test that exceptions raised from a blocking webRequest listener that returns
+// a promise are logged as expected.
+add_task(async function test_logged_error_on_promise_result() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`],
+ },
+
+ background() {
+ async function onBeforeRequest() {
+ throw new Error("Expected webRequest exception from a promise result");
+ }
+
+ let exceptionRaised = false;
+
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ if (exceptionRaised) {
+ return;
+ }
+
+ // We only need to raise the exception once.
+ exceptionRaised = true;
+ return onBeforeRequest();
+ },
+ {
+ urls: ["http://example.com/*"],
+ types: ["main_frame"],
+ },
+ ["blocking"]
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ browser.test.sendMessage("web-request-event-received");
+ },
+ {
+ urls: ["http://example.com/*"],
+ types: ["main_frame"],
+ },
+ ["blocking"]
+ );
+ },
+ });
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy`
+ );
+ await extension.awaitMessage("web-request-event-received");
+ await contentPage.close();
+ });
+
+ ok(
+ messages.some(msg =>
+ /Expected webRequest exception from a promise result/.test(msg.message)
+ ),
+ "Got expected console message"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js
new file mode 100644
index 0000000000..9c2296c7da
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js
@@ -0,0 +1,33 @@
+"use strict";
+
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+
+/**
+ * If this test fails, likely nsIClassifiedChannel has added or changed a
+ * CLASSIFIED_* flag. Those changes must be in sync with
+ * ChannelWrapper.webidl/cpp and the web_request.json schema file.
+ */
+add_task(async function test_webrequest_url_classification_enum() {
+ // use normalizeManifest to get the schema loaded.
+ await ExtensionTestUtils.normalizeManifest({ permissions: ["webRequest"] });
+
+ let ns = Schemas.getNamespace("webRequest");
+ let schema_enum = ns.get("UrlClassificationFlags").enumeration;
+ ok(
+ !!schema_enum.length,
+ `UrlClassificationFlags: ${JSON.stringify(schema_enum)}`
+ );
+
+ let prefix = /^(?:CLASSIFIED_)/;
+ let entries = 0;
+ for (let c of Object.keys(Ci.nsIClassifiedChannel).filter(name =>
+ prefix.test(name)
+ )) {
+ let entry = c.replace(prefix, "").toLowerCase();
+ if (!entry.startsWith("socialtracking")) {
+ ok(schema_enum.includes(entry), `schema ${entry} is in IDL`);
+ entries++;
+ }
+ }
+ equal(schema_enum.length, entries, "same number of entries");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js
new file mode 100644
index 0000000000..9710aa5990
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js
@@ -0,0 +1,41 @@
+"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("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_userContextId_webrequest() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ async details => {
+ browser.test.assertEq(
+ details.cookieStoreId,
+ "firefox-container-2",
+ "cookieStoreId is set"
+ );
+ browser.test.notifyPass("webRequest");
+ },
+ { urls: ["<all_urls>"] },
+ ["blocking"]
+ );
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { userContextId: 2 }
+ );
+ await extension.awaitFinish("webRequest");
+
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js
new file mode 100644
index 0000000000..35b713e59b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js
@@ -0,0 +1,95 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_webRequest_viewsource() {
+ function background(serverPort) {
+ browser.proxy.onRequest.addListener(
+ details => {
+ if (details.url === `http://example.com:${serverPort}/dummy`) {
+ browser.test.assertTrue(
+ true,
+ "viewsource protocol worked in proxy request"
+ );
+ browser.test.sendMessage("proxied");
+ }
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ `http://example.com:${serverPort}/redirect`,
+ details.url,
+ "viewsource protocol worked in webRequest"
+ );
+ browser.test.sendMessage("viewed");
+ return { redirectUrl: `http://example.com:${serverPort}/dummy` };
+ },
+ { urls: ["http://example.com/redirect"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ `http://example.com:${serverPort}/dummy`,
+ details.url,
+ "viewsource protocol worked in webRequest"
+ );
+ browser.test.sendMessage("redirected");
+ return { cancel: true };
+ },
+ { urls: ["http://example.com/dummy"] },
+ ["blocking"]
+ );
+
+ browser.webRequest.onCompleted.addListener(
+ details => {
+ // If cancel fails we get onCompleted.
+ browser.test.fail("onCompleted received");
+ },
+ { urls: ["http://example.com/dummy"] }
+ );
+
+ browser.webRequest.onErrorOccurred.addListener(
+ details => {
+ browser.test.assertEq(
+ details.error,
+ "NS_ERROR_ABORT",
+ "request cancelled"
+ );
+ browser.test.sendMessage("cancelled");
+ },
+ { urls: ["http://example.com/dummy"] }
+ );
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background: `(${background})(${server.identity.primaryPort})`,
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `view-source:http://example.com:${server.identity.primaryPort}/redirect`
+ );
+
+ await Promise.all([
+ extension.awaitMessage("proxied"),
+ extension.awaitMessage("viewed"),
+ extension.awaitMessage("redirected"),
+ extension.awaitMessage("cancelled"),
+ ]);
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js
new file mode 100644
index 0000000000..ccb46eb4db
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js
@@ -0,0 +1,144 @@
+"use strict";
+
+const server = createHttpServer();
+const BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`;
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+server.registerPathHandler("/redir", (request, response) => {
+ response.setStatusLine(request.httpVersion, 303, "See Other");
+ response.setHeader("Location", `${BASE_URL}/dummy`);
+});
+
+async function testViewSource(viewSourceUrl) {
+ function background(BASE_URL) {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(`${BASE_URL}/dummy`, details.url, "expected URL");
+ browser.test.assertEq("main_frame", details.type, "details.type");
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstart = () => {
+ filter.write(new TextEncoder().encode("PREFIX_"));
+ };
+ filter.ondata = event => {
+ filter.write(event.data);
+ };
+ filter.onstop = () => {
+ filter.write(new TextEncoder().encode("_SUFFIX"));
+ filter.disconnect();
+ browser.test.notifyPass("filter_end");
+ };
+ filter.onerror = () => {
+ browser.test.fail(`Unexpected error: ${filter.error}`);
+ browser.test.notifyFail("filter_end");
+ };
+ },
+ { urls: ["*://*/dummy"] },
+ ["blocking"]
+ );
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(`${BASE_URL}/redir`, details.url, "Got redirect");
+
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstop = () => {
+ filter.disconnect();
+ browser.test.fail("Unexpected onstop for redirect");
+ browser.test.sendMessage("redirect_done");
+ };
+ filter.onerror = () => {
+ browser.test.assertEq(
+ // TODO bug 1683862: must be "Channel redirected", but it is not
+ // because document requests are handled differently compared to
+ // other requests, see the comment at the top of
+ // test_ext_webRequest_redirect_StreamFilter.js.
+ "Invalid request ID",
+ filter.error,
+ "Expected error in filter.onerror"
+ );
+ browser.test.sendMessage("redirect_done");
+ };
+ },
+ { urls: ["*://*/redir"] },
+ ["blocking"]
+ );
+ }
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://*/*"],
+ },
+ background: `(${background})(${JSON.stringify(BASE_URL)})`,
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(viewSourceUrl);
+ if (viewSourceUrl.includes("/redir")) {
+ info("Awaiting observed completion of redirection request");
+ await extension.awaitMessage("redirect_done");
+ }
+ info("Awaiting completion of StreamFilter on request");
+ await extension.awaitFinish("filter_end");
+ let contentText = await contentPage.spawn(null, () => {
+ return this.content.document.body.textContent;
+ });
+ equal(contentText, "PREFIX_ok_SUFFIX", "view-source response body");
+ await contentPage.close();
+ await extension.unload();
+}
+
+add_task(async function test_StreamFilter_viewsource() {
+ await testViewSource(`view-source:${BASE_URL}/dummy`);
+});
+
+add_task(async function test_StreamFilter_viewsource_redirect_target() {
+ await testViewSource(`view-source:${BASE_URL}/redir`);
+});
+
+// Sanity check: nothing bad happens if the underlying response is aborted.
+add_task(async function test_StreamFilter_viewsource_cancel() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "*://*/*"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ let filter = browser.webRequest.filterResponseData(details.requestId);
+ filter.onstart = () => {
+ filter.disconnect();
+ browser.test.fail("Unexpected filter.onstart");
+ browser.test.notifyFail("filter_end");
+ };
+ filter.onerror = () => {
+ browser.test.assertEq("Invalid request ID", filter.error, "Error?");
+ browser.test.notifyPass("filter_end");
+ };
+ },
+ { urls: ["*://*/dummy"] },
+ ["blocking"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ () => {
+ browser.test.log("Intentionally canceling view-source request");
+ return { cancel: true };
+ },
+ { urls: ["*://*/dummy"] },
+ ["blocking"]
+ );
+ },
+ });
+ await extension.startup();
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ `${BASE_URL}/dummy`
+ );
+ await extension.awaitFinish("filter_end");
+ let contentText = await contentPage.spawn(null, () => {
+ return this.content.document.body.textContent;
+ });
+ equal(contentText, "", "view-source request should have been canceled");
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js
new file mode 100644
index 0000000000..7e34d2b0b3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js
@@ -0,0 +1,55 @@
+"use strict";
+
+const HOSTS = new Set(["example.com"]);
+
+const server = createHttpServer({ hosts: HOSTS });
+
+server.registerPathHandler("/dummy", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("ok");
+});
+
+add_task(async function test_webSocket() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ browser.test.assertEq(
+ "ws:",
+ new URL(details.url).protocol,
+ "ws protocol worked"
+ );
+ browser.test.notifyPass("websocket");
+ },
+ { urls: ["ws://example.com/*"] },
+ ["blocking"]
+ );
+
+ browser.test.onMessage.addListener(msg => {
+ let ws = new WebSocket("ws://example.com/dummy");
+ ws.onopen = e => {
+ ws.send("data");
+ };
+ ws.onclose = e => {};
+ ws.onerror = e => {};
+ ws.onmessage = e => {
+ ws.close();
+ };
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ extension.sendMessage("go");
+ await extension.awaitFinish("websocket");
+
+ // Wait until the next tick so that listener responses are processed
+ // before we unload.
+ await new Promise(executeSoon);
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js
new file mode 100644
index 0000000000..a1a387b5a4
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js
@@ -0,0 +1,150 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com", "example.org"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+let image = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0))
+ .buffer;
+
+async function testImageLoading(src, expectedAction) {
+ let imageLoadingPromise = new Promise((resolve, reject) => {
+ let cleanupListeners;
+ let testImage = document.createElement("img");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testImage.wrappedJSObject.setAttribute("src", src);
+
+ let loadListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "loaded");
+ };
+
+ let errorListener = () => {
+ cleanupListeners();
+ resolve(expectedAction === "blocked");
+ };
+
+ cleanupListeners = () => {
+ testImage.removeEventListener("load", loadListener);
+ testImage.removeEventListener("error", errorListener);
+ };
+
+ testImage.addEventListener("load", loadListener);
+ testImage.addEventListener("error", errorListener);
+
+ document.body.appendChild(testImage);
+ });
+
+ let success = await imageLoadingPromise;
+ browser.runtime.sendMessage({
+ name: "image-loading",
+ expectedAction,
+ success,
+ });
+}
+
+add_task(async function test_web_accessible_resources_csp() {
+ function background() {
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ if (msg.name === "image-loading") {
+ browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`);
+ browser.test.sendMessage(`image-${msg.expectedAction}`);
+ } else {
+ browser.test.sendMessage(msg);
+ }
+ });
+
+ browser.test.sendMessage("background-ready");
+ }
+
+ function content() {
+ window.addEventListener("message", function rcv(event) {
+ browser.runtime.sendMessage("script-ran");
+ window.removeEventListener("message", rcv);
+ });
+
+ testImageLoading(browser.extension.getURL("image.png"), "loaded");
+
+ let testScriptElement = document.createElement("script");
+ // Set the src via wrappedJSObject so the load is triggered with the
+ // content page's principal rather than ours.
+ testScriptElement.wrappedJSObject.setAttribute(
+ "src",
+ browser.extension.getURL("test_script.js")
+ );
+ document.head.appendChild(testScriptElement);
+ browser.runtime.sendMessage("script-loaded");
+ }
+
+ function testScript() {
+ window.postMessage("test-script-loaded", "*");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/*/file_csp.html"],
+ run_at: "document_end",
+ js: ["content_script_helper.js", "content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["image.png", "test_script.js"],
+ },
+ background,
+ files: {
+ "content_script_helper.js": `${testImageLoading}`,
+ "content_script.js": content,
+ "test_script.js": testScript,
+ "image.png": IMAGE_ARRAYBUFFER,
+ },
+ });
+
+ await Promise.all([
+ extension.startup(),
+ extension.awaitMessage("background-ready"),
+ ]);
+
+ let page = await ExtensionTestUtils.loadContentPage(
+ `http://example.com/data/file_sample.html`
+ );
+ await page.spawn(null, () => {
+ let { Services } = ChromeUtils.import(
+ "resource://gre/modules/Services.jsm"
+ );
+ this.obs = {
+ events: [],
+ observe(subject, topic, data) {
+ this.events.push(subject.QueryInterface(Ci.nsIURI).spec);
+ },
+ done() {
+ Services.obs.removeObserver(this, "csp-on-violate-policy");
+ return this.events;
+ },
+ };
+ Services.obs.addObserver(this.obs, "csp-on-violate-policy");
+ content.location.href = "http://example.com/data/file_csp.html";
+ });
+
+ await Promise.all([
+ extension.awaitMessage("image-loaded"),
+ extension.awaitMessage("script-loaded"),
+ extension.awaitMessage("script-ran"),
+ ]);
+
+ let events = await page.spawn(null, () => this.obs.done());
+ equal(events.length, 2, "Two items were rejected by CSP");
+ for (let url of events) {
+ ok(
+ url.includes("file_image_bad.png") || url.includes("file_script_bad.js"),
+ `Expected file: ${url} rejected by CSP`
+ );
+ }
+
+ await page.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js
new file mode 100644
index 0000000000..640e5be0de
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js
@@ -0,0 +1,72 @@
+"use strict";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function test_xhr_capabilities() {
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", browser.extension.getURL("bad.xml"));
+
+ browser.test.sendMessage("result", {
+ name: "Background script XHRs should not be privileged",
+ result: xhr.channel === undefined,
+ });
+
+ xhr.onload = () => {
+ browser.test.sendMessage("result", {
+ name: "Background script XHRs should not yield <parsererrors>",
+ result: xhr.responseXML === null,
+ });
+ };
+ xhr.send();
+ },
+
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["http://example.com/data/file_sample.html"],
+ js: ["content_script.js"],
+ },
+ ],
+ web_accessible_resources: ["bad.xml"],
+ },
+
+ files: {
+ "bad.xml": "<xml",
+ "content_script.js"() {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", browser.extension.getURL("bad.xml"));
+
+ browser.test.sendMessage("result", {
+ name: "Content script XHRs should not be privileged",
+ result: xhr.channel === undefined,
+ });
+
+ xhr.onload = () => {
+ browser.test.sendMessage("result", {
+ name: "Content script XHRs should not yield <parsererrors>",
+ result: xhr.responseXML === null,
+ });
+ };
+ xhr.send();
+ },
+ },
+ });
+
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+
+ // We expect four test results from the content/background scripts.
+ for (let i = 0; i < 4; ++i) {
+ let result = await extension.awaitMessage("result");
+ ok(result.result, result.name);
+ }
+
+ await contentPage.close();
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js
new file mode 100644
index 0000000000..9e168107ff
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js
@@ -0,0 +1,99 @@
+"use strict";
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { ExtensionPermissions } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionPermissions.jsm"
+);
+
+add_task(async function setup() {
+ // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this
+ // test does not make sense with the legacy method (which will be removed in
+ // the above bug).
+ await ExtensionPermissions._uninit();
+});
+
+const GOOD_JSON_FILE = {
+ "wikipedia@search.mozilla.org": {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ },
+ "amazon@search.mozilla.org": {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ },
+ "doh-rollout@mozilla.org": {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ },
+};
+
+const BAD_JSON_FILE = {
+ "test@example.org": "what",
+};
+
+const BAD_FILE = "what is this { } {";
+
+const gOldSettingsJSON = do_get_profile().clone();
+gOldSettingsJSON.append("extension-preferences.json");
+
+async function test_file(json, extensionIds, expected, fileDeleted) {
+ await ExtensionPermissions._resetVersion();
+ await ExtensionPermissions._uninit();
+
+ await OS.File.writeAtomic(gOldSettingsJSON.path, json, {
+ encoding: "utf-8",
+ });
+
+ for (let extensionId of extensionIds) {
+ let permissions = await ExtensionPermissions.get(extensionId);
+ Assert.deepEqual(permissions, expected, "permissions match");
+ }
+
+ Assert.equal(
+ await OS.File.exists(gOldSettingsJSON.path),
+ !fileDeleted,
+ "old file was deleted"
+ );
+}
+
+add_task(async function test_migrate_good_json() {
+ let expected = {
+ permissions: ["internal:privateBrowsingAllowed"],
+ origins: [],
+ };
+
+ await test_file(
+ JSON.stringify(GOOD_JSON_FILE),
+ [
+ "wikipedia@search.mozilla.org",
+ "amazon@search.mozilla.org",
+ "doh-rollout@mozilla.org",
+ ],
+ expected,
+ /* fileDeleted */ true
+ );
+});
+
+add_task(async function test_migrate_bad_json() {
+ let expected = { permissions: [], origins: [] };
+
+ await test_file(
+ BAD_FILE,
+ ["test@example.org"],
+ expected,
+ /* fileDeleted */ false
+ );
+ await OS.File.remove(gOldSettingsJSON.path);
+});
+
+add_task(async function test_migrate_bad_file() {
+ let expected = { permissions: [], origins: [] };
+
+ await test_file(
+ JSON.stringify(BAD_JSON_FILE),
+ ["test2@example.org"],
+ expected,
+ /* fileDeleted */ false
+ );
+ await OS.File.remove(gOldSettingsJSON.path);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js
new file mode 100644
index 0000000000..54a24233e2
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js
@@ -0,0 +1,172 @@
+"use strict";
+
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+
+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";
+
+const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon";
+const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content";
+const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools";
+
+let schemaURLs = new Set();
+schemaURLs.add("chrome://extensions/content/schemas/experiments.json");
+
+// Helper class used to load the API modules similarly to the apiManager
+// defined in ExtensionParent.jsm.
+class FakeAPIManager extends ExtensionCommon.SchemaAPIManager {
+ constructor(processType = "main") {
+ super(processType, Schemas);
+ this.initialized = false;
+ }
+
+ getModuleJSONURLs() {
+ return Array.from(
+ Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES),
+ ({ value }) => value
+ );
+ }
+
+ async lazyInit() {
+ if (this.initialized) {
+ return;
+ }
+
+ this.initialized = true;
+
+ let modulesPromise = this.loadModuleJSON(this.getModuleJSONURLs());
+
+ let scriptURLs = [];
+ for (let { value } of Services.catMan.enumerateCategory(
+ CATEGORY_EXTENSION_SCRIPTS
+ )) {
+ scriptURLs.push(value);
+ }
+
+ 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.
+ await Schemas.load(BASE_SCHEMA).then(() => {
+ let promises = [];
+ for (let { value } of Services.catMan.enumerateCategory(
+ CATEGORY_EXTENSION_SCHEMAS
+ )) {
+ promises.push(Schemas.load(value));
+ }
+ for (let [url, { content }] of this.schemaURLs) {
+ promises.push(Schemas.load(url, content));
+ }
+ for (let url of schemaURLs) {
+ promises.push(Schemas.load(url));
+ }
+ return Promise.all(promises).then(() => {
+ Schemas.updateSharedSchemas();
+ });
+ });
+ }
+
+ async loadAllModules(reverseOrder = false) {
+ await this.lazyInit();
+
+ let apiModuleNames = Array.from(this.modules.keys())
+ .filter(moduleName => {
+ let moduleDesc = this.modules.get(moduleName);
+ return moduleDesc && !!moduleDesc.url;
+ })
+ .sort();
+
+ apiModuleNames = reverseOrder ? apiModuleNames.reverse() : apiModuleNames;
+
+ for (let apiModule of apiModuleNames) {
+ info(
+ `Loading apiModule ${apiModule}: ${this.modules.get(apiModule).url}`
+ );
+ await this.asyncLoadModule(apiModule);
+ }
+ }
+}
+
+// Specialized helper class used to test loading "child process" modules (similarly to the
+// SchemaAPIManagers sub-classes defined in ExtensionPageChild.jsm and ExtensionContent.jsm).
+class FakeChildProcessAPIManager extends FakeAPIManager {
+ constructor({ processType, categoryScripts }) {
+ super(processType, Schemas);
+
+ this.categoryScripts = categoryScripts;
+ }
+
+ async lazyInit() {
+ if (!this.initialized) {
+ this.initialized = true;
+ this.initGlobal();
+ for (let { value } of Services.catMan.enumerateCategory(
+ this.categoryScripts
+ )) {
+ await this.loadScript(value);
+ }
+ }
+ }
+}
+
+async function test_loading_api_modules(createAPIManager) {
+ let fakeAPIManager;
+
+ info("Load API modules in alphabetic order");
+
+ fakeAPIManager = createAPIManager();
+ await fakeAPIManager.loadAllModules();
+
+ info("Load API modules in reverse order");
+
+ fakeAPIManager = createAPIManager();
+ await fakeAPIManager.loadAllModules(true);
+}
+
+add_task(function test_loading_main_process_api_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeAPIManager();
+ });
+});
+
+add_task(function test_loading_extension_process_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeChildProcessAPIManager({
+ processType: "addon",
+ categoryScripts: CATEGORY_EXTENSION_SCRIPTS_ADDON,
+ });
+ });
+});
+
+add_task(function test_loading_devtools_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeChildProcessAPIManager({
+ processType: "devtools",
+ categoryScripts: CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS,
+ });
+ });
+});
+
+add_task(async function test_loading_content_process_modules() {
+ return test_loading_api_modules(() => {
+ return new FakeChildProcessAPIManager({
+ processType: "content",
+ categoryScripts: CATEGORY_EXTENSION_SCRIPTS_CONTENT,
+ });
+ });
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_converter.js b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js
new file mode 100644
index 0000000000..6729639cc9
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js
@@ -0,0 +1,146 @@
+"use strict";
+
+const convService = Cc["@mozilla.org/streamConverters;1"].getService(
+ Ci.nsIStreamConverterService
+);
+
+const UUID = "72b61ee3-aceb-476c-be1b-0822b036c9f1";
+const ADDON_ID = "test@web.extension";
+const URI = NetUtil.newURI(`moz-extension://${UUID}/file.css`);
+
+const FROM_TYPE = "application/vnd.mozilla.webext.unlocalized";
+const TO_TYPE = "text/css";
+
+function StringStream(string) {
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+
+ stream.data = string;
+ return stream;
+}
+
+// Initialize the policy service with a stub localizer for our
+// add-on ID.
+add_task(async function init() {
+ let policy = new WebExtensionPolicy({
+ id: ADDON_ID,
+ mozExtensionHostname: UUID,
+ baseURL: "file:///",
+
+ allowedOrigins: new MatchPatternSet([]),
+
+ localizeCallback(string) {
+ return string.replace(/__MSG_(.*?)__/g, "<localized-$1>");
+ },
+ });
+
+ policy.active = true;
+
+ registerCleanupFunction(() => {
+ policy.active = false;
+ });
+});
+
+// Test that the synchronous converter works as expected with a
+// simple string.
+add_task(async function testSynchronousConvert() {
+ let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz");
+
+ let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI);
+
+ let result = NetUtil.readInputStreamToString(
+ resultStream,
+ resultStream.available()
+ );
+
+ equal(result, "Foo <localized-xxx> bar <localized-yyy> baz");
+});
+
+// Test that the asynchronous converter works as expected with input
+// split into multiple chunks, and a boundary in the middle of a
+// replacement token.
+add_task(async function testAsyncConvert() {
+ let listener;
+ let awaitResult = new Promise((resolve, reject) => {
+ listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+
+ onDataAvailable(request, inputStream, offset, count) {
+ this.resultParts.push(
+ NetUtil.readInputStreamToString(inputStream, count)
+ );
+ },
+
+ onStartRequest() {
+ ok(!("resultParts" in this));
+ this.resultParts = [];
+ },
+
+ onStopRequest(request, context, statusCode) {
+ if (!Components.isSuccessCode(statusCode)) {
+ reject(new Error(statusCode));
+ }
+
+ resolve(this.resultParts.join("\n"));
+ },
+ };
+ });
+
+ let parts = ["Foo __MSG_x", "xx__ bar __MSG_yyy__ baz"];
+
+ let converter = convService.asyncConvertData(
+ FROM_TYPE,
+ TO_TYPE,
+ listener,
+ URI
+ );
+ converter.onStartRequest(null, null);
+
+ for (let part of parts) {
+ converter.onDataAvailable(null, StringStream(part), 0, part.length);
+ }
+
+ converter.onStopRequest(null, null, Cr.NS_OK);
+
+ let result = await awaitResult;
+ equal(result, "Foo <localized-xxx> bar <localized-yyy> baz");
+});
+
+// Test that attempting to initialize a converter with the URI of a
+// nonexistent WebExtension fails.
+add_task(async function testInvalidUUID() {
+ let uri = NetUtil.newURI(
+ "moz-extension://eb4f3be8-41c9-4970-aa6d-b84d1ecc02b2/file.css"
+ );
+ let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz");
+
+ // Assert.throws raise a TypeError exception when the expected param
+ // is an arrow function. (See Bug 1237961 for rationale)
+ let expectInvalidContextException = function(e) {
+ return e.result === Cr.NS_ERROR_INVALID_ARG && /Invalid context/.test(e);
+ };
+
+ Assert.throws(() => {
+ convService.convert(stream, FROM_TYPE, TO_TYPE, uri);
+ }, expectInvalidContextException);
+
+ Assert.throws(() => {
+ let listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
+ };
+
+ convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, uri);
+ }, expectInvalidContextException);
+});
+
+// Test that an empty stream does not throw an NS_ERROR_ILLEGAL_VALUE.
+add_task(async function testEmptyStream() {
+ let stream = StringStream("");
+ let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI);
+ equal(
+ resultStream.available(),
+ 0,
+ "Size of output stream should match size of input stream"
+ );
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_data.js b/toolkit/components/extensions/test/xpcshell/test_locale_data.js
new file mode 100644
index 0000000000..32155a7c91
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js
@@ -0,0 +1,221 @@
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+const { ExtensionData } = ChromeUtils.import(
+ "resource://gre/modules/Extension.jsm"
+);
+
+async function generateAddon(data) {
+ let xpi = AddonTestUtils.createTempWebExtensionFile(data);
+
+ let fileURI = Services.io.newFileURI(xpi);
+ let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/`);
+
+ let extension = new ExtensionData(jarURI);
+ await extension.loadManifest();
+
+ return extension;
+}
+
+add_task(async function testMissingDefaultLocale() {
+ let extension = await generateAddon({
+ files: {
+ "_locales/en_US/messages.json": {},
+ },
+ });
+
+ equal(extension.errors.length, 0, "No errors reported");
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 1, "One error reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes('"default_locale" property is required'),
+ "Got missing default_locale error"
+ );
+});
+
+add_task(async function testInvalidDefaultLocale() {
+ let extension = await generateAddon({
+ manifest: {
+ default_locale: "en",
+ },
+
+ files: {
+ "_locales/en_US/messages.json": {},
+ },
+ });
+
+ equal(extension.errors.length, 1, "One error reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes(
+ "Loading locale file _locales/en/messages.json"
+ ),
+ "Got invalid default_locale error"
+ );
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 2, "Two errors reported");
+
+ info(`Got error: ${extension.errors[1]}`);
+
+ ok(
+ extension.errors[1].includes('"default_locale" property must correspond'),
+ "Got invalid default_locale error"
+ );
+});
+
+add_task(async function testUnexpectedDefaultLocale() {
+ let extension = await generateAddon({
+ manifest: {
+ default_locale: "en_US",
+ },
+ });
+
+ equal(extension.errors.length, 1, "One error reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes(
+ "Loading locale file _locales/en-US/messages.json"
+ ),
+ "Got invalid default_locale error"
+ );
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 2, "One error reported");
+
+ info(`Got error: ${extension.errors[1]}`);
+
+ ok(
+ extension.errors[1].includes('"default_locale" property must correspond'),
+ "Got unexpected default_locale error"
+ );
+});
+
+add_task(async function testInvalidSyntax() {
+ let extension = await generateAddon({
+ manifest: {
+ default_locale: "en_US",
+ },
+
+ files: {
+ "_locales/en_US/messages.json":
+ '{foo: {message: "bar", description: "baz"}}',
+ },
+ });
+
+ equal(extension.errors.length, 1, "No errors reported");
+
+ info(`Got error: ${extension.errors[0]}`);
+
+ ok(
+ extension.errors[0].includes(
+ "Loading locale file _locales/en_US/messages.json: SyntaxError"
+ ),
+ "Got syntax error"
+ );
+
+ await extension.initAllLocales();
+
+ equal(extension.errors.length, 2, "One error reported");
+
+ info(`Got error: ${extension.errors[1]}`);
+
+ ok(
+ extension.errors[1].includes(
+ "Loading locale file _locales/en_US/messages.json: SyntaxError"
+ ),
+ "Got syntax error"
+ );
+});
+
+add_task(async function testExtractLocalizedManifest() {
+ let extension = await generateAddon({
+ manifest: {
+ name: "__MSG_extensionName__",
+ default_locale: "en_US",
+ icons: {
+ "16": "__MSG_extensionIcon__",
+ },
+ },
+
+ files: {
+ "_locales/en_US/messages.json": `{
+ "extensionName": {"message": "foo"},
+ "extensionIcon": {"message": "icon-en.png"}
+ }`,
+ "_locales/de_DE/messages.json": `{
+ "extensionName": {"message": "bar"},
+ "extensionIcon": {"message": "icon-de.png"}
+ }`,
+ },
+ });
+
+ await extension.loadManifest();
+ equal(extension.manifest.name, "foo", "name localized");
+ equal(extension.manifest.icons["16"], "icon-en.png", "icons localized");
+
+ let manifest = await extension.getLocalizedManifest("de-DE");
+ ok(extension.localeData.has("de-DE"), "has de_DE locale");
+ equal(manifest.name, "bar", "name localized");
+ equal(manifest.icons["16"], "icon-de.png", "icons localized");
+
+ await Assert.rejects(
+ extension.getLocalizedManifest("xx-XX"),
+ /does not contain the locale xx-XX/,
+ "xx-XX does not exist"
+ );
+});
+
+add_task(async function testRestartThenExtractLocalizedManifest() {
+ await AddonTestUtils.promiseStartupManager();
+
+ let wrapper = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "__MSG_extensionName__",
+ default_locale: "en_US",
+ },
+ useAddonManager: "permanent",
+ files: {
+ "_locales/en_US/messages.json": '{"extensionName": {"message": "foo"}}',
+ "_locales/de_DE/messages.json": '{"extensionName": {"message": "bar"}}',
+ },
+ });
+
+ await wrapper.startup();
+
+ await AddonTestUtils.promiseRestartManager();
+ await wrapper.startupPromise;
+
+ let { extension } = wrapper;
+ let manifest = await extension.getLocalizedManifest("de-DE");
+ ok(extension.localeData.has("de-DE"), "has de_DE locale");
+ equal(manifest.name, "bar", "name localized");
+
+ await Assert.rejects(
+ extension.getLocalizedManifest("xx-XX"),
+ /does not contain the locale xx-XX/,
+ "xx-XX does not exist"
+ );
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_native_manifests.js b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js
new file mode 100644
index 0000000000..ca32517fd5
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js
@@ -0,0 +1,443 @@
+"use strict";
+
+const { AsyncShutdown } = ChromeUtils.import(
+ "resource://gre/modules/AsyncShutdown.jsm"
+);
+const { ExtensionCommon } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionCommon.jsm"
+);
+const { NativeManifests } = ChromeUtils.import(
+ "resource://gre/modules/NativeManifests.jsm"
+);
+const { FileUtils } = ChromeUtils.import(
+ "resource://gre/modules/FileUtils.jsm"
+);
+const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm");
+const { Subprocess, SubprocessImpl } = ChromeUtils.import(
+ "resource://gre/modules/Subprocess.jsm",
+ null
+);
+const { NativeApp } = ChromeUtils.import(
+ "resource://gre/modules/NativeMessaging.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+let registry = null;
+if (AppConstants.platform == "win") {
+ var { MockRegistry } = ChromeUtils.import(
+ "resource://testing-common/MockRegistry.jsm"
+ );
+ registry = new MockRegistry();
+ registerCleanupFunction(() => {
+ registry.shutdown();
+ });
+}
+
+const REGPATH = "Software\\Mozilla\\NativeMessagingHosts";
+
+const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json";
+
+const TYPE_SLUG =
+ AppConstants.platform === "linux"
+ ? "native-messaging-hosts"
+ : "NativeMessagingHosts";
+
+let dir = FileUtils.getDir("TmpD", ["NativeManifests"]);
+dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+let userDir = dir.clone();
+userDir.append("user");
+userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+let globalDir = dir.clone();
+globalDir.append("global");
+globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+OS.File.makeDir(OS.Path.join(userDir.path, TYPE_SLUG));
+OS.File.makeDir(OS.Path.join(globalDir.path, TYPE_SLUG));
+
+let dirProvider = {
+ getFile(property) {
+ if (property == "XREUserNativeManifests") {
+ return userDir.clone();
+ } else if (property == "XRESysNativeManifests") {
+ return globalDir.clone();
+ }
+ return null;
+ },
+};
+
+Services.dirsvc.registerProvider(dirProvider);
+
+registerCleanupFunction(() => {
+ Services.dirsvc.unregisterProvider(dirProvider);
+ dir.remove(true);
+});
+
+function writeManifest(path, manifest) {
+ if (typeof manifest != "string") {
+ manifest = JSON.stringify(manifest);
+ }
+ return OS.File.writeAtomic(path, manifest);
+}
+
+let PYTHON;
+add_task(async function setup() {
+ await Schemas.load(BASE_SCHEMA);
+
+ const env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ try {
+ PYTHON = await Subprocess.pathSearch(env.get("PYTHON"));
+ } catch (e) {
+ notEqual(
+ PYTHON,
+ null,
+ `Can't find a suitable python interpreter ${e.message}`
+ );
+ }
+});
+
+let global = this;
+
+// Test of NativeManifests.lookupApplication() begin here...
+let context = {
+ extension: {
+ id: "extension@tests.mozilla.org",
+ },
+ envType: "addon_parent",
+ url: null,
+ jsonStringify(...args) {
+ return JSON.stringify(...args);
+ },
+ cloneScope: global,
+ logError() {},
+ preprocessors: {},
+ callOnClose: () => {},
+ forgetOnClose: () => {},
+};
+
+class MockContext extends ExtensionCommon.BaseContext {
+ constructor(extensionId) {
+ let fakeExtension = { id: extensionId };
+ super("addon_parent", fakeExtension);
+ this.sandbox = Cu.Sandbox(global);
+ }
+
+ get cloneScope() {
+ return global;
+ }
+
+ get principal() {
+ return Cu.getObjectPrincipal(this.sandbox);
+ }
+}
+
+let templateManifest = {
+ name: "test",
+ description: "this is only a test",
+ path: "/bin/cat",
+ type: "stdio",
+ allowed_extensions: ["extension@tests.mozilla.org"],
+};
+
+function lookupApplication(app, ctx) {
+ return NativeManifests.lookupManifest("stdio", app, ctx);
+}
+
+add_task(async function test_nonexistent_manifest() {
+ let result = await lookupApplication("test", context);
+ equal(
+ result,
+ null,
+ "lookupApplication returns null for non-existent application"
+ );
+});
+
+const USER_TEST_JSON = OS.Path.join(userDir.path, TYPE_SLUG, "test.json");
+
+add_task(async function test_nonexistent_manifest_with_registry_entry() {
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+
+ await OS.File.remove(USER_TEST_JSON);
+ let { messages, result } = await promiseConsoleOutput(() =>
+ lookupApplication("test", context)
+ );
+ equal(
+ result,
+ null,
+ "lookupApplication returns null for non-existent manifest"
+ );
+
+ let noSuchFileErrors = messages.filter(logMessage =>
+ logMessage.message.includes(
+ "file is referenced in the registry but does not exist"
+ )
+ );
+
+ if (registry) {
+ equal(
+ noSuchFileErrors.length,
+ 1,
+ "lookupApplication logs a non-existent manifest file pointed to by the registry"
+ );
+ } else {
+ equal(
+ noSuchFileErrors.length,
+ 0,
+ "lookupApplication does not log about registry on non-windows platforms"
+ );
+ }
+});
+
+add_task(async function test_good_manifest() {
+ await writeManifest(USER_TEST_JSON, templateManifest);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+
+ let result = await lookupApplication("test", context);
+ notEqual(result, null, "lookupApplication finds a good manifest");
+ equal(
+ result.path,
+ USER_TEST_JSON,
+ "lookupApplication returns the correct path"
+ );
+ deepEqual(
+ result.manifest,
+ templateManifest,
+ "lookupApplication returns the manifest contents"
+ );
+});
+
+add_task(async function test_invalid_json() {
+ await writeManifest(USER_TEST_JSON, "this is not valid json");
+ let result = await lookupApplication("test", context);
+ equal(result, null, "lookupApplication ignores bad json");
+});
+
+add_task(async function test_invalid_name() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.name = "../test";
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(result, null, "lookupApplication ignores an invalid name");
+});
+
+add_task(async function test_name_mismatch() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.name = "not test";
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ let what = AppConstants.platform == "win" ? "registry key" : "json filename";
+ equal(
+ result,
+ null,
+ `lookupApplication ignores mistmatch between ${what} and name property`
+ );
+});
+
+add_task(async function test_missing_props() {
+ const PROPS = ["name", "description", "path", "type", "allowed_extensions"];
+ for (let prop of PROPS) {
+ let manifest = Object.assign({}, templateManifest);
+ delete manifest[prop];
+
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(result, null, `lookupApplication ignores missing ${prop}`);
+ }
+});
+
+add_task(async function test_invalid_type() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.type = "bogus";
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(result, null, "lookupApplication ignores invalid type");
+});
+
+add_task(async function test_no_allowed_extensions() {
+ let manifest = Object.assign({}, templateManifest);
+ manifest.allowed_extensions = [];
+ await writeManifest(USER_TEST_JSON, manifest);
+ let result = await lookupApplication("test", context);
+ equal(
+ result,
+ null,
+ "lookupApplication ignores manifest with no allowed_extensions"
+ );
+});
+
+const GLOBAL_TEST_JSON = OS.Path.join(globalDir.path, TYPE_SLUG, "test.json");
+let globalManifest = Object.assign({}, templateManifest);
+globalManifest.description = "This manifest is from the systemwide directory";
+
+add_task(async function good_manifest_system_dir() {
+ await OS.File.remove(USER_TEST_JSON);
+ await writeManifest(GLOBAL_TEST_JSON, globalManifest);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ `${REGPATH}\\test`,
+ "",
+ GLOBAL_TEST_JSON
+ );
+ }
+
+ let where =
+ AppConstants.platform == "win" ? "registry location" : "directory";
+ let result = await lookupApplication("test", context);
+ notEqual(
+ result,
+ null,
+ `lookupApplication finds a manifest in the system-wide ${where}`
+ );
+ equal(
+ result.path,
+ GLOBAL_TEST_JSON,
+ `lookupApplication returns path in the system-wide ${where}`
+ );
+ deepEqual(
+ result.manifest,
+ globalManifest,
+ `lookupApplication returns manifest contents from the system-wide ${where}`
+ );
+});
+
+add_task(async function test_user_dir_precedence() {
+ await writeManifest(USER_TEST_JSON, templateManifest);
+ if (registry) {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\test`,
+ "",
+ USER_TEST_JSON
+ );
+ }
+ // global test.json and LOCAL_MACHINE registry key on windows are
+ // still present from the previous test
+
+ let result = await lookupApplication("test", context);
+ notEqual(
+ result,
+ null,
+ "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations"
+ );
+ equal(
+ result.path,
+ USER_TEST_JSON,
+ "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist"
+ );
+ deepEqual(
+ result.manifest,
+ templateManifest,
+ "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist"
+ );
+});
+
+// Test shutdown handling in NativeApp
+add_task(async function test_native_app_shutdown() {
+ const SCRIPT = String.raw`
+import signal
+import struct
+import sys
+
+signal.signal(signal.SIGTERM, signal.SIG_IGN)
+
+stdin = getattr(sys.stdin, 'buffer', sys.stdin)
+stdout = getattr(sys.stdout, 'buffer', sys.stdout)
+
+while True:
+ rawlen = stdin.read(4)
+ if len(rawlen) == 0:
+ signal.pause()
+ msglen = struct.unpack('@I', rawlen)[0]
+ msg = stdin.read(msglen)
+
+ stdout.write(struct.pack('@I', msglen))
+ stdout.write(msg)
+`;
+
+ let scriptPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.py");
+ let manifestPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.json");
+
+ const ID = "native@tests.mozilla.org";
+ let manifest = {
+ name: "wontdie",
+ description: "test async shutdown of native apps",
+ type: "stdio",
+ allowed_extensions: [ID],
+ };
+
+ if (AppConstants.platform == "win") {
+ await OS.File.writeAtomic(scriptPath, SCRIPT);
+
+ let batPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.bat");
+ let batBody = `@ECHO OFF\n${PYTHON} -u "${scriptPath}" %*\n`;
+ await OS.File.writeAtomic(batPath, batBody);
+ await OS.File.setPermissions(batPath, { unixMode: 0o755 });
+
+ manifest.path = batPath;
+ await writeManifest(manifestPath, manifest);
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ `${REGPATH}\\wontdie`,
+ "",
+ manifestPath
+ );
+ } else {
+ await OS.File.writeAtomic(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`);
+ await OS.File.setPermissions(scriptPath, { unixMode: 0o755 });
+ manifest.path = scriptPath;
+ await writeManifest(manifestPath, manifest);
+ }
+
+ let mockContext = new MockContext(ID);
+ let app = new NativeApp(mockContext, "wontdie");
+
+ // send a message and wait for the reply to make sure the app is running
+ let MSG = "test";
+ let recvPromise = new Promise(resolve => {
+ let listener = (what, msg) => {
+ equal(msg, MSG, "Received test message");
+ app.off("message", listener);
+ resolve();
+ };
+ app.on("message", listener);
+ });
+
+ let buffer = NativeApp.encodeMessage(mockContext, MSG);
+ app.send(new StructuredCloneHolder(buffer));
+ await recvPromise;
+
+ app._cleanup();
+
+ info("waiting for async shutdown");
+ Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+ AsyncShutdown.profileBeforeChange._trigger();
+ Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+
+ let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []);
+ equal(procs.size, 0, "native process exited");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js
new file mode 100644
index 0000000000..0763c60abe
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js
@@ -0,0 +1,103 @@
+"use strict";
+
+/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */
+
+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("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_incognito_proxy_onRequest_access() {
+ // No specific support exists in the proxy api for this test,
+ // rather it depends on functionality existing in ChannelWrapper
+ // that prevents notification of private channels if the
+ // extension does not have permission.
+ Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false);
+
+ // This extension will fail if it gets a private request.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ async background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set"
+ );
+ browser.test.notifyPass("proxy.onRequest");
+ },
+ { urls: ["<all_urls>"], types: ["main_frame"] }
+ );
+
+ // Actual call arguments do not matter here.
+ await browser.test.assertRejects(
+ browser.proxy.settings.set({
+ value: {
+ proxyType: "none",
+ },
+ }),
+ /proxy.settings requires private browsing permission/,
+ "proxy.settings requires private browsing permission."
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ });
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let pextension = ExtensionTestUtils.loadExtension({
+ incognitoOverride: "spanning",
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertTrue(
+ details.incognito,
+ "incognito flag is set with filter"
+ );
+ browser.test.sendMessage("proxy.onRequest.private");
+ },
+ { urls: ["<all_urls>"], types: ["main_frame"], incognito: true }
+ );
+
+ browser.proxy.onRequest.addListener(
+ async details => {
+ browser.test.assertFalse(
+ details.incognito,
+ "incognito flag is not set with filter"
+ );
+ browser.test.notifyPass("proxy.onRequest.spanning");
+ },
+ { urls: ["<all_urls>"], types: ["main_frame"], incognito: false }
+ );
+ },
+ });
+ await pextension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { privateBrowsing: true }
+ );
+ await pextension.awaitMessage("proxy.onRequest.private");
+ await contentPage.close();
+
+ contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy"
+ );
+ await extension.awaitFinish("proxy.onRequest");
+ await pextension.awaitFinish("proxy.onRequest.spanning");
+ await contentPage.close();
+
+ await pextension.unload();
+ await extension.unload();
+
+ Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault");
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js
new file mode 100644
index 0000000000..c222642d52
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js
@@ -0,0 +1,469 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gProxyService",
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService"
+);
+
+const TRANSPARENT_PROXY_RESOLVES_HOST =
+ Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+
+let extension;
+add_task(async function setup() {
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ let settings = { proxy: null };
+
+ browser.proxy.onError.addListener(error => {
+ browser.test.log(`error received ${error.message}`);
+ browser.test.sendMessage("proxy-error-received", error);
+ });
+ browser.test.onMessage.addListener((message, data) => {
+ if (message === "set-proxy") {
+ settings.proxy = data.proxy;
+ browser.test.sendMessage("proxy-set", settings.proxy);
+ }
+ });
+ browser.proxy.onRequest.addListener(
+ () => {
+ return settings.proxy;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ },
+ };
+ extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+});
+
+async function setupProxyResult(proxy) {
+ extension.sendMessage("set-proxy", { proxy });
+ let proxyInfoSent = await extension.awaitMessage("proxy-set");
+ deepEqual(
+ proxyInfoSent,
+ proxy,
+ "got back proxy data from the proxy listener"
+ );
+}
+
+async function testProxyResolution(test) {
+ let { uri, proxy, expected } = test;
+ let errorMsg;
+ if (expected.error) {
+ errorMsg = extension.awaitMessage("proxy-error-received");
+ }
+ let proxyInfo = await new Promise((resolve, reject) => {
+ let channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ });
+
+ gProxyService.asyncResolve(channel, 0, {
+ onProxyAvailable(req, uri, pi, status) {
+ resolve(pi && pi.QueryInterface(Ci.nsIProxyInfo));
+ },
+ });
+ });
+
+ let expectedProxyInfo = expected.proxyInfo;
+ if (expected.error) {
+ equal(proxyInfo, null, "Expected proxyInfo to be null");
+ equal((await errorMsg).message, expected.error, "error received");
+ } else if (proxy == null) {
+ equal(proxyInfo, expectedProxyInfo, "proxy is direct");
+ } else {
+ for (
+ let proxyUsed = proxyInfo;
+ proxyUsed;
+ proxyUsed = proxyUsed.failoverProxy
+ ) {
+ let {
+ type,
+ host,
+ port,
+ username,
+ password,
+ proxyDNS,
+ failoverTimeout,
+ } = expectedProxyInfo;
+ equal(proxyUsed.host, host, `Expected proxy host to be ${host}`);
+ equal(proxyUsed.port, port, `Expected proxy port to be ${port}`);
+ equal(proxyUsed.type, type, `Expected proxy type to be ${type}`);
+ // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo
+ equal(
+ proxyUsed.username || "",
+ username || "",
+ `Expected proxy username to be ${username}`
+ );
+ equal(
+ proxyUsed.password || "",
+ password || "",
+ `Expected proxy password to be ${password}`
+ );
+ equal(
+ proxyUsed.flags,
+ proxyDNS == undefined ? 0 : proxyDNS,
+ `Expected proxyDNS to be ${proxyDNS}`
+ );
+ // Default timeout is 10
+ equal(
+ proxyUsed.failoverTimeout,
+ failoverTimeout || 10,
+ `Expected failoverTimeout to be ${failoverTimeout}`
+ );
+ expectedProxyInfo = expectedProxyInfo.failoverProxy;
+ }
+ }
+}
+
+add_task(async function test_proxyInfo_results() {
+ let tests = [
+ {
+ proxy: 5,
+ expected: {
+ error: "ProxyInfoData: proxyData must be an object or array of objects",
+ },
+ },
+ {
+ proxy: "INVALID",
+ expected: {
+ error: "ProxyInfoData: proxyData must be an object or array of objects",
+ },
+ },
+ {
+ proxy: {
+ type: "socks",
+ },
+ expected: {
+ error: 'ProxyInfoData: Invalid proxy server host: "undefined"',
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "pptp",
+ host: "foo.bar",
+ port: 1080,
+ username: "mungosantamaria",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ {
+ type: "http",
+ host: "192.168.1.1",
+ port: 1128,
+ username: "mungosantamaria",
+ password: "word321",
+ },
+ ],
+ expected: {
+ error: 'ProxyInfoData: Invalid proxy server type: "pptp"',
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "http",
+ host: "foo.bar",
+ port: 65536,
+ username: "mungosantamaria",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ {
+ type: "http",
+ host: "192.168.1.1",
+ port: 3128,
+ username: "mungosantamaria",
+ password: "word321",
+ },
+ ],
+ expected: {
+ error:
+ "ProxyInfoData: Proxy server port 65536 outside range 1 to 65535",
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "http",
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ },
+ ],
+ expected: {
+ error: 'ProxyInfoData: ProxyAuthorizationHeader requires type "https"',
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "http",
+ host: "foo.bar",
+ port: 3128,
+ connectionIsolationKey: 1234,
+ },
+ ],
+ expected: {
+ error: 'ProxyInfoData: Invalid proxy connection isolation key: "1234"',
+ },
+ },
+ {
+ proxy: [{ type: "direct" }],
+ expected: {
+ proxyInfo: null,
+ },
+ },
+ {
+ proxy: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: null,
+ },
+ expected: {
+ proxyInfo: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: null,
+ },
+ },
+ },
+ {
+ uri: "ftp://mozilla.org",
+ proxy: {
+ host: "1.2.3.4",
+ port: "8180",
+ type: "http",
+ failoverProxy: null,
+ },
+ expected: {
+ proxyInfo: {
+ host: "1.2.3.4",
+ port: "8180",
+ type: "http",
+ failoverProxy: null,
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "2.3.4.5",
+ port: "8181",
+ type: "http",
+ failoverProxy: null,
+ },
+ expected: {
+ proxyInfo: {
+ host: "2.3.4.5",
+ port: "8181",
+ type: "http",
+ failoverProxy: null,
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: {
+ host: "4.4.4.4",
+ port: "9000",
+ type: "socks",
+ failoverProxy: {
+ type: "direct",
+ host: null,
+ port: -1,
+ },
+ },
+ },
+ expected: {
+ proxyInfo: {
+ host: "1.2.3.4",
+ port: "8080",
+ type: "http",
+ failoverProxy: {
+ host: "4.4.4.4",
+ port: "9000",
+ type: "socks",
+ failoverProxy: {
+ type: "direct",
+ host: null,
+ port: -1,
+ },
+ },
+ },
+ },
+ },
+ {
+ proxy: [{ type: "http", host: "foo.bar", port: 3128 }],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "3128",
+ type: "http",
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks",
+ },
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks",
+ },
+ },
+ },
+ {
+ proxy: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks4",
+ },
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "1080",
+ type: "socks4",
+ },
+ },
+ },
+ {
+ proxy: [{ type: "https", host: "foo.bar", port: 3128 }],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "3128",
+ type: "https",
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "mungo",
+ password: "santamaria123",
+ proxyDNS: true,
+ failoverTimeout: 5,
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "mungo",
+ password: "santamaria123",
+ failoverTimeout: 5,
+ failoverProxy: null,
+ proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "johnsmith",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ { type: "http", host: "192.168.1.1", port: 3128 },
+ { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 },
+ {
+ type: "socks",
+ host: "192.168.1.3",
+ port: 1999,
+ proxyDNS: true,
+ username: "mungosantamaria",
+ password: "foobar",
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ proxyDNS: true,
+ username: "johnsmith",
+ password: "pass123",
+ failoverTimeout: 3,
+ failoverProxy: {
+ host: "192.168.1.1",
+ port: 3128,
+ type: "http",
+ failoverProxy: {
+ host: "192.168.1.2",
+ port: 1121,
+ type: "https",
+ failoverTimeout: 1,
+ failoverProxy: {
+ host: "192.168.1.3",
+ port: 1999,
+ type: "socks",
+ proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+ username: "mungosantamaria",
+ password: "foobar",
+ failoverProxy: {
+ type: "direct",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ proxy: [
+ {
+ type: "https",
+ host: "foo.bar",
+ port: 3128,
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ },
+ ],
+ expected: {
+ proxyInfo: {
+ host: "foo.bar",
+ port: "3128",
+ type: "https",
+ proxyAuthorizationHeader: "test",
+ connectionIsolationKey: "key",
+ },
+ },
+ },
+ ];
+ for (let test of tests) {
+ await setupProxyResult(test.proxy);
+ if (!test.uri) {
+ test.uri = "http://www.mozilla.org/";
+ }
+ await testProxyResolution(test);
+ }
+});
+
+add_task(async function shutdown() {
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
new file mode 100644
index 0000000000..5dc099baf6
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js
@@ -0,0 +1,318 @@
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gProxyService",
+ "@mozilla.org/network/protocol-proxy-service;1",
+ "nsIProtocolProxyService"
+);
+
+const TRANSPARENT_PROXY_RESOLVES_HOST =
+ Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST;
+
+function getProxyInfo(url = "http://www.mozilla.org/") {
+ return new Promise((resolve, reject) => {
+ let channel = NetUtil.newChannel({
+ uri: url,
+ loadUsingSystemPrincipal: true,
+ });
+
+ gProxyService.asyncResolve(channel, 0, {
+ onProxyAvailable(req, uri, pi, status) {
+ resolve(pi);
+ },
+ });
+ });
+}
+
+const testData = [
+ {
+ // An ExtensionError is thrown for this, but we are unable to catch it as we
+ // do with the PAC script api. In this case, we expect null for proxyInfo.
+ proxyInfo: "not_defined",
+ expected: {
+ proxyInfo: null,
+ },
+ },
+ {
+ proxyInfo: 1,
+ expected: {
+ error: {
+ message:
+ "ProxyInfoData: proxyData must be an object or array of objects",
+ },
+ },
+ },
+ {
+ proxyInfo: [
+ {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ username: "johnsmith",
+ password: "pass123",
+ proxyDNS: true,
+ failoverTimeout: 3,
+ },
+ { type: "http", host: "192.168.1.1", port: 3128 },
+ { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 },
+ {
+ type: "socks",
+ host: "192.168.1.3",
+ port: 1999,
+ proxyDNS: true,
+ username: "mungosantamaria",
+ password: "foobar",
+ },
+ { type: "direct" },
+ ],
+ expected: {
+ proxyInfo: {
+ type: "socks",
+ host: "foo.bar",
+ port: 1080,
+ proxyDNS: true,
+ username: "johnsmith",
+ password: "pass123",
+ failoverTimeout: 3,
+ failoverProxy: {
+ host: "192.168.1.1",
+ port: 3128,
+ type: "http",
+ failoverProxy: {
+ host: "192.168.1.2",
+ port: 1121,
+ type: "https",
+ failoverTimeout: 1,
+ failoverProxy: {
+ host: "192.168.1.3",
+ port: 1999,
+ type: "socks",
+ proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST,
+ username: "mungosantamaria",
+ password: "foobar",
+ failoverProxy: {
+ type: "direct",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+];
+
+add_task(async function test_proxy_listener() {
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ // Some tests generate multiple errors, we'll just rely on the first.
+ let seenError = false;
+ let proxyInfo;
+ browser.proxy.onError.addListener(error => {
+ if (!seenError) {
+ browser.test.sendMessage("proxy-error-received", error);
+ seenError = true;
+ }
+ });
+
+ browser.proxy.onRequest.addListener(
+ details => {
+ browser.test.log(`onRequest ${JSON.stringify(details)}`);
+ if (proxyInfo == "not_defined") {
+ return not_defined; // eslint-disable-line no-undef
+ }
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+
+ browser.test.onMessage.addListener((message, data) => {
+ if (message === "set-proxy") {
+ seenError = false;
+ proxyInfo = data.proxyInfo;
+ }
+ });
+
+ browser.test.sendMessage("ready");
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ for (let test of testData) {
+ extension.sendMessage("set-proxy", test);
+ let testError = test.expected.error;
+ let errorWait = testError && extension.awaitMessage("proxy-error-received");
+
+ let proxyInfo = await getProxyInfo();
+ let expectedProxyInfo = test.expected.proxyInfo;
+
+ if (testError) {
+ info("waiting for error data");
+ let error = await errorWait;
+ equal(error.message, testError.message, "Correct error message received");
+ equal(proxyInfo, null, "no proxyInfo received");
+ } else if (expectedProxyInfo === null) {
+ equal(proxyInfo, null, "no proxyInfo received");
+ } else {
+ for (
+ let proxyUsed = proxyInfo;
+ proxyUsed;
+ proxyUsed = proxyUsed.failoverProxy
+ ) {
+ let {
+ type,
+ host,
+ port,
+ username,
+ password,
+ proxyDNS,
+ failoverTimeout,
+ } = expectedProxyInfo;
+ equal(proxyUsed.host, host, `Expected proxy host to be ${host}`);
+ equal(proxyUsed.port, port || -1, `Expected proxy port to be ${port}`);
+ equal(proxyUsed.type, type, `Expected proxy type to be ${type}`);
+ // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo
+ equal(
+ proxyUsed.username || "",
+ username || "",
+ `Expected proxy username to be ${username}`
+ );
+ equal(
+ proxyUsed.password || "",
+ password || "",
+ `Expected proxy password to be ${password}`
+ );
+ equal(
+ proxyUsed.flags,
+ proxyDNS == undefined ? 0 : proxyDNS,
+ `Expected proxyDNS to be ${proxyDNS}`
+ );
+ // Default timeout is 10
+ equal(
+ proxyUsed.failoverTimeout,
+ failoverTimeout || 10,
+ `Expected failoverTimeout to be ${failoverTimeout}`
+ );
+ expectedProxyInfo = expectedProxyInfo.failoverProxy;
+ }
+ ok(!expectedProxyInfo, "no left over failoverProxy");
+ }
+ }
+
+ await extension.unload();
+});
+
+async function getExtension(expectedProxyInfo) {
+ function background(proxyInfo) {
+ browser.test.log(
+ `testing proxy.onRequest with proxyInfo = ${JSON.stringify(proxyInfo)}`
+ );
+ browser.proxy.onRequest.addListener(
+ details => {
+ return proxyInfo;
+ },
+ { urls: ["<all_urls>"] }
+ );
+ }
+ let extensionData = {
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background: `(${background})(${JSON.stringify(expectedProxyInfo)})`,
+ };
+ let extension = ExtensionTestUtils.loadExtension(extensionData);
+ await extension.startup();
+ return extension;
+}
+
+add_task(async function test_passthrough() {
+ let ext1 = await getExtension(null);
+ let ext2 = await getExtension({ host: "1.2.3.4", port: 8888, type: "https" });
+
+ // Also use a restricted url to test the ability to proxy those.
+ let proxyInfo = await getProxyInfo("https://addons.mozilla.org/");
+
+ equal(proxyInfo.host, "1.2.3.4", `second extension won`);
+ equal(proxyInfo.port, "8888", `second extension won`);
+ equal(proxyInfo.type, "https", `second extension won`);
+
+ await ext2.unload();
+
+ proxyInfo = await getProxyInfo();
+ equal(proxyInfo, null, `expected no proxy`);
+ await ext1.unload();
+});
+
+add_task(async function test_ftp() {
+ Services.prefs.setBoolPref("network.ftp.enabled", true);
+ let extension = await getExtension({
+ host: "1.2.3.4",
+ port: 8888,
+ type: "http",
+ });
+
+ let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/");
+
+ equal(proxyInfo.host, "1.2.3.4", `proxy host correct`);
+ equal(proxyInfo.port, "8888", `proxy port correct`);
+ equal(proxyInfo.type, "http", `proxy type correct`);
+
+ await extension.unload();
+ Services.prefs.clearUserPref("network.ftp.enabled");
+});
+
+add_task(async function test_ftp_disabled() {
+ Services.prefs.setBoolPref("network.ftp.enabled", false);
+ let extension = await getExtension({
+ host: "1.2.3.4",
+ port: 8888,
+ type: "http",
+ });
+
+ let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/");
+
+ equal(
+ proxyInfo,
+ null,
+ `proxy of ftp request is not available when ftp is disabled`
+ );
+
+ await extension.unload();
+ Services.prefs.clearUserPref("network.ftp.enabled");
+});
+
+add_task(async function test_ws() {
+ let proxyRequestCount = 0;
+ let proxy = createHttpServer();
+ proxy.registerPathHandler("CONNECT", (request, response) => {
+ response.setStatusLine(request.httpVersion, 404, "Proxy not found");
+ ++proxyRequestCount;
+ });
+
+ let extension = await getExtension({
+ host: proxy.identity.primaryHost,
+ port: proxy.identity.primaryPort,
+ type: "http",
+ });
+
+ // We need a page to use the WebSocket constructor, so let's use an extension.
+ let dummy = ExtensionTestUtils.loadExtension({
+ background() {
+ // The connection will not be upgraded to WebSocket, so it will close.
+ let ws = new WebSocket("wss://example.net/");
+ ws.onclose = () => browser.test.sendMessage("websocket_closed");
+ },
+ });
+ await dummy.startup();
+ await dummy.awaitMessage("websocket_closed");
+ await dummy.unload();
+
+ equal(proxyRequestCount, 1, "Expected one proxy request");
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js
new file mode 100644
index 0000000000..5dea560e02
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js
@@ -0,0 +1,43 @@
+"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("<!DOCTYPE html><html></html>");
+});
+
+add_task(async function test_userContextId_proxy_onRequest() {
+ // This extension will succeed if it gets a request
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["proxy", "<all_urls>"],
+ },
+ background() {
+ browser.proxy.onRequest.addListener(
+ async details => {
+ if (details.url != "http://example.com/dummy") {
+ return;
+ }
+ browser.test.assertEq(
+ details.cookieStoreId,
+ "firefox-container-2",
+ "cookieStoreId is set"
+ );
+ browser.test.notifyPass("proxy.onRequest");
+ },
+ { urls: ["<all_urls>"] }
+ );
+ },
+ });
+ await extension.startup();
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/dummy",
+ { userContextId: 2 }
+ );
+ await extension.awaitFinish("proxy.onRequest");
+ await extension.unload();
+ await contentPage.close();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js
new file mode 100644
index 0000000000..7c083c7805
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js
@@ -0,0 +1,79 @@
+"use strict";
+
+var { WebRequest } = ChromeUtils.import(
+ "resource://gre/modules/WebRequest.jsm"
+);
+var { PromiseUtils } = ChromeUtils.import(
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function setup() {
+ // When WebRequest.jsm is used directly instead of through ext-webRequest.js,
+ // ExtensionParent.apiManager is not automatically initialized. Do it here.
+ await ExtensionParent.apiManager.lazyInit();
+});
+
+add_task(async function test_ancestors_exist() {
+ let deferred = PromiseUtils.defer();
+ function onBeforeRequest(details) {
+ info(`onBeforeRequest ${details.url}`);
+ ok(
+ typeof details.frameAncestors === "object",
+ `ancestors exists [${typeof details.frameAncestors}]`
+ );
+ deferred.resolve();
+ }
+
+ WebRequest.onBeforeRequest.addListener(
+ onBeforeRequest,
+ { urls: new MatchPatternSet(["http://example.com/*"]) },
+ ["blocking"]
+ );
+
+ let contentPage = await ExtensionTestUtils.loadContentPage(
+ "http://example.com/data/file_sample.html"
+ );
+ await deferred.promise;
+ await contentPage.close();
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+});
+
+add_task(async function test_ancestors_null() {
+ let deferred = PromiseUtils.defer();
+ function onBeforeRequest(details) {
+ info(`onBeforeRequest ${details.url}`);
+ ok(details.frameAncestors === undefined, "ancestors do not exist");
+ deferred.resolve();
+ }
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, null, ["blocking"]);
+
+ function fetch(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.mozBackgroundRequest = true;
+ xhr.open("GET", url);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = () => {
+ reject(xhr.status);
+ };
+ // use a different contextId to avoid auth cache.
+ xhr.setOriginAttributes({ userContextId: 1 });
+ xhr.send();
+ });
+ }
+
+ await fetch("http://example.com/data/file_sample.html");
+ await deferred.promise;
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js
new file mode 100644
index 0000000000..d13b2be40d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js
@@ -0,0 +1,102 @@
+"use strict";
+
+var { WebRequest } = ChromeUtils.import(
+ "resource://gre/modules/WebRequest.jsm"
+);
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerPathHandler("/", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ if (request.hasHeader("Cookie")) {
+ let value = request.getHeader("Cookie");
+ if (value == "blinky=1") {
+ response.setHeader("Set-Cookie", "dinky=1", false);
+ }
+ response.write("cookie-present");
+ } else {
+ response.setHeader("Set-Cookie", "foopy=1", false);
+ response.write("cookie-not-present");
+ }
+});
+
+const URL = "http://example.com/";
+
+var countBefore = 0;
+var countAfter = 0;
+
+function onBeforeSendHeaders(details) {
+ if (details.url != URL) {
+ return undefined;
+ }
+
+ countBefore++;
+
+ info(`onBeforeSendHeaders ${details.url}`);
+ let found = false;
+ let headers = [];
+ for (let { name, value } of details.requestHeaders) {
+ info(`Saw header ${name} '${value}'`);
+ if (name == "Cookie") {
+ equal(value, "foopy=1", "Cookie is correct");
+ headers.push({ name, value: "blinky=1" });
+ found = true;
+ } else {
+ headers.push({ name, value });
+ }
+ }
+ ok(found, "Saw cookie header");
+ equal(countBefore, 1, "onBeforeSendHeaders hit once");
+
+ return { requestHeaders: headers };
+}
+
+function onResponseStarted(details) {
+ if (details.url != URL) {
+ return;
+ }
+
+ countAfter++;
+
+ info(`onResponseStarted ${details.url}`);
+ let found = false;
+ for (let { name, value } of details.responseHeaders) {
+ info(`Saw header ${name} '${value}'`);
+ if (name == "set-cookie") {
+ equal(value, "dinky=1", "Cookie is correct");
+ found = true;
+ }
+ }
+ ok(found, "Saw cookie header");
+ equal(countAfter, 1, "onResponseStarted hit once");
+}
+
+add_task(async function setup() {
+ // When WebRequest.jsm is used directly instead of through ext-webRequest.js,
+ // ExtensionParent.apiManager is not automatically initialized. Do it here.
+ await ExtensionParent.apiManager.lazyInit();
+});
+
+add_task(async function filter_urls() {
+ // First load the URL so that we set cookie foopy=1.
+ let contentPage = await ExtensionTestUtils.loadContentPage(URL);
+ await contentPage.close();
+
+ // Now load with WebRequest set up.
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, [
+ "blocking",
+ "requestHeaders",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, null, [
+ "responseHeaders",
+ ]);
+
+ contentPage = await ExtensionTestUtils.loadContentPage(URL);
+ await contentPage.close();
+
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js
new file mode 100644
index 0000000000..156ba6267d
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js
@@ -0,0 +1,182 @@
+"use strict";
+
+var { WebRequest } = ChromeUtils.import(
+ "resource://gre/modules/WebRequest.jsm"
+);
+
+var { ExtensionParent } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionParent.jsm"
+);
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+const BASE = "http://example.com/data/";
+const URL = BASE + "/file_WebRequest_page2.html";
+
+var requested = [];
+
+function onBeforeRequest(details) {
+ info(`onBeforeRequest ${details.url}`);
+ if (details.url.startsWith(BASE)) {
+ requested.push(details.url);
+ }
+}
+
+var sendHeaders = [];
+
+function onBeforeSendHeaders(details) {
+ info(`onBeforeSendHeaders ${details.url}`);
+ if (details.url.startsWith(BASE)) {
+ sendHeaders.push(details.url);
+ }
+}
+
+var completed = [];
+
+function onResponseStarted(details) {
+ if (details.url.startsWith(BASE)) {
+ completed.push(details.url);
+ }
+}
+
+const expected_urls = [
+ BASE + "/file_style_good.css",
+ BASE + "/file_style_bad.css",
+ BASE + "/file_style_redirect.css",
+];
+
+function resetExpectations() {
+ requested.length = 0;
+ sendHeaders.length = 0;
+ completed.length = 0;
+}
+
+function removeDupes(list) {
+ let j = 0;
+ for (let i = 1; i < list.length; i++) {
+ if (list[i] != list[j]) {
+ j++;
+ if (i != j) {
+ list[j] = list[i];
+ }
+ }
+ }
+ list.length = j + 1;
+}
+
+function compareLists(list1, list2, kind) {
+ list1.sort();
+ removeDupes(list1);
+ list2.sort();
+ removeDupes(list2);
+ equal(String(list1), String(list2), `${kind} URLs correct`);
+}
+
+async function openAndCloseContentPage(url) {
+ let contentPage = await ExtensionTestUtils.loadContentPage(URL);
+ // Clear the sheet cache so that it doesn't interact with following tests: A
+ // stylesheet with the same URI loaded from the same origin doesn't otherwise
+ // guarantee that onBeforeRequest and so on happen, because it may not need
+ // to go through necko at all.
+ await contentPage.spawn(null, () =>
+ content.windowUtils.clearSharedStyleSheetCache()
+ );
+ await contentPage.close();
+}
+
+add_task(async function setup() {
+ // Disable rcwn to make cache behavior deterministic.
+ Services.prefs.setBoolPref("network.http.rcwn.enabled", false);
+
+ // When WebRequest.jsm is used directly instead of through ext-webRequest.js,
+ // ExtensionParent.apiManager is not automatically initialized. Do it here.
+ await ExtensionParent.apiManager.lazyInit();
+});
+
+add_task(async function filter_urls() {
+ let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]) };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, expected_urls, "requested");
+ compareLists(sendHeaders, expected_urls, "sendHeaders");
+ compareLists(completed, expected_urls, "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
+
+add_task(async function filter_types() {
+ resetExpectations();
+ let filter = { types: ["stylesheet"] };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, expected_urls, "requested");
+ compareLists(sendHeaders, expected_urls, "sendHeaders");
+ compareLists(completed, expected_urls, "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
+
+add_task(async function filter_windowId() {
+ resetExpectations();
+ // Check that adding windowId will exclude non-matching requests.
+ // test_ext_webrequest_filter.html provides coverage for matching requests.
+ let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), windowId: 0 };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, [], "requested");
+ compareLists(sendHeaders, [], "sendHeaders");
+ compareLists(completed, [], "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
+
+add_task(async function filter_tabId() {
+ resetExpectations();
+ // Check that adding tabId will exclude non-matching requests.
+ // test_ext_webrequest_filter.html provides coverage for matching requests.
+ let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), tabId: 0 };
+
+ WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]);
+ WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [
+ "blocking",
+ ]);
+ WebRequest.onResponseStarted.addListener(onResponseStarted, filter);
+
+ await openAndCloseContentPage(URL);
+
+ compareLists(requested, [], "requested");
+ compareLists(sendHeaders, [], "sendHeaders");
+ compareLists(completed, [], "completed");
+
+ WebRequest.onBeforeRequest.removeListener(onBeforeRequest);
+ WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders);
+ WebRequest.onResponseStarted.removeListener(onResponseStarted);
+});
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini
new file mode 100644
index 0000000000..332921e685
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini
@@ -0,0 +1,13 @@
+# Similar to xpcshell-common.ini, except tests here only run
+# when e10s is enabled (with or without out-of-process extensions).
+
+[test_ext_webRequest_filterResponseData.js]
+# tsan failure is for test_filter_301 timing out, bug 1674773
+skip-if = tsan || os == "android" && debug
+[test_ext_webRequest_redirect_StreamFilter.js]
+[test_ext_webRequest_responseBody.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_startup_StreamFilter.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_viewsource_StreamFilter.js]
+skip-if = tsan # Bug 1683730 \ No newline at end of file
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
new file mode 100644
index 0000000000..32d76194bb
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -0,0 +1,260 @@
+[DEFAULT]
+# Some tests of downloads.download() expect a file picker, which is only shown
+# by default when the browser.download.useDownloadDir pref is set to true. This
+# is the case on desktop Firefox, but not on Thunderbird.
+# Force pref value to true to get download tests to pass on Thunderbird.
+prefs = browser.download.useDownloadDir=true
+
+[test_change_remote_mode.js]
+[test_ext_MessageManagerProxy.js]
+skip-if = os == 'android' # Bug 1545439
+[test_ext_activityLog.js]
+[test_ext_alarms.js]
+[test_ext_alarms_does_not_fire.js]
+[test_ext_alarms_periodic.js]
+[test_ext_alarms_replaces.js]
+[test_ext_api_permissions.js]
+[test_ext_background_api_injection.js]
+[test_ext_background_early_shutdown.js]
+[test_ext_background_generated_load_events.js]
+[test_ext_background_generated_reload.js]
+[test_ext_background_global_history.js]
+skip-if = os == "android" # Android does not use Places for history.
+[test_ext_background_private_browsing.js]
+[test_ext_background_runtime_connect_params.js]
+[test_ext_background_sub_windows.js]
+[test_ext_background_teardown.js]
+[test_ext_background_telemetry.js]
+[test_ext_background_window_properties.js]
+skip-if = os == "android"
+[test_ext_browserSettings.js]
+[test_ext_browserSettings_homepage.js]
+skip-if = appname == "thunderbird" || os == "android"
+[test_ext_browsingData.js]
+[test_ext_browsingData_cookies_cache.js]
+[test_ext_browsingData_cookies_cookieStoreId.js]
+[test_ext_captivePortal.js]
+# As with test_captive_portal_service.js, we use the same limits here.
+skip-if = appname == "thunderbird" || os == "android" || (os == "mac" && debug) # CP service is disabled on Android, macosx1014/debug due to 1564534
+run-sequentially = node server exceptions dont replay well
+[test_ext_captivePortal_url.js]
+# As with test_captive_portal_service.js, we use the same limits here.
+skip-if = appname == "thunderbird" || os == "android" || (os == "mac" && debug) # CP service is disabled on Android, macosx1014/debug due to 1564534
+run-sequentially = node server exceptions dont replay well
+[test_ext_cookieBehaviors.js]
+skip-if = appname == "thunderbird" || tsan # Bug 1683730
+[test_ext_cookies_firstParty.js]
+skip-if = appname == "thunderbird" || os == "android" || tsan # Android: Bug 1680132. tsan: Bug 1683730
+[test_ext_cookies_samesite.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_content_security_policy.js]
+skip-if = (os == "win" && debug) #Bug 1485567
+[test_ext_contentscript_api_injection.js]
+[test_ext_contentscript_async_loading.js]
+skip-if = os == 'android' && debug # The generated script takes too long to load on Android debug
+[test_ext_contentscript_context.js]
+skip-if = tsan # Bug 1683730
+[test_ext_contentscript_context_isolation.js]
+skip-if = tsan # Bug 1683730
+[test_ext_contentscript_create_iframe.js]
+[test_ext_contentscript_csp.js]
+[test_ext_contentscript_css.js]
+[test_ext_contentscript_exporthelpers.js]
+[test_ext_contentscript_in_background.js]
+[test_ext_contentscript_restrictSchemes.js]
+[test_ext_contentscript_teardown.js]
+skip-if = tsan # Bug 1683730
+[test_ext_contentscript_unregister_during_loadContentScript.js]
+[test_ext_contentscript_xml_prettyprint.js]
+[test_ext_contextual_identities.js]
+skip-if = appname == "thunderbird" || os == "android" # Containers are not exposed to android.
+[test_ext_debugging_utils.js]
+[test_ext_dns.js]
+skip-if = socketprocess_networking || os == "android" # Android: Bug 1680132
+[test_ext_downloads.js]
+[test_ext_downloads_cookies.js]
+skip-if = os == "android" # downloads API needs to be implemented in GeckoView - bug 1538348
+[test_ext_downloads_download.js]
+skip-if = appname == "thunderbird" || os == "android" || tsan # tsan: bug 1612707
+[test_ext_downloads_misc.js]
+skip-if = os == "android" || (os=='linux' && bits==32) || tsan # linux32: bug 1324870, tsan: bug 1612707
+[test_ext_downloads_private.js]
+skip-if = os == "android"
+[test_ext_downloads_search.js]
+skip-if = os == "android" || tsan # tsan: bug 1612707
+[test_ext_downloads_urlencoded.js]
+skip-if = os == "android"
+[test_ext_error_location.js]
+[test_ext_eventpage_warning.js]
+[test_ext_experiments.js]
+[test_ext_extension.js]
+[test_ext_extensionPreferencesManager.js]
+[test_ext_extensionSettingsStore.js]
+[test_ext_extension_content_telemetry.js]
+skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
+[test_ext_extension_startup_failure.js]
+[test_ext_extension_startup_telemetry.js]
+[test_ext_file_access.js]
+[test_ext_geckoProfiler_control.js]
+skip-if = os == "android" || tsan # Not shipped on Android. tsan: bug 1612707
+[test_ext_geturl.js]
+[test_ext_idle.js]
+[test_ext_incognito.js]
+skip-if = appname == "thunderbird"
+[test_ext_l10n.js]
+[test_ext_localStorage.js]
+[test_ext_management.js]
+skip-if = (os == "win" && !debug) #Bug 1419183 disable on Windows
+[test_ext_management_uninstall_self.js]
+[test_ext_messaging_startup.js]
+skip-if = appname == "thunderbird" || (os == "android" && debug)
+[test_ext_networkStatus.js]
+[test_ext_notifications_incognito.js]
+skip-if = appname == "thunderbird"
+[test_ext_notifications_unsupported.js]
+[test_ext_onmessage_removelistener.js]
+skip-if = true # This test no longer tests what it is meant to test.
+[test_ext_permission_xhr.js]
+[test_ext_persistent_events.js]
+[test_ext_privacy.js]
+skip-if = appname == "thunderbird" || (os == "android" && debug) || (os == "linux" && !debug) #Bug 1625455
+[test_ext_privacy_disable.js]
+skip-if = appname == "thunderbird"
+[test_ext_privacy_update.js]
+[test_ext_proxy_authorization_via_proxyinfo.js]
+skip-if = true # Bug 1622433 needs h2 proxy implementation
+[test_ext_proxy_config.js]
+skip-if = appname == "thunderbird" || os == "android" # Android: Bug 1680132
+[test_ext_proxy_onauthrequired.js]
+[test_ext_proxy_settings.js]
+skip-if = appname == "thunderbird" || os == "android" # proxy settings are not supported on android
+[test_ext_proxy_socks.js]
+skip-if = socketprocess_networking
+run-sequentially = TCPServerSocket fails otherwise
+[test_ext_proxy_speculative.js]
+skip-if = ccov && os == 'linux' # bug 1607581
+[test_ext_proxy_startup.js]
+skip-if = ccov && os == 'linux' # bug 1607581
+[test_ext_redirects.js]
+skip-if = os == "android" && debug
+[test_ext_runtime_connect_no_receiver.js]
+[test_ext_runtime_getBrowserInfo.js]
+[test_ext_runtime_getPlatformInfo.js]
+[test_ext_runtime_id.js]
+skip-if = ccov && os == 'linux' # bug 1607581
+[test_ext_runtime_messaging_self.js]
+[test_ext_runtime_onInstalled_and_onStartup.js]
+[test_ext_runtime_ports.js]
+[test_ext_runtime_ports_gc.js]
+[test_ext_runtime_sendMessage.js]
+[test_ext_runtime_sendMessage_errors.js]
+[test_ext_runtime_sendMessage_multiple.js]
+[test_ext_runtime_sendMessage_no_receiver.js]
+[test_ext_same_site_cookies.js]
+[test_ext_same_site_redirects.js]
+[test_ext_sandbox_var.js]
+[test_ext_schema.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_shared_workers.js]
+[test_ext_shutdown_cleanup.js]
+[test_ext_simple.js]
+[test_ext_startupData.js]
+[test_ext_startup_cache.js]
+skip-if = os == "android"
+[test_ext_startup_perf.js]
+[test_ext_startup_request_handler.js]
+[test_ext_storage_local.js]
+skip-if = os == "android" && debug
+[test_ext_storage_idb_data_migration.js]
+skip-if = appname == "thunderbird" || (os == "android" && debug)
+[test_ext_storage_content_local.js]
+skip-if = os == "android" && debug
+[test_ext_storage_content_sync.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_storage_content_sync_kinto.js]
+skip-if = os == "android" && debug
+[test_ext_storage_quota_exceeded_errors.js]
+skip-if = os == "android" # Bug 1564871
+[test_ext_storage_managed.js]
+skip-if = os == "android"
+[test_ext_storage_managed_policy.js]
+skip-if = appname == "thunderbird" || os == "android"
+[test_ext_storage_sanitizer.js]
+skip-if = appname == "thunderbird" || os == "android" # Android: Bug 1680132
+[test_ext_storage_sync.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_storage_sync_kinto.js]
+skip-if = appname == "thunderbird" || os == "android"
+[test_ext_storage_sync_kinto_crypto.js]
+skip-if = appname == "thunderbird" || os == "android"
+[test_ext_storage_tab.js]
+[test_ext_storage_telemetry.js]
+skip-if = os == "android" # checking for telemetry needs to be updated: 1384923
+[test_ext_tab_teardown.js]
+skip-if = os == 'android' # Bug 1258975 on android.
+[test_ext_telemetry.js]
+[test_ext_trustworthy_origin.js]
+[test_ext_unlimitedStorage.js]
+[test_ext_unload_frame.js]
+skip-if = true # Too frequent intermittent failures
+[test_ext_userScripts.js]
+[test_ext_userScripts_exports.js]
+[test_ext_userScripts_telemetry.js]
+[test_ext_webRequest_auth.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_cached.js]
+skip-if = os == "android" # Bug 1573511
+[test_ext_webRequest_cancelWithReason.js]
+[test_ext_webRequest_download.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_webRequest_filterTypes.js]
+[test_ext_webRequest_from_extension_page.js]
+[test_ext_webRequest_incognito.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_filter_urls.js]
+[test_ext_webRequest_host.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_mergecsp.js]
+skip-if = tsan # Bug 1683730
+[test_ext_webRequest_permission.js]
+skip-if = os == "android" && debug
+[test_ext_webRequest_redirect_mozextension.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_webRequest_requestSize.js]
+skip-if = socketprocess_networking
+[test_ext_webRequest_set_cookie.js]
+skip-if = appname == "thunderbird"
+[test_ext_webRequest_startup.js]
+skip-if = os == "android" # bug 1683159
+[test_ext_webRequest_style_cache.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_webRequest_suspend.js]
+[test_ext_webRequest_userContextId.js]
+[test_ext_webRequest_viewsource.js]
+[test_ext_webRequest_webSocket.js]
+skip-if = appname == "thunderbird"
+[test_ext_xhr_capabilities.js]
+[test_native_manifests.js]
+subprocess = true
+skip-if = os == "android"
+[test_ext_permissions.js]
+skip-if = appname == "thunderbird" || os == "android" # Bug 1350559
+[test_ext_permissions_api.js]
+skip-if = appname == "thunderbird" || os == "android" # Bug 1350559
+[test_ext_permissions_migrate.js]
+skip-if = appname == "thunderbird" || os == "android" # Bug 1350559
+[test_ext_permissions_uninstall.js]
+skip-if = appname == "thunderbird" || os == "android" # Bug 1350559
+[test_proxy_listener.js]
+skip-if = appname == "thunderbird"
+[test_proxy_incognito.js]
+skip-if = os == "android" # incognito not supported on android
+[test_proxy_info_results.js]
+[test_proxy_userContextId.js]
+[test_webRequest_ancestors.js]
+[test_webRequest_cookies.js]
+[test_webRequest_filtering.js]
+[test_ext_brokenlinks.js]
+skip-if = os == "android" # Android: Bug 1680132
+[test_ext_performance_counters.js]
+skip-if = appname == "thunderbird" || os == "android"
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
new file mode 100644
index 0000000000..0950f7a9d3
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+prefs =
+ javascript.options.experimental.private_fields=true
+
+[test_ext_i18n.js]
+skip-if = os == "android" || (os == "win" && debug) || (os == "linux")
+[test_ext_i18n_css.js]
+[test_ext_contentscript.js]
+[test_ext_contentscript_about_blank_start.js]
+[test_ext_contentscript_canvas_tainting.js]
+[test_ext_contentscript_scriptCreated.js]
+[test_ext_contentscript_triggeringPrincipal.js]
+skip-if = os == "android" || (os == "win" && debug) || tsan || socketprocess_networking # Windows: Bug 1438796, tsan: bug 1612707, Android: Bug 1680132
+[test_ext_contentscript_xrays.js]
+[test_ext_contentScripts_register.js]
+[test_ext_contexts_gc.js]
+[test_ext_adoption_with_xrays.js]
+[test_ext_adoption_with_private_field_xrays.js]
+skip-if = !nightly_build
+[test_ext_shadowdom.js]
+skip-if = ccov && os == 'linux' # bug 1607581
+[test_ext_web_accessible_resources.js]
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini
new file mode 100644
index 0000000000..228492d00b
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini
@@ -0,0 +1,28 @@
+[DEFAULT]
+head = head.js head_e10s.js
+tail =
+firefox-appdir = browser
+skip-if = appname == "thunderbird" || os == "android"
+dupe-manifest =
+support-files =
+ data/**
+ xpcshell-content.ini
+tags = webextensions webextensions-e10s
+
+# services.settings.server/default_bucket:
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the default remote settings bucket pref name to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=http://localhost:7777/remote-settings-dummy/v1
+ services.settings.default_bucket=nonexistent-bucket-foo
+
+[include:xpcshell-common-e10s.ini]
+[include:xpcshell-content.ini]
+
+# Tests that need to run with e10s only must NOT be placed here,
+# but in xpcshell-common-e10s.ini.
+# A test here will only run on one configuration, e10s + in-process extensions,
+# while the primary target is e10s + out-of-process extensions.
+# xpcshell-common-e10s.ini runs in both configurations.
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini
new file mode 100644
index 0000000000..2df5e54b68
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+head = head.js head_remote.js head_e10s.js head_legacy_ep.js
+tail =
+firefox-appdir = browser
+skip-if = appname == "thunderbird" || os == "android"
+dupe-manifest =
+
+# services.settings.server/default_bucket:
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the default remote settings bucket pref name to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=http://localhost:7777/remote-settings-dummy/v1
+ services.settings.default_bucket=nonexistent-bucket-foo
+
+# Bug 1646182: Test the legacy ExtensionPermission backend until we fully
+# migrate to rkv
+[test_ext_permissions.js]
+[test_ext_permissions_api.js]
+[test_ext_permissions_migrate.js]
+[test_ext_permissions_uninstall.js]
+[test_ext_proxy_config.js]
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
new file mode 100644
index 0000000000..2ccd923230
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini
@@ -0,0 +1,30 @@
+[DEFAULT]
+head = head.js head_remote.js head_e10s.js head_telemetry.js head_sync.js head_storage.js
+tail =
+firefox-appdir = browser
+skip-if = os == "android"
+dupe-manifest =
+support-files =
+ data/**
+ xpcshell-content.ini
+tags = webextensions remote-webextensions
+
+# services.settings.server/default_bucket:
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the default remote settings bucket pref name to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=http://localhost:7777/remote-settings-dummy/v1
+ services.settings.default_bucket=nonexistent-bucket-foo
+
+[include:xpcshell-common.ini]
+[include:xpcshell-common-e10s.ini]
+[include:xpcshell-content.ini]
+
+[test_ext_contentscript_perf_observers.js] # Inexplicably, PerformanceObserver used in the test doesn't fire in non-e10s mode.
+skip-if = tsan
+[test_ext_contentscript_xorigin_frame.js]
+[test_WebExtensionContentScript.js]
+[test_ext_ipcBlob.js]
+skip-if = os == 'android' && processor == 'x86_64'
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..27086a31ef
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini
@@ -0,0 +1,89 @@
+[DEFAULT]
+head = head.js head_telemetry.js head_sync.js head_storage.js
+firefox-appdir = browser
+dupe-manifest =
+support-files =
+ data/**
+ xpcshell-content.ini
+tags = webextensions in-process-webextensions
+
+# services.settings.server/default_bucket:
+# Make sure that loading the default settings for url-classifier-skip-urls
+# doesn't interfere with running our tests while IDB operations are in
+# flight by overriding the default remote settings bucket pref name to
+# ensure that the IDB database isn't created in the first place.
+prefs =
+ services.settings.server=http://localhost:7777/remote-settings-dummy/v1
+ services.settings.default_bucket=nonexistent-bucket-foo
+
+# This file contains tests which are not affected by multi-process
+# configuration, or do not support out-of-process content or extensions
+# for one reason or another.
+#
+# Tests which are affected by remote content or remote extensions should
+# go in one of:
+#
+# - xpcshell-common.ini
+# For tests which should run in all configurations.
+# - xpcshell-common-e10s.ini
+# For tests which should run in all configurations where e10s is enabled.
+# - xpcshell-remote.ini
+# For tests which should only run with both remote extensions and remote content.
+# - xpcshell-content.ini
+# For tests which rely on content pages, and should run in all configurations.
+# - xpcshell-e10s.ini
+# For tests which rely on content pages, and should only run with remote content
+# but in-process extensions.
+
+[test_ExtensionStorageSync_migration_kinto.js]
+skip-if = os == 'android' # Not shipped on Android
+[test_MatchPattern.js]
+[test_StorageSyncService.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_WebExtensionPolicy.js]
+
+[test_csp_custom_policies.js]
+[test_csp_validator.js]
+[test_ext_contexts.js]
+[test_ext_json_parser.js]
+[test_ext_geckoProfiler_schema.js]
+skip-if = os == 'android' # Not shipped on Android
+[test_ext_manifest.js]
+skip-if = toolkit == 'android' # browser_action icon testing not supported on android
+[test_ext_manifest_content_security_policy.js]
+[test_ext_manifest_incognito.js]
+[test_ext_indexedDB_principal.js]
+[test_ext_manifest_minimum_chrome_version.js]
+[test_ext_manifest_minimum_opera_version.js]
+[test_ext_manifest_themes.js]
+[test_ext_permission_warnings.js]
+[test_ext_schemas.js]
+[test_ext_schemas_roots.js]
+[test_ext_schemas_async.js]
+[test_ext_schemas_allowed_contexts.js]
+[test_ext_schemas_interactive.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_ext_schemas_manifest_permissions.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_ext_schemas_privileged.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_ext_schemas_revoke.js]
+[test_ext_test_mock.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_ext_test_wrapper.js]
+[test_ext_unknown_permissions.js]
+skip-if = os == 'android' && processor == 'x86_64'
+[test_ext_webRequest_urlclassification.js]
+[test_extension_permissions_migration.js]
+[test_load_all_api_modules.js]
+[test_locale_converter.js]
+[test_locale_data.js]
+skip-if = os == 'android' && processor == 'x86_64'
+
+[test_ext_runtime_sendMessage_args.js]
+skip-if = os == 'android' && processor == 'x86_64'
+
+[include:xpcshell-common.ini]
+run-if = os == 'android' # Android has no remote extensions, Bug 1535365
+[include:xpcshell-content.ini]
+run-if = os == 'android' # Android has no remote extensions, Bug 1535365
diff --git a/toolkit/components/extensions/webrequest/ChannelWrapper.cpp b/toolkit/components/extensions/webrequest/ChannelWrapper.cpp
new file mode 100644
index 0000000000..f313a3aec8
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/ChannelWrapper.cpp
@@ -0,0 +1,1205 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#include "ChannelWrapper.h"
+
+#include "jsapi.h"
+#include "xpcpublic.h"
+
+#include "mozilla/BasePrincipal.h"
+#include "mozilla/SystemPrincipal.h"
+
+#include "NSSErrorsService.h"
+#include "nsITransportSecurityInfo.h"
+
+#include "mozilla/AddonManagerWebAPI.h"
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/ErrorNames.h"
+#include "mozilla/ResultExtensions.h"
+#include "mozilla/Unused.h"
+#include "mozilla/dom/Element.h"
+#include "mozilla/dom/Event.h"
+#include "mozilla/dom/EventBinding.h"
+#include "mozilla/dom/BrowserHost.h"
+#include "mozIThirdPartyUtil.h"
+#include "nsContentUtils.h"
+#include "nsIContentPolicy.h"
+#include "nsIClassifiedChannel.h"
+#include "nsIHttpChannelInternal.h"
+#include "nsIHttpHeaderVisitor.h"
+#include "nsIInterfaceRequestor.h"
+#include "nsIInterfaceRequestorUtils.h"
+#include "nsILoadContext.h"
+#include "nsIProxiedChannel.h"
+#include "nsIProxyInfo.h"
+#include "nsITraceableChannel.h"
+#include "nsIWritablePropertyBag.h"
+#include "nsIWritablePropertyBag2.h"
+#include "nsNetUtil.h"
+#include "nsProxyRelease.h"
+#include "nsPrintfCString.h"
+
+using namespace mozilla::dom;
+using namespace JS;
+
+namespace mozilla {
+namespace extensions {
+
+#define CHANNELWRAPPER_PROP_KEY u"ChannelWrapper::CachedInstance"_ns
+
+using CF = nsIClassifiedChannel::ClassificationFlags;
+using MUC = MozUrlClassificationFlags;
+
+struct ClassificationStruct {
+ uint32_t mFlag;
+ MozUrlClassificationFlags mValue;
+};
+static const ClassificationStruct classificationArray[] = {
+ {CF::CLASSIFIED_FINGERPRINTING, MUC::Fingerprinting},
+ {CF::CLASSIFIED_FINGERPRINTING_CONTENT, MUC::Fingerprinting_content},
+ {CF::CLASSIFIED_CRYPTOMINING, MUC::Cryptomining},
+ {CF::CLASSIFIED_CRYPTOMINING_CONTENT, MUC::Cryptomining_content},
+ {CF::CLASSIFIED_TRACKING, MUC::Tracking},
+ {CF::CLASSIFIED_TRACKING_AD, MUC::Tracking_ad},
+ {CF::CLASSIFIED_TRACKING_ANALYTICS, MUC::Tracking_analytics},
+ {CF::CLASSIFIED_TRACKING_SOCIAL, MUC::Tracking_social},
+ {CF::CLASSIFIED_TRACKING_CONTENT, MUC::Tracking_content},
+ {CF::CLASSIFIED_SOCIALTRACKING, MUC::Socialtracking},
+ {CF::CLASSIFIED_SOCIALTRACKING_FACEBOOK, MUC::Socialtracking_facebook},
+ {CF::CLASSIFIED_SOCIALTRACKING_LINKEDIN, MUC::Socialtracking_linkedin},
+ {CF::CLASSIFIED_SOCIALTRACKING_TWITTER, MUC::Socialtracking_twitter},
+ {CF::CLASSIFIED_ANY_BASIC_TRACKING, MUC::Any_basic_tracking},
+ {CF::CLASSIFIED_ANY_STRICT_TRACKING, MUC::Any_strict_tracking},
+ {CF::CLASSIFIED_ANY_SOCIAL_TRACKING, MUC::Any_social_tracking}};
+
+/*****************************************************************************
+ * Lifetimes
+ *****************************************************************************/
+
+namespace {
+class ChannelListHolder : public LinkedList<ChannelWrapper> {
+ public:
+ ChannelListHolder() : LinkedList<ChannelWrapper>() {}
+
+ ~ChannelListHolder();
+};
+
+} // anonymous namespace
+
+ChannelListHolder::~ChannelListHolder() {
+ while (ChannelWrapper* wrapper = popFirst()) {
+ wrapper->Die();
+ }
+}
+
+static LinkedList<ChannelWrapper>& ChannelList() {
+ static UniquePtr<ChannelListHolder> sChannelList;
+ if (!sChannelList) {
+ sChannelList.reset(new ChannelListHolder());
+ ClearOnShutdown(&sChannelList, ShutdownPhase::Shutdown);
+ }
+ return *sChannelList;
+}
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(ChannelWrapper::ChannelWrapperStub)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(ChannelWrapper::ChannelWrapperStub)
+
+NS_IMPL_CYCLE_COLLECTION(ChannelWrapper::ChannelWrapperStub, mChannelWrapper)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ChannelWrapper::ChannelWrapperStub)
+ NS_INTERFACE_MAP_ENTRY_TEAROFF(ChannelWrapper, mChannelWrapper)
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+/*****************************************************************************
+ * Initialization
+ *****************************************************************************/
+
+ChannelWrapper::ChannelWrapper(nsISupports* aParent, nsIChannel* aChannel)
+ : ChannelHolder(aChannel), mParent(aParent) {
+ mStub = new ChannelWrapperStub(this);
+
+ ChannelList().insertBack(this);
+}
+
+ChannelWrapper::~ChannelWrapper() {
+ if (LinkedListElement<ChannelWrapper>::isInList()) {
+ LinkedListElement<ChannelWrapper>::remove();
+ }
+}
+
+void ChannelWrapper::Die() {
+ if (mStub) {
+ mStub->mChannelWrapper = nullptr;
+ }
+}
+
+/* static */
+already_AddRefed<ChannelWrapper> ChannelWrapper::Get(const GlobalObject& global,
+ nsIChannel* channel) {
+ RefPtr<ChannelWrapper> wrapper;
+
+ nsCOMPtr<nsIWritablePropertyBag2> props = do_QueryInterface(channel);
+ if (props) {
+ Unused << props->GetPropertyAsInterface(CHANNELWRAPPER_PROP_KEY,
+ NS_GET_IID(ChannelWrapper),
+ getter_AddRefs(wrapper));
+
+ if (wrapper) {
+ // Assume cached attributes may have changed at this point.
+ wrapper->ClearCachedAttributes();
+ }
+ }
+
+ if (!wrapper) {
+ wrapper = new ChannelWrapper(global.GetAsSupports(), channel);
+ if (props) {
+ Unused << props->SetPropertyAsInterface(CHANNELWRAPPER_PROP_KEY,
+ wrapper->mStub);
+ }
+ }
+
+ return wrapper.forget();
+}
+
+already_AddRefed<ChannelWrapper> ChannelWrapper::GetRegisteredChannel(
+ const GlobalObject& global, uint64_t aChannelId,
+ const WebExtensionPolicy& aAddon, nsIRemoteTab* aRemoteTab) {
+ ContentParent* contentParent = nullptr;
+ if (BrowserHost* host = BrowserHost::GetFrom(aRemoteTab)) {
+ contentParent = host->GetActor()->Manager();
+ }
+
+ auto& webreq = WebRequestService::GetSingleton();
+
+ nsCOMPtr<nsITraceableChannel> channel =
+ webreq.GetTraceableChannel(aChannelId, aAddon.Id(), contentParent);
+ if (!channel) {
+ return nullptr;
+ }
+ nsCOMPtr<nsIChannel> chan(do_QueryInterface(channel));
+ return ChannelWrapper::Get(global, chan);
+}
+
+void ChannelWrapper::SetChannel(nsIChannel* aChannel) {
+ detail::ChannelHolder::SetChannel(aChannel);
+ ClearCachedAttributes();
+ ChannelWrapper_Binding::ClearCachedFinalURIValue(this);
+ ChannelWrapper_Binding::ClearCachedFinalURLValue(this);
+ mFinalURLInfo.reset();
+ ChannelWrapper_Binding::ClearCachedProxyInfoValue(this);
+}
+
+void ChannelWrapper::ClearCachedAttributes() {
+ ChannelWrapper_Binding::ClearCachedRemoteAddressValue(this);
+ ChannelWrapper_Binding::ClearCachedStatusCodeValue(this);
+ ChannelWrapper_Binding::ClearCachedStatusLineValue(this);
+ ChannelWrapper_Binding::ClearCachedUrlClassificationValue(this);
+ if (!mFiredErrorEvent) {
+ ChannelWrapper_Binding::ClearCachedErrorStringValue(this);
+ }
+
+ ChannelWrapper_Binding::ClearCachedRequestSizeValue(this);
+ ChannelWrapper_Binding::ClearCachedResponseSizeValue(this);
+}
+
+/*****************************************************************************
+ * ...
+ *****************************************************************************/
+
+void ChannelWrapper::Cancel(uint32_t aResult, uint32_t aReason,
+ ErrorResult& aRv) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
+ nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo();
+ if (aReason > 0 && loadInfo) {
+ loadInfo->SetRequestBlockingReason(aReason);
+ }
+ rv = chan->Cancel(nsresult(aResult));
+ ErrorCheck();
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+void ChannelWrapper::RedirectTo(nsIURI* aURI, ErrorResult& aRv) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ rv = chan->RedirectTo(aURI);
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+void ChannelWrapper::UpgradeToSecure(ErrorResult& aRv) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ rv = chan->UpgradeToSecure();
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+void ChannelWrapper::Suspend(ErrorResult& aRv) {
+ if (!mSuspended) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
+ mSuspendTime = mozilla::TimeStamp::NowUnfuzzed();
+ rv = chan->Suspend();
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ } else {
+ mSuspended = true;
+ }
+ }
+}
+
+void ChannelWrapper::Resume(const nsCString& aText, ErrorResult& aRv) {
+ if (mSuspended) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
+ rv = chan->Resume();
+
+ PROFILER_MARKER_TEXT("Extension Suspend", NETWORK,
+ MarkerTiming::IntervalUntilNowFrom(mSuspendTime),
+ aText);
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ } else {
+ mSuspended = false;
+ }
+ }
+}
+
+void ChannelWrapper::GetContentType(nsCString& aContentType) const {
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->GetContentType(aContentType);
+ }
+}
+
+void ChannelWrapper::SetContentType(const nsACString& aContentType) {
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->SetContentType(aContentType);
+ }
+}
+
+/*****************************************************************************
+ * Headers
+ *****************************************************************************/
+
+namespace {
+
+class MOZ_STACK_CLASS HeaderVisitor final : public nsIHttpHeaderVisitor {
+ public:
+ NS_DECL_NSIHTTPHEADERVISITOR
+
+ explicit HeaderVisitor(nsTArray<dom::MozHTTPHeader>& aHeaders)
+ : mHeaders(aHeaders) {}
+
+ HeaderVisitor(nsTArray<dom::MozHTTPHeader>& aHeaders,
+ const nsCString& aContentTypeHdr)
+ : mHeaders(aHeaders), mContentTypeHdr(aContentTypeHdr) {}
+
+ void VisitRequestHeaders(nsIHttpChannel* aChannel, ErrorResult& aRv) {
+ CheckResult(aChannel->VisitRequestHeaders(this), aRv);
+ }
+
+ void VisitResponseHeaders(nsIHttpChannel* aChannel, ErrorResult& aRv) {
+ CheckResult(aChannel->VisitResponseHeaders(this), aRv);
+ }
+
+ NS_IMETHOD QueryInterface(REFNSIID aIID, void** aInstancePtr) override;
+
+ // Stub AddRef/Release since this is a stack class.
+ NS_IMETHOD_(MozExternalRefCountType) AddRef(void) override {
+ return ++mRefCnt;
+ }
+
+ NS_IMETHOD_(MozExternalRefCountType) Release(void) override {
+ return --mRefCnt;
+ }
+
+ virtual ~HeaderVisitor() { MOZ_DIAGNOSTIC_ASSERT(mRefCnt == 0); }
+
+ private:
+ bool CheckResult(nsresult aNSRv, ErrorResult& aRv) {
+ if (NS_FAILED(aNSRv)) {
+ aRv.Throw(aNSRv);
+ return false;
+ }
+ return true;
+ }
+
+ nsTArray<dom::MozHTTPHeader>& mHeaders;
+ nsCString mContentTypeHdr = VoidCString();
+
+ nsrefcnt mRefCnt = 0;
+};
+
+NS_IMETHODIMP
+HeaderVisitor::VisitHeader(const nsACString& aHeader,
+ const nsACString& aValue) {
+ auto dict = mHeaders.AppendElement(fallible);
+ if (!dict) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ dict->mName = aHeader;
+
+ if (!mContentTypeHdr.IsVoid() &&
+ aHeader.LowerCaseEqualsLiteral("content-type")) {
+ dict->mValue = mContentTypeHdr;
+ } else {
+ dict->mValue = aValue;
+ }
+
+ return NS_OK;
+}
+
+NS_IMPL_QUERY_INTERFACE(HeaderVisitor, nsIHttpHeaderVisitor)
+
+} // anonymous namespace
+
+void ChannelWrapper::GetRequestHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal,
+ ErrorResult& aRv) const {
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ HeaderVisitor visitor(aRetVal);
+ visitor.VisitRequestHeaders(chan, aRv);
+ } else {
+ aRv.Throw(NS_ERROR_UNEXPECTED);
+ }
+}
+
+void ChannelWrapper::GetRequestHeader(const nsCString& aHeader,
+ nsCString& aResult,
+ ErrorResult& aRv) const {
+ aResult.SetIsVoid(true);
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->GetRequestHeader(aHeader, aResult);
+ } else {
+ aRv.Throw(NS_ERROR_UNEXPECTED);
+ }
+}
+
+void ChannelWrapper::GetResponseHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal,
+ ErrorResult& aRv) const {
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ HeaderVisitor visitor(aRetVal, mContentTypeHdr);
+ visitor.VisitResponseHeaders(chan, aRv);
+ } else {
+ aRv.Throw(NS_ERROR_UNEXPECTED);
+ }
+}
+
+void ChannelWrapper::SetRequestHeader(const nsCString& aHeader,
+ const nsCString& aValue, bool aMerge,
+ ErrorResult& aRv) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ rv = chan->SetRequestHeader(aHeader, aValue, aMerge);
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+void ChannelWrapper::SetResponseHeader(const nsCString& aHeader,
+ const nsCString& aValue, bool aMerge,
+ ErrorResult& aRv) {
+ nsresult rv = NS_ERROR_UNEXPECTED;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ if (aHeader.LowerCaseEqualsLiteral("content-type")) {
+ rv = chan->SetContentType(aValue);
+ if (NS_SUCCEEDED(rv)) {
+ mContentTypeHdr = aValue;
+ }
+ } else {
+ rv = chan->SetResponseHeader(aHeader, aValue, aMerge);
+ }
+ }
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+/*****************************************************************************
+ * LoadInfo
+ *****************************************************************************/
+
+already_AddRefed<nsILoadContext> ChannelWrapper::GetLoadContext() const {
+ if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
+ nsCOMPtr<nsILoadContext> ctxt;
+ NS_QueryNotificationCallbacks(chan, ctxt);
+ return ctxt.forget();
+ }
+ return nullptr;
+}
+
+already_AddRefed<Element> ChannelWrapper::GetBrowserElement() const {
+ if (nsCOMPtr<nsILoadContext> ctxt = GetLoadContext()) {
+ RefPtr<Element> elem;
+ if (NS_SUCCEEDED(ctxt->GetTopFrameElement(getter_AddRefs(elem)))) {
+ return elem.forget();
+ }
+ }
+ return nullptr;
+}
+
+static inline bool IsSystemPrincipal(nsIPrincipal* aPrincipal) {
+ return BasePrincipal::Cast(aPrincipal)->Is<SystemPrincipal>();
+}
+
+bool ChannelWrapper::IsSystemLoad() const {
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ if (nsIPrincipal* prin = loadInfo->GetLoadingPrincipal()) {
+ return IsSystemPrincipal(prin);
+ }
+
+ if (RefPtr<BrowsingContext> bc = loadInfo->GetBrowsingContext();
+ !bc || bc->IsTop()) {
+ return false;
+ }
+
+ if (nsIPrincipal* prin = loadInfo->PrincipalToInherit()) {
+ return IsSystemPrincipal(prin);
+ }
+ if (nsIPrincipal* prin = loadInfo->TriggeringPrincipal()) {
+ return IsSystemPrincipal(prin);
+ }
+ }
+ return false;
+}
+
+bool ChannelWrapper::CanModify() const {
+ if (WebExtensionPolicy::IsRestrictedURI(FinalURLInfo())) {
+ return false;
+ }
+
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ if (nsIPrincipal* prin = loadInfo->GetLoadingPrincipal()) {
+ if (IsSystemPrincipal(prin)) {
+ return false;
+ }
+
+ auto* docURI = DocumentURLInfo();
+ if (docURI && WebExtensionPolicy::IsRestrictedURI(*docURI)) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+already_AddRefed<nsIURI> ChannelWrapper::GetOriginURI() const {
+ nsCOMPtr<nsIURI> uri;
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ if (nsIPrincipal* prin = loadInfo->TriggeringPrincipal()) {
+ if (prin->GetIsContentPrincipal()) {
+ auto* basePrin = BasePrincipal::Cast(prin);
+ Unused << basePrin->GetURI(getter_AddRefs(uri));
+ }
+ }
+ }
+ return uri.forget();
+}
+
+already_AddRefed<nsIURI> ChannelWrapper::GetDocumentURI() const {
+ nsCOMPtr<nsIURI> uri;
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ if (nsIPrincipal* prin = loadInfo->GetLoadingPrincipal()) {
+ if (prin->GetIsContentPrincipal()) {
+ auto* basePrin = BasePrincipal::Cast(prin);
+ Unused << basePrin->GetURI(getter_AddRefs(uri));
+ }
+ }
+ }
+ return uri.forget();
+}
+
+void ChannelWrapper::GetOriginURL(nsCString& aRetVal) const {
+ if (nsCOMPtr<nsIURI> uri = GetOriginURI()) {
+ Unused << uri->GetSpec(aRetVal);
+ }
+}
+
+void ChannelWrapper::GetDocumentURL(nsCString& aRetVal) const {
+ if (nsCOMPtr<nsIURI> uri = GetDocumentURI()) {
+ Unused << uri->GetSpec(aRetVal);
+ }
+}
+
+const URLInfo& ChannelWrapper::FinalURLInfo() const {
+ if (mFinalURLInfo.isNothing()) {
+ ErrorResult rv;
+ nsCOMPtr<nsIURI> uri = FinalURI();
+ MOZ_ASSERT(uri);
+
+ // If this is a view-source scheme, get the nested uri.
+ while (uri && uri->SchemeIs("view-source")) {
+ nsCOMPtr<nsINestedURI> nested = do_QueryInterface(uri);
+ if (!nested) {
+ break;
+ }
+ nested->GetInnerURI(getter_AddRefs(uri));
+ }
+ mFinalURLInfo.emplace(uri.get(), true);
+
+ // If this is a WebSocket request, mangle the URL so that the scheme is
+ // ws: or wss:, as appropriate.
+ auto& url = mFinalURLInfo.ref();
+ if (Type() == MozContentPolicyType::Websocket &&
+ (url.Scheme() == nsGkAtoms::http || url.Scheme() == nsGkAtoms::https)) {
+ nsAutoCString spec(url.CSpec());
+ spec.Replace(0, 4, "ws"_ns);
+
+ Unused << NS_NewURI(getter_AddRefs(uri), spec);
+ MOZ_RELEASE_ASSERT(uri);
+ mFinalURLInfo.reset();
+ mFinalURLInfo.emplace(uri.get(), true);
+ }
+ }
+ return mFinalURLInfo.ref();
+}
+
+const URLInfo* ChannelWrapper::DocumentURLInfo() const {
+ if (mDocumentURLInfo.isNothing()) {
+ nsCOMPtr<nsIURI> uri = GetDocumentURI();
+ if (!uri) {
+ return nullptr;
+ }
+ mDocumentURLInfo.emplace(uri.get(), true);
+ }
+ return &mDocumentURLInfo.ref();
+}
+
+bool ChannelWrapper::Matches(
+ const dom::MozRequestFilter& aFilter, const WebExtensionPolicy* aExtension,
+ const dom::MozRequestMatchOptions& aOptions) const {
+ if (!HaveChannel()) {
+ return false;
+ }
+
+ if (!aFilter.mTypes.IsNull() && !aFilter.mTypes.Value().Contains(Type())) {
+ return false;
+ }
+
+ auto& urlInfo = FinalURLInfo();
+ if (aFilter.mUrls && !aFilter.mUrls->Matches(urlInfo)) {
+ return false;
+ }
+
+ nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo();
+ bool isPrivate =
+ loadInfo && loadInfo->GetOriginAttributes().mPrivateBrowsingId > 0;
+ if (!aFilter.mIncognito.IsNull() && aFilter.mIncognito.Value() != isPrivate) {
+ return false;
+ }
+
+ if (aExtension) {
+ // Verify extension access to private requests
+ if (isPrivate && !aExtension->PrivateBrowsingAllowed()) {
+ return false;
+ }
+
+ bool isProxy =
+ aOptions.mIsProxy && aExtension->HasPermission(nsGkAtoms::proxy);
+ // Proxies are allowed access to all urls, including restricted urls.
+ if (!aExtension->CanAccessURI(urlInfo, false, !isProxy, true)) {
+ return false;
+ }
+
+ // If this isn't the proxy phase of the request, check that the extension
+ // has origin permissions for origin that originated the request.
+ if (!isProxy) {
+ if (IsSystemLoad()) {
+ return false;
+ }
+
+ auto origin = DocumentURLInfo();
+ // Extensions with the file:-permission may observe requests from file:
+ // origins, because such documents can already be modified by content
+ // scripts anyway.
+ if (origin && !aExtension->CanAccessURI(*origin, false, true, true)) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+int64_t NormalizeFrameID(nsILoadInfo* aLoadInfo, uint64_t bcID) {
+ if (RefPtr<BrowsingContext> bc = aLoadInfo->GetBrowsingContext();
+ !bc || bcID == bc->Top()->Id()) {
+ return 0;
+ }
+ return bcID;
+}
+
+uint64_t ChannelWrapper::BrowsingContextId(nsILoadInfo* aLoadInfo) const {
+ auto frameID = aLoadInfo->GetFrameBrowsingContextID();
+ if (!frameID) {
+ frameID = aLoadInfo->GetBrowsingContextID();
+ }
+ return frameID;
+}
+
+int64_t ChannelWrapper::FrameId() const {
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ return NormalizeFrameID(loadInfo, BrowsingContextId(loadInfo));
+ }
+ return 0;
+}
+
+int64_t ChannelWrapper::ParentFrameId() const {
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ if (RefPtr<BrowsingContext> bc = loadInfo->GetBrowsingContext()) {
+ if (BrowsingContextId(loadInfo) == bc->Top()->Id()) {
+ return -1;
+ }
+
+ uint64_t parentID = -1;
+ if (loadInfo->GetFrameBrowsingContextID()) {
+ parentID = loadInfo->GetBrowsingContextID();
+ } else if (bc->GetParent()) {
+ parentID = bc->GetParent()->Id();
+ }
+ return NormalizeFrameID(loadInfo, parentID);
+ }
+ }
+ return -1;
+}
+
+void ChannelWrapper::GetFrameAncestors(
+ dom::Nullable<nsTArray<dom::MozFrameAncestorInfo>>& aFrameAncestors,
+ ErrorResult& aRv) const {
+ nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo();
+ if (!loadInfo || BrowsingContextId(loadInfo) == 0) {
+ aFrameAncestors.SetNull();
+ return;
+ }
+
+ nsresult rv = GetFrameAncestors(loadInfo, aFrameAncestors.SetValue());
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ }
+}
+
+nsresult ChannelWrapper::GetFrameAncestors(
+ nsILoadInfo* aLoadInfo,
+ nsTArray<dom::MozFrameAncestorInfo>& aFrameAncestors) const {
+ const nsTArray<nsCOMPtr<nsIPrincipal>>& ancestorPrincipals =
+ aLoadInfo->AncestorPrincipals();
+ const nsTArray<uint64_t>& ancestorBrowsingContextIDs =
+ aLoadInfo->AncestorBrowsingContextIDs();
+ uint32_t size = ancestorPrincipals.Length();
+ MOZ_DIAGNOSTIC_ASSERT(size == ancestorBrowsingContextIDs.Length());
+ if (size != ancestorBrowsingContextIDs.Length()) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ bool subFrame = aLoadInfo->GetExternalContentPolicyType() ==
+ ExtContentPolicy::TYPE_SUBDOCUMENT;
+ if (!aFrameAncestors.SetCapacity(subFrame ? size : size + 1, fallible)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+
+ // The immediate parent is always the first element in the ancestor arrays,
+ // however SUBDOCUMENTs do not have their immediate parent included, so we
+ // inject it here. This will force wrapper.parentBrowsingContextId ==
+ // wrapper.frameAncestors[0].frameId to always be true. All ather requests
+ // already match this way.
+ if (subFrame) {
+ auto ancestor = aFrameAncestors.AppendElement();
+ GetDocumentURL(ancestor->mUrl);
+ ancestor->mFrameId = ParentFrameId();
+ }
+
+ for (uint32_t i = 0; i < size; ++i) {
+ auto ancestor = aFrameAncestors.AppendElement();
+ MOZ_TRY(ancestorPrincipals[i]->GetAsciiSpec(ancestor->mUrl));
+ ancestor->mFrameId =
+ NormalizeFrameID(aLoadInfo, ancestorBrowsingContextIDs[i]);
+ }
+ return NS_OK;
+}
+
+/*****************************************************************************
+ * Response filtering
+ *****************************************************************************/
+
+void ChannelWrapper::RegisterTraceableChannel(const WebExtensionPolicy& aAddon,
+ nsIRemoteTab* aBrowserParent) {
+ // We can't attach new listeners after the response has started, so don't
+ // bother registering anything.
+ if (mResponseStarted || !CanModify()) {
+ return;
+ }
+
+ mAddonEntries.Put(aAddon.Id(), aBrowserParent);
+ if (!mChannelEntry) {
+ mChannelEntry = WebRequestService::GetSingleton().RegisterChannel(this);
+ CheckEventListeners();
+ }
+}
+
+already_AddRefed<nsITraceableChannel> ChannelWrapper::GetTraceableChannel(
+ nsAtom* aAddonId, dom::ContentParent* aContentParent) const {
+ nsCOMPtr<nsIRemoteTab> remoteTab;
+ if (mAddonEntries.Get(aAddonId, getter_AddRefs(remoteTab))) {
+ ContentParent* contentParent = nullptr;
+ if (remoteTab) {
+ contentParent =
+ BrowserHost::GetFrom(remoteTab.get())->GetActor()->Manager();
+ }
+
+ if (contentParent == aContentParent) {
+ nsCOMPtr<nsITraceableChannel> chan = QueryChannel();
+ return chan.forget();
+ }
+ }
+ return nullptr;
+}
+
+/*****************************************************************************
+ * ...
+ *****************************************************************************/
+
+MozContentPolicyType GetContentPolicyType(ExtContentPolicyType aType) {
+ // Note: Please keep this function in sync with the external types in
+ // nsIContentPolicy.idl
+ switch (aType) {
+ case ExtContentPolicy::TYPE_DOCUMENT:
+ return MozContentPolicyType::Main_frame;
+ case ExtContentPolicy::TYPE_SUBDOCUMENT:
+ return MozContentPolicyType::Sub_frame;
+ case ExtContentPolicy::TYPE_STYLESHEET:
+ return MozContentPolicyType::Stylesheet;
+ case ExtContentPolicy::TYPE_SCRIPT:
+ return MozContentPolicyType::Script;
+ case ExtContentPolicy::TYPE_IMAGE:
+ return MozContentPolicyType::Image;
+ case ExtContentPolicy::TYPE_OBJECT:
+ return MozContentPolicyType::Object;
+ case ExtContentPolicy::TYPE_OBJECT_SUBREQUEST:
+ return MozContentPolicyType::Object_subrequest;
+ case ExtContentPolicy::TYPE_XMLHTTPREQUEST:
+ return MozContentPolicyType::Xmlhttprequest;
+ // TYPE_FETCH returns xmlhttprequest for cross-browser compatibility.
+ case ExtContentPolicy::TYPE_FETCH:
+ return MozContentPolicyType::Xmlhttprequest;
+ case ExtContentPolicy::TYPE_XSLT:
+ return MozContentPolicyType::Xslt;
+ case ExtContentPolicy::TYPE_PING:
+ return MozContentPolicyType::Ping;
+ case ExtContentPolicy::TYPE_BEACON:
+ return MozContentPolicyType::Beacon;
+ case ExtContentPolicy::TYPE_DTD:
+ return MozContentPolicyType::Xml_dtd;
+ case ExtContentPolicy::TYPE_FONT:
+ return MozContentPolicyType::Font;
+ case ExtContentPolicy::TYPE_MEDIA:
+ return MozContentPolicyType::Media;
+ case ExtContentPolicy::TYPE_WEBSOCKET:
+ return MozContentPolicyType::Websocket;
+ case ExtContentPolicy::TYPE_CSP_REPORT:
+ return MozContentPolicyType::Csp_report;
+ case ExtContentPolicy::TYPE_IMAGESET:
+ return MozContentPolicyType::Imageset;
+ case ExtContentPolicy::TYPE_WEB_MANIFEST:
+ return MozContentPolicyType::Web_manifest;
+ case ExtContentPolicy::TYPE_SPECULATIVE:
+ return MozContentPolicyType::Speculative;
+ case ExtContentPolicy::TYPE_INVALID:
+ case ExtContentPolicy::TYPE_OTHER:
+ case ExtContentPolicy::TYPE_SAVEAS_DOWNLOAD:
+ break;
+ // Do not add default: so that compilers can catch the missing case.
+ }
+ return MozContentPolicyType::Other;
+}
+
+MozContentPolicyType ChannelWrapper::Type() const {
+ if (nsCOMPtr<nsILoadInfo> loadInfo = GetLoadInfo()) {
+ return GetContentPolicyType(loadInfo->GetExternalContentPolicyType());
+ }
+ return MozContentPolicyType::Other;
+}
+
+void ChannelWrapper::GetMethod(nsCString& aMethod) const {
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->GetRequestMethod(aMethod);
+ }
+}
+
+/*****************************************************************************
+ * ...
+ *****************************************************************************/
+
+uint32_t ChannelWrapper::StatusCode() const {
+ uint32_t result = 0;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->GetResponseStatus(&result);
+ }
+ return result;
+}
+
+void ChannelWrapper::GetStatusLine(nsCString& aRetVal) const {
+ nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel();
+ nsCOMPtr<nsIHttpChannelInternal> internal = do_QueryInterface(chan);
+
+ if (internal) {
+ nsAutoCString statusText;
+ uint32_t major, minor, status;
+ if (NS_FAILED(chan->GetResponseStatus(&status)) ||
+ NS_FAILED(chan->GetResponseStatusText(statusText)) ||
+ NS_FAILED(internal->GetResponseVersion(&major, &minor))) {
+ return;
+ }
+
+ aRetVal = nsPrintfCString("HTTP/%u.%u %u %s", major, minor, status,
+ statusText.get());
+ }
+}
+
+uint64_t ChannelWrapper::ResponseSize() const {
+ uint64_t result = 0;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->GetTransferSize(&result);
+ }
+ return result;
+}
+
+uint64_t ChannelWrapper::RequestSize() const {
+ uint64_t result = 0;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ Unused << chan->GetRequestSize(&result);
+ }
+ return result;
+}
+
+/*****************************************************************************
+ * ...
+ *****************************************************************************/
+
+already_AddRefed<nsIURI> ChannelWrapper::FinalURI() const {
+ nsCOMPtr<nsIURI> uri;
+ if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
+ NS_GetFinalChannelURI(chan, getter_AddRefs(uri));
+ }
+ return uri.forget();
+}
+
+void ChannelWrapper::GetFinalURL(nsString& aRetVal) const {
+ if (HaveChannel()) {
+ aRetVal = FinalURLInfo().Spec();
+ }
+}
+
+/*****************************************************************************
+ * ...
+ *****************************************************************************/
+
+nsresult FillProxyInfo(MozProxyInfo& aDict, nsIProxyInfo* aProxyInfo) {
+ MOZ_TRY(aProxyInfo->GetHost(aDict.mHost));
+ MOZ_TRY(aProxyInfo->GetPort(&aDict.mPort));
+ MOZ_TRY(aProxyInfo->GetType(aDict.mType));
+ MOZ_TRY(aProxyInfo->GetUsername(aDict.mUsername));
+ MOZ_TRY(
+ aProxyInfo->GetProxyAuthorizationHeader(aDict.mProxyAuthorizationHeader));
+ MOZ_TRY(aProxyInfo->GetConnectionIsolationKey(aDict.mConnectionIsolationKey));
+ MOZ_TRY(aProxyInfo->GetFailoverTimeout(&aDict.mFailoverTimeout.Construct()));
+
+ uint32_t flags;
+ MOZ_TRY(aProxyInfo->GetFlags(&flags));
+ aDict.mProxyDNS = flags & nsIProxyInfo::TRANSPARENT_PROXY_RESOLVES_HOST;
+
+ return NS_OK;
+}
+
+void ChannelWrapper::GetProxyInfo(dom::Nullable<MozProxyInfo>& aRetVal,
+ ErrorResult& aRv) const {
+ nsCOMPtr<nsIProxyInfo> proxyInfo;
+ if (nsCOMPtr<nsIProxiedChannel> proxied = QueryChannel()) {
+ Unused << proxied->GetProxyInfo(getter_AddRefs(proxyInfo));
+ }
+ if (proxyInfo) {
+ MozProxyInfo result;
+
+ nsresult rv = FillProxyInfo(result, proxyInfo);
+ if (NS_FAILED(rv)) {
+ aRv.Throw(rv);
+ } else {
+ aRetVal.SetValue(std::move(result));
+ }
+ }
+}
+
+void ChannelWrapper::GetRemoteAddress(nsCString& aRetVal) const {
+ aRetVal.SetIsVoid(true);
+ if (nsCOMPtr<nsIHttpChannelInternal> internal = QueryChannel()) {
+ Unused << internal->GetRemoteAddress(aRetVal);
+ }
+}
+
+void FillClassification(
+ Sequence<mozilla::dom::MozUrlClassificationFlags>& classifications,
+ uint32_t classificationFlags, ErrorResult& aRv) {
+ if (classificationFlags == 0) {
+ return;
+ }
+ for (const auto& entry : classificationArray) {
+ if (classificationFlags & entry.mFlag) {
+ if (!classifications.AppendElement(entry.mValue, mozilla::fallible)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+ }
+ }
+}
+
+void ChannelWrapper::GetUrlClassification(
+ dom::Nullable<dom::MozUrlClassification>& aRetVal, ErrorResult& aRv) const {
+ MozUrlClassification classification;
+ if (nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel()) {
+ nsCOMPtr<nsIClassifiedChannel> classified = do_QueryInterface(chan);
+ MOZ_DIAGNOSTIC_ASSERT(
+ classified,
+ "Must be an object inheriting from both nsIHttpChannel and "
+ "nsIClassifiedChannel");
+ uint32_t classificationFlags;
+ classified->GetFirstPartyClassificationFlags(&classificationFlags);
+ FillClassification(classification.mFirstParty, classificationFlags, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ classified->GetThirdPartyClassificationFlags(&classificationFlags);
+ FillClassification(classification.mThirdParty, classificationFlags, aRv);
+ }
+ aRetVal.SetValue(std::move(classification));
+}
+
+bool ChannelWrapper::ThirdParty() const {
+ nsCOMPtr<mozIThirdPartyUtil> thirdPartyUtil = services::GetThirdPartyUtil();
+ if (NS_WARN_IF(!thirdPartyUtil)) {
+ return true;
+ }
+
+ nsCOMPtr<nsIHttpChannel> chan = MaybeHttpChannel();
+ if (!chan) {
+ return false;
+ }
+
+ bool thirdParty = false;
+ nsresult rv = thirdPartyUtil->IsThirdPartyChannel(chan, nullptr, &thirdParty);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return true;
+ }
+
+ return thirdParty;
+}
+
+/*****************************************************************************
+ * Error handling
+ *****************************************************************************/
+
+void ChannelWrapper::GetErrorString(nsString& aRetVal) const {
+ if (nsCOMPtr<nsIChannel> chan = MaybeChannel()) {
+ nsCOMPtr<nsISupports> securityInfo;
+ Unused << chan->GetSecurityInfo(getter_AddRefs(securityInfo));
+ if (nsCOMPtr<nsITransportSecurityInfo> tsi =
+ do_QueryInterface(securityInfo)) {
+ int32_t errorCode = 0;
+ tsi->GetErrorCode(&errorCode);
+ if (psm::IsNSSErrorCode(errorCode)) {
+ nsCOMPtr<nsINSSErrorsService> nsserr =
+ do_GetService(NS_NSS_ERRORS_SERVICE_CONTRACTID);
+
+ nsresult rv = psm::GetXPCOMFromNSSError(errorCode);
+ if (nsserr && NS_SUCCEEDED(nsserr->GetErrorMessage(rv, aRetVal))) {
+ return;
+ }
+ }
+ }
+
+ nsresult status;
+ if (NS_SUCCEEDED(chan->GetStatus(&status)) && NS_FAILED(status)) {
+ nsAutoCString name;
+ GetErrorName(status, name);
+ AppendUTF8toUTF16(name, aRetVal);
+ } else {
+ aRetVal.SetIsVoid(true);
+ }
+ } else {
+ aRetVal.AssignLiteral("NS_ERROR_UNEXPECTED");
+ }
+}
+
+void ChannelWrapper::ErrorCheck() {
+ if (!mFiredErrorEvent) {
+ nsAutoString error;
+ GetErrorString(error);
+ if (error.Length()) {
+ mChannelEntry = nullptr;
+ mFiredErrorEvent = true;
+ ChannelWrapper_Binding::ClearCachedErrorStringValue(this);
+ FireEvent(u"error"_ns);
+ }
+ }
+}
+
+/*****************************************************************************
+ * nsIWebRequestListener
+ *****************************************************************************/
+
+NS_IMPL_ISUPPORTS(ChannelWrapper::RequestListener, nsIStreamListener,
+ nsIMultiPartChannelListener, nsIRequestObserver,
+ nsIThreadRetargetableStreamListener)
+
+ChannelWrapper::RequestListener::~RequestListener() {
+ NS_ReleaseOnMainThread("RequestListener::mChannelWrapper",
+ mChannelWrapper.forget());
+}
+
+nsresult ChannelWrapper::RequestListener::Init() {
+ if (nsCOMPtr<nsITraceableChannel> chan = mChannelWrapper->QueryChannel()) {
+ return chan->SetNewListener(this, false,
+ getter_AddRefs(mOrigStreamListener));
+ }
+ return NS_ERROR_UNEXPECTED;
+}
+
+NS_IMETHODIMP
+ChannelWrapper::RequestListener::OnStartRequest(nsIRequest* request) {
+ MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener");
+
+ mChannelWrapper->mChannelEntry = nullptr;
+ mChannelWrapper->mResponseStarted = true;
+ mChannelWrapper->ErrorCheck();
+ mChannelWrapper->FireEvent(u"start"_ns);
+
+ return mOrigStreamListener->OnStartRequest(request);
+}
+
+NS_IMETHODIMP
+ChannelWrapper::RequestListener::OnStopRequest(nsIRequest* request,
+ nsresult aStatus) {
+ MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener");
+
+ mChannelWrapper->mChannelEntry = nullptr;
+ mChannelWrapper->ErrorCheck();
+ mChannelWrapper->FireEvent(u"stop"_ns);
+
+ return mOrigStreamListener->OnStopRequest(request, aStatus);
+}
+
+NS_IMETHODIMP
+ChannelWrapper::RequestListener::OnDataAvailable(nsIRequest* request,
+ nsIInputStream* inStr,
+ uint64_t sourceOffset,
+ uint32_t count) {
+ MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener");
+ return mOrigStreamListener->OnDataAvailable(request, inStr, sourceOffset,
+ count);
+}
+
+NS_IMETHODIMP
+ChannelWrapper::RequestListener::OnAfterLastPart(nsresult aStatus) {
+ MOZ_ASSERT(mOrigStreamListener, "Should have mOrigStreamListener");
+ if (nsCOMPtr<nsIMultiPartChannelListener> listener =
+ do_QueryInterface(mOrigStreamListener)) {
+ return listener->OnAfterLastPart(aStatus);
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+ChannelWrapper::RequestListener::CheckListenerChain() {
+ MOZ_ASSERT(NS_IsMainThread(), "Should be on main thread!");
+ nsresult rv;
+ nsCOMPtr<nsIThreadRetargetableStreamListener> retargetableListener =
+ do_QueryInterface(mOrigStreamListener, &rv);
+ if (retargetableListener) {
+ return retargetableListener->CheckListenerChain();
+ }
+ return rv;
+}
+
+/*****************************************************************************
+ * Event dispatching
+ *****************************************************************************/
+
+void ChannelWrapper::FireEvent(const nsAString& aType) {
+ EventInit init;
+ init.mBubbles = false;
+ init.mCancelable = false;
+
+ RefPtr<Event> event = Event::Constructor(this, aType, init);
+ event->SetTrusted(true);
+
+ DispatchEvent(*event);
+}
+
+void ChannelWrapper::CheckEventListeners() {
+ if (!mAddedStreamListener &&
+ (HasListenersFor(nsGkAtoms::onerror) ||
+ HasListenersFor(nsGkAtoms::onstart) ||
+ HasListenersFor(nsGkAtoms::onstop) || mChannelEntry)) {
+ auto listener = MakeRefPtr<RequestListener>(this);
+ if (!NS_WARN_IF(NS_FAILED(listener->Init()))) {
+ mAddedStreamListener = true;
+ }
+ }
+}
+
+void ChannelWrapper::EventListenerAdded(nsAtom* aType) {
+ CheckEventListeners();
+}
+
+void ChannelWrapper::EventListenerRemoved(nsAtom* aType) {
+ CheckEventListeners();
+}
+
+/*****************************************************************************
+ * Glue
+ *****************************************************************************/
+
+JSObject* ChannelWrapper::WrapObject(JSContext* aCx, HandleObject aGivenProto) {
+ return ChannelWrapper_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(ChannelWrapper)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ChannelWrapper)
+ NS_INTERFACE_MAP_ENTRY(ChannelWrapper)
+NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(ChannelWrapper,
+ DOMEventTargetHelper)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mStub)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK_WEAK_PTR
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(ChannelWrapper,
+ DOMEventTargetHelper)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mStub)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(ChannelWrapper,
+ DOMEventTargetHelper)
+NS_IMPL_CYCLE_COLLECTION_TRACE_END
+
+NS_IMPL_ADDREF_INHERITED(ChannelWrapper, DOMEventTargetHelper)
+NS_IMPL_RELEASE_INHERITED(ChannelWrapper, DOMEventTargetHelper)
+
+} // namespace extensions
+} // namespace mozilla
diff --git a/toolkit/components/extensions/webrequest/ChannelWrapper.h b/toolkit/components/extensions/webrequest/ChannelWrapper.h
new file mode 100644
index 0000000000..3f2299c93c
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/ChannelWrapper.h
@@ -0,0 +1,352 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_extensions_ChannelWrapper_h
+#define mozilla_extensions_ChannelWrapper_h
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/ChannelWrapperBinding.h"
+
+#include "mozilla/WebRequestService.h"
+#include "mozilla/extensions/MatchPattern.h"
+#include "mozilla/extensions/WebExtensionPolicy.h"
+
+#include "mozilla/Attributes.h"
+#include "mozilla/LinkedList.h"
+#include "mozilla/Maybe.h"
+#include "mozilla/UniquePtr.h"
+#include "mozilla/WeakPtr.h"
+
+#include "mozilla/DOMEventTargetHelper.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsIChannel.h"
+#include "nsIHttpChannel.h"
+#include "nsIMultiPartChannel.h"
+#include "nsIStreamListener.h"
+#include "nsIRemoteTab.h"
+#include "nsIThreadRetargetableStreamListener.h"
+#include "nsPointerHashKeys.h"
+#include "nsInterfaceHashtable.h"
+#include "nsIWeakReferenceUtils.h"
+#include "nsWrapperCache.h"
+
+#define NS_CHANNELWRAPPER_IID \
+ { \
+ 0xc06162d2, 0xb803, 0x43b4, { \
+ 0xaa, 0x31, 0xcf, 0x69, 0x7f, 0x93, 0x68, 0x1c \
+ } \
+ }
+
+class nsILoadContext;
+class nsITraceableChannel;
+
+namespace mozilla {
+namespace dom {
+class ContentParent;
+class Element;
+} // namespace dom
+namespace extensions {
+
+namespace detail {
+
+// We need to store our wrapped channel as a weak reference, since channels
+// are not cycle collected, and we're going to be hanging this wrapper
+// instance off the channel in order to ensure the same channel always has
+// the same wrapper.
+//
+// But since performance matters here, and we don't want to have to
+// QueryInterface the channel every time we touch it, we store separate
+// nsIChannel and nsIHttpChannel weak references, and check that the WeakPtr
+// is alive before returning it.
+//
+// This holder class prevents us from accidentally touching the weak pointer
+// members directly from our ChannelWrapper class.
+struct ChannelHolder {
+ explicit ChannelHolder(nsIChannel* aChannel)
+ : mChannel(do_GetWeakReference(aChannel)), mWeakChannel(aChannel) {}
+
+ bool HaveChannel() const { return mChannel && mChannel->IsAlive(); }
+
+ void SetChannel(nsIChannel* aChannel) {
+ mChannel = do_GetWeakReference(aChannel);
+ mWeakChannel = aChannel;
+ mWeakHttpChannel.reset();
+ }
+
+ already_AddRefed<nsIChannel> MaybeChannel() const {
+ if (!HaveChannel()) {
+ mWeakChannel = nullptr;
+ }
+ return do_AddRef(mWeakChannel);
+ }
+
+ already_AddRefed<nsIHttpChannel> MaybeHttpChannel() const {
+ if (mWeakHttpChannel.isNothing()) {
+ nsCOMPtr<nsIHttpChannel> chan = QueryChannel();
+ mWeakHttpChannel.emplace(chan.get());
+ }
+
+ if (!HaveChannel()) {
+ mWeakHttpChannel.ref() = nullptr;
+ }
+ return do_AddRef(mWeakHttpChannel.value());
+ }
+
+ const nsQueryReferent QueryChannel() const {
+ return do_QueryReferent(mChannel);
+ }
+
+ private:
+ nsWeakPtr mChannel;
+
+ mutable nsIChannel* MOZ_NON_OWNING_REF mWeakChannel;
+ mutable Maybe<nsIHttpChannel*> MOZ_NON_OWNING_REF mWeakHttpChannel;
+};
+} // namespace detail
+
+class WebRequestChannelEntry;
+
+class ChannelWrapper final : public DOMEventTargetHelper,
+ public SupportsWeakPtr,
+ public LinkedListElement<ChannelWrapper>,
+ private detail::ChannelHolder {
+ public:
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(ChannelWrapper,
+ DOMEventTargetHelper)
+
+ NS_DECLARE_STATIC_IID_ACCESSOR(NS_CHANNELWRAPPER_IID)
+
+ void Die();
+
+ static already_AddRefed<extensions::ChannelWrapper> Get(
+ const dom::GlobalObject& global, nsIChannel* channel);
+ static already_AddRefed<extensions::ChannelWrapper> GetRegisteredChannel(
+ const dom::GlobalObject& global, uint64_t aChannelId,
+ const WebExtensionPolicy& aAddon, nsIRemoteTab* aBrowserParent);
+
+ uint64_t Id() const { return mId; }
+
+ already_AddRefed<nsIChannel> GetChannel() const { return MaybeChannel(); }
+
+ void SetChannel(nsIChannel* aChannel);
+
+ void Cancel(uint32_t result, uint32_t reason, ErrorResult& aRv);
+
+ void RedirectTo(nsIURI* uri, ErrorResult& aRv);
+ void UpgradeToSecure(ErrorResult& aRv);
+
+ bool Suspended() const { return mSuspended; }
+ void Suspend(ErrorResult& aRv);
+ void Resume(const nsCString& aText, ErrorResult& aRv);
+
+ void GetContentType(nsCString& aContentType) const;
+ void SetContentType(const nsACString& aContentType);
+
+ void RegisterTraceableChannel(const WebExtensionPolicy& aAddon,
+ nsIRemoteTab* aBrowserParent);
+
+ already_AddRefed<nsITraceableChannel> GetTraceableChannel(
+ nsAtom* aAddonId, dom::ContentParent* aContentParent) const;
+
+ void GetMethod(nsCString& aRetVal) const;
+
+ dom::MozContentPolicyType Type() const;
+
+ uint32_t StatusCode() const;
+
+ uint64_t ResponseSize() const;
+
+ uint64_t RequestSize() const;
+
+ void GetStatusLine(nsCString& aRetVal) const;
+
+ void GetErrorString(nsString& aRetVal) const;
+
+ void ErrorCheck();
+
+ IMPL_EVENT_HANDLER(error);
+ IMPL_EVENT_HANDLER(start);
+ IMPL_EVENT_HANDLER(stop);
+
+ already_AddRefed<nsIURI> FinalURI() const;
+
+ void GetFinalURL(nsString& aRetVal) const;
+
+ bool Matches(const dom::MozRequestFilter& aFilter,
+ const WebExtensionPolicy* aExtension,
+ const dom::MozRequestMatchOptions& aOptions) const;
+
+ already_AddRefed<nsILoadInfo> GetLoadInfo() const {
+ nsCOMPtr<nsIChannel> chan = MaybeChannel();
+ if (chan) {
+ return chan->LoadInfo();
+ }
+ return nullptr;
+ }
+
+ int64_t FrameId() const;
+
+ int64_t ParentFrameId() const;
+
+ void GetFrameAncestors(
+ dom::Nullable<nsTArray<dom::MozFrameAncestorInfo>>& aFrameAncestors,
+ ErrorResult& aRv) const;
+
+ bool IsSystemLoad() const;
+
+ void GetOriginURL(nsCString& aRetVal) const;
+
+ void GetDocumentURL(nsCString& aRetVal) const;
+
+ already_AddRefed<nsIURI> GetOriginURI() const;
+
+ already_AddRefed<nsIURI> GetDocumentURI() const;
+
+ already_AddRefed<nsILoadContext> GetLoadContext() const;
+
+ already_AddRefed<dom::Element> GetBrowserElement() const;
+
+ bool CanModify() const;
+ bool GetCanModify(ErrorResult& aRv) const { return CanModify(); }
+
+ void GetProxyInfo(dom::Nullable<dom::MozProxyInfo>& aRetVal,
+ ErrorResult& aRv) const;
+
+ void GetRemoteAddress(nsCString& aRetVal) const;
+
+ void GetRequestHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal,
+ ErrorResult& aRv) const;
+ void GetRequestHeader(const nsCString& aHeader, nsCString& aResult,
+ ErrorResult& aRv) const;
+
+ void GetResponseHeaders(nsTArray<dom::MozHTTPHeader>& aRetVal,
+ ErrorResult& aRv) const;
+
+ void SetRequestHeader(const nsCString& header, const nsCString& value,
+ bool merge, ErrorResult& aRv);
+
+ void SetResponseHeader(const nsCString& header, const nsCString& value,
+ bool merge, ErrorResult& aRv);
+
+ void GetUrlClassification(dom::Nullable<dom::MozUrlClassification>& aRetVal,
+ ErrorResult& aRv) const;
+
+ bool ThirdParty() const;
+
+ using EventTarget::EventListenerAdded;
+ using EventTarget::EventListenerRemoved;
+ virtual void EventListenerAdded(nsAtom* aType) override;
+ virtual void EventListenerRemoved(nsAtom* aType) override;
+
+ nsISupports* GetParentObject() const { return mParent; }
+
+ JSObject* WrapObject(JSContext* aCx, JS::HandleObject aGivenProto) override;
+
+ protected:
+ ~ChannelWrapper();
+
+ private:
+ ChannelWrapper(nsISupports* aParent, nsIChannel* aChannel);
+
+ void ClearCachedAttributes();
+
+ bool CheckAlive(ErrorResult& aRv) const {
+ if (!HaveChannel()) {
+ aRv.Throw(NS_ERROR_UNEXPECTED);
+ return false;
+ }
+ return true;
+ }
+
+ void FireEvent(const nsAString& aType);
+
+ const URLInfo& FinalURLInfo() const;
+ const URLInfo* DocumentURLInfo() const;
+
+ uint64_t BrowsingContextId(nsILoadInfo* aLoadInfo) const;
+
+ nsresult GetFrameAncestors(
+ nsILoadInfo* aLoadInfo,
+ nsTArray<dom::MozFrameAncestorInfo>& aFrameAncestors) const;
+
+ static uint64_t GetNextId() {
+ static uint64_t sNextId = 1;
+ return ++sNextId;
+ }
+
+ void CheckEventListeners();
+
+ class ChannelWrapperStub final : public nsISupports {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS(ChannelWrapperStub)
+
+ explicit ChannelWrapperStub(ChannelWrapper* aChannelWrapper)
+ : mChannelWrapper(aChannelWrapper) {}
+
+ private:
+ friend class ChannelWrapper;
+
+ RefPtr<ChannelWrapper> mChannelWrapper;
+
+ protected:
+ ~ChannelWrapperStub() = default;
+ };
+
+ RefPtr<ChannelWrapperStub> mStub;
+
+ mutable Maybe<URLInfo> mFinalURLInfo;
+ mutable Maybe<URLInfo> mDocumentURLInfo;
+
+ UniquePtr<WebRequestChannelEntry> mChannelEntry;
+
+ // The overridden Content-Type header value.
+ nsCString mContentTypeHdr = VoidCString();
+
+ const uint64_t mId = GetNextId();
+ nsCOMPtr<nsISupports> mParent;
+
+ bool mAddedStreamListener = false;
+ bool mFiredErrorEvent = false;
+ bool mSuspended = false;
+ bool mResponseStarted = false;
+
+ nsInterfaceHashtable<nsPtrHashKey<const nsAtom>, nsIRemoteTab> mAddonEntries;
+
+ mozilla::TimeStamp mSuspendTime;
+
+ class RequestListener final : public nsIStreamListener,
+ public nsIMultiPartChannelListener,
+ public nsIThreadRetargetableStreamListener {
+ public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_NSIREQUESTOBSERVER
+ NS_DECL_NSISTREAMLISTENER
+ NS_DECL_NSIMULTIPARTCHANNELLISTENER
+ NS_DECL_NSITHREADRETARGETABLESTREAMLISTENER
+
+ explicit RequestListener(ChannelWrapper* aWrapper)
+ : mChannelWrapper(aWrapper) {}
+
+ nsresult Init();
+
+ protected:
+ virtual ~RequestListener();
+
+ private:
+ RefPtr<ChannelWrapper> mChannelWrapper;
+ nsCOMPtr<nsIStreamListener> mOrigStreamListener;
+ };
+};
+
+NS_DEFINE_STATIC_IID_ACCESSOR(ChannelWrapper, NS_CHANNELWRAPPER_IID)
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_ChannelWrapper_h
diff --git a/toolkit/components/extensions/webrequest/PStreamFilter.ipdl b/toolkit/components/extensions/webrequest/PStreamFilter.ipdl
new file mode 100644
index 0000000000..80e03cc8cf
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/PStreamFilter.ipdl
@@ -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/. */
+
+include protocol PBackground;
+
+namespace mozilla {
+namespace extensions {
+
+async protocol PStreamFilter
+{
+parent:
+ async Write(uint8_t[] data);
+
+ async FlushedData();
+
+ async Suspend();
+ async Resume();
+ async Close();
+ async Disconnect();
+ async Destroy();
+
+child:
+ async Resumed();
+ async Suspended();
+ async Closed();
+ async Error(nsCString error);
+
+ async FlushData();
+
+ async StartRequest();
+ async Data(uint8_t[] data);
+ async StopRequest(nsresult aStatus);
+};
+
+} // namespace extensions
+} // namespace mozilla
+
diff --git a/toolkit/components/extensions/webrequest/SecurityInfo.jsm b/toolkit/components/extensions/webrequest/SecurityInfo.jsm
new file mode 100644
index 0000000000..4652aa28da
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/SecurityInfo.jsm
@@ -0,0 +1,328 @@
+/* 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 EXPORTED_SYMBOLS = ["SecurityInfo"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const wpl = Ci.nsIWebProgressListener;
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "NSSErrorsService",
+ "@mozilla.org/nss_errors_service;1",
+ "nsINSSErrorsService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "sss",
+ "@mozilla.org/ssservice;1",
+ "nsISiteSecurityService"
+);
+
+// NOTE: SecurityInfo is largely reworked from the devtools NetworkHelper with changes
+// to better support the WebRequest api. The objects returned are formatted specifically
+// to pass through as part of a response to webRequest listeners.
+
+const SecurityInfo = {
+ /**
+ * Extracts security information from nsIChannel.securityInfo.
+ *
+ * @param {nsIChannel} channel
+ * If null channel is assumed to be insecure.
+ * @param {Object} options
+ *
+ * @returns {Object}
+ * Returns an object containing following members:
+ * - state: The security of the connection used to fetch this
+ * request. Has one of following string values:
+ * * "insecure": the connection was not secure (only http)
+ * * "weak": the connection has minor security issues
+ * * "broken": secure connection failed (e.g. expired cert)
+ * * "secure": the connection was properly secured.
+ * If state == broken:
+ * - errorMessage: full error message from
+ * nsITransportSecurityInfo.
+ * If state == secure:
+ * - protocolVersion: one of TLSv1, TLSv1.1, TLSv1.2, TLSv1.3.
+ * - cipherSuite: the cipher suite used in this connection.
+ * - cert: information about certificate used in this connection.
+ * See parseCertificateInfo for the contents.
+ * - hsts: true if host uses Strict Transport Security,
+ * false otherwise
+ * - hpkp: true if host uses Public Key Pinning, false otherwise
+ * If state == weak: Same as state == secure and
+ * - weaknessReasons: list of reasons that cause the request to be
+ * considered weak. See getReasonsForWeakness.
+ */
+ getSecurityInfo(channel, options = {}) {
+ const info = {
+ state: "insecure",
+ };
+
+ /**
+ * Different scenarios to consider here and how they are handled:
+ * - request is HTTP, the connection is not secure
+ * => securityInfo is null
+ * => state === "insecure"
+ *
+ * - request is HTTPS, the connection is secure
+ * => .securityState has STATE_IS_SECURE flag
+ * => state === "secure"
+ *
+ * - request is HTTPS, the connection has security issues
+ * => .securityState has STATE_IS_INSECURE flag
+ * => .errorCode is an NSS error code.
+ * => state === "broken"
+ *
+ * - request is HTTPS, the connection was terminated before the security
+ * could be validated
+ * => .securityState has STATE_IS_INSECURE flag
+ * => .errorCode is NOT an NSS error code.
+ * => .errorMessage is not available.
+ * => state === "insecure"
+ *
+ * - request is HTTPS but it uses a weak cipher or old protocol, see
+ * https://hg.mozilla.org/mozilla-central/annotate/def6ed9d1c1a/
+ * security/manager/ssl/nsNSSCallbacks.cpp#l1233
+ * - request is mixed content (which makes no sense whatsoever)
+ * => .securityState has STATE_IS_BROKEN flag
+ * => .errorCode is NOT an NSS error code
+ * => .errorMessage is not available
+ * => state === "weak"
+ */
+
+ let securityInfo = channel.securityInfo;
+ if (!securityInfo) {
+ return info;
+ }
+
+ securityInfo.QueryInterface(Ci.nsITransportSecurityInfo);
+
+ if (NSSErrorsService.isNSSErrorCode(securityInfo.errorCode)) {
+ // The connection failed.
+ info.state = "broken";
+ info.errorMessage = securityInfo.errorMessage;
+ if (options.certificateChain && securityInfo.failedCertChain) {
+ info.certificates = this.getCertificateChain(
+ securityInfo.failedCertChain,
+ options
+ );
+ }
+ return info;
+ }
+
+ const state = securityInfo.securityState;
+
+ let uri = channel.URI;
+ if (uri && !uri.schemeIs("https") && !uri.schemeIs("wss")) {
+ // it is not enough to look at the transport security info -
+ // schemes other than https and wss are subject to
+ // downgrade/etc at the scheme level and should always be
+ // considered insecure.
+ // Leave info.state = "insecure";
+ } else if (state & wpl.STATE_IS_SECURE) {
+ // The connection is secure if the scheme is sufficient
+ info.state = "secure";
+ } else if (state & wpl.STATE_IS_BROKEN) {
+ // The connection is not secure, there was no error but there's some
+ // minor security issues.
+ info.state = "weak";
+ info.weaknessReasons = this.getReasonsForWeakness(state);
+ } else if (state & wpl.STATE_IS_INSECURE) {
+ // This was most likely an https request that was aborted before
+ // validation. Return info as info.state = insecure.
+ return info;
+ } else {
+ // No known STATE_IS_* flags.
+ return info;
+ }
+
+ // Cipher suite.
+ info.cipherSuite = securityInfo.cipherName;
+
+ // Key exchange group name.
+ if (securityInfo.keaGroupName !== "none") {
+ info.keaGroupName = securityInfo.keaGroupName;
+ }
+
+ // Certificate signature scheme.
+ if (securityInfo.signatureSchemeName !== "none") {
+ info.signatureSchemeName = securityInfo.signatureSchemeName;
+ }
+
+ info.isDomainMismatch = securityInfo.isDomainMismatch;
+ info.isExtendedValidation = securityInfo.isExtendedValidation;
+ info.isNotValidAtThisTime = securityInfo.isNotValidAtThisTime;
+ info.isUntrusted = securityInfo.isUntrusted;
+
+ info.certificateTransparencyStatus = this.getTransparencyStatus(
+ securityInfo.certificateTransparencyStatus
+ );
+
+ // Protocol version.
+ info.protocolVersion = this.formatSecurityProtocol(
+ securityInfo.protocolVersion
+ );
+
+ if (options.certificateChain && securityInfo.succeededCertChain) {
+ info.certificates = this.getCertificateChain(
+ securityInfo.succeededCertChain,
+ options
+ );
+ } else {
+ info.certificates = [
+ this.parseCertificateInfo(securityInfo.serverCert, options),
+ ];
+ }
+
+ // HSTS and static pinning if available.
+ if (uri && uri.host) {
+ // SiteSecurityService uses different storage if the channel is
+ // private. Thus we must give isSecureURI correct flags or we
+ // might get incorrect results.
+ let flags = 0;
+ if (
+ channel instanceof Ci.nsIPrivateBrowsingChannel &&
+ channel.isChannelPrivate
+ ) {
+ flags = Ci.nsISocketProvider.NO_PERMANENT_STORAGE;
+ }
+
+ info.hsts = sss.isSecureURI(sss.HEADER_HSTS, uri, flags);
+ info.hpkp = sss.isSecureURI(sss.STATIC_PINNING, uri, flags);
+ } else {
+ info.hsts = false;
+ info.hpkp = false;
+ }
+
+ return info;
+ },
+
+ getCertificateChain(certChain, options = {}) {
+ let certificates = [];
+ for (let cert of certChain) {
+ certificates.push(this.parseCertificateInfo(cert, options));
+ }
+ return certificates;
+ },
+
+ /**
+ * Takes an nsIX509Cert and returns an object with certificate information.
+ *
+ * @param {nsIX509Cert} cert
+ * The certificate to extract the information from.
+ * @param {Object} options
+ * @returns {Object}
+ * An object with following format:
+ * {
+ * subject: subjectName,
+ * issuer: issuerName,
+ * validity: { start, end },
+ * fingerprint: { sha1, sha256 }
+ * }
+ */
+ parseCertificateInfo(cert, options = {}) {
+ if (!cert) {
+ return {};
+ }
+
+ let certData = {
+ subject: cert.subjectName,
+ issuer: cert.issuerName,
+ validity: {
+ start: cert.validity.notBefore
+ ? Math.trunc(cert.validity.notBefore / 1000)
+ : 0,
+ end: cert.validity.notAfter
+ ? Math.trunc(cert.validity.notAfter / 1000)
+ : 0,
+ },
+ fingerprint: {
+ sha1: cert.sha1Fingerprint,
+ sha256: cert.sha256Fingerprint,
+ },
+ serialNumber: cert.serialNumber,
+ isBuiltInRoot: cert.isBuiltInRoot,
+ subjectPublicKeyInfoDigest: {
+ sha256: cert.sha256SubjectPublicKeyInfoDigest,
+ },
+ };
+ if (options.rawDER) {
+ certData.rawDER = cert.getRawDER();
+ }
+ return certData;
+ },
+
+ // Bug 1355903 Transparency is currently disabled using security.pki.certificate_transparency.mode
+ getTransparencyStatus(status) {
+ switch (status) {
+ case Ci.nsITransportSecurityInfo.CERTIFICATE_TRANSPARENCY_NOT_APPLICABLE:
+ return "not_applicable";
+ case Ci.nsITransportSecurityInfo
+ .CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT:
+ return "policy_compliant";
+ case Ci.nsITransportSecurityInfo
+ .CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS:
+ return "policy_not_enough_scts";
+ case Ci.nsITransportSecurityInfo
+ .CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS:
+ return "policy_not_diverse_scts";
+ }
+ return "unknown";
+ },
+
+ /**
+ * Takes protocolVersion of TransportSecurityInfo object and returns human readable
+ * description.
+ *
+ * @param {number} version
+ * One of nsITransportSecurityInfo version constants.
+ * @returns {string}
+ * One of TLSv1, TLSv1.1, TLSv1.2, TLSv1.3 if version
+ * is valid, Unknown otherwise.
+ */
+ formatSecurityProtocol(version) {
+ switch (version) {
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1:
+ return "TLSv1";
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_1:
+ return "TLSv1.1";
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_2:
+ return "TLSv1.2";
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_3:
+ return "TLSv1.3";
+ }
+ return "unknown";
+ },
+
+ /**
+ * Takes the securityState bitfield and returns reasons for weak connection
+ * as an array of strings.
+ *
+ * @param {number} state
+ * nsITransportSecurityInfo.securityState.
+ *
+ * @returns {array<string>}
+ * List of weakness reasons. A subset of { cipher } where
+ * * cipher: The cipher suite is consireded to be weak (RC4).
+ */
+ getReasonsForWeakness(state) {
+ // If there's non-fatal security issues the request has STATE_IS_BROKEN
+ // flag set. See https://hg.mozilla.org/mozilla-central/file/44344099d119
+ // /security/manager/ssl/nsNSSCallbacks.cpp#l1233
+ let reasons = [];
+
+ if (state & wpl.STATE_IS_BROKEN) {
+ if (state & wpl.STATE_USES_WEAK_CRYPTO) {
+ reasons.push("cipher");
+ }
+ }
+
+ return reasons;
+ },
+};
diff --git a/toolkit/components/extensions/webrequest/StreamFilter.cpp b/toolkit/components/extensions/webrequest/StreamFilter.cpp
new file mode 100644
index 0000000000..178689f333
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilter.cpp
@@ -0,0 +1,286 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#include "StreamFilter.h"
+
+#include "jsapi.h"
+#include "jsfriendapi.h"
+#include "xpcpublic.h"
+
+#include "mozilla/AbstractThread.h"
+#include "mozilla/HoldDropJSObjects.h"
+#include "mozilla/extensions/StreamFilterChild.h"
+#include "mozilla/extensions/StreamFilterEvents.h"
+#include "mozilla/extensions/StreamFilterParent.h"
+#include "mozilla/dom/ContentChild.h"
+#include "mozilla/ipc/Endpoint.h"
+#include "nsContentUtils.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsLiteralString.h"
+#include "nsThreadUtils.h"
+#include "nsTArray.h"
+
+using namespace JS;
+using namespace mozilla::dom;
+
+namespace mozilla {
+namespace extensions {
+
+/*****************************************************************************
+ * Initialization
+ *****************************************************************************/
+
+StreamFilter::StreamFilter(nsIGlobalObject* aParent, uint64_t aRequestId,
+ const nsAString& aAddonId)
+ : mParent(aParent), mChannelId(aRequestId), mAddonId(NS_Atomize(aAddonId)) {
+ MOZ_ASSERT(aParent);
+
+ Connect();
+};
+
+StreamFilter::~StreamFilter() { ForgetActor(); }
+
+void StreamFilter::ForgetActor() {
+ if (mActor) {
+ mActor->Cleanup();
+ mActor->SetStreamFilter(nullptr);
+ }
+}
+
+/* static */
+already_AddRefed<StreamFilter> StreamFilter::Create(GlobalObject& aGlobal,
+ uint64_t aRequestId,
+ const nsAString& aAddonId) {
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+ MOZ_ASSERT(global);
+
+ RefPtr<StreamFilter> filter = new StreamFilter(global, aRequestId, aAddonId);
+ return filter.forget();
+}
+
+/*****************************************************************************
+ * Actor allocation
+ *****************************************************************************/
+
+void StreamFilter::Connect() {
+ MOZ_ASSERT(!mActor);
+
+ mActor = new StreamFilterChild();
+ mActor->SetStreamFilter(this);
+
+ nsAutoString addonId;
+ mAddonId->ToString(addonId);
+
+ ContentChild* cc = ContentChild::GetSingleton();
+ RefPtr<StreamFilter> self(this);
+ if (cc) {
+ cc->SendInitStreamFilter(mChannelId, addonId)
+ ->Then(
+ GetCurrentSerialEventTarget(), __func__,
+ [self](mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint) {
+ self->FinishConnect(std::move(aEndpoint));
+ },
+ [self](mozilla::ipc::ResponseRejectReason&& aReason) {
+ self->mActor->RecvInitialized(false);
+ });
+ } else {
+ StreamFilterParent::Create(nullptr, mChannelId, addonId)
+ ->Then(
+ GetCurrentSerialEventTarget(), __func__,
+ [self](mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint) {
+ self->FinishConnect(std::move(aEndpoint));
+ },
+ [self](bool aDummy) { self->mActor->RecvInitialized(false); });
+ }
+}
+
+void StreamFilter::FinishConnect(
+ mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint) {
+ if (aEndpoint.IsValid()) {
+ MOZ_RELEASE_ASSERT(aEndpoint.Bind(mActor));
+ mActor->RecvInitialized(true);
+
+ // IPC now owns this reference.
+ Unused << do_AddRef(mActor);
+ } else {
+ mActor->RecvInitialized(false);
+ }
+}
+
+bool StreamFilter::CheckAlive() {
+ // Check whether the global that owns this StreamFitler is still scriptable
+ // and, if not, disconnect the actor so that it can be cleaned up.
+ JSObject* wrapper = GetWrapperPreserveColor();
+ if (!wrapper || !xpc::Scriptability::Get(wrapper).Allowed()) {
+ ForgetActor();
+ return false;
+ }
+ return true;
+}
+
+/*****************************************************************************
+ * Binding methods
+ *****************************************************************************/
+
+template <typename T>
+static inline bool ReadTypedArrayData(nsTArray<uint8_t>& aData, const T& aArray,
+ ErrorResult& aRv) {
+ aArray.ComputeState();
+ if (!aData.SetLength(aArray.Length(), fallible)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return false;
+ }
+ memcpy(aData.Elements(), aArray.Data(), aArray.Length());
+ return true;
+}
+
+void StreamFilter::Write(const ArrayBufferOrUint8Array& aData,
+ ErrorResult& aRv) {
+ if (!mActor) {
+ aRv.Throw(NS_ERROR_NOT_INITIALIZED);
+ return;
+ }
+
+ nsTArray<uint8_t> data;
+
+ bool ok;
+ if (aData.IsArrayBuffer()) {
+ ok = ReadTypedArrayData(data, aData.GetAsArrayBuffer(), aRv);
+ } else if (aData.IsUint8Array()) {
+ ok = ReadTypedArrayData(data, aData.GetAsUint8Array(), aRv);
+ } else {
+ MOZ_ASSERT_UNREACHABLE("Argument should be ArrayBuffer or Uint8Array");
+ return;
+ }
+
+ if (ok) {
+ mActor->Write(std::move(data), aRv);
+ }
+}
+
+StreamFilterStatus StreamFilter::Status() const {
+ if (!mActor) {
+ return StreamFilterStatus::Uninitialized;
+ }
+ return mActor->Status();
+}
+
+void StreamFilter::Suspend(ErrorResult& aRv) {
+ if (mActor) {
+ mActor->Suspend(aRv);
+ } else {
+ aRv.Throw(NS_ERROR_NOT_INITIALIZED);
+ }
+}
+
+void StreamFilter::Resume(ErrorResult& aRv) {
+ if (mActor) {
+ mActor->Resume(aRv);
+ } else {
+ aRv.Throw(NS_ERROR_NOT_INITIALIZED);
+ }
+}
+
+void StreamFilter::Disconnect(ErrorResult& aRv) {
+ if (mActor) {
+ mActor->Disconnect(aRv);
+ } else {
+ aRv.Throw(NS_ERROR_NOT_INITIALIZED);
+ }
+}
+
+void StreamFilter::Close(ErrorResult& aRv) {
+ if (mActor) {
+ mActor->Close(aRv);
+ } else {
+ aRv.Throw(NS_ERROR_NOT_INITIALIZED);
+ }
+}
+
+/*****************************************************************************
+ * Event emitters
+ *****************************************************************************/
+
+void StreamFilter::FireEvent(const nsAString& aType) {
+ EventInit init;
+ init.mBubbles = false;
+ init.mCancelable = false;
+
+ RefPtr<Event> event = Event::Constructor(this, aType, init);
+ event->SetTrusted(true);
+
+ DispatchEvent(*event);
+}
+
+void StreamFilter::FireDataEvent(const nsTArray<uint8_t>& aData) {
+ AutoEntryScript aes(mParent, "StreamFilter data event");
+ JSContext* cx = aes.cx();
+
+ RootedDictionary<StreamFilterDataEventInit> init(cx);
+ init.mBubbles = false;
+ init.mCancelable = false;
+
+ auto buffer = ArrayBuffer::Create(cx, aData.Length(), aData.Elements());
+ if (!buffer) {
+ // TODO: There is no way to recover from this. This chunk of data is lost.
+ FireErrorEvent(u"Out of memory"_ns);
+ return;
+ }
+
+ init.mData.Init(buffer);
+
+ RefPtr<StreamFilterDataEvent> event =
+ StreamFilterDataEvent::Constructor(this, u"data"_ns, init);
+ event->SetTrusted(true);
+
+ DispatchEvent(*event);
+}
+
+void StreamFilter::FireErrorEvent(const nsAString& aError) {
+ MOZ_ASSERT(mError.IsEmpty());
+
+ mError = aError;
+ FireEvent(u"error"_ns);
+}
+
+/*****************************************************************************
+ * Glue
+ *****************************************************************************/
+
+/* static */
+bool StreamFilter::IsAllowedInContext(JSContext* aCx, JSObject* /* unused */) {
+ return nsContentUtils::CallerHasPermission(aCx,
+ nsGkAtoms::webRequestBlocking);
+}
+
+JSObject* StreamFilter::WrapObject(JSContext* aCx, HandleObject aGivenProto) {
+ return StreamFilter_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(StreamFilter)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(StreamFilter)
+NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper)
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(StreamFilter,
+ DOMEventTargetHelper)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mParent)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(StreamFilter,
+ DOMEventTargetHelper)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mParent)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(StreamFilter,
+ DOMEventTargetHelper)
+NS_IMPL_CYCLE_COLLECTION_TRACE_END
+
+NS_IMPL_ADDREF_INHERITED(StreamFilter, DOMEventTargetHelper)
+NS_IMPL_RELEASE_INHERITED(StreamFilter, DOMEventTargetHelper)
+
+} // namespace extensions
+} // namespace mozilla
diff --git a/toolkit/components/extensions/webrequest/StreamFilter.h b/toolkit/components/extensions/webrequest/StreamFilter.h
new file mode 100644
index 0000000000..4c29dc19ac
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilter.h
@@ -0,0 +1,97 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_extensions_StreamFilter_h
+#define mozilla_extensions_StreamFilter_h
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/StreamFilterBinding.h"
+
+#include "mozilla/DOMEventTargetHelper.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsAtom.h"
+
+namespace mozilla {
+namespace ipc {
+template <class T>
+class Endpoint;
+}
+
+namespace extensions {
+
+class PStreamFilterChild;
+class StreamFilterChild;
+
+using namespace mozilla::dom;
+
+class StreamFilter : public DOMEventTargetHelper {
+ friend class StreamFilterChild;
+
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(StreamFilter,
+ DOMEventTargetHelper)
+
+ static already_AddRefed<StreamFilter> Create(GlobalObject& global,
+ uint64_t aRequestId,
+ const nsAString& aAddonId);
+
+ explicit StreamFilter(nsIGlobalObject* aParent, uint64_t aRequestId,
+ const nsAString& aAddonId);
+
+ IMPL_EVENT_HANDLER(start);
+ IMPL_EVENT_HANDLER(stop);
+ IMPL_EVENT_HANDLER(data);
+ IMPL_EVENT_HANDLER(error);
+
+ void Write(const ArrayBufferOrUint8Array& aData, ErrorResult& aRv);
+
+ void GetError(nsAString& aError) { aError = mError; }
+
+ StreamFilterStatus Status() const;
+ void Suspend(ErrorResult& aRv);
+ void Resume(ErrorResult& aRv);
+ void Disconnect(ErrorResult& aRv);
+ void Close(ErrorResult& aRv);
+
+ nsISupports* GetParentObject() const { return mParent; }
+
+ virtual JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ static bool IsAllowedInContext(JSContext* aCx, JSObject* aObj);
+
+ protected:
+ virtual ~StreamFilter();
+
+ void FireEvent(const nsAString& aType);
+
+ void FireDataEvent(const nsTArray<uint8_t>& aData);
+
+ void FireErrorEvent(const nsAString& aError);
+
+ bool CheckAlive();
+
+ private:
+ void Connect();
+
+ void FinishConnect(mozilla::ipc::Endpoint<PStreamFilterChild>&& aEndpoint);
+
+ void ForgetActor();
+
+ nsCOMPtr<nsIGlobalObject> mParent;
+ RefPtr<StreamFilterChild> mActor;
+
+ nsString mError;
+
+ const uint64_t mChannelId;
+ const RefPtr<nsAtom> mAddonId;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_StreamFilter_h
diff --git a/toolkit/components/extensions/webrequest/StreamFilterBase.h b/toolkit/components/extensions/webrequest/StreamFilterBase.h
new file mode 100644
index 0000000000..4f413835ef
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterBase.h
@@ -0,0 +1,38 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_extensions_StreamFilterBase_h
+#define mozilla_extensions_StreamFilterBase_h
+
+#include "mozilla/LinkedList.h"
+#include "nsTArray.h"
+
+namespace mozilla {
+namespace extensions {
+
+class StreamFilterBase {
+ public:
+ typedef nsTArray<uint8_t> Data;
+
+ protected:
+ class BufferedData : public LinkedListElement<BufferedData> {
+ public:
+ explicit BufferedData(Data&& aData) : mData(std::move(aData)) {}
+
+ Data mData;
+ };
+
+ LinkedList<BufferedData> mBufferedData;
+
+ inline void BufferData(Data&& aData) {
+ mBufferedData.insertBack(new BufferedData(std::move(aData)));
+ };
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_StreamFilterBase_h
diff --git a/toolkit/components/extensions/webrequest/StreamFilterChild.cpp b/toolkit/components/extensions/webrequest/StreamFilterChild.cpp
new file mode 100644
index 0000000000..90a6e11ad2
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterChild.cpp
@@ -0,0 +1,520 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#include "StreamFilterChild.h"
+#include "StreamFilter.h"
+
+#include "mozilla/Assertions.h"
+#include "mozilla/UniquePtr.h"
+
+namespace mozilla {
+namespace extensions {
+
+using mozilla::dom::StreamFilterStatus;
+using mozilla::ipc::IPCResult;
+
+/*****************************************************************************
+ * Initialization and cleanup
+ *****************************************************************************/
+
+void StreamFilterChild::Cleanup() {
+ switch (mState) {
+ case State::Closing:
+ case State::Closed:
+ case State::Error:
+ case State::Disconnecting:
+ case State::Disconnected:
+ break;
+
+ default:
+ ErrorResult rv;
+ Disconnect(rv);
+ break;
+ }
+}
+
+/*****************************************************************************
+ * State change methods
+ *****************************************************************************/
+
+void StreamFilterChild::Suspend(ErrorResult& aRv) {
+ switch (mState) {
+ case State::TransferringData:
+ mState = State::Suspending;
+ mNextState = State::Suspended;
+
+ SendSuspend();
+ break;
+
+ case State::Suspending:
+ switch (mNextState) {
+ case State::Suspended:
+ case State::Resuming:
+ mNextState = State::Suspended;
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+ break;
+
+ case State::Resuming:
+ switch (mNextState) {
+ case State::TransferringData:
+ case State::Suspending:
+ mNextState = State::Suspending;
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+ break;
+
+ case State::Suspended:
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ break;
+ }
+}
+
+void StreamFilterChild::Resume(ErrorResult& aRv) {
+ switch (mState) {
+ case State::Suspended:
+ mState = State::Resuming;
+ mNextState = State::TransferringData;
+
+ SendResume();
+ break;
+
+ case State::Suspending:
+ switch (mNextState) {
+ case State::Suspended:
+ case State::Resuming:
+ mNextState = State::Resuming;
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+ break;
+
+ case State::Resuming:
+ case State::TransferringData:
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+
+ FlushBufferedData();
+}
+
+void StreamFilterChild::Disconnect(ErrorResult& aRv) {
+ switch (mState) {
+ case State::Suspended:
+ case State::TransferringData:
+ case State::FinishedTransferringData:
+ mState = State::Disconnecting;
+ mNextState = State::Disconnected;
+
+ WriteBufferedData();
+ SendDisconnect();
+ break;
+
+ case State::Suspending:
+ case State::Resuming:
+ switch (mNextState) {
+ case State::Suspended:
+ case State::Resuming:
+ case State::Disconnecting:
+ mNextState = State::Disconnecting;
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+ break;
+
+ case State::Disconnecting:
+ case State::Disconnected:
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+}
+
+void StreamFilterChild::Close(ErrorResult& aRv) {
+ switch (mState) {
+ case State::Suspended:
+ case State::TransferringData:
+ case State::FinishedTransferringData:
+ mState = State::Closing;
+ mNextState = State::Closed;
+
+ SendClose();
+ break;
+
+ case State::Suspending:
+ case State::Resuming:
+ mNextState = State::Closing;
+ break;
+
+ case State::Closing:
+ MOZ_DIAGNOSTIC_ASSERT(mNextState == State::Closed);
+ break;
+
+ case State::Closed:
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+
+ mBufferedData.clear();
+}
+
+/*****************************************************************************
+ * Internal state management
+ *****************************************************************************/
+
+void StreamFilterChild::SetNextState() {
+ mState = mNextState;
+
+ switch (mNextState) {
+ case State::Suspending:
+ mNextState = State::Suspended;
+ SendSuspend();
+ break;
+
+ case State::Resuming:
+ mNextState = State::TransferringData;
+ SendResume();
+ break;
+
+ case State::Closing:
+ mNextState = State::Closed;
+ SendClose();
+ break;
+
+ case State::Disconnecting:
+ mNextState = State::Disconnected;
+ SendDisconnect();
+ break;
+
+ case State::FinishedTransferringData:
+ if (mStreamFilter) {
+ mStreamFilter->FireEvent(u"stop"_ns);
+ // We don't need access to the stream filter after this point, so break
+ // our reference cycle, so that it can be collected if we're the last
+ // reference.
+ mStreamFilter = nullptr;
+ }
+ break;
+
+ case State::TransferringData:
+ FlushBufferedData();
+ break;
+
+ case State::Closed:
+ case State::Disconnected:
+ case State::Error:
+ mStreamFilter = nullptr;
+ break;
+
+ default:
+ break;
+ }
+}
+
+void StreamFilterChild::MaybeStopRequest() {
+ if (!mReceivedOnStop || !mBufferedData.isEmpty()) {
+ return;
+ }
+
+ if (mStreamFilter) {
+ Unused << mStreamFilter->CheckAlive();
+ }
+
+ switch (mState) {
+ case State::Suspending:
+ case State::Resuming:
+ mNextState = State::FinishedTransferringData;
+ return;
+
+ case State::Disconnecting:
+ case State::Closing:
+ case State::Closed:
+ break;
+
+ default:
+ mState = State::FinishedTransferringData;
+ if (mStreamFilter) {
+ mStreamFilter->FireEvent(u"stop"_ns);
+ // We don't need access to the stream filter after this point, so break
+ // our reference cycle, so that it can be collected if we're the last
+ // reference.
+ mStreamFilter = nullptr;
+ }
+ break;
+ }
+}
+
+/*****************************************************************************
+ * State change acknowledgment callbacks
+ *****************************************************************************/
+
+void StreamFilterChild::RecvInitialized(bool aSuccess) {
+ MOZ_ASSERT(mState == State::Uninitialized);
+
+ if (aSuccess) {
+ mState = State::Initialized;
+ } else {
+ mState = State::Error;
+ if (mStreamFilter) {
+ mStreamFilter->FireErrorEvent(u"Invalid request ID"_ns);
+ mStreamFilter = nullptr;
+ }
+ }
+}
+
+IPCResult StreamFilterChild::RecvError(const nsCString& aError) {
+ mState = State::Error;
+ if (mStreamFilter) {
+ mStreamFilter->FireErrorEvent(NS_ConvertUTF8toUTF16(aError));
+ mStreamFilter = nullptr;
+ }
+ SendDestroy();
+ return IPC_OK();
+}
+
+IPCResult StreamFilterChild::RecvClosed() {
+ MOZ_DIAGNOSTIC_ASSERT(mState == State::Closing);
+
+ SetNextState();
+ return IPC_OK();
+}
+
+IPCResult StreamFilterChild::RecvSuspended() {
+ MOZ_DIAGNOSTIC_ASSERT(mState == State::Suspending);
+
+ SetNextState();
+ return IPC_OK();
+}
+
+IPCResult StreamFilterChild::RecvResumed() {
+ MOZ_DIAGNOSTIC_ASSERT(mState == State::Resuming);
+
+ SetNextState();
+ return IPC_OK();
+}
+
+IPCResult StreamFilterChild::RecvFlushData() {
+ MOZ_DIAGNOSTIC_ASSERT(mState == State::Disconnecting);
+
+ SendFlushedData();
+ SetNextState();
+ return IPC_OK();
+}
+
+/*****************************************************************************
+ * Other binding methods
+ *****************************************************************************/
+
+void StreamFilterChild::Write(Data&& aData, ErrorResult& aRv) {
+ switch (mState) {
+ case State::Suspending:
+ case State::Resuming:
+ switch (mNextState) {
+ case State::Suspended:
+ case State::TransferringData:
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+ break;
+
+ case State::Suspended:
+ case State::TransferringData:
+ case State::FinishedTransferringData:
+ break;
+
+ default:
+ aRv.Throw(NS_ERROR_FAILURE);
+ return;
+ }
+
+ SendWrite(std::move(aData));
+}
+
+StreamFilterStatus StreamFilterChild::Status() const {
+ switch (mState) {
+ case State::Uninitialized:
+ case State::Initialized:
+ return StreamFilterStatus::Uninitialized;
+
+ case State::TransferringData:
+ return StreamFilterStatus::Transferringdata;
+
+ case State::Suspended:
+ return StreamFilterStatus::Suspended;
+
+ case State::FinishedTransferringData:
+ return StreamFilterStatus::Finishedtransferringdata;
+
+ case State::Resuming:
+ case State::Suspending:
+ switch (mNextState) {
+ case State::TransferringData:
+ case State::Resuming:
+ return StreamFilterStatus::Transferringdata;
+
+ case State::Suspended:
+ case State::Suspending:
+ return StreamFilterStatus::Suspended;
+
+ case State::Closing:
+ return StreamFilterStatus::Closed;
+
+ case State::Disconnecting:
+ return StreamFilterStatus::Disconnected;
+
+ default:
+ MOZ_ASSERT_UNREACHABLE("Unexpected next state");
+ return StreamFilterStatus::Suspended;
+ }
+ break;
+
+ case State::Closing:
+ case State::Closed:
+ return StreamFilterStatus::Closed;
+
+ case State::Disconnecting:
+ case State::Disconnected:
+ return StreamFilterStatus::Disconnected;
+
+ case State::Error:
+ return StreamFilterStatus::Failed;
+ };
+
+ MOZ_ASSERT_UNREACHABLE("Not reached");
+ return StreamFilterStatus::Failed;
+}
+
+/*****************************************************************************
+ * Request state notifications
+ *****************************************************************************/
+
+IPCResult StreamFilterChild::RecvStartRequest() {
+ MOZ_ASSERT(mState == State::Initialized);
+
+ mState = State::TransferringData;
+
+ if (mStreamFilter) {
+ mStreamFilter->FireEvent(u"start"_ns);
+ Unused << mStreamFilter->CheckAlive();
+ }
+ return IPC_OK();
+}
+
+IPCResult StreamFilterChild::RecvStopRequest(const nsresult& aStatus) {
+ mReceivedOnStop = true;
+ MaybeStopRequest();
+ return IPC_OK();
+}
+
+/*****************************************************************************
+ * Incoming request data handling
+ *****************************************************************************/
+
+void StreamFilterChild::EmitData(const Data& aData) {
+ MOZ_ASSERT(CanFlushData());
+ if (mStreamFilter) {
+ mStreamFilter->FireDataEvent(aData);
+ }
+
+ MaybeStopRequest();
+}
+
+void StreamFilterChild::FlushBufferedData() {
+ while (!mBufferedData.isEmpty() && CanFlushData()) {
+ UniquePtr<BufferedData> data(mBufferedData.popFirst());
+
+ EmitData(data->mData);
+ }
+}
+
+void StreamFilterChild::WriteBufferedData() {
+ while (!mBufferedData.isEmpty()) {
+ UniquePtr<BufferedData> data(mBufferedData.popFirst());
+
+ SendWrite(data->mData);
+ }
+}
+
+IPCResult StreamFilterChild::RecvData(Data&& aData) {
+ MOZ_ASSERT(!mReceivedOnStop);
+
+ if (mStreamFilter) {
+ Unused << mStreamFilter->CheckAlive();
+ }
+
+ switch (mState) {
+ case State::TransferringData:
+ case State::Resuming:
+ EmitData(aData);
+ break;
+
+ case State::FinishedTransferringData:
+ MOZ_ASSERT_UNREACHABLE("Received data in unexpected state");
+ EmitData(aData);
+ break;
+
+ case State::Suspending:
+ case State::Suspended:
+ BufferData(std::move(aData));
+ break;
+
+ case State::Disconnecting:
+ SendWrite(std::move(aData));
+ break;
+
+ case State::Closing:
+ break;
+
+ default:
+ MOZ_ASSERT_UNREACHABLE("Received data in unexpected state");
+ return IPC_FAIL_NO_REASON(this);
+ }
+
+ return IPC_OK();
+}
+
+/*****************************************************************************
+ * Glue
+ *****************************************************************************/
+
+void StreamFilterChild::ActorDestroy(ActorDestroyReason aWhy) {
+ mStreamFilter = nullptr;
+}
+
+void StreamFilterChild::ActorDealloc() {
+ RefPtr<StreamFilterChild> self = dont_AddRef(this);
+}
+
+} // namespace extensions
+} // namespace mozilla
diff --git a/toolkit/components/extensions/webrequest/StreamFilterChild.h b/toolkit/components/extensions/webrequest/StreamFilterChild.h
new file mode 100644
index 0000000000..9cc6e04cce
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterChild.h
@@ -0,0 +1,137 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_extensions_StreamFilterChild_h
+#define mozilla_extensions_StreamFilterChild_h
+
+#include "StreamFilterBase.h"
+#include "mozilla/extensions/PStreamFilterChild.h"
+#include "mozilla/extensions/StreamFilter.h"
+
+#include "mozilla/LinkedList.h"
+#include "mozilla/dom/StreamFilterBinding.h"
+#include "nsISupportsImpl.h"
+
+namespace mozilla {
+class ErrorResult;
+
+namespace extensions {
+
+using mozilla::dom::StreamFilterStatus;
+using mozilla::ipc::IPCResult;
+
+class StreamFilter;
+
+class StreamFilterChild final : public PStreamFilterChild,
+ public StreamFilterBase {
+ friend class StreamFilter;
+ friend class PStreamFilterChild;
+
+ public:
+ NS_INLINE_DECL_REFCOUNTING(StreamFilterChild)
+
+ StreamFilterChild() : mState(State::Uninitialized), mReceivedOnStop(false) {}
+
+ enum class State {
+ // Uninitialized, waiting for constructor response from parent.
+ Uninitialized,
+ // Initialized, but channel has not begun transferring data.
+ Initialized,
+ // The stream's OnStartRequest event has been dispatched, and the channel is
+ // transferring data.
+ TransferringData,
+ // The channel's OnStopRequest event has been dispatched, and the channel is
+ // no longer transferring data. Data may still be written to the output
+ // stream listener.
+ FinishedTransferringData,
+ // The channel is being suspended, and we're waiting for confirmation of
+ // suspension from the parent.
+ Suspending,
+ // The channel has been suspended in the parent. Data may still be written
+ // to the output stream listener in this state.
+ Suspended,
+ // The channel is suspended. Resume has been called, and we are waiting for
+ // confirmation of resumption from the parent.
+ Resuming,
+ // The close() method has been called, and no further output may be written.
+ // We are waiting for confirmation from the parent.
+ Closing,
+ // The close() method has been called, and we have been disconnected from
+ // our parent.
+ Closed,
+ // The channel is being disconnected from the parent, and all further events
+ // and data will pass unfiltered. Data received by the child in this state
+ // will be automatically written to the output stream listener. No data may
+ // be explicitly written.
+ Disconnecting,
+ // The channel has been disconnected from the parent, and all further data
+ // and events will be transparently passed to the output stream listener
+ // without passing through the child.
+ Disconnected,
+ // An error has occurred and the child is disconnected from the parent.
+ Error,
+ };
+
+ void Suspend(ErrorResult& aRv);
+ void Resume(ErrorResult& aRv);
+ void Disconnect(ErrorResult& aRv);
+ void Close(ErrorResult& aRv);
+ void Cleanup();
+
+ void Write(Data&& aData, ErrorResult& aRv);
+
+ State GetState() const { return mState; }
+
+ StreamFilterStatus Status() const;
+
+ void RecvInitialized(bool aSuccess);
+
+ protected:
+ IPCResult RecvStartRequest();
+ IPCResult RecvData(Data&& data);
+ IPCResult RecvStopRequest(const nsresult& aStatus);
+ IPCResult RecvError(const nsCString& aError);
+
+ IPCResult RecvClosed();
+ IPCResult RecvSuspended();
+ IPCResult RecvResumed();
+ IPCResult RecvFlushData();
+
+ virtual void ActorDealloc() override;
+
+ void SetStreamFilter(StreamFilter* aStreamFilter) {
+ mStreamFilter = aStreamFilter;
+ }
+
+ private:
+ ~StreamFilterChild() = default;
+
+ void SetNextState();
+
+ void MaybeStopRequest();
+
+ void EmitData(const Data& aData);
+
+ bool CanFlushData() {
+ return (mState == State::TransferringData || mState == State::Resuming);
+ }
+
+ void FlushBufferedData();
+ void WriteBufferedData();
+
+ virtual void ActorDestroy(ActorDestroyReason aWhy) override;
+
+ State mState;
+ State mNextState;
+ bool mReceivedOnStop;
+
+ RefPtr<StreamFilter> mStreamFilter;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_StreamFilterChild_h
diff --git a/toolkit/components/extensions/webrequest/StreamFilterEvents.cpp b/toolkit/components/extensions/webrequest/StreamFilterEvents.cpp
new file mode 100644
index 0000000000..980e155d42
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterEvents.cpp
@@ -0,0 +1,53 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#include "mozilla/extensions/StreamFilterEvents.h"
+
+namespace mozilla {
+namespace extensions {
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(StreamFilterDataEvent)
+
+NS_IMPL_ADDREF_INHERITED(StreamFilterDataEvent, Event)
+NS_IMPL_RELEASE_INHERITED(StreamFilterDataEvent, Event)
+
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(StreamFilterDataEvent, Event)
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+
+NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(StreamFilterDataEvent, Event)
+ NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mData)
+NS_IMPL_CYCLE_COLLECTION_TRACE_END
+
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(StreamFilterDataEvent, Event)
+ tmp->mData = nullptr;
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(StreamFilterDataEvent)
+NS_INTERFACE_MAP_END_INHERITING(Event)
+
+/* static */
+already_AddRefed<StreamFilterDataEvent> StreamFilterDataEvent::Constructor(
+ EventTarget* aEventTarget, const nsAString& aType,
+ const StreamFilterDataEventInit& aParam) {
+ RefPtr<StreamFilterDataEvent> event = new StreamFilterDataEvent(aEventTarget);
+
+ bool trusted = event->Init(aEventTarget);
+ event->InitEvent(aType, aParam.mBubbles, aParam.mCancelable);
+ event->SetTrusted(trusted);
+ event->SetComposed(aParam.mComposed);
+
+ event->SetData(aParam.mData);
+
+ return event.forget();
+}
+
+JSObject* StreamFilterDataEvent::WrapObjectInternal(
+ JSContext* aCx, JS::Handle<JSObject*> aGivenProto) {
+ return StreamFilterDataEvent_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+} // namespace extensions
+} // namespace mozilla
diff --git a/toolkit/components/extensions/webrequest/StreamFilterEvents.h b/toolkit/components/extensions/webrequest/StreamFilterEvents.h
new file mode 100644
index 0000000000..c058fa1910
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterEvents.h
@@ -0,0 +1,68 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2; -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_extensions_StreamFilterEvents_h
+#define mozilla_extensions_StreamFilterEvents_h
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/StreamFilterDataEventBinding.h"
+#include "mozilla/extensions/StreamFilter.h"
+
+#include "js/RootingAPI.h"
+#include "js/TypeDecls.h"
+
+#include "mozilla/HoldDropJSObjects.h"
+#include "mozilla/dom/Event.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+
+namespace mozilla {
+namespace extensions {
+
+using namespace JS;
+using namespace mozilla::dom;
+
+class StreamFilterDataEvent : public Event {
+ NS_DECL_ISUPPORTS_INHERITED
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(StreamFilterDataEvent,
+ Event)
+
+ explicit StreamFilterDataEvent(EventTarget* aEventTarget)
+ : Event(aEventTarget, nullptr, nullptr) {
+ mozilla::HoldJSObjects(this);
+ }
+
+ static already_AddRefed<StreamFilterDataEvent> Constructor(
+ EventTarget* aEventTarget, const nsAString& aType,
+ const StreamFilterDataEventInit& aParam);
+
+ static already_AddRefed<StreamFilterDataEvent> Constructor(
+ GlobalObject& aGlobal, const nsAString& aType,
+ const StreamFilterDataEventInit& aParam) {
+ nsCOMPtr<EventTarget> target = do_QueryInterface(aGlobal.GetAsSupports());
+ return Constructor(target, aType, aParam);
+ }
+
+ void GetData(JSContext* aCx, JS::MutableHandleObject aResult) {
+ aResult.set(mData);
+ }
+
+ virtual JSObject* WrapObjectInternal(
+ JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
+
+ protected:
+ virtual ~StreamFilterDataEvent() { mozilla::DropJSObjects(this); }
+
+ private:
+ JS::Heap<JSObject*> mData;
+
+ void SetData(const ArrayBuffer& aData) { mData = aData.Obj(); }
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_StreamFilterEvents_h
diff --git a/toolkit/components/extensions/webrequest/StreamFilterParent.cpp b/toolkit/components/extensions/webrequest/StreamFilterParent.cpp
new file mode 100644
index 0000000000..e71e23ec33
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterParent.cpp
@@ -0,0 +1,777 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#include "StreamFilterParent.h"
+
+#include "mozilla/Unused.h"
+#include "mozilla/dom/ContentParent.h"
+#include "mozilla/net/ChannelEventQueue.h"
+#include "nsHttpChannel.h"
+#include "nsIChannel.h"
+#include "nsIInputStream.h"
+#include "nsITraceableChannel.h"
+#include "nsProxyRelease.h"
+#include "nsQueryObject.h"
+#include "nsSocketTransportService2.h"
+#include "nsStringStream.h"
+#include "mozilla/net/DocumentChannelChild.h"
+#include "nsIViewSourceChannel.h"
+
+namespace mozilla {
+namespace extensions {
+
+/*****************************************************************************
+ * Event queueing helpers
+ *****************************************************************************/
+
+using net::ChannelEvent;
+using net::ChannelEventQueue;
+
+namespace {
+
+// Define some simple ChannelEvent sub-classes that store the appropriate
+// EventTarget and delegate their Run methods to a wrapped Runnable or lambda
+// function.
+
+class ChannelEventWrapper : public ChannelEvent {
+ public:
+ ChannelEventWrapper(nsIEventTarget* aTarget) : mTarget(aTarget) {}
+
+ already_AddRefed<nsIEventTarget> GetEventTarget() override {
+ return do_AddRef(mTarget);
+ }
+
+ protected:
+ ~ChannelEventWrapper() override = default;
+
+ private:
+ nsCOMPtr<nsIEventTarget> mTarget;
+};
+
+class ChannelEventFunction final : public ChannelEventWrapper {
+ public:
+ ChannelEventFunction(nsIEventTarget* aTarget, std::function<void()>&& aFunc)
+ : ChannelEventWrapper(aTarget), mFunc(std::move(aFunc)) {}
+
+ void Run() override { mFunc(); }
+
+ protected:
+ ~ChannelEventFunction() override = default;
+
+ private:
+ std::function<void()> mFunc;
+};
+
+class ChannelEventRunnable final : public ChannelEventWrapper {
+ public:
+ ChannelEventRunnable(nsIEventTarget* aTarget,
+ already_AddRefed<Runnable> aRunnable)
+ : ChannelEventWrapper(aTarget), mRunnable(aRunnable) {}
+
+ void Run() override {
+ nsresult rv = mRunnable->Run();
+ Unused << NS_WARN_IF(NS_FAILED(rv));
+ }
+
+ protected:
+ ~ChannelEventRunnable() override = default;
+
+ private:
+ RefPtr<Runnable> mRunnable;
+};
+
+} // anonymous namespace
+
+/*****************************************************************************
+ * Initialization
+ *****************************************************************************/
+
+StreamFilterParent::StreamFilterParent()
+ : mMainThread(GetCurrentEventTarget()),
+ mIOThread(mMainThread),
+ mQueue(new ChannelEventQueue(static_cast<nsIStreamListener*>(this))),
+ mBufferMutex("StreamFilter buffer mutex"),
+ mReceivedStop(false),
+ mSentStop(false),
+ mContext(nullptr),
+ mOffset(0),
+ mState(State::Uninitialized) {}
+
+StreamFilterParent::~StreamFilterParent() {
+ NS_ReleaseOnMainThread("StreamFilterParent::mChannel", mChannel.forget());
+ NS_ReleaseOnMainThread("StreamFilterParent::mLoadGroup", mLoadGroup.forget());
+ NS_ReleaseOnMainThread("StreamFilterParent::mOrigListener",
+ mOrigListener.forget());
+ NS_ReleaseOnMainThread("StreamFilterParent::mContext", mContext.forget());
+}
+
+auto StreamFilterParent::Create(dom::ContentParent* aContentParent,
+ uint64_t aChannelId, const nsAString& aAddonId)
+ -> RefPtr<ChildEndpointPromise> {
+ AssertIsMainThread();
+
+ auto& webreq = WebRequestService::GetSingleton();
+
+ RefPtr<nsAtom> addonId = NS_Atomize(aAddonId);
+ nsCOMPtr<nsITraceableChannel> channel =
+ webreq.GetTraceableChannel(aChannelId, addonId, aContentParent);
+
+ RefPtr<mozilla::net::nsHttpChannel> chan = do_QueryObject(channel);
+ if (!chan) {
+ return ChildEndpointPromise::CreateAndReject(false, __func__);
+ }
+
+ // Disable alt-data for extension stream listeners.
+ nsCOMPtr<nsIHttpChannelInternal> internal(do_QueryObject(channel));
+ internal->DisableAltDataCache();
+
+ return chan->AttachStreamFilter(aContentParent ? aContentParent->OtherPid()
+ : base::GetCurrentProcId());
+}
+
+/* static */
+void StreamFilterParent::Attach(nsIChannel* aChannel,
+ ParentEndpoint&& aEndpoint) {
+ auto self = MakeRefPtr<StreamFilterParent>();
+
+ self->ActorThread()->Dispatch(
+ NewRunnableMethod<ParentEndpoint&&>("StreamFilterParent::Bind", self,
+ &StreamFilterParent::Bind,
+ std::move(aEndpoint)),
+ NS_DISPATCH_NORMAL);
+
+ self->Init(aChannel);
+
+ // IPC owns this reference now.
+ Unused << self.forget();
+}
+
+void StreamFilterParent::Bind(ParentEndpoint&& aEndpoint) {
+ aEndpoint.Bind(this);
+}
+
+void StreamFilterParent::Init(nsIChannel* aChannel) {
+ mChannel = aChannel;
+
+ nsCOMPtr<nsITraceableChannel> traceable = do_QueryInterface(aChannel);
+ if (MOZ_UNLIKELY(!traceable)) {
+ // nsViewSourceChannel is not nsITraceableChannel, but wraps one. Unwrap it.
+ nsCOMPtr<nsIViewSourceChannel> vsc = do_QueryInterface(aChannel);
+ if (vsc) {
+ traceable = do_QueryObject(vsc->GetInnerChannel());
+ // OnStartRequest etc. is passed the unwrapped channel, so update mChannel
+ // to prevent OnStartRequest from mistaking it for a redirect, which would
+ // close the filter.
+ mChannel = do_QueryObject(traceable);
+ }
+ // TODO bug 1683403: Replace assertion; Close StreamFilter instead.
+ MOZ_RELEASE_ASSERT(traceable);
+ }
+
+ nsresult rv =
+ traceable->SetNewListener(this, /* aMustApplyContentConversion = */ true,
+ getter_AddRefs(mOrigListener));
+ MOZ_RELEASE_ASSERT(NS_SUCCEEDED(rv));
+}
+
+/*****************************************************************************
+ * nsIThreadRetargetableStreamListener
+ *****************************************************************************/
+
+NS_IMETHODIMP
+StreamFilterParent::CheckListenerChain() {
+ AssertIsMainThread();
+
+ nsCOMPtr<nsIThreadRetargetableStreamListener> trsl =
+ do_QueryInterface(mOrigListener);
+ if (trsl) {
+ return trsl->CheckListenerChain();
+ }
+ return NS_ERROR_FAILURE;
+}
+
+/*****************************************************************************
+ * Error handling
+ *****************************************************************************/
+
+void StreamFilterParent::Broken() {
+ AssertIsActorThread();
+
+ switch (mState) {
+ case State::Initialized:
+ case State::TransferringData:
+ case State::Suspended: {
+ mState = State::Disconnecting;
+ RefPtr<StreamFilterParent> self(this);
+ RunOnMainThread(FUNC, [=] {
+ if (self->mChannel) {
+ self->mChannel->Cancel(NS_ERROR_FAILURE);
+ }
+ });
+
+ FinishDisconnect();
+ } break;
+
+ default:
+ break;
+ }
+}
+
+/*****************************************************************************
+ * State change requests
+ *****************************************************************************/
+
+IPCResult StreamFilterParent::RecvClose() {
+ AssertIsActorThread();
+
+ mState = State::Closed;
+
+ if (!mSentStop) {
+ RefPtr<StreamFilterParent> self(this);
+ RunOnMainThread(FUNC, [=] {
+ nsresult rv = self->EmitStopRequest(NS_OK);
+ Unused << NS_WARN_IF(NS_FAILED(rv));
+ });
+ }
+
+ Unused << SendClosed();
+ Destroy();
+ return IPC_OK();
+}
+
+void StreamFilterParent::Destroy() {
+ // Close the channel asynchronously so the actor is never destroyed before
+ // this message is fully processed.
+ ActorThread()->Dispatch(NewRunnableMethod("StreamFilterParent::Close", this,
+ &StreamFilterParent::Close),
+ NS_DISPATCH_NORMAL);
+}
+
+IPCResult StreamFilterParent::RecvDestroy() {
+ AssertIsActorThread();
+ Destroy();
+ return IPC_OK();
+}
+
+IPCResult StreamFilterParent::RecvSuspend() {
+ AssertIsActorThread();
+
+ if (mState == State::TransferringData) {
+ RefPtr<StreamFilterParent> self(this);
+ RunOnMainThread(FUNC, [=] {
+ self->mChannel->Suspend();
+
+ RunOnActorThread(FUNC, [=] {
+ if (self->IPCActive()) {
+ self->mState = State::Suspended;
+ self->CheckResult(self->SendSuspended());
+ }
+ });
+ });
+ }
+ return IPC_OK();
+}
+
+IPCResult StreamFilterParent::RecvResume() {
+ AssertIsActorThread();
+
+ if (mState == State::Suspended) {
+ // Change state before resuming so incoming data is handled correctly
+ // immediately after resuming.
+ mState = State::TransferringData;
+
+ RefPtr<StreamFilterParent> self(this);
+ RunOnMainThread(FUNC, [=] {
+ self->mChannel->Resume();
+
+ RunOnActorThread(FUNC, [=] {
+ if (self->IPCActive()) {
+ self->CheckResult(self->SendResumed());
+ }
+ });
+ });
+ }
+ return IPC_OK();
+}
+IPCResult StreamFilterParent::RecvDisconnect() {
+ AssertIsActorThread();
+
+ if (mState == State::Suspended) {
+ RefPtr<StreamFilterParent> self(this);
+ RunOnMainThread(FUNC, [=] { self->mChannel->Resume(); });
+ } else if (mState != State::TransferringData) {
+ return IPC_OK();
+ }
+
+ mState = State::Disconnecting;
+ CheckResult(SendFlushData());
+ return IPC_OK();
+}
+
+IPCResult StreamFilterParent::RecvFlushedData() {
+ AssertIsActorThread();
+
+ MOZ_ASSERT(mState == State::Disconnecting);
+
+ Destroy();
+
+ FinishDisconnect();
+ return IPC_OK();
+}
+
+void StreamFilterParent::FinishDisconnect() {
+ RefPtr<StreamFilterParent> self(this);
+ RunOnIOThread(FUNC, [=] {
+ self->FlushBufferedData();
+
+ RunOnMainThread(FUNC, [=] {
+ if (self->mReceivedStop && !self->mSentStop) {
+ nsresult rv = self->EmitStopRequest(NS_OK);
+ Unused << NS_WARN_IF(NS_FAILED(rv));
+ } else if (self->mLoadGroup && !self->mDisconnected) {
+ Unused << self->mLoadGroup->RemoveRequest(self, nullptr, NS_OK);
+ }
+ self->mDisconnected = true;
+ });
+
+ RunOnActorThread(FUNC, [=] {
+ if (self->mState != State::Closed) {
+ self->mState = State::Disconnected;
+ }
+ });
+ });
+}
+
+/*****************************************************************************
+ * Data output
+ *****************************************************************************/
+
+IPCResult StreamFilterParent::RecvWrite(Data&& aData) {
+ AssertIsActorThread();
+
+ RunOnIOThread(NewRunnableMethod<Data&&>("StreamFilterParent::WriteMove", this,
+ &StreamFilterParent::WriteMove,
+ std::move(aData)));
+ return IPC_OK();
+}
+
+void StreamFilterParent::WriteMove(Data&& aData) {
+ nsresult rv = Write(aData);
+ Unused << NS_WARN_IF(NS_FAILED(rv));
+}
+
+nsresult StreamFilterParent::Write(Data& aData) {
+ AssertIsIOThread();
+
+ nsCOMPtr<nsIInputStream> stream;
+ nsresult rv = NS_NewByteInputStream(
+ getter_AddRefs(stream),
+ Span(reinterpret_cast<char*>(aData.Elements()), aData.Length()),
+ NS_ASSIGNMENT_DEPEND);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv =
+ mOrigListener->OnDataAvailable(mChannel, stream, mOffset, aData.Length());
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ mOffset += aData.Length();
+ return NS_OK;
+}
+
+/*****************************************************************************
+ * nsIRequest
+ *****************************************************************************/
+
+NS_IMETHODIMP
+StreamFilterParent::GetName(nsACString& aName) {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ return mChannel->GetName(aName);
+}
+
+NS_IMETHODIMP
+StreamFilterParent::GetStatus(nsresult* aStatus) {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ return mChannel->GetStatus(aStatus);
+}
+
+NS_IMETHODIMP
+StreamFilterParent::IsPending(bool* aIsPending) {
+ switch (mState) {
+ case State::Initialized:
+ case State::TransferringData:
+ case State::Suspended:
+ *aIsPending = true;
+ break;
+ default:
+ *aIsPending = false;
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+StreamFilterParent::Cancel(nsresult aResult) {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ return mChannel->Cancel(aResult);
+}
+
+NS_IMETHODIMP
+StreamFilterParent::Suspend() {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ return mChannel->Suspend();
+}
+
+NS_IMETHODIMP
+StreamFilterParent::Resume() {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ return mChannel->Resume();
+}
+
+NS_IMETHODIMP
+StreamFilterParent::GetLoadGroup(nsILoadGroup** aLoadGroup) {
+ *aLoadGroup = mLoadGroup;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+StreamFilterParent::SetLoadGroup(nsILoadGroup* aLoadGroup) {
+ return NS_ERROR_NOT_IMPLEMENTED;
+}
+
+NS_IMETHODIMP
+StreamFilterParent::GetLoadFlags(nsLoadFlags* aLoadFlags) {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ MOZ_TRY(mChannel->GetLoadFlags(aLoadFlags));
+ *aLoadFlags &= ~nsIChannel::LOAD_DOCUMENT_URI;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+StreamFilterParent::SetLoadFlags(nsLoadFlags aLoadFlags) {
+ AssertIsMainThread();
+ MOZ_ASSERT(mChannel);
+ return mChannel->SetLoadFlags(aLoadFlags);
+}
+
+NS_IMETHODIMP
+StreamFilterParent::GetTRRMode(nsIRequest::TRRMode* aTRRMode) {
+ return GetTRRModeImpl(aTRRMode);
+}
+
+NS_IMETHODIMP
+StreamFilterParent::SetTRRMode(nsIRequest::TRRMode aTRRMode) {
+ return SetTRRModeImpl(aTRRMode);
+}
+
+/*****************************************************************************
+ * nsIStreamListener
+ *****************************************************************************/
+
+NS_IMETHODIMP
+StreamFilterParent::OnStartRequest(nsIRequest* aRequest) {
+ AssertIsMainThread();
+
+ // Always reset mChannel if aRequest is different. Various calls in
+ // StreamFilterParent will use mChannel, but aRequest is *always* the
+ // right channel to use at this point.
+ //
+ // For ALL redirections, we will disconnect this listener. Extensions
+ // will create a new filter if they need it.
+ if (aRequest != mChannel) {
+ nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest);
+ nsCOMPtr<nsILoadInfo> loadInfo = channel ? channel->LoadInfo() : nullptr;
+ mChannel = channel;
+
+ if (!(loadInfo &&
+ loadInfo->RedirectChainIncludingInternalRedirects().IsEmpty())) {
+ mDisconnected = true;
+ mDisconnectedByOnStartRequest = true;
+
+ RefPtr<StreamFilterParent> self(this);
+ RunOnActorThread(FUNC, [=] {
+ if (self->IPCActive()) {
+ self->mState = State::Disconnected;
+ CheckResult(self->SendError("Channel redirected"_ns));
+ }
+ });
+ }
+ }
+
+ // Check if alterate cached data is being sent, if so we receive un-decoded
+ // data and we must disconnect the filter and send an error to the extension.
+ if (!mDisconnected) {
+ RefPtr<net::HttpBaseChannel> chan = do_QueryObject(aRequest);
+ if (chan && chan->IsDeliveringAltData()) {
+ mDisconnected = true;
+ mDisconnectedByOnStartRequest = true;
+
+ RefPtr<StreamFilterParent> self(this);
+ RunOnActorThread(FUNC, [=] {
+ if (self->IPCActive()) {
+ self->mState = State::Disconnected;
+ CheckResult(
+ self->SendError("Channel is delivering cached alt-data"_ns));
+ }
+ });
+ }
+ }
+
+ if (!mDisconnected) {
+ Unused << mChannel->GetLoadGroup(getter_AddRefs(mLoadGroup));
+ if (mLoadGroup) {
+ Unused << mLoadGroup->AddRequest(this, nullptr);
+ }
+ }
+
+ nsresult rv = mOrigListener->OnStartRequest(aRequest);
+
+ // Important: Do this only *after* running the next listener in the chain, so
+ // that we get the final delivery target after any retargeting that it may do.
+ if (nsCOMPtr<nsIThreadRetargetableRequest> req =
+ do_QueryInterface(aRequest)) {
+ nsCOMPtr<nsIEventTarget> thread;
+ Unused << req->GetDeliveryTarget(getter_AddRefs(thread));
+ if (thread) {
+ mIOThread = std::move(thread);
+ }
+ }
+
+ // Important: Do this *after* we have set the thread delivery target, or it is
+ // possible in rare circumstances for an extension to attempt to write data
+ // before the thread has been set up, even though there are several layers of
+ // asynchrony involved.
+ if (!mDisconnected) {
+ RefPtr<StreamFilterParent> self(this);
+ RunOnActorThread(FUNC, [=] {
+ if (self->IPCActive()) {
+ self->mState = State::TransferringData;
+ self->CheckResult(self->SendStartRequest());
+ }
+ });
+ }
+
+ return rv;
+}
+
+NS_IMETHODIMP
+StreamFilterParent::OnStopRequest(nsIRequest* aRequest, nsresult aStatusCode) {
+ AssertIsMainThread();
+ MOZ_ASSERT(aRequest == mChannel);
+
+ mReceivedStop = true;
+ if (mDisconnected) {
+ return EmitStopRequest(aStatusCode);
+ }
+
+ RefPtr<StreamFilterParent> self(this);
+ RunOnActorThread(FUNC, [=] {
+ if (self->IPCActive()) {
+ self->CheckResult(self->SendStopRequest(aStatusCode));
+ } else if (self->mState != State::Disconnecting) {
+ // If we're currently disconnecting, then we'll emit a stop
+ // request at the end of that process. Otherwise we need to
+ // manually emit one here, since we won't be getting a response
+ // from the child.
+ RunOnMainThread(FUNC, [=] {
+ if (!self->mSentStop) {
+ self->EmitStopRequest(aStatusCode);
+ }
+ });
+ }
+ });
+ return NS_OK;
+}
+
+nsresult StreamFilterParent::EmitStopRequest(nsresult aStatusCode) {
+ AssertIsMainThread();
+ MOZ_ASSERT(!mSentStop);
+
+ mSentStop = true;
+ nsresult rv = mOrigListener->OnStopRequest(mChannel, aStatusCode);
+
+ if (mLoadGroup && !mDisconnected) {
+ Unused << mLoadGroup->RemoveRequest(this, nullptr, aStatusCode);
+ }
+
+ return rv;
+}
+
+/*****************************************************************************
+ * Incoming data handling
+ *****************************************************************************/
+
+void StreamFilterParent::DoSendData(Data&& aData) {
+ AssertIsActorThread();
+
+ if (mState == State::TransferringData) {
+ CheckResult(SendData(aData));
+ }
+}
+
+NS_IMETHODIMP
+StreamFilterParent::OnDataAvailable(nsIRequest* aRequest,
+ nsIInputStream* aInputStream,
+ uint64_t aOffset, uint32_t aCount) {
+ AssertIsIOThread();
+
+ if (mDisconnectedByOnStartRequest || mState == State::Disconnected) {
+ // If we're offloading data in a thread pool, it's possible that we'll
+ // have buffered some additional data while waiting for the buffer to
+ // flush. So, if there's any buffered data left, flush that before we
+ // flush this incoming data.
+ //
+ // Note: When in the eDisconnected state, the buffer list is guaranteed
+ // never to be accessed by another thread during an OnDataAvailable call.
+ if (!mBufferedData.isEmpty()) {
+ FlushBufferedData();
+ }
+
+ mOffset += aCount;
+ return mOrigListener->OnDataAvailable(aRequest, aInputStream,
+ mOffset - aCount, aCount);
+ }
+
+ Data data;
+ data.SetLength(aCount);
+
+ uint32_t count;
+ nsresult rv = aInputStream->Read(reinterpret_cast<char*>(data.Elements()),
+ aCount, &count);
+ NS_ENSURE_SUCCESS(rv, rv);
+ NS_ENSURE_TRUE(count == aCount, NS_ERROR_UNEXPECTED);
+
+ if (mState == State::Disconnecting) {
+ MutexAutoLock al(mBufferMutex);
+ BufferData(std::move(data));
+ } else if (mState == State::Closed) {
+ return NS_ERROR_FAILURE;
+ } else {
+ ActorThread()->Dispatch(
+ NewRunnableMethod<Data&&>("StreamFilterParent::DoSendData", this,
+ &StreamFilterParent::DoSendData,
+ std::move(data)),
+ NS_DISPATCH_NORMAL);
+ }
+ return NS_OK;
+}
+
+nsresult StreamFilterParent::FlushBufferedData() {
+ AssertIsIOThread();
+
+ // When offloading data to a thread pool, OnDataAvailable isn't guaranteed
+ // to always run in the same thread, so it's possible for this function to
+ // run in parallel with OnDataAvailable.
+ MutexAutoLock al(mBufferMutex);
+
+ while (!mBufferedData.isEmpty()) {
+ UniquePtr<BufferedData> data(mBufferedData.popFirst());
+
+ nsresult rv = Write(data->mData);
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ return NS_OK;
+}
+
+/*****************************************************************************
+ * Thread helpers
+ *****************************************************************************/
+
+nsIEventTarget* StreamFilterParent::ActorThread() {
+ return net::gSocketTransportService;
+}
+
+bool StreamFilterParent::IsActorThread() {
+ return ActorThread()->IsOnCurrentThread();
+}
+
+void StreamFilterParent::AssertIsActorThread() { MOZ_ASSERT(IsActorThread()); }
+
+nsIEventTarget* StreamFilterParent::IOThread() { return mIOThread; }
+
+bool StreamFilterParent::IsIOThread() { return mIOThread->IsOnCurrentThread(); }
+
+void StreamFilterParent::AssertIsIOThread() { MOZ_ASSERT(IsIOThread()); }
+
+template <typename Function>
+void StreamFilterParent::RunOnMainThread(const char* aName, Function&& aFunc) {
+ mQueue->RunOrEnqueue(new ChannelEventFunction(mMainThread, std::move(aFunc)));
+}
+
+void StreamFilterParent::RunOnMainThread(already_AddRefed<Runnable> aRunnable) {
+ mQueue->RunOrEnqueue(
+ new ChannelEventRunnable(mMainThread, std::move(aRunnable)));
+}
+
+template <typename Function>
+void StreamFilterParent::RunOnIOThread(const char* aName, Function&& aFunc) {
+ mQueue->RunOrEnqueue(new ChannelEventFunction(mIOThread, std::move(aFunc)));
+}
+
+void StreamFilterParent::RunOnIOThread(already_AddRefed<Runnable> aRunnable) {
+ mQueue->RunOrEnqueue(
+ new ChannelEventRunnable(mIOThread, std::move(aRunnable)));
+}
+
+template <typename Function>
+void StreamFilterParent::RunOnActorThread(const char* aName, Function&& aFunc) {
+ // We don't use mQueue for dispatch to the actor thread.
+ //
+ // The main thread and IO thread are used for dispatching events to the
+ // wrapped stream listener, and those events need to be processed
+ // consistently, in the order they were dispatched. An event dispatched to the
+ // main thread can't be run before events that were dispatched to the IO
+ // thread before it.
+ //
+ // Additionally, the IO thread is likely to be a thread pool, which means that
+ // without thread-safe queuing, it's possible for multiple events dispatched
+ // to it to be processed in parallel, or out of order.
+ //
+ // The actor thread, however, is always a serial event target. Its events are
+ // always processed in order, and events dispatched to the actor thread are
+ // independent of the events in the output event queue.
+ if (IsActorThread()) {
+ aFunc();
+ } else {
+ ActorThread()->Dispatch(std::move(NS_NewRunnableFunction(aName, aFunc)),
+ NS_DISPATCH_NORMAL);
+ }
+}
+
+/*****************************************************************************
+ * Glue
+ *****************************************************************************/
+
+void StreamFilterParent::ActorDestroy(ActorDestroyReason aWhy) {
+ AssertIsActorThread();
+
+ if (mState != State::Disconnected && mState != State::Closed) {
+ Broken();
+ }
+}
+
+void StreamFilterParent::ActorDealloc() {
+ RefPtr<StreamFilterParent> self = dont_AddRef(this);
+}
+
+NS_INTERFACE_MAP_BEGIN(StreamFilterParent)
+ NS_INTERFACE_MAP_ENTRY(nsIStreamListener)
+ NS_INTERFACE_MAP_ENTRY(nsIRequestObserver)
+ NS_INTERFACE_MAP_ENTRY(nsIRequest)
+ NS_INTERFACE_MAP_ENTRY(nsIThreadRetargetableStreamListener)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIStreamListener)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_ADDREF(StreamFilterParent)
+NS_IMPL_RELEASE(StreamFilterParent)
+
+} // namespace extensions
+} // namespace mozilla
diff --git a/toolkit/components/extensions/webrequest/StreamFilterParent.h b/toolkit/components/extensions/webrequest/StreamFilterParent.h
new file mode 100644
index 0000000000..6a7b6cd0d0
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/StreamFilterParent.h
@@ -0,0 +1,195 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_extensions_StreamFilterParent_h
+#define mozilla_extensions_StreamFilterParent_h
+
+#include "StreamFilterBase.h"
+#include "mozilla/extensions/PStreamFilterParent.h"
+
+#include "mozilla/LinkedList.h"
+#include "mozilla/Mutex.h"
+#include "mozilla/WebRequestService.h"
+#include "nsIStreamListener.h"
+#include "nsIThread.h"
+#include "nsIThreadRetargetableStreamListener.h"
+#include "nsThreadUtils.h"
+
+#if defined(_MSC_VER)
+# define FUNC __FUNCSIG__
+#else
+# define FUNC __PRETTY_FUNCTION__
+#endif
+
+namespace mozilla {
+namespace dom {
+class ContentParent;
+}
+namespace net {
+class ChannelEventQueue;
+class nsHttpChannel;
+} // namespace net
+
+namespace extensions {
+
+using namespace mozilla::dom;
+using mozilla::ipc::IPCResult;
+
+class StreamFilterParent final : public PStreamFilterParent,
+ public nsIStreamListener,
+ public nsIThreadRetargetableStreamListener,
+ public nsIRequest,
+ public StreamFilterBase {
+ friend class PStreamFilterParent;
+
+ public:
+ NS_DECL_THREADSAFE_ISUPPORTS
+ NS_DECL_NSISTREAMLISTENER
+ NS_DECL_NSIREQUEST
+ NS_DECL_NSIREQUESTOBSERVER
+ NS_DECL_NSITHREADRETARGETABLESTREAMLISTENER
+
+ StreamFilterParent();
+
+ using ParentEndpoint = mozilla::ipc::Endpoint<PStreamFilterParent>;
+ using ChildEndpoint = mozilla::ipc::Endpoint<PStreamFilterChild>;
+
+ using ChildEndpointPromise = MozPromise<ChildEndpoint, bool, true>;
+
+ [[nodiscard]] static RefPtr<ChildEndpointPromise> Create(
+ ContentParent* aContentParent, uint64_t aChannelId,
+ const nsAString& aAddonId);
+
+ static void Attach(nsIChannel* aChannel, ParentEndpoint&& aEndpoint);
+
+ enum class State {
+ // The parent has been created, but not yet constructed by the child.
+ Uninitialized,
+ // The parent has been successfully constructed.
+ Initialized,
+ // The OnRequestStarted event has been received, and data is being
+ // transferred to the child.
+ TransferringData,
+ // The channel is suspended.
+ Suspended,
+ // The channel has been closed by the child, and will send or receive data.
+ Closed,
+ // The channel is being disconnected from the child, so that all further
+ // data and events pass unfiltered to the output listener. Any data
+ // currnetly in transit to, or buffered by, the child will be written to the
+ // output listener before we enter the Disconnected atate.
+ Disconnecting,
+ // The channel has been disconnected from the child, and all further data
+ // and events will be passed directly to the output listener.
+ Disconnected,
+ };
+
+ protected:
+ virtual ~StreamFilterParent();
+
+ IPCResult RecvWrite(Data&& aData);
+ IPCResult RecvFlushedData();
+ IPCResult RecvSuspend();
+ IPCResult RecvResume();
+ IPCResult RecvClose();
+ IPCResult RecvDisconnect();
+ IPCResult RecvDestroy();
+
+ virtual void ActorDealloc() override;
+
+ private:
+ bool IPCActive() {
+ return (mState != State::Closed && mState != State::Disconnecting &&
+ mState != State::Disconnected);
+ }
+
+ void Init(nsIChannel* aChannel);
+
+ void Bind(ParentEndpoint&& aEndpoint);
+
+ void Destroy();
+
+ nsresult FlushBufferedData();
+
+ nsresult Write(Data& aData);
+
+ void WriteMove(Data&& aData);
+
+ void DoSendData(Data&& aData);
+
+ nsresult EmitStopRequest(nsresult aStatusCode);
+
+ virtual void ActorDestroy(ActorDestroyReason aWhy) override;
+
+ void Broken();
+ void FinishDisconnect();
+
+ void CheckResult(bool aResult) {
+ if (NS_WARN_IF(!aResult)) {
+ Broken();
+ }
+ }
+
+ inline nsIEventTarget* ActorThread();
+
+ inline nsIEventTarget* IOThread();
+
+ inline bool IsIOThread();
+
+ inline bool IsActorThread();
+
+ inline void AssertIsActorThread();
+
+ inline void AssertIsIOThread();
+
+ static void AssertIsMainThread() { MOZ_ASSERT(NS_IsMainThread()); }
+
+ template <typename Function>
+ void RunOnMainThread(const char* aName, Function&& aFunc);
+
+ void RunOnMainThread(already_AddRefed<Runnable> aRunnable);
+
+ template <typename Function>
+ void RunOnActorThread(const char* aName, Function&& aFunc);
+
+ template <typename Function>
+ void RunOnIOThread(const char* aName, Function&& aFunc);
+
+ void RunOnIOThread(already_AddRefed<Runnable>);
+
+ nsCOMPtr<nsIChannel> mChannel;
+ nsCOMPtr<nsILoadGroup> mLoadGroup;
+ nsCOMPtr<nsIStreamListener> mOrigListener;
+
+ nsCOMPtr<nsIEventTarget> mMainThread;
+ nsCOMPtr<nsIEventTarget> mIOThread;
+
+ RefPtr<net::ChannelEventQueue> mQueue;
+
+ Mutex mBufferMutex;
+
+ bool mReceivedStop;
+ bool mSentStop;
+ bool mDisconnected = false;
+
+ // If redirection happens or alterate cached data is being sent, the stream
+ // filter is disconnected in OnStartRequest and the following ODA would not
+ // be filtered. Using mDisconnected causes race condition. mState is possible
+ // to late to be set, which leads out of sync.
+ bool mDisconnectedByOnStartRequest = false;
+
+ nsCOMPtr<nsISupports> mContext;
+ uint64_t mOffset;
+
+ // Use Release-Acquire ordering to ensure the OMT ODA is not sent while
+ // the channel is disconnecting or closed.
+ Atomic<State, ReleaseAcquire> mState;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_extensions_StreamFilterParent_h
diff --git a/toolkit/components/extensions/webrequest/WebRequest.jsm b/toolkit/components/extensions/webrequest/WebRequest.jsm
new file mode 100644
index 0000000000..7385178ce8
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/WebRequest.jsm
@@ -0,0 +1,1187 @@
+/* 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 EXPORTED_SYMBOLS = ["WebRequest"];
+
+/* exported WebRequest */
+
+/* globals ChannelWrapper */
+
+const { nsIHttpActivityObserver, nsISocketTransport } = Ci;
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.jsm",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm",
+ WebRequestUpload: "resource://gre/modules/WebRequestUpload.jsm",
+ SecurityInfo: "resource://gre/modules/SecurityInfo.jsm",
+});
+
+// WebRequest.jsm's only consumer is ext-webRequest.js, so we can depend on
+// the apiManager.global being initialized.
+XPCOMUtils.defineLazyGetter(this, "tabTracker", () => {
+ return ExtensionParent.apiManager.global.tabTracker;
+});
+XPCOMUtils.defineLazyGetter(this, "getCookieStoreIdForOriginAttributes", () => {
+ return ExtensionParent.apiManager.global.getCookieStoreIdForOriginAttributes;
+});
+
+function runLater(job) {
+ Services.tm.dispatchToMainThread(job);
+}
+
+function parseFilter(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,
+ };
+}
+
+function parseExtra(extra, allowed = [], optionsObj = {}) {
+ if (extra) {
+ for (let ex of extra) {
+ if (!allowed.includes(ex)) {
+ throw new ExtensionUtils.ExtensionError(`Invalid option ${ex}`);
+ }
+ }
+ }
+
+ let result = Object.assign({}, optionsObj);
+ for (let al of allowed) {
+ if (extra && extra.includes(al)) {
+ result[al] = true;
+ }
+ }
+ return result;
+}
+
+function isThenable(value) {
+ return value && typeof value === "object" && typeof value.then === "function";
+}
+
+class HeaderChanger {
+ constructor(channel) {
+ this.channel = channel;
+
+ this.array = this.readHeaders();
+ }
+
+ getMap() {
+ if (!this.map) {
+ this.map = new Map();
+ for (let header of this.array) {
+ this.map.set(header.name.toLowerCase(), header);
+ }
+ }
+ return this.map;
+ }
+
+ toArray() {
+ return this.array;
+ }
+
+ validateHeaders(headers) {
+ // We should probably use schema validation for this.
+
+ if (!Array.isArray(headers)) {
+ return false;
+ }
+
+ return headers.every(header => {
+ if (typeof header !== "object" || header === null) {
+ return false;
+ }
+
+ if (typeof header.name !== "string") {
+ return false;
+ }
+
+ return (
+ typeof header.value === "string" || Array.isArray(header.binaryValue)
+ );
+ });
+ }
+
+ applyChanges(headers, opts = {}) {
+ if (!this.validateHeaders(headers)) {
+ /* globals uneval */
+ Cu.reportError(`Invalid header array: ${uneval(headers)}`);
+ return;
+ }
+
+ let newHeaders = new Set(headers.map(({ name }) => name.toLowerCase()));
+
+ // Remove missing headers.
+ let origHeaders = this.getMap();
+ for (let name of origHeaders.keys()) {
+ if (!newHeaders.has(name)) {
+ this.setHeader(name, "", false, opts, name);
+ }
+ }
+
+ // Set new or changed headers. If there are multiple headers with the same
+ // name (e.g. Set-Cookie), merge them, instead of having new values
+ // overwrite previous ones.
+ //
+ // When the new value of a header is equal the existing value of the header
+ // (e.g. the initial response set "Set-Cookie: examplename=examplevalue",
+ // and an extension also added the header
+ // "Set-Cookie: examplename=examplevalue") then the header value is not
+ // re-set, but subsequent headers of the same type will be merged in.
+ //
+ // Multiple addons will be able to provide modifications to any headers
+ // listed in the default set.
+ let headersAlreadySet = new Set();
+ for (let { name, value, binaryValue } of headers) {
+ if (binaryValue) {
+ value = String.fromCharCode(...binaryValue);
+ }
+
+ let lowerCaseName = name.toLowerCase();
+ let original = origHeaders.get(lowerCaseName);
+
+ if (!original || value !== original.value) {
+ let shouldMerge = headersAlreadySet.has(lowerCaseName);
+ this.setHeader(name, value, shouldMerge, opts, lowerCaseName);
+ }
+
+ headersAlreadySet.add(lowerCaseName);
+ }
+ }
+}
+
+const checkRestrictedHeaderValue = (value, opts = {}) => {
+ let uri = Services.io.newURI(`https://${value}/`);
+ let { policy } = opts;
+
+ if (policy && !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 RequestHeaderChanger extends HeaderChanger {
+ setHeader(name, value, merge, opts, lowerCaseName) {
+ try {
+ if (value && lowerCaseName === "host") {
+ checkRestrictedHeaderValue(value, opts);
+ }
+ this.channel.setRequestHeader(name, value, merge);
+ } catch (e) {
+ Cu.reportError(new Error(`Error setting request header ${name}: ${e}`));
+ }
+ }
+
+ readHeaders() {
+ return this.channel.getRequestHeaders();
+ }
+}
+
+class ResponseHeaderChanger extends HeaderChanger {
+ didModifyCSP = false;
+
+ setHeader(name, value, merge, opts, lowerCaseName) {
+ if (lowerCaseName === "content-security-policy") {
+ // When multiple add-ons change the CSP, enforce the combined (strictest)
+ // policy - see bug 1462989 for motivation.
+ // When value is unset, don't force the header to be merged, to allow
+ // add-ons to clear the header if wanted.
+ if (value) {
+ merge = merge || this.didModifyCSP;
+ }
+ this.didModifyCSP = true;
+ }
+ try {
+ this.channel.setResponseHeader(name, value, merge);
+ } catch (e) {
+ Cu.reportError(new Error(`Error setting response header ${name}: ${e}`));
+ }
+ }
+
+ readHeaders() {
+ return this.channel.getResponseHeaders();
+ }
+}
+
+const MAYBE_CACHED_EVENTS = new Set([
+ "onResponseStarted",
+ "onHeadersReceived",
+ "onBeforeRedirect",
+ "onCompleted",
+ "onErrorOccurred",
+]);
+
+const OPTIONAL_PROPERTIES = [
+ "requestHeaders",
+ "responseHeaders",
+ "statusCode",
+ "statusLine",
+ "error",
+ "redirectUrl",
+ "requestBody",
+ "scheme",
+ "realm",
+ "isProxy",
+ "challenger",
+ "proxyInfo",
+ "ip",
+ "frameAncestors",
+ "urlClassification",
+ "requestSize",
+ "responseSize",
+];
+
+function serializeRequestData(eventName) {
+ let data = {
+ requestId: this.requestId,
+ url: this.url,
+ originUrl: this.originUrl,
+ documentUrl: this.documentUrl,
+ method: this.method,
+ type: this.type,
+ timeStamp: Date.now(),
+ tabId: this.tabId,
+ frameId: this.frameId,
+ parentFrameId: this.parentFrameId,
+ incognito: this.incognito,
+ thirdParty: this.thirdParty,
+ cookieStoreId: this.cookieStoreId,
+ };
+
+ if (MAYBE_CACHED_EVENTS.has(eventName)) {
+ data.fromCache = !!this.fromCache;
+ }
+
+ for (let opt of OPTIONAL_PROPERTIES) {
+ if (typeof this[opt] !== "undefined") {
+ data[opt] = this[opt];
+ }
+ }
+
+ if (this.urlClassification) {
+ data.urlClassification = {
+ firstParty: this.urlClassification.firstParty.filter(
+ c => !c.startsWith("socialtracking_")
+ ),
+ thirdParty: this.urlClassification.thirdParty.filter(
+ c => !c.startsWith("socialtracking_")
+ ),
+ };
+ }
+
+ return data;
+}
+
+var HttpObserverManager;
+
+var ChannelEventSink = {
+ _classDescription: "WebRequest channel event sink",
+ _classID: Components.ID("115062f8-92f1-11e5-8b7f-080027b0f7ec"),
+ _contractID: "@mozilla.org/webrequest/channel-event-sink;1",
+
+ QueryInterface: ChromeUtils.generateQI(["nsIChannelEventSink", "nsIFactory"]),
+
+ init() {
+ Components.manager
+ .QueryInterface(Ci.nsIComponentRegistrar)
+ .registerFactory(
+ this._classID,
+ this._classDescription,
+ this._contractID,
+ this
+ );
+ },
+
+ register() {
+ Services.catMan.addCategoryEntry(
+ "net-channel-event-sinks",
+ this._contractID,
+ this._contractID,
+ false,
+ true
+ );
+ },
+
+ unregister() {
+ Services.catMan.deleteCategoryEntry(
+ "net-channel-event-sinks",
+ this._contractID,
+ false
+ );
+ },
+
+ // nsIChannelEventSink implementation
+ asyncOnChannelRedirect(oldChannel, newChannel, flags, redirectCallback) {
+ runLater(() => redirectCallback.onRedirectVerifyCallback(Cr.NS_OK));
+ try {
+ HttpObserverManager.onChannelReplaced(oldChannel, newChannel);
+ } catch (e) {
+ // we don't wanna throw: it would abort the redirection
+ }
+ },
+
+ // nsIFactory implementation
+ createInstance(outer, iid) {
+ if (outer) {
+ throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION);
+ }
+ return this.QueryInterface(iid);
+ },
+};
+
+ChannelEventSink.init();
+
+// nsIAuthPrompt2 implementation for onAuthRequired
+class AuthRequestor {
+ constructor(channel, httpObserver) {
+ this.notificationCallbacks = channel.notificationCallbacks;
+ this.loadGroupCallbacks =
+ channel.loadGroup && channel.loadGroup.notificationCallbacks;
+ this.httpObserver = httpObserver;
+ }
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIAuthPromptProvider) || iid.equals(Ci.nsIAuthPrompt2)) {
+ return this;
+ }
+ try {
+ return this.notificationCallbacks.getInterface(iid);
+ } catch (e) {}
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ _getForwardedInterface(iid) {
+ try {
+ return this.notificationCallbacks.getInterface(iid);
+ } catch (e) {
+ return this.loadGroupCallbacks.getInterface(iid);
+ }
+ }
+
+ // nsIAuthPromptProvider getAuthPrompt
+ getAuthPrompt(reason, iid) {
+ // This should never get called without getInterface having been called first.
+ if (iid.equals(Ci.nsIAuthPrompt2)) {
+ return this;
+ }
+ return this._getForwardedInterface(Ci.nsIAuthPromptProvider).getAuthPrompt(
+ reason,
+ iid
+ );
+ }
+
+ // nsIAuthPrompt2 promptAuth
+ promptAuth(channel, level, authInfo) {
+ this._getForwardedInterface(Ci.nsIAuthPrompt2).promptAuth(
+ channel,
+ level,
+ authInfo
+ );
+ }
+
+ _getForwardPrompt(data) {
+ let reason = data.isProxy
+ ? Ci.nsIAuthPromptProvider.PROMPT_PROXY
+ : Ci.nsIAuthPromptProvider.PROMPT_NORMAL;
+ for (let callbacks of [
+ this.notificationCallbacks,
+ this.loadGroupCallbacks,
+ ]) {
+ try {
+ return callbacks
+ .getInterface(Ci.nsIAuthPromptProvider)
+ .getAuthPrompt(reason, Ci.nsIAuthPrompt2);
+ } catch (e) {}
+ try {
+ return callbacks.getInterface(Ci.nsIAuthPrompt2);
+ } catch (e) {}
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ // nsIAuthPrompt2 asyncPromptAuth
+ asyncPromptAuth(channel, callback, context, level, authInfo) {
+ let wrapper = ChannelWrapper.get(channel);
+
+ let uri = channel.URI;
+ let proxyInfo;
+ let isProxy = !!(authInfo.flags & authInfo.AUTH_PROXY);
+ if (isProxy && channel instanceof Ci.nsIProxiedChannel) {
+ proxyInfo = channel.proxyInfo;
+ }
+ let data = {
+ scheme: authInfo.authenticationScheme,
+ realm: authInfo.realm,
+ isProxy,
+ challenger: {
+ host: proxyInfo ? proxyInfo.host : uri.host,
+ port: proxyInfo ? proxyInfo.port : uri.port,
+ },
+ };
+
+ // In the case that no listener provides credentials, we fallback to the
+ // previously set callback class for authentication.
+ wrapper.authPromptForward = () => {
+ try {
+ let prompt = this._getForwardPrompt(data);
+ prompt.asyncPromptAuth(channel, callback, context, level, authInfo);
+ } catch (e) {
+ Cu.reportError(`webRequest asyncPromptAuth failure ${e}`);
+ callback.onAuthCancelled(context, false);
+ }
+ wrapper.authPromptForward = null;
+ wrapper.authPromptCallback = null;
+ };
+ wrapper.authPromptCallback = authCredentials => {
+ // The API allows for canceling the request, providing credentials or
+ // doing nothing, so we do not provide a way to call onAuthCanceled.
+ // Canceling the request will result in canceling the authentication.
+ if (
+ authCredentials &&
+ typeof authCredentials.username === "string" &&
+ typeof authCredentials.password === "string"
+ ) {
+ authInfo.username = authCredentials.username;
+ authInfo.password = authCredentials.password;
+ try {
+ callback.onAuthAvailable(context, authInfo);
+ } catch (e) {
+ Cu.reportError(`webRequest onAuthAvailable failure ${e}`);
+ }
+ // At least one addon has responded, so we won't forward to the regular
+ // prompt handlers.
+ wrapper.authPromptForward = null;
+ wrapper.authPromptCallback = null;
+ }
+ };
+
+ this.httpObserver.runChannelListener(wrapper, "onAuthRequired", data);
+
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsICancelable"]),
+ cancel() {
+ try {
+ callback.onAuthCancelled(context, false);
+ } catch (e) {
+ Cu.reportError(`webRequest onAuthCancelled failure ${e}`);
+ }
+ wrapper.authPromptForward = null;
+ wrapper.authPromptCallback = null;
+ },
+ };
+ }
+}
+
+AuthRequestor.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIInterfaceRequestor",
+ "nsIAuthPromptProvider",
+ "nsIAuthPrompt2",
+]);
+
+// Most WebRequest events are implemented via the observer services, but
+// a few use custom xpcom interfaces. This class (HttpObserverManager)
+// serves two main purposes:
+// 1. It abstracts away the names and details of the underlying
+// implementation (e.g., onBeforeBeforeRequest is dispatched from
+// the http-on-modify-request observable).
+// 2. It aggregates multiple listeners so that a single observer or
+// handler can serve multiple webRequest listeners.
+HttpObserverManager = {
+ listeners: {
+ // onBeforeRequest uses http-on-modify observer for HTTP(S).
+ onBeforeRequest: new Map(),
+
+ // onBeforeSendHeaders and onSendHeaders correspond to the
+ // http-on-before-connect observer.
+ onBeforeSendHeaders: new Map(),
+ onSendHeaders: new Map(),
+
+ // onHeadersReceived corresponds to the http-on-examine-* obserservers.
+ onHeadersReceived: new Map(),
+
+ // onAuthRequired is handled via the nsIAuthPrompt2 xpcom interface
+ // which is managed here by AuthRequestor.
+ onAuthRequired: new Map(),
+
+ // onBeforeRedirect is handled by the nsIChannelEVentSink xpcom interface
+ // which is managed here by ChannelEventSink.
+ onBeforeRedirect: new Map(),
+
+ // onResponseStarted, onErrorOccurred, and OnCompleted correspond
+ // to events dispatched by the ChannelWrapper EventTarget.
+ onResponseStarted: new Map(),
+ onErrorOccurred: new Map(),
+ onCompleted: new Map(),
+ },
+
+ openingInitialized: false,
+ beforeConnectInitialized: false,
+ examineInitialized: false,
+ redirectInitialized: false,
+ activityInitialized: false,
+ needTracing: false,
+ hasRedirects: false,
+
+ getWrapper(nativeChannel) {
+ let wrapper = ChannelWrapper.get(nativeChannel);
+ if (!wrapper._addedListeners) {
+ /* eslint-disable mozilla/balanced-listeners */
+ if (this.listeners.onErrorOccurred.size) {
+ wrapper.addEventListener("error", this);
+ }
+ if (this.listeners.onResponseStarted.size) {
+ wrapper.addEventListener("start", this);
+ }
+ if (this.listeners.onCompleted.size) {
+ wrapper.addEventListener("stop", this);
+ }
+ /* eslint-enable mozilla/balanced-listeners */
+
+ wrapper._addedListeners = true;
+ }
+ return wrapper;
+ },
+
+ get activityDistributor() {
+ return Cc["@mozilla.org/network/http-activity-distributor;1"].getService(
+ Ci.nsIHttpActivityDistributor
+ );
+ },
+
+ // This method is called whenever webRequest listeners are added or removed.
+ // It reconciles the set of listeners with underlying observers, event
+ // handlers, etc. by adding new low-level handlers for any newly added
+ // webRequest listeners and removing those that are no longer needed if
+ // there are no more listeners for corresponding webRequest events.
+ addOrRemove() {
+ let needOpening = this.listeners.onBeforeRequest.size;
+ let needBeforeConnect =
+ this.listeners.onBeforeSendHeaders.size ||
+ this.listeners.onSendHeaders.size;
+ if (needOpening && !this.openingInitialized) {
+ this.openingInitialized = true;
+ Services.obs.addObserver(this, "http-on-modify-request");
+ } else if (!needOpening && this.openingInitialized) {
+ this.openingInitialized = false;
+ Services.obs.removeObserver(this, "http-on-modify-request");
+ }
+ if (needBeforeConnect && !this.beforeConnectInitialized) {
+ this.beforeConnectInitialized = true;
+ Services.obs.addObserver(this, "http-on-before-connect");
+ } else if (!needBeforeConnect && this.beforeConnectInitialized) {
+ this.beforeConnectInitialized = false;
+ Services.obs.removeObserver(this, "http-on-before-connect");
+ }
+
+ let haveBlocking = Object.values(this.listeners).some(listeners =>
+ Array.from(listeners.values()).some(listener => listener.blockingAllowed)
+ );
+
+ this.needTracing =
+ this.listeners.onResponseStarted.size ||
+ this.listeners.onErrorOccurred.size ||
+ this.listeners.onCompleted.size ||
+ haveBlocking;
+
+ let needExamine =
+ this.needTracing ||
+ this.listeners.onHeadersReceived.size ||
+ this.listeners.onAuthRequired.size;
+
+ if (needExamine && !this.examineInitialized) {
+ this.examineInitialized = true;
+ Services.obs.addObserver(this, "http-on-examine-response");
+ Services.obs.addObserver(this, "http-on-examine-cached-response");
+ Services.obs.addObserver(this, "http-on-examine-merged-response");
+ } else if (!needExamine && this.examineInitialized) {
+ this.examineInitialized = false;
+ Services.obs.removeObserver(this, "http-on-examine-response");
+ Services.obs.removeObserver(this, "http-on-examine-cached-response");
+ Services.obs.removeObserver(this, "http-on-examine-merged-response");
+ }
+
+ // If we have any listeners, we need the channelsink so the channelwrapper is
+ // updated properly. Otherwise events for channels that are redirected will not
+ // happen correctly. If we have no listeners, shut it down.
+ this.hasRedirects = this.listeners.onBeforeRedirect.size > 0;
+ let needRedirect =
+ this.hasRedirects || needExamine || needOpening || needBeforeConnect;
+ if (needRedirect && !this.redirectInitialized) {
+ this.redirectInitialized = true;
+ ChannelEventSink.register();
+ } else if (!needRedirect && this.redirectInitialized) {
+ this.redirectInitialized = false;
+ ChannelEventSink.unregister();
+ }
+
+ let needActivity = this.listeners.onErrorOccurred.size;
+ if (needActivity && !this.activityInitialized) {
+ this.activityInitialized = true;
+ this.activityDistributor.addObserver(this);
+ } else if (!needActivity && this.activityInitialized) {
+ this.activityInitialized = false;
+ this.activityDistributor.removeObserver(this);
+ }
+ },
+
+ addListener(kind, callback, opts) {
+ this.listeners[kind].set(callback, opts);
+ this.addOrRemove();
+ },
+
+ removeListener(kind, callback) {
+ this.listeners[kind].delete(callback);
+ this.addOrRemove();
+ },
+
+ observe(subject, topic, data) {
+ let channel = this.getWrapper(subject);
+ switch (topic) {
+ case "http-on-modify-request":
+ this.runChannelListener(channel, "onBeforeRequest");
+ break;
+ case "http-on-before-connect":
+ this.runChannelListener(channel, "onBeforeSendHeaders");
+ break;
+ case "http-on-examine-cached-response":
+ case "http-on-examine-merged-response":
+ channel.fromCache = true;
+ // falls through
+ case "http-on-examine-response":
+ this.examine(channel, topic, data);
+ break;
+ }
+ },
+
+ // We map activity values with tentative error names, e.g. "STATUS_RESOLVING" => "NS_ERROR_NET_ON_RESOLVING".
+ get activityErrorsMap() {
+ let prefix = /^(?:ACTIVITY_SUBTYPE_|STATUS_)/;
+ let map = new Map();
+ for (let iface of [nsIHttpActivityObserver, nsISocketTransport]) {
+ for (let c of Object.keys(iface).filter(name => prefix.test(name))) {
+ map.set(iface[c], c.replace(prefix, "NS_ERROR_NET_ON_"));
+ }
+ }
+ delete this.activityErrorsMap;
+ this.activityErrorsMap = map;
+ return this.activityErrorsMap;
+ },
+ GOOD_LAST_ACTIVITY: nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_HEADER,
+ observeActivity(
+ nativeChannel,
+ activityType,
+ activitySubtype /* , aTimestamp, aExtraSizeData, aExtraStringData */
+ ) {
+ // Sometimes we get a NullHttpChannel, which implements
+ // nsIHttpChannel but not nsIChannel.
+ if (!(nativeChannel instanceof Ci.nsIChannel)) {
+ return;
+ }
+ let channel = this.getWrapper(nativeChannel);
+
+ let lastActivity = channel.lastActivity || 0;
+ if (
+ activitySubtype ===
+ nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE &&
+ lastActivity &&
+ lastActivity !== this.GOOD_LAST_ACTIVITY
+ ) {
+ // Make a trip through the event loop to make sure errors have a
+ // chance to be processed before we fall back to a generic error
+ // string.
+ Services.tm.dispatchToMainThread(() => {
+ channel.errorCheck();
+ if (!channel.errorString) {
+ this.runChannelListener(channel, "onErrorOccurred", {
+ error:
+ this.activityErrorsMap.get(lastActivity) ||
+ `NS_ERROR_NET_UNKNOWN_${lastActivity}`,
+ });
+ }
+ });
+ } else if (
+ lastActivity !== this.GOOD_LAST_ACTIVITY &&
+ lastActivity !==
+ nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE
+ ) {
+ channel.lastActivity = activitySubtype;
+ }
+ },
+
+ 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,
+
+ tabId: this.getBrowserData(channel).tabId,
+ frameId: channel.frameId,
+ parentFrameId: channel.parentFrameId,
+
+ frameAncestors: channel.frameAncestors || undefined,
+
+ ip: channel.remoteAddress,
+
+ proxyInfo: channel.proxyInfo,
+
+ serialize: serializeRequestData,
+ requestSize: channel.requestSize,
+ responseSize: channel.responseSize,
+ urlClassification: channel.urlClassification,
+ };
+
+ if (originAttributes) {
+ data.cookieStoreId = getCookieStoreIdForOriginAttributes(
+ originAttributes
+ );
+ }
+
+ return Object.assign(data, extraData);
+ },
+
+ handleEvent(event) {
+ let channel = event.currentTarget;
+ switch (event.type) {
+ case "error":
+ this.runChannelListener(channel, "onErrorOccurred", {
+ error: channel.errorString,
+ });
+ break;
+ case "start":
+ this.runChannelListener(channel, "onResponseStarted");
+ break;
+ case "stop":
+ this.runChannelListener(channel, "onCompleted");
+ break;
+ }
+ },
+
+ STATUS_TYPES: new Set([
+ "onHeadersReceived",
+ "onAuthRequired",
+ "onBeforeRedirect",
+ "onResponseStarted",
+ "onCompleted",
+ ]),
+ FILTER_TYPES: new Set([
+ "onBeforeRequest",
+ "onBeforeSendHeaders",
+ "onSendHeaders",
+ "onHeadersReceived",
+ "onAuthRequired",
+ "onBeforeRedirect",
+ ]),
+
+ getBrowserData(wrapper) {
+ let browserData = wrapper._browserData;
+ if (!browserData) {
+ if (wrapper.browserElement) {
+ browserData = tabTracker.getBrowserData(wrapper.browserElement);
+ } else {
+ browserData = { tabId: -1, windowId: -1 };
+ }
+ wrapper._browserData = browserData;
+ }
+ return browserData;
+ },
+
+ runChannelListener(channel, kind, extraData = null) {
+ let handlerResults = [];
+ let requestHeaders;
+ let responseHeaders;
+
+ try {
+ if (kind !== "onErrorOccurred" && channel.errorString) {
+ return;
+ }
+
+ let registerFilter = this.FILTER_TYPES.has(kind);
+ let commonData = null;
+ let requestBody;
+ this.listeners[kind].forEach((opts, callback) => {
+ if (opts.filter.tabId !== null || opts.filter.windowId !== null) {
+ const { tabId, windowId } = this.getBrowserData(channel);
+ if (
+ (opts.filter.tabId !== null && tabId != opts.filter.tabId) ||
+ (opts.filter.windowId !== null && windowId != opts.filter.windowId)
+ ) {
+ return;
+ }
+ }
+ if (!channel.matches(opts.filter, opts.policy, extraData)) {
+ return;
+ }
+
+ if (!commonData) {
+ commonData = this.getRequestData(channel, extraData);
+ if (this.STATUS_TYPES.has(kind)) {
+ commonData.statusCode = channel.statusCode;
+ commonData.statusLine = channel.statusLine;
+ }
+ }
+ let data = Object.create(commonData);
+
+ if (registerFilter && opts.blocking && opts.policy) {
+ data.registerTraceableChannel = (policy, remoteTab) => {
+ // `channel` is a ChannelWrapper, which contains the actual
+ // underlying nsIChannel in `channel.channel`. For startup events
+ // that are held until the extension background page is started,
+ // it is possible that the underlying channel can be closed and
+ // cleaned up between the time the event occurred and the time
+ // we reach this code.
+ if (channel.channel) {
+ channel.registerTraceableChannel(policy, remoteTab);
+ }
+ };
+ }
+
+ if (opts.requestHeaders) {
+ requestHeaders = requestHeaders || new RequestHeaderChanger(channel);
+ data.requestHeaders = requestHeaders.toArray();
+ }
+
+ if (opts.responseHeaders) {
+ try {
+ responseHeaders =
+ responseHeaders || new ResponseHeaderChanger(channel);
+ data.responseHeaders = responseHeaders.toArray();
+ } catch (e) {
+ /* headers may not be available on some redirects */
+ }
+ }
+
+ if (opts.requestBody && channel.canModify) {
+ requestBody =
+ requestBody || WebRequestUpload.createRequestBody(channel.channel);
+ data.requestBody = requestBody;
+ }
+
+ try {
+ let result = callback(data);
+
+ // isProxy is set during onAuth if the auth request is for a proxy.
+ // We allow handling proxy auth regardless of canModify.
+ if (
+ (channel.canModify || data.isProxy) &&
+ typeof result === "object" &&
+ opts.blocking
+ ) {
+ handlerResults.push({ opts, result });
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ });
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ return this.applyChanges(
+ kind,
+ channel,
+ handlerResults,
+ requestHeaders,
+ responseHeaders
+ );
+ },
+
+ async applyChanges(
+ kind,
+ channel,
+ handlerResults,
+ requestHeaders,
+ responseHeaders
+ ) {
+ let shouldResume = !channel.suspended;
+ let suspenders = [];
+
+ try {
+ for (let { opts, result } of handlerResults) {
+ if (isThenable(result)) {
+ suspenders.push(opts.addonId);
+ channel.suspend();
+ try {
+ result = await result;
+ } catch (e) {
+ let error;
+
+ if (e instanceof Error) {
+ error = e;
+ } else if (typeof e === "object" && e.message) {
+ error = new Error(e.message, e.fileName, e.lineNumber);
+ }
+
+ Cu.reportError(error);
+ continue;
+ }
+ if (!result || typeof result !== "object") {
+ continue;
+ }
+ }
+
+ if (
+ kind === "onAuthRequired" &&
+ result.authCredentials &&
+ channel.authPromptCallback
+ ) {
+ channel.authPromptCallback(result.authCredentials);
+ }
+
+ // We allow proxy auth to cancel or handle authCredentials regardless of
+ // canModify, but ensure we do nothing else.
+ if (!channel.canModify) {
+ continue;
+ }
+
+ if (result.cancel) {
+ let text = "";
+ if (Services.profiler?.IsActive()) {
+ text =
+ `${kind} ${channel.finalURL}` +
+ ` by ${suspenders.join(", ")} canceled`;
+ }
+ channel.resume(text);
+ channel.cancel(
+ Cr.NS_ERROR_ABORT,
+ Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
+ );
+ let { policy } = opts;
+ if (policy) {
+ let properties = channel.channel.QueryInterface(
+ Ci.nsIWritablePropertyBag
+ );
+ properties.setProperty("cancelledByExtension", policy.id);
+ }
+ return;
+ }
+
+ if (result.redirectUrl) {
+ try {
+ let text = "";
+ if (Services.profiler?.IsActive()) {
+ text =
+ `${kind} ${channel.finalURL}` +
+ ` by ${suspenders.join(", ")}` +
+ ` redirected to ${result.redirectUrl}`;
+ }
+ channel.resume(text);
+ channel.redirectTo(Services.io.newURI(result.redirectUrl));
+
+ // Web Extensions using the WebRequest API are allowed
+ // to redirect a channel to a data: URI, hence we mark
+ // the channel to let the redirect blocker know. Please
+ // note that this marking needs to happen after the
+ // channel.redirectTo is called because the channel's
+ // RedirectTo() implementation explicitly drops the flag
+ // to avoid additional redirects not caused by the
+ // Web Extension.
+ channel.loadInfo.allowInsecureRedirectToDataURI = true;
+
+ // To pass CORS checks, we pretend the current request's
+ // response allows the triggering origin to access.
+ let origin = channel.getRequestHeader("Origin");
+ if (origin) {
+ channel.setResponseHeader("Access-Control-Allow-Origin", origin);
+ channel.setResponseHeader(
+ "Access-Control-Allow-Credentials",
+ "true"
+ );
+
+ // Compute an arbitrary 'Access-Control-Allow-Headers'
+ // for the internal Redirect
+
+ let allowHeaders = channel
+ .getRequestHeaders()
+ .map(header => header.name)
+ .join();
+ channel.setResponseHeader(
+ "Access-Control-Allow-Headers",
+ allowHeaders
+ );
+
+ channel.setResponseHeader(
+ "Access-Control-Allow-Methods",
+ channel.method
+ );
+ }
+
+ return;
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ if (result.upgradeToSecure && kind === "onBeforeRequest") {
+ try {
+ channel.upgradeToSecure();
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ if (opts.requestHeaders && result.requestHeaders && requestHeaders) {
+ requestHeaders.applyChanges(result.requestHeaders, opts);
+ }
+
+ if (opts.responseHeaders && result.responseHeaders && responseHeaders) {
+ responseHeaders.applyChanges(result.responseHeaders, opts);
+ }
+ }
+
+ // If a listener did not cancel the request or provide credentials, we
+ // forward the auth request to the base handler.
+ if (kind === "onAuthRequired" && channel.authPromptForward) {
+ channel.authPromptForward();
+ }
+
+ if (kind === "onBeforeSendHeaders" && this.listeners.onSendHeaders.size) {
+ this.runChannelListener(channel, "onSendHeaders");
+ } else if (kind !== "onErrorOccurred") {
+ channel.errorCheck();
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ // Only resume the channel if it was suspended by this call.
+ if (shouldResume) {
+ let text = "";
+ if (Services.profiler?.IsActive()) {
+ text = `${kind} ${channel.finalURL} by ${suspenders.join(", ")}`;
+ }
+ channel.resume(text);
+ }
+ },
+
+ shouldHookListener(listener, channel, extraData) {
+ if (listener.size == 0) {
+ return false;
+ }
+
+ for (let opts of listener.values()) {
+ if (channel.matches(opts.filter, opts.policy, extraData)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ examine(channel, topic, data) {
+ if (this.listeners.onHeadersReceived.size) {
+ this.runChannelListener(channel, "onHeadersReceived");
+ }
+
+ if (
+ !channel.hasAuthRequestor &&
+ this.shouldHookListener(this.listeners.onAuthRequired, channel, {
+ isProxy: true,
+ })
+ ) {
+ channel.channel.notificationCallbacks = new AuthRequestor(
+ channel.channel,
+ this
+ );
+ channel.hasAuthRequestor = true;
+ }
+ },
+
+ onChannelReplaced(oldChannel, newChannel) {
+ let channel = this.getWrapper(oldChannel);
+
+ // We want originalURI, this will provide a moz-ext rather than jar or file
+ // uri on redirects.
+ if (this.hasRedirects) {
+ this.runChannelListener(channel, "onBeforeRedirect", {
+ redirectUrl: newChannel.originalURI.spec,
+ });
+ }
+ channel.channel = newChannel;
+ },
+};
+
+function HttpEvent(internalEvent, options) {
+ this.internalEvent = internalEvent;
+ this.options = options;
+}
+
+HttpEvent.prototype = {
+ addListener(callback, filter = null, options = null, optionsObject = null) {
+ let opts = parseExtra(options, this.options, optionsObject);
+ opts.filter = parseFilter(filter);
+ HttpObserverManager.addListener(this.internalEvent, callback, opts);
+ },
+
+ removeListener(callback) {
+ HttpObserverManager.removeListener(this.internalEvent, callback);
+ },
+};
+
+var onBeforeRequest = new HttpEvent("onBeforeRequest", [
+ "blocking",
+ "requestBody",
+]);
+var onBeforeSendHeaders = new HttpEvent("onBeforeSendHeaders", [
+ "requestHeaders",
+ "blocking",
+]);
+var onSendHeaders = new HttpEvent("onSendHeaders", ["requestHeaders"]);
+var onHeadersReceived = new HttpEvent("onHeadersReceived", [
+ "blocking",
+ "responseHeaders",
+]);
+var onAuthRequired = new HttpEvent("onAuthRequired", [
+ "blocking",
+ "responseHeaders",
+]);
+var onBeforeRedirect = new HttpEvent("onBeforeRedirect", ["responseHeaders"]);
+var onResponseStarted = new HttpEvent("onResponseStarted", ["responseHeaders"]);
+var onCompleted = new HttpEvent("onCompleted", ["responseHeaders"]);
+var onErrorOccurred = new HttpEvent("onErrorOccurred");
+
+var WebRequest = {
+ onBeforeRequest,
+ onBeforeSendHeaders,
+ onSendHeaders,
+ onHeadersReceived,
+ onAuthRequired,
+ onBeforeRedirect,
+ onResponseStarted,
+ onCompleted,
+ onErrorOccurred,
+
+ getSecurityInfo: details => {
+ let channel = ChannelWrapper.getRegisteredChannel(
+ details.id,
+ details.policy,
+ details.remoteTab
+ );
+ if (channel) {
+ return SecurityInfo.getSecurityInfo(channel.channel, details.options);
+ }
+ },
+};
diff --git a/toolkit/components/extensions/webrequest/WebRequestService.cpp b/toolkit/components/extensions/webrequest/WebRequestService.cpp
new file mode 100644
index 0000000000..891edf2515
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/WebRequestService.cpp
@@ -0,0 +1,54 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#include "WebRequestService.h"
+
+#include "mozilla/Assertions.h"
+#include "mozilla/ClearOnShutdown.h"
+
+using namespace mozilla;
+using namespace mozilla::dom;
+using namespace mozilla::extensions;
+
+static StaticRefPtr<WebRequestService> sWebRequestService;
+
+/* static */ WebRequestService& WebRequestService::GetSingleton() {
+ if (!sWebRequestService) {
+ sWebRequestService = new WebRequestService();
+ ClearOnShutdown(&sWebRequestService);
+ }
+ return *sWebRequestService;
+}
+
+UniquePtr<WebRequestChannelEntry> WebRequestService::RegisterChannel(
+ ChannelWrapper* aChannel) {
+ UniquePtr<ChannelEntry> entry(new ChannelEntry(aChannel));
+
+ auto key = mChannelEntries.LookupForAdd(entry->mChannelId);
+ MOZ_DIAGNOSTIC_ASSERT(!key);
+ key.OrInsert([&entry]() { return entry.get(); });
+
+ return entry;
+}
+
+already_AddRefed<nsITraceableChannel> WebRequestService::GetTraceableChannel(
+ uint64_t aChannelId, nsAtom* aAddonId, ContentParent* aContentParent) {
+ if (auto entry = mChannelEntries.Get(aChannelId)) {
+ if (entry->mChannel) {
+ return entry->mChannel->GetTraceableChannel(aAddonId, aContentParent);
+ }
+ }
+ return nullptr;
+}
+
+WebRequestChannelEntry::WebRequestChannelEntry(ChannelWrapper* aChannel)
+ : mChannelId(aChannel->Id()), mChannel(aChannel) {}
+
+WebRequestChannelEntry::~WebRequestChannelEntry() {
+ if (sWebRequestService) {
+ sWebRequestService->mChannelEntries.Remove(mChannelId);
+ }
+}
diff --git a/toolkit/components/extensions/webrequest/WebRequestService.h b/toolkit/components/extensions/webrequest/WebRequestService.h
new file mode 100644
index 0000000000..0fec784b8d
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/WebRequestService.h
@@ -0,0 +1,77 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 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/. */
+
+#ifndef mozilla_WebRequestService_h
+#define mozilla_WebRequestService_h
+
+#include "mozilla/LinkedList.h"
+#include "mozilla/UniquePtr.h"
+
+#include "mozilla/extensions/ChannelWrapper.h"
+#include "mozilla/extensions/WebExtensionPolicy.h"
+
+#include "nsHashKeys.h"
+#include "nsDataHashtable.h"
+
+class nsAtom;
+class nsIRemoteTab;
+class nsITraceableChannel;
+
+namespace mozilla {
+namespace dom {
+class BrowserParent;
+class ContentParent;
+} // namespace dom
+
+namespace extensions {
+
+class WebRequestChannelEntry final {
+ public:
+ ~WebRequestChannelEntry();
+
+ private:
+ friend class WebRequestService;
+
+ explicit WebRequestChannelEntry(ChannelWrapper* aChannel);
+
+ uint64_t mChannelId;
+ WeakPtr<ChannelWrapper> mChannel;
+};
+
+class WebRequestService final {
+ public:
+ NS_INLINE_DECL_REFCOUNTING(WebRequestService)
+
+ WebRequestService() = default;
+
+ static already_AddRefed<WebRequestService> GetInstance() {
+ return do_AddRef(&GetSingleton());
+ }
+
+ static WebRequestService& GetSingleton();
+
+ using ChannelEntry = WebRequestChannelEntry;
+
+ UniquePtr<ChannelEntry> RegisterChannel(ChannelWrapper* aChannel);
+
+ void UnregisterTraceableChannel(uint64_t aChannelId);
+
+ already_AddRefed<nsITraceableChannel> GetTraceableChannel(
+ uint64_t aChannelId, nsAtom* aAddonId,
+ dom::ContentParent* aContentParent);
+
+ private:
+ ~WebRequestService() = default;
+
+ friend ChannelEntry;
+
+ nsDataHashtable<nsUint64HashKey, ChannelEntry*> mChannelEntries;
+};
+
+} // namespace extensions
+} // namespace mozilla
+
+#endif // mozilla_WebRequestService_h
diff --git a/toolkit/components/extensions/webrequest/WebRequestUpload.jsm b/toolkit/components/extensions/webrequest/WebRequestUpload.jsm
new file mode 100644
index 0000000000..eb8a2bc6b5
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/WebRequestUpload.jsm
@@ -0,0 +1,552 @@
+/* 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 EXPORTED_SYMBOLS = ["WebRequestUpload"];
+
+/* exported WebRequestUpload */
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+const { ExtensionUtils } = ChromeUtils.import(
+ "resource://gre/modules/ExtensionUtils.jsm"
+);
+
+const { DefaultMap } = ExtensionUtils;
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["TextEncoder"]);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "mimeHeader",
+ "@mozilla.org/network/mime-hdrparam;1",
+ "nsIMIMEHeaderParam"
+);
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+const ConverterInputStream = Components.Constructor(
+ "@mozilla.org/intl/converter-input-stream;1",
+ "nsIConverterInputStream",
+ "init"
+);
+
+var WebRequestUpload;
+
+/**
+ * Parses the given raw header block, and stores the value of each
+ * lower-cased header name in the resulting map.
+ */
+class Headers extends Map {
+ constructor(headerText) {
+ super();
+
+ if (headerText) {
+ this.parseHeaders(headerText);
+ }
+ }
+
+ parseHeaders(headerText) {
+ let lines = headerText.split("\r\n");
+
+ let lastHeader;
+ for (let line of lines) {
+ // The first empty line indicates the end of the header block.
+ if (line === "") {
+ return;
+ }
+
+ // Lines starting with whitespace are appended to the previous
+ // header.
+ if (/^\s/.test(line)) {
+ if (lastHeader) {
+ let val = this.get(lastHeader);
+ this.set(lastHeader, `${val}\r\n${line}`);
+ }
+ continue;
+ }
+
+ let match = /^(.*?)\s*:\s+(.*)/.exec(line);
+ if (match) {
+ lastHeader = match[1].toLowerCase();
+ this.set(lastHeader, match[2]);
+ }
+ }
+ }
+
+ /**
+ * If the given header exists, and contains the given parameter,
+ * returns the value of that parameter.
+ *
+ * @param {string} name
+ * The lower-cased header name.
+ * @param {string} paramName
+ * The name of the parameter to retrieve, or empty to retrieve
+ * the first (possibly unnamed) parameter.
+ * @returns {string | null}
+ */
+ getParam(name, paramName) {
+ return Headers.getParam(this.get(name), paramName);
+ }
+
+ /**
+ * If the given header value is non-null, and contains the given
+ * parameter, returns the value of that parameter.
+ *
+ * @param {string | null} header
+ * The text of the header from which to retrieve the param.
+ * @param {string} paramName
+ * The name of the parameter to retrieve, or empty to retrieve
+ * the first (possibly unnamed) parameter.
+ * @returns {string | null}
+ */
+ static getParam(header, paramName) {
+ if (header) {
+ // The service expects this to be a raw byte string, so convert to
+ // UTF-8.
+ let bytes = new TextEncoder().encode(header);
+ let binHeader = String.fromCharCode(...bytes);
+
+ return mimeHeader.getParameterHTTP(binHeader, paramName, null, false, {});
+ }
+
+ return null;
+ }
+}
+
+/**
+ * Creates a new Object with a corresponding property for every
+ * key-value pair in the given Map.
+ *
+ * @param {Map} map
+ * The map to convert.
+ * @returns {Object}
+ */
+function mapToObject(map) {
+ let result = {};
+ for (let [key, value] of map) {
+ result[key] = value;
+ }
+ return result;
+}
+
+/**
+ * Rewinds the given seekable input stream to its beginning, and catches
+ * any resulting errors.
+ *
+ * @param {nsISeekableStream} stream
+ * The stream to rewind.
+ */
+function rewind(stream) {
+ // Do this outside the try-catch so that we throw if the stream is not
+ // actually seekable.
+ stream.QueryInterface(Ci.nsISeekableStream);
+
+ try {
+ stream.seek(0, 0);
+ } catch (e) {
+ // It might be already closed, e.g. because of a previous error.
+ Cu.reportError(e);
+ }
+}
+
+/**
+ * Iterates over all of the sub-streams that make up the given stream,
+ * or yields the stream itself if it is not a multi-part stream.
+ *
+ * @param {nsIIMultiplexInputStream|nsIStreamBufferAccess<nsIMultiplexInputStream>|nsIInputStream} outerStream
+ * The outer stream over which to iterate.
+ */
+function* getStreams(outerStream) {
+ // If this is a multi-part stream, we need to iterate over its sub-streams,
+ // rather than treating it as a simple input stream. Since it may be wrapped
+ // in a buffered input stream, unwrap it before we do any checks.
+ let unbuffered = outerStream;
+ if (outerStream instanceof Ci.nsIStreamBufferAccess) {
+ unbuffered = outerStream.unbufferedStream;
+ }
+
+ if (unbuffered instanceof Ci.nsIMultiplexInputStream) {
+ let count = unbuffered.count;
+ for (let i = 0; i < count; i++) {
+ yield unbuffered.getStream(i);
+ }
+ } else {
+ yield outerStream;
+ }
+}
+
+/**
+ * Parses the form data of the given stream as either multipart/form-data or
+ * x-www-form-urlencoded, and returns a map of its fields.
+ *
+ * @param {nsIInputStream} stream
+ * The input stream from which to parse the form data.
+ * @param {nsIHttpChannel} channel
+ * The channel to which the stream belongs.
+ * @param {boolean} [lenient = false]
+ * If true, the operation will succeed even if there are UTF-8
+ * decoding errors.
+ *
+ * @returns {Map<string, Array<string>> | null}
+ */
+function parseFormData(stream, channel, lenient = false) {
+ const BUFFER_SIZE = 8192;
+
+ let touchedStreams = new Set();
+ let converterStreams = [];
+
+ /**
+ * Creates a converter input stream from the given raw input stream,
+ * and adds it to the list of streams to be rewound at the end of
+ * parsing.
+ *
+ * Returns null if the given raw stream cannot be rewound.
+ *
+ * @param {nsIInputStream} stream
+ * The base stream from which to create a converter.
+ * @returns {ConverterInputStream | null}
+ */
+ function createTextStream(stream) {
+ if (!(stream instanceof Ci.nsISeekableStream)) {
+ return null;
+ }
+
+ touchedStreams.add(stream);
+ let converterStream = ConverterInputStream(
+ stream,
+ "UTF-8",
+ 0,
+ lenient ? Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER : 0
+ );
+ converterStreams.push(converterStream);
+ return converterStream;
+ }
+
+ /**
+ * Reads a string of no more than the given length from the given text
+ * stream.
+ *
+ * @param {ConverterInputStream} stream
+ * The stream to read.
+ * @param {integer} [length = BUFFER_SIZE]
+ * The maximum length of data to read.
+ * @returns {string}
+ */
+ function readString(stream, length = BUFFER_SIZE) {
+ let data = {};
+ stream.readString(length, data);
+ return data.value;
+ }
+
+ /**
+ * Iterates over all of the sub-streams of the given (possibly multi-part)
+ * input stream, and yields a ConverterInputStream for each
+ * nsIStringInputStream among them.
+ *
+ * @param {nsIInputStream|nsIMultiplexInputStream} outerStream
+ * The multi-part stream over which to iterate.
+ */
+ function* getTextStreams(outerStream) {
+ for (let stream of getStreams(outerStream)) {
+ if (stream instanceof Ci.nsIStringInputStream) {
+ touchedStreams.add(outerStream);
+ yield createTextStream(stream);
+ }
+ }
+ }
+
+ /**
+ * Iterates over all of the string streams of the given (possibly
+ * multi-part) input stream, and yields all of the available data in each as
+ * chunked strings, each no more than BUFFER_SIZE in length.
+ *
+ * @param {nsIInputStream|nsIMultiplexInputStream} outerStream
+ * The multi-part stream over which to iterate.
+ */
+ function* readAllStrings(outerStream) {
+ for (let textStream of getTextStreams(outerStream)) {
+ let str;
+ while ((str = readString(textStream))) {
+ yield str;
+ }
+ }
+ }
+
+ /**
+ * Iterates over the text contents of all of the string streams in the given
+ * (possibly multi-part) input stream, splits them at occurrences of the
+ * given boundary string, and yields each part.
+ *
+ * @param {nsIInputStream|nsIMultiplexInputStream} stream
+ * The multi-part stream over which to iterate.
+ * @param {string} boundary
+ * The boundary at which to split the parts.
+ * @param {string} [tail = ""]
+ * Any initial data to prepend to the start of the stream data.
+ */
+ function* getParts(stream, boundary, tail = "") {
+ for (let chunk of readAllStrings(stream)) {
+ chunk = tail + chunk;
+
+ let parts = chunk.split(boundary);
+ tail = parts.pop();
+
+ yield* parts;
+ }
+
+ if (tail) {
+ yield tail;
+ }
+ }
+
+ /**
+ * Parses the given stream as multipart/form-data and returns a map of its fields.
+ *
+ * @param {nsIMultiplexInputStream|nsIInputStream} stream
+ * The (possibly multi-part) stream to parse.
+ * @param {string} boundary
+ * The boundary at which to split the parts.
+ * @returns {Map<string, Array<string>>}
+ */
+ function parseMultiPart(stream, boundary) {
+ let formData = new DefaultMap(() => []);
+
+ for (let part of getParts(stream, boundary, "\r\n")) {
+ if (part === "") {
+ // The first part will always be empty.
+ continue;
+ }
+ if (part === "--\r\n") {
+ // This indicates the end of the stream.
+ break;
+ }
+
+ let end = part.indexOf("\r\n\r\n");
+
+ // All valid parts must begin with \r\n, and we can't process form
+ // fields without any header block.
+ if (!part.startsWith("\r\n") || end <= 0) {
+ throw new Error("Invalid MIME stream");
+ }
+
+ let content = part.slice(end + 4);
+ let headerText = part.slice(2, end);
+ let headers = new Headers(headerText);
+
+ let name = headers.getParam("content-disposition", "name");
+ if (
+ !name ||
+ headers.getParam("content-disposition", "") !== "form-data"
+ ) {
+ throw new Error(
+ "Invalid MIME stream: No valid Content-Disposition header"
+ );
+ }
+
+ if (headers.has("content-type")) {
+ // For file upload fields, we return the filename, rather than the
+ // file data.
+ let filename = headers.getParam("content-disposition", "filename");
+ content = filename || "";
+ }
+ formData.get(name).push(content);
+ }
+
+ return formData;
+ }
+
+ /**
+ * Parses the given stream as x-www-form-urlencoded, and returns a map of its fields.
+ *
+ * @param {nsIInputStream} stream
+ * The stream to parse.
+ * @returns {Map<string, Array<string>>}
+ */
+ function parseUrlEncoded(stream) {
+ let formData = new DefaultMap(() => []);
+
+ for (let part of getParts(stream, "&")) {
+ let [name, value] = part
+ .replace(/\+/g, " ")
+ .split("=")
+ .map(decodeURIComponent);
+ formData.get(name).push(value);
+ }
+
+ return formData;
+ }
+
+ try {
+ if (stream instanceof Ci.nsIMIMEInputStream && stream.data) {
+ stream = stream.data;
+ }
+
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ let contentType = channel.getRequestHeader("Content-Type");
+
+ switch (Headers.getParam(contentType, "")) {
+ case "multipart/form-data":
+ let boundary = Headers.getParam(contentType, "boundary");
+ return parseMultiPart(stream, `\r\n--${boundary}`);
+
+ case "application/x-www-form-urlencoded":
+ return parseUrlEncoded(stream);
+ }
+ } finally {
+ for (let stream of touchedStreams) {
+ rewind(stream);
+ }
+ for (let converterStream of converterStreams) {
+ // Release the reference to the underlying input stream, to prevent the
+ // destructor of nsConverterInputStream from closing the stream, which
+ // would cause uploads to break.
+ converterStream.init(null, null, 0, 0);
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Parses the form data of the given stream as either multipart/form-data or
+ * x-www-form-urlencoded, and returns a map of its fields.
+ *
+ * Returns null if the stream is not seekable.
+ *
+ * @param {nsIMultiplexInputStream|nsIInputStream} stream
+ * The (possibly multi-part) stream from which to create the form data.
+ * @param {nsIChannel} channel
+ * The channel to which the stream belongs.
+ * @param {boolean} [lenient = false]
+ * If true, the operation will succeed even if there are UTF-8
+ * decoding errors.
+ * @returns {Map<string, Array<string>> | null}
+ */
+function createFormData(stream, channel, lenient) {
+ if (!(stream instanceof Ci.nsISeekableStream)) {
+ return null;
+ }
+
+ try {
+ let formData = parseFormData(stream, channel, lenient);
+ if (formData) {
+ return mapToObject(formData);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ } finally {
+ rewind(stream);
+ }
+ return null;
+}
+
+/**
+ * Iterates over all of the sub-streams of the given (possibly multi-part)
+ * input stream, and yields an object containing the data for each chunk, up
+ * to a total of `maxRead` bytes.
+ *
+ * @param {nsIMultiplexInputStream|nsIInputStream} outerStream
+ * The stream for which to return data.
+ * @param {integer} [maxRead = WebRequestUpload.MAX_RAW_BYTES]
+ * The maximum total bytes to read.
+ */
+function* getRawDataChunked(
+ outerStream,
+ maxRead = WebRequestUpload.MAX_RAW_BYTES
+) {
+ for (let stream of getStreams(outerStream)) {
+ // We need to inspect the stream to make sure it's not a file input
+ // stream. If it's wrapped in a buffered input stream, unwrap it first,
+ // so we can inspect the inner stream directly.
+ let unbuffered = stream;
+ if (stream instanceof Ci.nsIStreamBufferAccess) {
+ unbuffered = stream.unbufferedStream;
+ }
+
+ // For file fields, we return an object containing the full path of
+ // the file, rather than its data.
+ if (
+ unbuffered instanceof Ci.nsIFileInputStream ||
+ unbuffered instanceof Ci.mozIRemoteLazyInputStream
+ ) {
+ // But this is not actually supported yet.
+ yield { file: "<file>" };
+ continue;
+ }
+
+ try {
+ let binaryStream = BinaryInputStream(stream);
+ let available;
+ while ((available = binaryStream.available())) {
+ let buffer = new ArrayBuffer(Math.min(maxRead, available));
+ binaryStream.readArrayBuffer(buffer.byteLength, buffer);
+
+ maxRead -= buffer.byteLength;
+
+ let chunk = { bytes: buffer };
+
+ if (buffer.byteLength < available) {
+ chunk.truncated = true;
+ chunk.originalSize = available;
+ }
+
+ yield chunk;
+
+ if (maxRead <= 0) {
+ return;
+ }
+ }
+ } finally {
+ rewind(stream);
+ }
+ }
+}
+
+WebRequestUpload = {
+ createRequestBody(channel) {
+ if (!(channel instanceof Ci.nsIUploadChannel) || !channel.uploadStream) {
+ return null;
+ }
+
+ if (
+ channel instanceof Ci.nsIUploadChannel2 &&
+ channel.uploadStreamHasHeaders
+ ) {
+ return { error: "Upload streams with headers are unsupported" };
+ }
+
+ try {
+ let stream = channel.uploadStream;
+
+ let formData = createFormData(stream, channel);
+ if (formData) {
+ return { formData };
+ }
+
+ // If we failed to parse the stream as form data, return it as a
+ // sequence of raw data chunks, along with a leniently-parsed form
+ // data object, which ignores encoding errors.
+ return {
+ raw: Array.from(getRawDataChunked(stream)),
+ lenientFormData: createFormData(stream, channel, true),
+ };
+ } catch (e) {
+ Cu.reportError(e);
+ return { error: e.message || String(e) };
+ }
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ WebRequestUpload,
+ "MAX_RAW_BYTES",
+ "webextensions.webRequest.requestBodyMaxRawBytes"
+);
diff --git a/toolkit/components/extensions/webrequest/moz.build b/toolkit/components/extensions/webrequest/moz.build
new file mode 100644
index 0000000000..d80b1333a9
--- /dev/null
+++ b/toolkit/components/extensions/webrequest/moz.build
@@ -0,0 +1,54 @@
+# -*- 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/.
+
+EXTRA_JS_MODULES += [
+ "SecurityInfo.jsm",
+ "WebRequest.jsm",
+ "WebRequestUpload.jsm",
+]
+
+UNIFIED_SOURCES += [
+ "ChannelWrapper.cpp",
+ "StreamFilter.cpp",
+ "StreamFilterChild.cpp",
+ "StreamFilterEvents.cpp",
+ "StreamFilterParent.cpp",
+ "WebRequestService.cpp",
+]
+
+IPDL_SOURCES += [
+ "PStreamFilter.ipdl",
+]
+
+EXPORTS.mozilla += [
+ "WebRequestService.h",
+]
+
+EXPORTS.mozilla.extensions += [
+ "ChannelWrapper.h",
+ "StreamFilter.h",
+ "StreamFilterBase.h",
+ "StreamFilterChild.h",
+ "StreamFilterEvents.h",
+ "StreamFilterParent.h",
+]
+
+LOCAL_INCLUDES += [
+ "/caps",
+]
+
+include("/ipc/chromium/chromium-config.mozbuild")
+
+LOCAL_INCLUDES += [
+ # For nsHttpChannel.h
+ "/netwerk/base",
+ "/netwerk/protocol/http",
+]
+
+FINAL_LIBRARY = "xul"
+
+with Files("**"):
+ BUG_COMPONENT = ("WebExtensions", "Request Handling")