summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/mozapps
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/mozapps')
-rw-r--r--toolkit/mozapps/defaultagent/BackgroundTask_defaultagent.sys.mjs456
-rw-r--r--toolkit/mozapps/defaultagent/Cache.cpp594
-rw-r--r--toolkit/mozapps/defaultagent/Cache.h189
-rw-r--r--toolkit/mozapps/defaultagent/DefaultAgent.cpp491
-rw-r--r--toolkit/mozapps/defaultagent/DefaultAgent.h28
-rw-r--r--toolkit/mozapps/defaultagent/DefaultBrowser.cpp240
-rw-r--r--toolkit/mozapps/defaultagent/DefaultBrowser.h39
-rw-r--r--toolkit/mozapps/defaultagent/DefaultPDF.cpp151
-rw-r--r--toolkit/mozapps/defaultagent/DefaultPDF.h34
-rw-r--r--toolkit/mozapps/defaultagent/EventLog.cpp11
-rw-r--r--toolkit/mozapps/defaultagent/EventLog.h24
-rw-r--r--toolkit/mozapps/defaultagent/Notification.cpp709
-rw-r--r--toolkit/mozapps/defaultagent/Notification.h60
-rw-r--r--toolkit/mozapps/defaultagent/Policy.cpp162
-rw-r--r--toolkit/mozapps/defaultagent/Policy.h17
-rw-r--r--toolkit/mozapps/defaultagent/Registry.cpp330
-rw-r--r--toolkit/mozapps/defaultagent/Registry.h100
-rw-r--r--toolkit/mozapps/defaultagent/ScheduledTask.cpp328
-rw-r--r--toolkit/mozapps/defaultagent/ScheduledTask.h23
-rw-r--r--toolkit/mozapps/defaultagent/ScheduledTaskRemove.cpp126
-rw-r--r--toolkit/mozapps/defaultagent/ScheduledTaskRemove.h37
-rw-r--r--toolkit/mozapps/defaultagent/SetDefaultBrowser.cpp347
-rw-r--r--toolkit/mozapps/defaultagent/SetDefaultBrowser.h66
-rw-r--r--toolkit/mozapps/defaultagent/Telemetry.cpp585
-rw-r--r--toolkit/mozapps/defaultagent/Telemetry.h24
-rw-r--r--toolkit/mozapps/defaultagent/UtfConvert.cpp59
-rw-r--r--toolkit/mozapps/defaultagent/UtfConvert.h24
-rw-r--r--toolkit/mozapps/defaultagent/WindowsMutex.cpp103
-rw-r--r--toolkit/mozapps/defaultagent/WindowsMutex.h45
-rw-r--r--toolkit/mozapps/defaultagent/common.cpp85
-rw-r--r--toolkit/mozapps/defaultagent/common.h29
-rw-r--r--toolkit/mozapps/defaultagent/components.conf21
-rw-r--r--toolkit/mozapps/defaultagent/defaultagent.ini9
-rw-r--r--toolkit/mozapps/defaultagent/docs/index.rst49
-rw-r--r--toolkit/mozapps/defaultagent/metrics.yaml208
-rw-r--r--toolkit/mozapps/defaultagent/module.ver1
-rw-r--r--toolkit/mozapps/defaultagent/moz.build113
-rw-r--r--toolkit/mozapps/defaultagent/nsIDefaultAgent.idl167
-rw-r--r--toolkit/mozapps/defaultagent/nsIWindowsMutex.idl62
-rw-r--r--toolkit/mozapps/defaultagent/pings.yaml42
-rw-r--r--toolkit/mozapps/defaultagent/proxy/Makefile.in16
-rw-r--r--toolkit/mozapps/defaultagent/proxy/default-browser-agent.exe.manifest31
-rw-r--r--toolkit/mozapps/defaultagent/proxy/main.cpp118
-rw-r--r--toolkit/mozapps/defaultagent/proxy/moz.build68
-rw-r--r--toolkit/mozapps/defaultagent/tests/gtest/CacheTest.cpp301
-rw-r--r--toolkit/mozapps/defaultagent/tests/gtest/SetDefaultBrowserTest.cpp55
-rw-r--r--toolkit/mozapps/defaultagent/tests/gtest/moz.build33
-rw-r--r--toolkit/mozapps/defaultagent/tests/xpcshell/test_windows_mutex.js144
-rw-r--r--toolkit/mozapps/defaultagent/tests/xpcshell/xpcshell.toml4
-rw-r--r--toolkit/mozapps/downloads/DownloadLastDir.sys.mjs254
-rw-r--r--toolkit/mozapps/downloads/DownloadUtils.sys.mjs616
-rw-r--r--toolkit/mozapps/downloads/HelperAppDlg.sys.mjs1349
-rw-r--r--toolkit/mozapps/downloads/components.conf14
-rw-r--r--toolkit/mozapps/downloads/content/unknownContentType.xhtml104
-rw-r--r--toolkit/mozapps/downloads/jar.mn7
-rw-r--r--toolkit/mozapps/downloads/moz.build22
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser.toml33
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js98
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js118
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js96
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js103
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js58
-rw-r--r--toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js68
-rw-r--r--toolkit/mozapps/downloads/tests/browser/example.jnlp0
-rw-r--r--toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/head.js17
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE0
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt1
-rw-r--r--toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^2
-rw-r--r--toolkit/mozapps/downloads/tests/moz.build8
-rw-r--r--toolkit/mozapps/downloads/tests/unit/head_downloads.js5
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js398
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js56
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js28
-rw-r--r--toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js33
-rw-r--r--toolkit/mozapps/downloads/tests/unit/xpcshell.toml10
-rw-r--r--toolkit/mozapps/extensions/.eslintrc.js36
-rw-r--r--toolkit/mozapps/extensions/AbuseReporter.sys.mjs703
-rw-r--r--toolkit/mozapps/extensions/AddonContentPolicy.cpp371
-rw-r--r--toolkit/mozapps/extensions/AddonContentPolicy.h18
-rw-r--r--toolkit/mozapps/extensions/AddonManager.sys.mjs5538
-rw-r--r--toolkit/mozapps/extensions/AddonManagerStartup-inlines.h229
-rw-r--r--toolkit/mozapps/extensions/AddonManagerStartup.cpp884
-rw-r--r--toolkit/mozapps/extensions/AddonManagerStartup.h59
-rw-r--r--toolkit/mozapps/extensions/AddonManagerWebAPI.cpp168
-rw-r--r--toolkit/mozapps/extensions/AddonManagerWebAPI.h32
-rw-r--r--toolkit/mozapps/extensions/Blocklist.sys.mjs1490
-rw-r--r--toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs32
-rw-r--r--toolkit/mozapps/extensions/amContentHandler.sys.mjs103
-rw-r--r--toolkit/mozapps/extensions/amIAddonManagerStartup.idl82
-rw-r--r--toolkit/mozapps/extensions/amIWebInstallPrompt.idl32
-rw-r--r--toolkit/mozapps/extensions/amInstallTrigger.sys.mjs272
-rw-r--r--toolkit/mozapps/extensions/amManager.sys.mjs359
-rw-r--r--toolkit/mozapps/extensions/amWebAPI.sys.mjs289
-rw-r--r--toolkit/mozapps/extensions/components.conf45
-rw-r--r--toolkit/mozapps/extensions/content/OpenH264-license.txt59
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.css759
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.html825
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddons.js4231
-rw-r--r--toolkit/mozapps/extensions/content/aboutaddonsCommon.js275
-rw-r--r--toolkit/mozapps/extensions/content/abuse-report-frame.html213
-rw-r--r--toolkit/mozapps/extensions/content/abuse-report-panel.css181
-rw-r--r--toolkit/mozapps/extensions/content/abuse-report-panel.js873
-rw-r--r--toolkit/mozapps/extensions/content/abuse-reports.js376
-rw-r--r--toolkit/mozapps/extensions/content/drag-drop-addon-installer.js81
-rw-r--r--toolkit/mozapps/extensions/content/shortcuts.css138
-rw-r--r--toolkit/mozapps/extensions/content/shortcuts.js658
-rw-r--r--toolkit/mozapps/extensions/content/view-controller.js204
-rw-r--r--toolkit/mozapps/extensions/default-theme/icon.svg7
-rw-r--r--toolkit/mozapps/extensions/default-theme/manifest.json95
-rw-r--r--toolkit/mozapps/extensions/default-theme/preview.svg46
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json83
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json10
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst177
-rw-r--r--toolkit/mozapps/extensions/docs/AMRemoteSettings.rst5
-rw-r--r--toolkit/mozapps/extensions/docs/AddonManager.rst4
-rw-r--r--toolkit/mozapps/extensions/docs/SystemAddons.rst275
-rw-r--r--toolkit/mozapps/extensions/docs/index.rst21
-rw-r--r--toolkit/mozapps/extensions/extensions.manifest9
-rw-r--r--toolkit/mozapps/extensions/gen_built_in_addons.py99
-rw-r--r--toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs1257
-rw-r--r--toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs138
-rw-r--r--toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs1876
-rw-r--r--toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs643
-rw-r--r--toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs934
-rw-r--r--toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs601
-rw-r--r--toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs661
-rw-r--r--toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs3832
-rw-r--r--toolkit/mozapps/extensions/internal/XPIExports.sys.mjs36
-rw-r--r--toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs4897
-rw-r--r--toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs3377
-rw-r--r--toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs57
-rw-r--r--toolkit/mozapps/extensions/internal/moz.build29
-rw-r--r--toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs72
-rw-r--r--toolkit/mozapps/extensions/jar.mn25
-rw-r--r--toolkit/mozapps/extensions/metrics.yaml527
-rw-r--r--toolkit/mozapps/extensions/moz.build101
-rw-r--r--toolkit/mozapps/extensions/test/browser/.eslintrc.js14
-rw-r--r--toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml6
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsabin0 -> 4210 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsabin0 -> 4210 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsabin0 -> 4218 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json13
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf8
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsabin0 -> 4213 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf5
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json22
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf12
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsabin0 -> 4197 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf4
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json11
-rw-r--r--toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html9
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser.toml193
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js220
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js129
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js204
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_amo_abuse_report.js85
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_bug572561.js96
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js36
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js265
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_dragdrop.js270
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js122
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js176
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js406
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_history_navigation.js623
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js1093
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js185
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js827
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js1675
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js668
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js219
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js83
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_list_view.js1063
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js293
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js185
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js651
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js136
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js311
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js180
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js165
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js229
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js178
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_updates.js750
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js290
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_installssl.js378
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js362
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_local_install.js245
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js331
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js198
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js180
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js93
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js15
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js128
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js124
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js178
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_reinstall.js277
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js262
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js166
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js214
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js76
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_subframe_install.js234
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_task_next_test.js17
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updateid.js87
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updatessl.js389
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updatessl.json17
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^1
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js62
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi.js125
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js375
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_access.js146
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js124
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js63
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_install.js652
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js60
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js79
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js72
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webext_icon.js82
-rw-r--r--toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js593
-rw-r--r--toolkit/mozapps/extensions/test/browser/discovery/api_response.json679
-rw-r--r--toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json1
-rw-r--r--toolkit/mozapps/extensions/test/browser/discovery/small-1x1.pngbin0 -> 82 bytes
-rw-r--r--toolkit/mozapps/extensions/test/browser/head.js1714
-rw-r--r--toolkit/mozapps/extensions/test/browser/head_abuse_report.js615
-rw-r--r--toolkit/mozapps/extensions/test/browser/head_disco.js125
-rw-r--r--toolkit/mozapps/extensions/test/browser/moz.build31
-rw-r--r--toolkit/mozapps/extensions/test/browser/redirect.sjs5
-rw-r--r--toolkit/mozapps/extensions/test/browser/sandboxed.html11
-rw-r--r--toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^1
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html30
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html13
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml6
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checkframed.html7
-rw-r--r--toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html29
-rw-r--r--toolkit/mozapps/extensions/test/create_xpi.py21
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/chrome.toml3
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/file_empty.html2
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/mochitest.toml6
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html31
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/test_bug887098.html70
-rw-r--r--toolkit/mozapps/extensions/test/mochitest/test_default_theme.html37
-rw-r--r--toolkit/mozapps/extensions/test/moz.build20
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js24
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml7
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml30
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml33
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi1
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpibin0 -> 633 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/empty.xpibin0 -> 197 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.binbin0 -> 32 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml45
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt1
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem15
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec5
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec4
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml5
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml11
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpibin0 -> 452 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpibin0 -> 4452 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpibin0 -> 413 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpibin0 -> 4761 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpibin0 -> 4659 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpibin0 -> 4702 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpibin0 -> 4697 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpibin0 -> 528 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json134
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json7
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json1
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json117
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons.json25
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons_empty.json8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json46
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml21
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml28
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml30
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json332
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json332
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml333
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json189
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json189
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml208
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml15
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml17
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml10
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml4
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json17
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json30
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json12
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json12
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json12
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json18
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json32
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json377
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json581
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json20
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json31
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json27
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json7
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml8
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml26
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml10
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml9
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js40
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_update.json120
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json14
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json28
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json269
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpibin0 -> 463 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpibin0 -> 4182 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_addons.js1223
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js31
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js33
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_compat.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_sideload.js76
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js486
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/head_unpack.js3
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js57
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js84
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js56
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js293
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js225
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js113
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js290
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js155
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js231
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js219
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js188
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js286
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js106
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js225
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js504
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js411
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js392
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js138
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js1389
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js73
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js67
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js112
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js71
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js190
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js124
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js61
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.toml102
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AMBrowserExtensionsImport.js500
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js908
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js488
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_appIsShuttingDown.js82
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js728
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js217
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js135
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js91
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js310
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js201
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js217
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AddonWrapper.js207
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js133
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js70
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js93
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js1049
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js102
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js189
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js41
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_badschema.js237
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js194
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js149
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js86
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js25
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js582
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_cookies.js102
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js216
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js25
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_db_path.js64
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js556
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js140
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js263
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_distribution.js115
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js112
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js124
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_error.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js223
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js327
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_general.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js477
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_harness.js13
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_hidden.js251
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install.js1063
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js549
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js92
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js180
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js62
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js346
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js21
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_isReady.js71
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js233
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_locale.js103
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js186
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js83
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js100
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_permissions.js199
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js99
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js221
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js43
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js96
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js65
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js59
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_proxies.js235
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js707
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js88
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_registry.js160
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js213
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_reload.js188
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_remote_pref_telemetry.js48
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_safemode.js90
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js157
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_seen.js277
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js131
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js215
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js62
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js188
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js117
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js149
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js429
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js337
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js67
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js23
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js130
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js109
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js967
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup.js648
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js58
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js125
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js156
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js113
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js486
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js204
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js539
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js118
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js182
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js492
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js142
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js78
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js186
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js95
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js166
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js181
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js57
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js166
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js417
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js56
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_temporary.js765
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_types.js117
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js584
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update.js834
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js139
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js75
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js112
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js181
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js43
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js216
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js121
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js209
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js167
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js423
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updateid.js82
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js101
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js199
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js73
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension.js676
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js94
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js212
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js696
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js669
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js365
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.toml15
-rw-r--r--toolkit/mozapps/extensions/test/xpcshell/xpcshell.toml362
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/amosigned.xpibin0 -> 4287 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs21
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser.toml175
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js86
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js77
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js63
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js73
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js72
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js71
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js46
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js46
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js129
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js31
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js34
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js51
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js69
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js63
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_containers.js116
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js64
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js53
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js80
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js1545
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_empty.js39
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js103
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_hash.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js47
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js52
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js49
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js53
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js107
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js36
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js61
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js42
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js55
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js54
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js89
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_offline.js82
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js133
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_relative.js67
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js156
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js32
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js36
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js48
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js68
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js77
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js58
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js43
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/bug540558.html24
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/bug638292.html17
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/bug645699.html32
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs23
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi1
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/empty.xpibin0 -> 197 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/enabled.html25
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs14
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/head.js568
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/incompatible.xpibin0 -> 428 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/installchrome.html23
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/installtrigger.html57
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html30
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/navigate.html25
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/recommended.xpibin0 -> 7884 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/redirect.sjs39
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/restartless.xpibin0 -> 4447 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs103
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html21
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html37
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/unsigned.xpibin0 -> 312 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpibin0 -> 316 bytes
-rw-r--r--toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpibin0 -> 7533 bytes
-rw-r--r--toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs465
-rw-r--r--toolkit/mozapps/handling/components.conf14
-rw-r--r--toolkit/mozapps/handling/content/appChooser.js369
-rw-r--r--toolkit/mozapps/handling/content/appChooser.xhtml73
-rw-r--r--toolkit/mozapps/handling/content/handler.css59
-rw-r--r--toolkit/mozapps/handling/content/permissionDialog.js223
-rw-r--r--toolkit/mozapps/handling/content/permissionDialog.xhtml56
-rw-r--r--toolkit/mozapps/handling/jar.mn11
-rw-r--r--toolkit/mozapps/handling/metrics.yaml37
-rw-r--r--toolkit/mozapps/handling/moz.build18
-rw-r--r--toolkit/mozapps/installer/find-dupes.py148
-rw-r--r--toolkit/mozapps/installer/informulate.py128
-rw-r--r--toolkit/mozapps/installer/js-compare-ast.js31
-rw-r--r--toolkit/mozapps/installer/l10n-repack.py80
-rw-r--r--toolkit/mozapps/installer/linux/rpm/mozilla.desktop21
-rw-r--r--toolkit/mozapps/installer/linux/rpm/mozilla.spec116
-rw-r--r--toolkit/mozapps/installer/moz.build8
-rw-r--r--toolkit/mozapps/installer/package-name.mk135
-rw-r--r--toolkit/mozapps/installer/packager.mk236
-rw-r--r--toolkit/mozapps/installer/packager.py295
-rw-r--r--toolkit/mozapps/installer/strip.py25
-rw-r--r--toolkit/mozapps/installer/unify.py77
-rw-r--r--toolkit/mozapps/installer/unpack.py25
-rw-r--r--toolkit/mozapps/installer/upload-files.mk434
-rwxr-xr-xtoolkit/mozapps/installer/windows/nsis/common.nsh8842
-rw-r--r--toolkit/mozapps/installer/windows/nsis/locale-fonts.nsh675
-rw-r--r--toolkit/mozapps/installer/windows/nsis/locale-rtl.nlf12
-rw-r--r--toolkit/mozapps/installer/windows/nsis/locale.nlf12
-rwxr-xr-xtoolkit/mozapps/installer/windows/nsis/locales.nsi23
-rwxr-xr-xtoolkit/mozapps/installer/windows/nsis/makensis.mk133
-rwxr-xr-xtoolkit/mozapps/installer/windows/nsis/overrides.nsh610
-rw-r--r--toolkit/mozapps/installer/windows/nsis/preprocess-locale.py379
-rw-r--r--toolkit/mozapps/installer/windows/nsis/setup.icobin0 -> 25214 bytes
-rw-r--r--toolkit/mozapps/notificationserver/NotificationCallback.cpp276
-rw-r--r--toolkit/mozapps/notificationserver/NotificationCallback.h73
-rw-r--r--toolkit/mozapps/notificationserver/NotificationComServer.cpp132
-rw-r--r--toolkit/mozapps/notificationserver/NotificationFactory.cpp33
-rw-r--r--toolkit/mozapps/notificationserver/NotificationFactory.h31
-rw-r--r--toolkit/mozapps/notificationserver/moz.build34
-rw-r--r--toolkit/mozapps/notificationserver/notificationserver.def6
-rw-r--r--toolkit/mozapps/preferences/changemp.js220
-rw-r--r--toolkit/mozapps/preferences/changemp.xhtml96
-rw-r--r--toolkit/mozapps/preferences/fontbuilder.js120
-rw-r--r--toolkit/mozapps/preferences/jar.mn11
-rw-r--r--toolkit/mozapps/preferences/moz.build10
-rw-r--r--toolkit/mozapps/preferences/removemp.js52
-rw-r--r--toolkit/mozapps/preferences/removemp.xhtml54
-rw-r--r--toolkit/mozapps/update/AppUpdater.sys.mjs880
-rw-r--r--toolkit/mozapps/update/BackgroundTask_backgroundupdate.sys.mjs470
-rw-r--r--toolkit/mozapps/update/BackgroundUpdate.sys.mjs1045
-rw-r--r--toolkit/mozapps/update/UpdateListener.sys.mjs524
-rw-r--r--toolkit/mozapps/update/UpdateLog.sys.mjs206
-rw-r--r--toolkit/mozapps/update/UpdateService.sys.mjs7241
-rw-r--r--toolkit/mozapps/update/UpdateServiceStub.sys.mjs388
-rw-r--r--toolkit/mozapps/update/UpdateTelemetry.sys.mjs652
-rw-r--r--toolkit/mozapps/update/common/certificatecheck.cpp241
-rw-r--r--toolkit/mozapps/update/common/certificatecheck.h22
-rw-r--r--toolkit/mozapps/update/common/commonupdatedir.cpp723
-rw-r--r--toolkit/mozapps/update/common/commonupdatedir.h39
-rw-r--r--toolkit/mozapps/update/common/moz.build76
-rw-r--r--toolkit/mozapps/update/common/pathhash.cpp128
-rw-r--r--toolkit/mozapps/update/common/pathhash.h21
-rw-r--r--toolkit/mozapps/update/common/readstrings.cpp396
-rw-r--r--toolkit/mozapps/update/common/readstrings.h91
-rw-r--r--toolkit/mozapps/update/common/registrycertificates.cpp148
-rw-r--r--toolkit/mozapps/update/common/registrycertificates.h14
-rw-r--r--toolkit/mozapps/update/common/uachelper.cpp186
-rw-r--r--toolkit/mozapps/update/common/uachelper.h24
-rw-r--r--toolkit/mozapps/update/common/updatecommon.cpp470
-rw-r--r--toolkit/mozapps/update/common/updatecommon.h43
-rw-r--r--toolkit/mozapps/update/common/updatedefines.h164
-rw-r--r--toolkit/mozapps/update/common/updatehelper.cpp763
-rw-r--r--toolkit/mozapps/update/common/updatehelper.h39
-rw-r--r--toolkit/mozapps/update/common/updatererrors.h130
-rw-r--r--toolkit/mozapps/update/common/updateutils_win.cpp166
-rw-r--r--toolkit/mozapps/update/common/updateutils_win.h47
-rw-r--r--toolkit/mozapps/update/components.conf38
-rw-r--r--toolkit/mozapps/update/content/history.js96
-rw-r--r--toolkit/mozapps/update/content/history.xhtml44
-rw-r--r--toolkit/mozapps/update/content/updateElevation.js138
-rw-r--r--toolkit/mozapps/update/content/updateElevation.xhtml80
-rw-r--r--toolkit/mozapps/update/docs/BackgroundUpdates.rst221
-rw-r--r--toolkit/mozapps/update/docs/MaintenanceServiceTests.rst103
-rw-r--r--toolkit/mozapps/update/docs/SettingUpAnUpdateServer.rst223
-rw-r--r--toolkit/mozapps/update/docs/index.rst10
-rw-r--r--toolkit/mozapps/update/jar.mn10
-rw-r--r--toolkit/mozapps/update/metrics.yaml440
-rw-r--r--toolkit/mozapps/update/moz.build59
-rw-r--r--toolkit/mozapps/update/nsIUpdateService.idl828
-rw-r--r--toolkit/mozapps/update/nsUpdateService.manifest1
-rw-r--r--toolkit/mozapps/update/pings.yaml35
-rw-r--r--toolkit/mozapps/update/tests/Makefile.in13
-rw-r--r--toolkit/mozapps/update/tests/TestAUSHelper.cpp462
-rw-r--r--toolkit/mozapps/update/tests/TestAUSReadStrings.cpp210
-rw-r--r--toolkit/mozapps/update/tests/TestAUSReadStrings1.ini47
-rw-r--r--toolkit/mozapps/update/tests/TestAUSReadStrings2.ini39
-rw-r--r--toolkit/mozapps/update/tests/TestAUSReadStrings3.ini39
-rw-r--r--toolkit/mozapps/update/tests/TestAUSReadStrings4.ini5
-rw-r--r--toolkit/mozapps/update/tests/browser/browser.bits.toml135
-rw-r--r--toolkit/mozapps/update/tests/browser/browser.toml203
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_checking.js32
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_and_install.js41
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_failed.js43
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_downloading.js47
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_internal_error.js45
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_no_update.js29
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_ready_for_restart.js27
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_staging.js64
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_swap.js64
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded.js17
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staged.js28
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staging.js55
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_stagingFailure.js31
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading.js76
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_notify.js67
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_staging.js68
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_multiUpdate.js52
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_apply_blocked.js94
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_cantApply.js24
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_malformedXML.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_noUpdate.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_otherInstance.js19
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_unsupported.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto.js62
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto_staging.js46
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn.js44
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn_staging.js52
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_failure.js18
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_offline.js31
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_completeBadSize.js36
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize.js36
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_complete.js38
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_completeBadSize.js53
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutDialog_internalError.js35
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_backgroundUpdateSetting.js172
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded.js17
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staged.js28
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staging.js59
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_stagingFailure.js29
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading.js63
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading_staging.js72
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_multiUpdate.js52
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_apply_blocked.js96
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_cantApply.js24
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_malformedXML.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_noUpdate.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_otherInstance.js19
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_unsupported.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto.js37
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto_staging.js46
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn.js44
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn_staging.js52
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_failure.js17
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_offline.js31
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_completeBadSize.js36
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize.js36
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_complete.js38
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_completeBadSize.js53
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_internalError.js35
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_aboutPrefs_settings.js151
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_cantApply.js18
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_malformedXML.js26
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_unsupported.js94
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures.js35
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures_bgWin.js80
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn.js57
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_bgWin.js63
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_staging.js29
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded.js18
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_disableBITS.js31
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_staged.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate.js93
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate_promptWaitTime.js89
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_completeBadSize.js33
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize.js33
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_complete.js18
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_completeBadSize.js33
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_completeApplyFailure.js23
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete.js22
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_completeBadSize.js32
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete_staging.js28
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_elevationDialog.js139
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_memory_allocation_error_fallback.js81
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_downloaded_ready.js69
-rw-r--r--toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_staged_ready.js73
-rw-r--r--toolkit/mozapps/update/tests/browser/downloadPage.html13
-rw-r--r--toolkit/mozapps/update/tests/browser/head.js1353
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/browser.toml24
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateFalse.js43
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateTrue.js43
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateFalse.js55
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateTrue.js55
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_noBackgroundUpdate.js17
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/config_manual_app_update_only.json5
-rw-r--r--toolkit/mozapps/update/tests/browser/manual_app_update_only/head.js9
-rw-r--r--toolkit/mozapps/update/tests/browser/testConstants.js7
-rw-r--r--toolkit/mozapps/update/tests/data/app_update.sjs251
-rw-r--r--toolkit/mozapps/update/tests/data/complete.exebin0 -> 79872 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/complete.marbin0 -> 86612 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/complete.pngbin0 -> 878 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/complete_log_success_mac332
-rw-r--r--toolkit/mozapps/update/tests/data/complete_log_success_win320
-rw-r--r--toolkit/mozapps/update/tests/data/complete_mac.marbin0 -> 87129 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/complete_precomplete18
-rw-r--r--toolkit/mozapps/update/tests/data/complete_precomplete_mac21
-rw-r--r--toolkit/mozapps/update/tests/data/complete_removed-files41
-rw-r--r--toolkit/mozapps/update/tests/data/complete_removed-files_mac41
-rw-r--r--toolkit/mozapps/update/tests/data/complete_update_manifest59
-rw-r--r--toolkit/mozapps/update/tests/data/old_version.marbin0 -> 709 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/partial.exebin0 -> 79872 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/partial.marbin0 -> 9872 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/partial.pngbin0 -> 776 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/partial_log_failure_mac192
-rw-r--r--toolkit/mozapps/update/tests/data/partial_log_failure_win192
-rw-r--r--toolkit/mozapps/update/tests/data/partial_log_success_mac279
-rw-r--r--toolkit/mozapps/update/tests/data/partial_log_success_win279
-rw-r--r--toolkit/mozapps/update/tests/data/partial_mac.marbin0 -> 10361 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/partial_precomplete19
-rw-r--r--toolkit/mozapps/update/tests/data/partial_precomplete_mac22
-rw-r--r--toolkit/mozapps/update/tests/data/partial_removed-files41
-rw-r--r--toolkit/mozapps/update/tests/data/partial_removed-files_mac41
-rw-r--r--toolkit/mozapps/update/tests/data/partial_update_manifest63
-rw-r--r--toolkit/mozapps/update/tests/data/replace_log_success6
-rw-r--r--toolkit/mozapps/update/tests/data/shared.js933
-rw-r--r--toolkit/mozapps/update/tests/data/sharedUpdateXML.js417
-rw-r--r--toolkit/mozapps/update/tests/data/simple.marbin0 -> 1419 bytes
-rw-r--r--toolkit/mozapps/update/tests/data/syncManagerTestChild.js55
-rw-r--r--toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js29
-rw-r--r--toolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js4881
-rw-r--r--toolkit/mozapps/update/tests/diff_base_service.bash116
-rw-r--r--toolkit/mozapps/update/tests/marionette/marionette.toml5
-rw-r--r--toolkit/mozapps/update/tests/marionette/test_no_window_update_restart.py255
-rw-r--r--toolkit/mozapps/update/tests/moz.build118
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/ausReadStrings.js33
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/backgroundUpdateTaskInternalUpdater.js85
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/canCheckForAndCanApplyUpdates.js62
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForDifferentChannel.js60
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForOlderAppVersion.js58
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForSameVersionAndBuildID.js59
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingIncorrectStatus.js57
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupPendingVersionFileIncorrectStatus.js57
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogMove.js77
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogsFIFO.js226
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesBackgroundTask.js48
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesNonBackgroundTask.js41
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedNoRecovery.js23
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedOffline.js21
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedRecovery.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/downloadResumeForSameAppVersion.js38
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/ensureExperimentToRolloutTransitionPerformed.js111
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/head_update.js8
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/languagePackUpdates.js291
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/multiUpdate.js398
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/onlyDownloadUpdatesThisSession.js69
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/perInstallationPrefs.js238
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/remoteUpdateXML.js327
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/testConstants.js8
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/updateAutoPrefMigrate.js74
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/updateCheckCombine.js38
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/updateDirectoryMigrate.js246
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/updateManagerXML.js593
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/updateSyncManager.js105
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/urlConstruction.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/verifyChannelPrefsFile.js38
-rw-r--r--toolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml89
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/head.js58
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_exitcodes.js80
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_glean.js275
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason.js66
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_schedule.js136
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_update.js321
-rw-r--r--toolkit/mozapps/update/tests/unit_background_update/xpcshell.toml24
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/head_update.js7
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFileNotInInstallDirFailure.js32
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFilePathTooLongFailure.js39
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTooLongFailure.js53
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTraversalFailure.js50
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallWorkingDirPathNotSameFailure_win.js45
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgPatchDirPathTraversalFailure.js32
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgStageDirNotInInstallDirFailure_win.js45
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathLocalUNCFailure_win.js45
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathRelativeFailure.js44
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppApplyDirLockedStageFailure_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateAppBinInUseStageSuccess_win.js50
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSkippedWriteAccess_win.js74
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageOldVersionFailure.js64
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageSuccess.js49
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSuccess.js47
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppInUseBackgroundTaskFailure_win.js51
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageFailureComplete_win.js37
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageSuccessComplete_unix.js68
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marAppInUseSuccessComplete.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessComplete_win.js30
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessPartial_win.js30
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessComplete_win.js24
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessPartial_win.js24
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marCallbackUmask_unix.js42
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFailurePartial.js39
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailureComplete_win.js40
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailurePartial_win.js40
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessComplete_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessPartial_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailureComplete_win.js27
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailurePartial_win.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailureComplete_win.js34
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailurePartial_win.js33
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettings.js42
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettingsStage.js35
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marPIDPersistsSuccessComplete_win.js25
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailureComplete_win.js43
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailurePartial_win.js41
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessComplete_win.js31
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessPartial_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marStageFailurePartial.js31
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessComplete.js71
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessPartial.js35
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marSuccessComplete.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartial.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartialWhileBackgroundTaskRunning.js121
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marVersionDowngrade.js41
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marWrongChannel.js43
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/marWrongChannelStage.js36
-rw-r--r--toolkit/mozapps/update/tests/unit_base_updater/xpcshell.toml181
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/bootstrapSvc.js23
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/checkUpdaterSigSvc.js53
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/fallbackOnSvcFailure.js38
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/head_update.js7
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTooLongFailureSvc.js53
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTraversalFailureSvc.js50
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallWorkingDirPathNotSameFailureSvc_win.js45
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathSuffixFailureSvc.js27
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathTraversalFailureSvc.js32
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgStageDirNotInInstallDirFailureSvc_win.js45
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathLocalUNCFailureSvc_win.js45
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathRelativeFailureSvc.js44
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppApplyDirLockedStageFailureSvc_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateAppBinInUseStageSuccessSvc_win.js50
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateStageSuccessSvc.js49
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateSuccessSvc.js47
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppInUseBackgroundTaskFailureSvc_win.js51
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppInUseStageFailureCompleteSvc_win.js37
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marAppInUseSuccessCompleteSvc.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessCompleteSvc_win.js30
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessPartialSvc_win.js30
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessCompleteSvc_win.js24
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessPartialSvc_win.js24
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFailurePartialSvc.js39
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailureCompleteSvc_win.js40
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailurePartialSvc_win.js40
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessCompleteSvc_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessPartialSvc_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailureCompleteSvc_win.js27
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailurePartialSvc_win.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailureCompleteSvc_win.js34
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailurePartialSvc_win.js33
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailureCompleteSvc_win.js43
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailurePartialSvc_win.js41
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessCompleteSvc_win.js31
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessPartialSvc_win.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marStageFailurePartialSvc.js31
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessCompleteSvc.js71
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessPartialSvc.js35
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marSuccessCompleteSvc.js26
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/marSuccessPartialSvc.js29
-rw-r--r--toolkit/mozapps/update/tests/unit_service_updater/xpcshell.toml134
-rw-r--r--toolkit/mozapps/update/updater/Launchd.plist10
-rw-r--r--toolkit/mozapps/update/updater/Makefile.in28
-rw-r--r--toolkit/mozapps/update/updater/TsanOptions.cpp23
-rw-r--r--toolkit/mozapps/update/updater/archivereader.cpp348
-rw-r--r--toolkit/mozapps/update/updater/archivereader.h46
-rw-r--r--toolkit/mozapps/update/updater/autograph_stage.pem14
-rw-r--r--toolkit/mozapps/update/updater/bspatch/LICENSE23
-rw-r--r--toolkit/mozapps/update/updater/bspatch/bspatch.cpp216
-rw-r--r--toolkit/mozapps/update/updater/bspatch/bspatch.h93
-rw-r--r--toolkit/mozapps/update/updater/bspatch/moz.build22
-rw-r--r--toolkit/mozapps/update/updater/bspatch/moz.yaml30
-rw-r--r--toolkit/mozapps/update/updater/crctable.h71
-rw-r--r--toolkit/mozapps/update/updater/dep1.derbin0 -> 1215 bytes
-rw-r--r--toolkit/mozapps/update/updater/dep2.derbin0 -> 1215 bytes
-rw-r--r--toolkit/mozapps/update/updater/gen_cert_header.py27
-rw-r--r--toolkit/mozapps/update/updater/launchchild_osx.mm519
-rw-r--r--toolkit/mozapps/update/updater/loaddlls.cpp84
-rw-r--r--toolkit/mozapps/update/updater/macbuild/Contents/Info.plist.in40
-rw-r--r--toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in8
-rw-r--r--toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib19
-rw-r--r--toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib22
-rw-r--r--toolkit/mozapps/update/updater/macbuild/Contents/Resources/updater.icnsbin0 -> 55969 bytes
-rw-r--r--toolkit/mozapps/update/updater/module.ver1
-rw-r--r--toolkit/mozapps/update/updater/moz.build78
-rw-r--r--toolkit/mozapps/update/updater/nightly_aurora_level3_primary.derbin0 -> 1225 bytes
-rw-r--r--toolkit/mozapps/update/updater/nightly_aurora_level3_secondary.derbin0 -> 1225 bytes
-rw-r--r--toolkit/mozapps/update/updater/progressui.h40
-rw-r--r--toolkit/mozapps/update/updater/progressui_gtk.cpp121
-rw-r--r--toolkit/mozapps/update/updater/progressui_null.cpp15
-rw-r--r--toolkit/mozapps/update/updater/progressui_osx.mm137
-rw-r--r--toolkit/mozapps/update/updater/progressui_win.cpp302
-rw-r--r--toolkit/mozapps/update/updater/release_primary.derbin0 -> 1225 bytes
-rw-r--r--toolkit/mozapps/update/updater/release_secondary.derbin0 -> 1225 bytes
-rw-r--r--toolkit/mozapps/update/updater/resource.h29
-rw-r--r--toolkit/mozapps/update/updater/updater-common.build142
-rw-r--r--toolkit/mozapps/update/updater/updater-dep/moz.build13
-rw-r--r--toolkit/mozapps/update/updater/updater-xpcshell/Makefile.in48
-rw-r--r--toolkit/mozapps/update/updater/updater-xpcshell/moz.build13
-rw-r--r--toolkit/mozapps/update/updater/updater.cpp4909
-rw-r--r--toolkit/mozapps/update/updater/updater.exe.comctl32.manifest43
-rw-r--r--toolkit/mozapps/update/updater/updater.exe.manifest31
-rw-r--r--toolkit/mozapps/update/updater/updater.icobin0 -> 92854 bytes
-rw-r--r--toolkit/mozapps/update/updater/updater.pngbin0 -> 2153 bytes
-rw-r--r--toolkit/mozapps/update/updater/updater.rc137
-rw-r--r--toolkit/mozapps/update/updater/xpcshellCertificate.derbin0 -> 1189 bytes
1012 files changed, 187649 insertions, 0 deletions
diff --git a/toolkit/mozapps/defaultagent/BackgroundTask_defaultagent.sys.mjs b/toolkit/mozapps/defaultagent/BackgroundTask_defaultagent.sys.mjs
new file mode 100644
index 0000000000..c727a55997
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/BackgroundTask_defaultagent.sys.mjs
@@ -0,0 +1,456 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { EXIT_CODE as EXIT_CODE_BASE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs";
+import { AppConstants as AC } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const EXIT_CODE = {
+ ...EXIT_CODE_BASE,
+ DISABLED_BY_POLICY: EXIT_CODE_BASE.LAST_RESERVED + 1,
+ INVALID_ARGUMENT: EXIT_CODE_BASE.LAST_RESERVED + 2,
+ MUTEX_NOT_LOCKABLE: EXIT_CODE_BASE.LAST_RESERVED + 3,
+};
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+});
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"],
+});
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ maxLogLevel: "error",
+ maxLogLevelPref: "app.defaultagent.loglevel",
+ prefix: "DefaultAgent",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+// Should be slightly longer than NOTIFICATION_WAIT_TIMEOUT_MS in
+// Notification.cpp (divided by 1000 to convert millseconds to seconds) to not
+// cause race between timeouts. Currently 12 hours + 5 additional minutes.
+export const backgroundTaskTimeoutSec = 12 * 60 * 60 + 60 * 5;
+const kNotificationTimeoutMs = 12 * 60 * 60 * 1000;
+
+const kNotificationShown = Object.freeze({
+ notShown: "not-shown",
+ shown: "shown",
+ error: "error",
+});
+
+const kNotificationAction = Object.freeze({
+ dismissedByTimeout: "dismissed-by-timeout",
+ dismissedByButton: "dismissed-by-button",
+ dismissedToActionCenter: "dismissed-to-action-center",
+ makeFirefoxDefaultButton: "make-firefox-default-button",
+ toastClicked: "toast-clicked",
+ noAction: "no-action",
+});
+
+// We expect to be given a command string in argv[1], perhaps followed by other
+// arguments depending on the command. The valid commands are:
+// register-task [unique-token]
+// Create a Windows scheduled task that will launch this binary with the
+// do-task command every 24 hours, starting from 24 hours after register-task
+// is run. unique-token is required and should be some string that uniquely
+// identifies this installation of the product; typically this will be the
+// install path hash that's used for the update directory, the AppUserModelID,
+// and other related purposes.
+// update-task [unique-token]
+// Update an existing task registration, without changing its schedule. This
+// should be called during updates of the application, in case this program
+// has been updated and any of the task parameters have changed. The unique
+// token argument is required and should be the same one that was passed in
+// when the task was registered.
+// unregister-task [unique-token]
+// Removes the previously created task. The unique token argument is required
+// and should be the same one that was passed in when the task was registered.
+// uninstall [unique-token]
+// Removes the previously created task, and also removes all registry entries
+// running the task may have created. The unique token argument is required
+// and should be the same one that was passed in when the task was registered.
+// do-task [app-user-model-id]
+// Actually performs the default agent task, which currently means generating
+// and sending our telemetry ping and possibly showing a notification to the
+// user if their browser has switched from Firefox to Edge with Blink.
+// set-default-browser-user-choice [app-user-model-id] [[.file1 ProgIDRoot1]
+// ...]
+// Set the default browser via the UserChoice registry keys. Additional
+// optional file extensions to register can be specified as additional
+// argument pairs: the first element is the file extension, the second element
+// is the root of a ProgID, which will be suffixed with `-$AUMI`.
+export async function runBackgroundTask(commandLine) {
+ Services.fog.initializeFOG(
+ undefined,
+ "firefox.desktop.background.defaultagent"
+ );
+
+ let defaultAgent = Cc["@mozilla.org/default-agent;1"].getService(
+ Ci.nsIDefaultAgent
+ );
+
+ let command = commandLine.getArgument(0);
+
+ // The uninstall and unregister commands are allowed even if the policy
+ // disabling the task is set, so that uninstalls and updates always work.
+ // Similarly, debug commands are always allowed.
+ switch (command) {
+ case "uninstall": {
+ let token = commandLine.getArgument(1);
+ lazy.log.info(`Uninstalling for token "${token}"`);
+ defaultAgent.uninstall(token);
+ return EXIT_CODE.SUCCESS;
+ }
+ case "unregister-task": {
+ let token = commandLine.getArgument(1);
+ lazy.log.info(`Unregistering task for token "${token}"`);
+ defaultAgent.unregisterTask(token);
+ return EXIT_CODE.SUCCESS;
+ }
+ }
+
+ // We check for disablement by policy because that's assumed to be static.
+ // But we don't check for disablement by remote settings so that
+ // `register-task` and `update-task` can proceed as part of the update
+ // cycle, waiting for remote (re-)enablement.
+ if (defaultAgent.agentDisabled()) {
+ lazy.log.warn("Default Agent disabled, exiting without running.");
+ return EXIT_CODE.DISABLED_BY_POLICY;
+ }
+
+ switch (command) {
+ case "register-task": {
+ let token = commandLine.getArgument(1);
+ lazy.log.info(`Registering task for token "${token}"`);
+ defaultAgent.registerTask(token);
+ return EXIT_CODE.SUCCESS;
+ }
+ case "update-task": {
+ let token = commandLine.getArgument(1);
+ lazy.log.info(`Updating task for token "${token}"`);
+ defaultAgent.updateTask(token);
+ return EXIT_CODE.SUCCESS;
+ }
+ case "do-task": {
+ let aumid = commandLine.getArgument(1);
+ let force = commandLine.findFlag("force", true) != -1;
+
+ lazy.log.info(`Running do-task with AUMID "${aumid}"`);
+
+ let cppFallback = false;
+ try {
+ await lazy.BackgroundTasksUtils.enableNimbus(commandLine);
+ cppFallback =
+ lazy.NimbusFeatures.defaultAgent.getVariable("cppFallback");
+ } catch (e) {
+ lazy.log.error(`Error enabling nimbus: ${e}`);
+ }
+
+ try {
+ if (!cppFallback) {
+ lazy.log.info("Running JS do-task.");
+ await runWithRegistryLocked(async () => {
+ await doTask(defaultAgent, force);
+ });
+ } else {
+ lazy.log.info("Running C++ do-task.");
+ defaultAgent.doTask(aumid, force);
+ }
+ } catch (e) {
+ if (e.message) {
+ lazy.log.error(e.message);
+ }
+
+ if (e.result == Cr.NS_ERROR_NOT_AVAILABLE) {
+ return EXIT_CODE.MUTEX_NOT_LOCKABLE;
+ }
+
+ return EXIT_CODE.EXCEPTION;
+ }
+
+ // Bug 1857333: We wait for arbitrary time for Glean to submit telemetry.
+ lazy.log.info("Pinged glean, waiting for submission.");
+ await new Promise(resolve => lazy.setTimeout(resolve, 5000));
+
+ return EXIT_CODE.SUCCESS;
+ }
+ }
+
+ return EXIT_CODE.INVALID_ARGUMENT;
+}
+
+// Throws if unable to lock mutex (therefore function isn't run).
+async function runWithRegistryLocked(aMutexGuardedFunction) {
+ const kVendor = Services.appinfo.vendor || "";
+ const kRegistryMutexName = `${kVendor}${AC.MOZ_APP_BASENAME}DefaultBrowserAgentRegistryMutex`;
+ let mutexFactory = Cc["@mozilla.org/windows-mutex-factory;1"].getService(
+ Ci.nsIWindowsMutexFactory
+ );
+
+ let mutex = mutexFactory.createMutex(kRegistryMutexName);
+ mutex.tryLock(kRegistryMutexName);
+ lazy.log.debug(`Locked named mutex: ${kRegistryMutexName}`);
+ try {
+ await aMutexGuardedFunction();
+ } finally {
+ mutex.unlock();
+ lazy.log.debug(`Unlocked named mutex: ${kRegistryMutexName}`);
+ }
+}
+
+async function doTask(defaultAgent, force) {
+ if (!defaultAgent.appRanRecently() && !force) {
+ lazy.log.warn("Main app has not ran recently, exiting without running.");
+ throw new Error("App hasn't ran recently");
+ }
+
+ let browser = defaultAgent.getDefaultBrowser();
+ lazy.log.debug(`Default browser: ${browser}`);
+ let previousBrowser = defaultAgent.getReplacePreviousDefaultBrowser(browser);
+ lazy.log.debug(`Previous browser: ${previousBrowser}`);
+ let defaultPdfHandler = defaultAgent.getDefaultPdfHandler();
+ lazy.log.debug(`Default PDF Handler: ${defaultPdfHandler}`);
+
+ let notificationTelemetry = {
+ shown: kNotificationShown.notShown,
+ action: kNotificationAction.noAction,
+ };
+ if ((browser == "edge-chrome" && previousBrowser == "firefox") || force) {
+ lazy.log.info("Showing default browser intervention notification.");
+
+ const alertName = "default_agent_intervention";
+ let notification = showNotification(alertName);
+ let timeout = makeTimeout(alertName);
+
+ notificationTelemetry = await Promise.race([notification, timeout]);
+ }
+ lazy.log.debug(`Notification telemetry: ${notificationTelemetry}`);
+
+ if (
+ notificationTelemetry.action ==
+ kNotificationAction.makeFirefoxDefaultButton ||
+ notificationTelemetry.action == kNotificationAction.toastClicked
+ ) {
+ await lazy.ShellService.setDefaultBrowser(false).catch(e => {
+ lazy.log.error(`setDefaultBrowser failed: ${e}`);
+ });
+ }
+
+ defaultAgent.sendPing(
+ browser,
+ previousBrowser,
+ defaultPdfHandler,
+ notificationTelemetry.shown,
+ notificationTelemetry.action
+ );
+}
+
+async function showNotification(name) {
+ let notificationTelemetry = {
+ shown: kNotificationShown.error,
+ action: kNotificationAction.noAction,
+ };
+
+ // Bug 1868714: We disable the notification server to defer on changes
+ // necessary for it to work with Background Tasks.
+ try {
+ lazy.log.debug("Disabling notification server.");
+ Services.prefs.setBoolPref(
+ "alerts.useSystemBackend.windows.notificationserver.enabled",
+ false
+ );
+
+ const l10n = new Localization([
+ "branding/brand.ftl",
+ // Background tasks are only used in a context where browser refs are
+ // present; that it's in toolkit instead of browser is a historical
+ // artifact of the default agent having previously been a
+ // standalone application.
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ "browser/backgroundtasks/defaultagent.ftl",
+ ]);
+ let [title, body, yesButtonText, noButtonText] = await l10n.formatValues([
+ { id: "default-browser-notification-header-text" },
+ { id: "default-browser-notification-body-text" },
+ { id: "default-browser-notification-yes-button-text" },
+ { id: "default-browser-notification-no-button-text" },
+ ]);
+
+ let yesAction = "yes-action";
+ let noAction = "no-action";
+
+ let alert = makeAlert({
+ name,
+ title,
+ body,
+ actions: [
+ {
+ action: yesAction,
+ title: yesButtonText,
+ },
+ {
+ action: noAction,
+ title: noButtonText,
+ },
+ ],
+ });
+
+ const { observer, shownPromise } = makeObserver({ yesAction, noAction });
+
+ lazy.AlertsService.showAlert(alert, observer);
+
+ notificationTelemetry = await shownPromise.promise;
+ } catch (e) {
+ if (e.message) {
+ lazy.log.error(e.message);
+ }
+ } finally {
+ // Reset the pref so we can assume the default value in the future.
+ lazy.log.debug("Reenabling notification server.");
+ Services.prefs.clearUserPref(
+ "alerts.useSystemBackend.windows.notificationserver.enabled"
+ );
+ }
+
+ return notificationTelemetry;
+}
+
+function makeAlert(options) {
+ let winalert = Cc["@mozilla.org/windows-alert-notification;1"].createInstance(
+ Ci.nsIWindowsAlertNotification
+ );
+ winalert.handleActions = true;
+ winalert.imagePlacement = winalert.eIcon;
+
+ let alert = winalert.QueryInterface(Ci.nsIAlertNotification);
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ alert.init(
+ options.name,
+ "chrome://branding/content/about-logo@2x.png",
+ options.title,
+ options.body,
+ true /* aTextClickable */,
+ null /* aCookie */,
+ null /* aDir */,
+ null /* aLang */,
+ null /* aData */,
+ systemPrincipal,
+ null /* aInPrivateBrowsing */,
+ true /* aRequireInteraction */
+ );
+
+ alert.actions = options.actions;
+
+ return alert;
+}
+
+function makeObserver(actions) {
+ let shownPromise = Promise.withResolvers();
+
+ // We'll receive multiple callbacks which individually might indicate an
+ // interaction. Only log the first one to disambiguate and reduce noise.
+ let firstInteraction = true;
+ let logFirstInteraction = message => {
+ if (firstInteraction) {
+ lazy.log.debug(message);
+ firstInteraction = false;
+ }
+ };
+
+ let observer = (subject, topic, data) => {
+ switch (topic) {
+ case "alertactioncallback":
+ switch (data) {
+ case actions.yesAction:
+ logFirstInteraction(
+ 'Notification "yes" button clicked, setting default browser.'
+ );
+ shownPromise.resolve({
+ shown: kNotificationShown.shown,
+ action: kNotificationAction.makeFirefoxDefaultButton,
+ });
+ break;
+ case actions.noAction:
+ logFirstInteraction("Notification dismissed by button.");
+ shownPromise.resolve({
+ shown: kNotificationShown.shown,
+ action: kNotificationAction.dismissedByButton,
+ });
+ break;
+ default:
+ lazy.log.error(`Unrecognized notification action ${data}`);
+ throw new Error(`Unexpected notification action received: ${data}`);
+ }
+ break;
+ case "alertclickcallback":
+ logFirstInteraction(
+ "Notification body clicked, setting default browser."
+ );
+ shownPromise.resolve({
+ shown: kNotificationShown.shown,
+ action: kNotificationAction.toastClicked,
+ });
+ break;
+ case "alerterror":
+ lazy.log.error("Error showing notification.");
+ shownPromise.resolve({
+ shown: kNotificationShown.error,
+ action: kNotificationAction.noAction,
+ });
+ break;
+ case "alertfinished":
+ logFirstInteraction("Notification dismissed from action center.");
+ shownPromise.resolve({
+ shown: kNotificationShown.shown,
+ action: kNotificationAction.dismissedToActionCenter,
+ });
+ break;
+ }
+ };
+
+ return { observer, shownPromise };
+}
+
+function makeTimeout(alertName) {
+ return new Promise(resolve => {
+ // If the notification hasn't been activated or dismissed within 12 hours,
+ // stop waiting for it.
+ let timeoutMs = kNotificationTimeoutMs;
+
+ // Allow overriding the notification timeout fron an environment variable.
+ const envTimeoutKey = "MOZ_NOTIFICATION_TIMEOUT_MS";
+ if (Services.env.exists(envTimeoutKey)) {
+ let envTimeoutValue = Services.env.get(envTimeoutKey);
+ if (!isNaN(envTimeoutValue)) {
+ timeoutMs = Number(envTimeoutValue);
+ } else {
+ lazy.log.error(
+ `Environment variable ${envTimeoutKey}=${envTimeoutValue} is not a number.`
+ );
+ }
+ }
+ lazy.log.info(`Registering notification timeout in ${timeoutMs}ms`);
+
+ lazy.setTimeout(() => {
+ lazy.log.warn(`Notification timed out after ${timeoutMs}ms`);
+
+ lazy.AlertsService.closeAlert(alertName);
+
+ resolve({
+ shown: kNotificationShown.shown,
+ action: kNotificationAction.dismissedByTimeout,
+ });
+ }, timeoutMs);
+ });
+}
diff --git a/toolkit/mozapps/defaultagent/Cache.cpp b/toolkit/mozapps/defaultagent/Cache.cpp
new file mode 100644
index 0000000000..1a323e54d9
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Cache.cpp
@@ -0,0 +1,594 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "Cache.h"
+
+#include <algorithm>
+
+#include "common.h"
+#include "EventLog.h"
+#include "mozilla/Unused.h"
+
+namespace mozilla::default_agent {
+
+// Cache entry version documentation:
+// Version 1:
+// The version number is written explicitly when version 1 cache entries are
+// migrated, but in their original location there is no version key.
+// Required Keys:
+// CacheEntryVersion: <DWORD>
+// NotificationType: <string>
+// NotificationShown: <string>
+// NotificationAction: <string>
+// Version 2:
+// Required Keys:
+// CacheEntryVersion: <DWORD>
+// NotificationType: <string>
+// NotificationShown: <string>
+// NotificationAction: <string>
+// PrevNotificationAction: <string>
+
+static std::wstring MakeVersionedRegSubKey(const wchar_t* baseKey) {
+ std::wstring key;
+ if (baseKey) {
+ key = baseKey;
+ } else {
+ key = Cache::kDefaultPingCacheRegKey;
+ }
+ key += L"\\version";
+ key += std::to_wstring(Cache::kVersion);
+ return key;
+}
+
+Cache::Cache(const wchar_t* cacheRegKey /* = nullptr */)
+ : mCacheRegKey(MakeVersionedRegSubKey(cacheRegKey)),
+ mInitializeResult(mozilla::Nothing()),
+ mCapacity(Cache::kDefaultCapacity),
+ mFront(0),
+ mSize(0) {}
+
+Cache::~Cache() {}
+
+VoidResult Cache::Init() {
+ if (mInitializeResult.isSome()) {
+ HRESULT hr = mInitializeResult.value();
+ if (FAILED(hr)) {
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ } else {
+ return mozilla::Ok();
+ }
+ }
+
+ VoidResult result = SetupCache();
+ if (result.isErr()) {
+ HRESULT hr = result.inspectErr().AsHResult();
+ mInitializeResult = mozilla::Some(hr);
+ return result;
+ }
+
+ // At this point, the cache is ready to use, so mark the initialization as
+ // complete. This is important so that when we attempt migration, below,
+ // the migration's attempts to write to the cache don't try to initialize
+ // the cache again.
+ mInitializeResult = mozilla::Some(S_OK);
+
+ // Ignore the result of the migration. If we failed to migrate, there may be
+ // some data loss. But that's better than failing to ever use the new cache
+ // just because there's something wrong with the old one.
+ mozilla::Unused << MaybeMigrateVersion1();
+
+ return mozilla::Ok();
+}
+
+// If the setting does not exist, the default value is written and returned.
+DwordResult Cache::EnsureDwordSetting(const wchar_t* regName,
+ uint32_t defaultValue) {
+ MaybeDwordResult readResult = RegistryGetValueDword(
+ IsPrefixed::Unprefixed, regName, mCacheRegKey.c_str());
+ if (readResult.isErr()) {
+ HRESULT hr = readResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Failed to read setting \"%s\": %#X", regName, hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+ mozilla::Maybe<uint32_t> maybeValue = readResult.unwrap();
+ if (maybeValue.isSome()) {
+ return maybeValue.value();
+ }
+
+ VoidResult writeResult = RegistrySetValueDword(
+ IsPrefixed::Unprefixed, regName, defaultValue, mCacheRegKey.c_str());
+ if (writeResult.isErr()) {
+ HRESULT hr = writeResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Failed to write setting \"%s\": %#X", regName, hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+ return defaultValue;
+}
+
+// This function does two things:
+// 1. It creates and sets the registry values used by the cache, if they don't
+// already exist.
+// 2. If the the values already existed, it reads the settings of the cache
+// into their member variables.
+VoidResult Cache::SetupCache() {
+ DwordResult result =
+ EnsureDwordSetting(Cache::kCapacityRegName, Cache::kDefaultCapacity);
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+ mCapacity = std::min(result.unwrap(), Cache::kMaxCapacity);
+
+ result = EnsureDwordSetting(Cache::kFrontRegName, 0);
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+ mFront = std::min(result.unwrap(), Cache::kMaxCapacity - 1);
+
+ result = EnsureDwordSetting(Cache::kSizeRegName, 0);
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+ mSize = std::min(result.unwrap(), mCapacity);
+
+ return mozilla::Ok();
+}
+
+static MaybeStringResult ReadVersion1CacheKey(const wchar_t* baseRegKeyName,
+ uint32_t index) {
+ std::wstring regName = Cache::kVersion1KeyPrefix;
+ regName += baseRegKeyName;
+ regName += std::to_wstring(index);
+
+ MaybeStringResult result =
+ RegistryGetValueString(IsPrefixed::Unprefixed, regName.c_str());
+ if (result.isErr()) {
+ HRESULT hr = result.inspectErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Failed to read \"%s\": %#X", regName.c_str(), hr);
+ }
+ return result;
+}
+
+static VoidResult DeleteVersion1CacheKey(const wchar_t* baseRegKeyName,
+ uint32_t index) {
+ std::wstring regName = Cache::kVersion1KeyPrefix;
+ regName += baseRegKeyName;
+ regName += std::to_wstring(index);
+
+ VoidResult result =
+ RegistryDeleteValue(IsPrefixed::Unprefixed, regName.c_str());
+ if (result.isErr()) {
+ HRESULT hr = result.inspectErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Failed to delete \"%s\": %#X", regName.c_str(), hr);
+ }
+ return result;
+}
+
+static VoidResult DeleteVersion1CacheEntry(uint32_t index) {
+ VoidResult typeResult =
+ DeleteVersion1CacheKey(Cache::kNotificationTypeKey, index);
+ VoidResult shownResult =
+ DeleteVersion1CacheKey(Cache::kNotificationShownKey, index);
+ VoidResult actionResult =
+ DeleteVersion1CacheKey(Cache::kNotificationActionKey, index);
+
+ if (typeResult.isErr()) {
+ return typeResult;
+ }
+ if (shownResult.isErr()) {
+ return shownResult;
+ }
+ return actionResult;
+}
+
+VoidResult Cache::MaybeMigrateVersion1() {
+ for (uint32_t index = 0; index < Cache::kVersion1MaxSize; ++index) {
+ MaybeStringResult typeResult =
+ ReadVersion1CacheKey(Cache::kNotificationTypeKey, index);
+ if (typeResult.isErr()) {
+ return mozilla::Err(typeResult.unwrapErr());
+ }
+ MaybeString maybeType = typeResult.unwrap();
+
+ MaybeStringResult shownResult =
+ ReadVersion1CacheKey(Cache::kNotificationShownKey, index);
+ if (shownResult.isErr()) {
+ return mozilla::Err(shownResult.unwrapErr());
+ }
+ MaybeString maybeShown = shownResult.unwrap();
+
+ MaybeStringResult actionResult =
+ ReadVersion1CacheKey(Cache::kNotificationActionKey, index);
+ if (actionResult.isErr()) {
+ return mozilla::Err(actionResult.unwrapErr());
+ }
+ MaybeString maybeAction = actionResult.unwrap();
+
+ if (maybeType.isSome() && maybeShown.isSome() && maybeAction.isSome()) {
+ // If something goes wrong, we'd rather lose a little data than migrate
+ // over and over again. So delete the old entry before we add the new one.
+ VoidResult result = DeleteVersion1CacheEntry(index);
+ if (result.isErr()) {
+ return result;
+ }
+
+ VersionedEntry entry = VersionedEntry{
+ .entryVersion = 1,
+ .notificationType = maybeType.value(),
+ .notificationShown = maybeShown.value(),
+ .notificationAction = maybeAction.value(),
+ .prevNotificationAction = mozilla::Nothing(),
+ };
+ result = VersionedEnqueue(entry);
+ if (result.isErr()) {
+ // We already deleted the version 1 cache entry. No real reason to abort
+ // now. May as well keep attempting to migrate.
+ LOG_ERROR_MESSAGE(L"Warning: Version 1 cache entry %u dropped: %#X",
+ index, result.unwrapErr().AsHResult());
+ }
+ } else if (maybeType.isNothing() && maybeShown.isNothing() &&
+ maybeAction.isNothing()) {
+ // Looks like we've reached the end of the version 1 cache.
+ break;
+ } else {
+ // This cache entry seems to be missing a key. Just drop it.
+ LOG_ERROR_MESSAGE(
+ L"Warning: Version 1 cache entry %u dropped due to missing keys",
+ index);
+ mozilla::Unused << DeleteVersion1CacheEntry(index);
+ }
+ }
+ return mozilla::Ok();
+}
+
+std::wstring Cache::MakeEntryRegKeyName(uint32_t index) {
+ std::wstring regName = mCacheRegKey;
+ regName += L'\\';
+ regName += std::to_wstring(index);
+ return regName;
+}
+
+VoidResult Cache::WriteEntryKeys(uint32_t index, const VersionedEntry& entry) {
+ std::wstring subKey = MakeEntryRegKeyName(index);
+
+ VoidResult result =
+ RegistrySetValueDword(IsPrefixed::Unprefixed, Cache::kEntryVersionKey,
+ entry.entryVersion, subKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Unable to write entry version to index %u: %#X", index,
+ result.inspectErr().AsHResult());
+ return result;
+ }
+
+ result = RegistrySetValueString(
+ IsPrefixed::Unprefixed, Cache::kNotificationTypeKey,
+ entry.notificationType.c_str(), subKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Unable to write notification type to index %u: %#X",
+ index, result.inspectErr().AsHResult());
+ return result;
+ }
+
+ result = RegistrySetValueString(
+ IsPrefixed::Unprefixed, Cache::kNotificationShownKey,
+ entry.notificationShown.c_str(), subKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Unable to write notification shown to index %u: %#X",
+ index, result.inspectErr().AsHResult());
+ return result;
+ }
+
+ result = RegistrySetValueString(
+ IsPrefixed::Unprefixed, Cache::kNotificationActionKey,
+ entry.notificationAction.c_str(), subKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Unable to write notification type to index %u: %#X",
+ index, result.inspectErr().AsHResult());
+ return result;
+ }
+
+ if (entry.prevNotificationAction.isSome()) {
+ result = RegistrySetValueString(
+ IsPrefixed::Unprefixed, Cache::kPrevNotificationActionKey,
+ entry.prevNotificationAction.value().c_str(), subKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(
+ L"Unable to write prev notification type to index %u: %#X", index,
+ result.inspectErr().AsHResult());
+ return result;
+ }
+ }
+
+ return mozilla::Ok();
+}
+
+// Returns success on an attempt to delete a non-existent entry.
+VoidResult Cache::DeleteEntry(uint32_t index) {
+ std::wstring key = AGENT_REGKEY_NAME;
+ key += L'\\';
+ key += MakeEntryRegKeyName(index);
+ // We could probably just delete they key here, rather than use this function,
+ // which deletes keys recursively. But this mechanism allows future entry
+ // versions to contain sub-keys without causing problems for older versions.
+ LSTATUS ls = RegDeleteTreeW(HKEY_CURRENT_USER, key.c_str());
+ if (ls != ERROR_SUCCESS && ls != ERROR_FILE_NOT_FOUND) {
+ return mozilla::Err(mozilla::WindowsError::FromWin32Error(ls));
+ }
+ return mozilla::Ok();
+}
+
+VoidResult Cache::SetFront(uint32_t newFront) {
+ VoidResult result =
+ RegistrySetValueDword(IsPrefixed::Unprefixed, Cache::kFrontRegName,
+ newFront, mCacheRegKey.c_str());
+ if (result.isOk()) {
+ mFront = newFront;
+ }
+ return result;
+}
+
+VoidResult Cache::SetSize(uint32_t newSize) {
+ VoidResult result =
+ RegistrySetValueDword(IsPrefixed::Unprefixed, Cache::kSizeRegName,
+ newSize, mCacheRegKey.c_str());
+ if (result.isOk()) {
+ mSize = newSize;
+ }
+ return result;
+}
+
+// The entry passed to this function MUST already be valid. This function does
+// not do any validation internally. We must not, for example, pass an entry
+// to it with a version of 2 and a prevNotificationAction of mozilla::Nothing()
+// because a version 2 entry requires that key.
+VoidResult Cache::VersionedEnqueue(const VersionedEntry& entry) {
+ VoidResult result = Init();
+ if (result.isErr()) {
+ return result;
+ }
+
+ if (mSize >= mCapacity) {
+ LOG_ERROR_MESSAGE(L"Attempted to add an entry to the cache, but it's full");
+ return mozilla::Err(mozilla::WindowsError::FromHResult(E_BOUNDS));
+ }
+
+ uint32_t index = (mFront + mSize) % mCapacity;
+
+ // We really don't want to write to a location that has stale cache entry data
+ // already lying around.
+ result = DeleteEntry(index);
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Unable to remove stale entry: %#X",
+ result.inspectErr().AsHResult());
+ return result;
+ }
+
+ result = WriteEntryKeys(index, entry);
+ if (result.isErr()) {
+ // We might have written a partial key. Attempt to clean up after ourself.
+ mozilla::Unused << DeleteEntry(index);
+ return result;
+ }
+
+ result = SetSize(mSize + 1);
+ if (result.isErr()) {
+ // If we failed to write the size, the new entry was not added successfully.
+ // Attempt to clean up after ourself.
+ mozilla::Unused << DeleteEntry(index);
+ return result;
+ }
+
+ return mozilla::Ok();
+}
+
+VoidResult Cache::Enqueue(const Cache::Entry& entry) {
+ Cache::VersionedEntry vEntry = Cache::VersionedEntry{
+ .entryVersion = Cache::kEntryVersion,
+ .notificationType = entry.notificationType,
+ .notificationShown = entry.notificationShown,
+ .notificationAction = entry.notificationAction,
+ .prevNotificationAction = mozilla::Some(entry.prevNotificationAction),
+ };
+ return VersionedEnqueue(vEntry);
+}
+
+VoidResult Cache::DiscardFront() {
+ if (mSize < 1) {
+ LOG_ERROR_MESSAGE(L"Attempted to discard entry from an empty cache");
+ return mozilla::Err(mozilla::WindowsError::FromHResult(E_BOUNDS));
+ }
+ // It's not a huge deal if we can't delete this. Moving mFront will result in
+ // it being excluded from the cache anyways. We'll try to delete it again
+ // anyways if we try to write to this index again.
+ mozilla::Unused << DeleteEntry(mFront);
+
+ VoidResult result = SetSize(mSize - 1);
+ // We don't really need to bother moving mFront to the next index if the cache
+ // is empty.
+ if (result.isErr() || mSize == 0) {
+ return result;
+ }
+ result = SetFront((mFront + 1) % mCapacity);
+ if (result.isErr()) {
+ // If we failed to set the front after we set the size, the cache is
+ // in an inconsistent state.
+ // But, even if the cache is inconsistent, we'll likely lose some data, but
+ // we should eventually be able to recover. Any expected entries with no
+ // data will be discarded and any unexpected entries with data will be
+ // cleared out before we write data there.
+ LOG_ERROR_MESSAGE(L"Cache inconsistent: Updated Size but not Front: %#X",
+ result.inspectErr().AsHResult());
+ }
+ return result;
+}
+
+/**
+ * This function reads a DWORD cache key's value and returns it. If the expected
+ * argument is true and the key is missing, this will delete the entire entry
+ * and return mozilla::Nothing().
+ */
+MaybeDwordResult Cache::ReadEntryKeyDword(const std::wstring& regKey,
+ const wchar_t* regName,
+ bool expected) {
+ MaybeDwordResult result =
+ RegistryGetValueDword(IsPrefixed::Unprefixed, regName, regKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Failed to read \"%s\" from \"%s\": %#X", regName,
+ regKey.c_str(), result.inspectErr().AsHResult());
+ return mozilla::Err(result.unwrapErr());
+ }
+ MaybeDword maybeValue = result.unwrap();
+ if (expected && maybeValue.isNothing()) {
+ LOG_ERROR_MESSAGE(L"Missing expected value \"%s\" from \"%s\"", regName,
+ regKey.c_str());
+ VoidResult result = DiscardFront();
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+ }
+ return maybeValue;
+}
+
+/**
+ * This function reads a string cache key's value and returns it. If the
+ * expected argument is true and the key is missing, this will delete the entire
+ * entry and return mozilla::Nothing().
+ */
+MaybeStringResult Cache::ReadEntryKeyString(const std::wstring& regKey,
+ const wchar_t* regName,
+ bool expected) {
+ MaybeStringResult result =
+ RegistryGetValueString(IsPrefixed::Unprefixed, regName, regKey.c_str());
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Failed to read \"%s\" from \"%s\": %#X", regName,
+ regKey.c_str(), result.inspectErr().AsHResult());
+ return mozilla::Err(result.unwrapErr());
+ }
+ MaybeString maybeValue = result.unwrap();
+ if (expected && maybeValue.isNothing()) {
+ LOG_ERROR_MESSAGE(L"Missing expected value \"%s\" from \"%s\"", regName,
+ regKey.c_str());
+ VoidResult result = DiscardFront();
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+ }
+ return maybeValue;
+}
+
+Cache::MaybeEntryResult Cache::Dequeue() {
+ VoidResult result = Init();
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+
+ std::wstring subKey = MakeEntryRegKeyName(mFront);
+
+ // We are going to read within a loop so that if we find incomplete entries,
+ // we can just discard them and try to read the next entry. We'll put a limit
+ // on the maximum number of times this loop can possibly run so that if
+ // something goes horribly wrong, we don't loop forever. If we exit this loop
+ // without returning, it means that not only were we not able to read
+ // anything, but something very unexpected happened.
+ // We are going to potentially loop over this mCapacity + 1 times so that if
+ // we end up discarding every item in the cache, we return mozilla::Nothing()
+ // rather than an error.
+ for (uint32_t i = 0; i <= mCapacity; ++i) {
+ if (mSize == 0) {
+ return MaybeEntry(mozilla::Nothing());
+ }
+
+ Cache::VersionedEntry entry;
+
+ // CacheEntryVersion
+ MaybeDwordResult dResult =
+ ReadEntryKeyDword(subKey, Cache::kEntryVersionKey, true);
+ if (dResult.isErr()) {
+ return mozilla::Err(dResult.unwrapErr());
+ }
+ MaybeDword maybeDValue = dResult.unwrap();
+ if (maybeDValue.isNothing()) {
+ // Note that we only call continue in this function after DiscardFront()
+ // has been called (either directly, or by one of the ReadEntryKey.*
+ // functions). So the continue call results in attempting to read the
+ // next entry in the cache.
+ continue;
+ }
+ entry.entryVersion = maybeDValue.value();
+ if (entry.entryVersion < 1) {
+ LOG_ERROR_MESSAGE(L"Invalid entry version of %u in \"%s\"",
+ entry.entryVersion, subKey.c_str());
+ VoidResult result = DiscardFront();
+ if (result.isErr()) {
+ return mozilla::Err(result.unwrapErr());
+ }
+ continue;
+ }
+
+ // NotificationType
+ MaybeStringResult sResult =
+ ReadEntryKeyString(subKey, Cache::kNotificationTypeKey, true);
+ if (sResult.isErr()) {
+ return mozilla::Err(sResult.unwrapErr());
+ }
+ MaybeString maybeSValue = sResult.unwrap();
+ if (maybeSValue.isNothing()) {
+ continue;
+ }
+ entry.notificationType = maybeSValue.value();
+
+ // NotificationShown
+ sResult = ReadEntryKeyString(subKey, Cache::kNotificationShownKey, true);
+ if (sResult.isErr()) {
+ return mozilla::Err(sResult.unwrapErr());
+ }
+ maybeSValue = sResult.unwrap();
+ if (maybeSValue.isNothing()) {
+ continue;
+ }
+ entry.notificationShown = maybeSValue.value();
+
+ // NotificationAction
+ sResult = ReadEntryKeyString(subKey, Cache::kNotificationActionKey, true);
+ if (sResult.isErr()) {
+ return mozilla::Err(sResult.unwrapErr());
+ }
+ maybeSValue = sResult.unwrap();
+ if (maybeSValue.isNothing()) {
+ continue;
+ }
+ entry.notificationAction = maybeSValue.value();
+
+ // PrevNotificationAction
+ bool expected =
+ entry.entryVersion >= Cache::kInitialVersionPrevNotificationActionKey;
+ sResult =
+ ReadEntryKeyString(subKey, Cache::kPrevNotificationActionKey, expected);
+ if (sResult.isErr()) {
+ return mozilla::Err(sResult.unwrapErr());
+ }
+ maybeSValue = sResult.unwrap();
+ if (expected && maybeSValue.isNothing()) {
+ continue;
+ }
+ entry.prevNotificationAction = maybeSValue;
+
+ // We successfully read the entry. Now we need to remove it from the cache.
+ VoidResult result = DiscardFront();
+ if (result.isErr()) {
+ // If we aren't able to remove the entry from the cache, don't return it.
+ // We don't want to return the same item over and over again if we get
+ // into a bad state.
+ return mozilla::Err(result.unwrapErr());
+ }
+
+ return mozilla::Some(entry);
+ }
+
+ LOG_ERROR_MESSAGE(L"Unexpected: This line shouldn't be reached");
+ return mozilla::Err(mozilla::WindowsError::FromHResult(E_FAIL));
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/Cache.h b/toolkit/mozapps/defaultagent/Cache.h
new file mode 100644
index 0000000000..1deacb17df
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Cache.h
@@ -0,0 +1,189 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_CACHE_H__
+#define __DEFAULT_BROWSER_AGENT_CACHE_H__
+
+#include <cstdint>
+#include <string>
+#include <windows.h>
+
+#include "Registry.h"
+
+namespace mozilla::default_agent {
+
+using DwordResult = mozilla::WindowsErrorResult<uint32_t>;
+
+/**
+ * This cache functions as a FIFO queue which writes its data to the Windows
+ * registry.
+ *
+ * Note that the cache is not thread-safe, so it is recommended that the WDBA's
+ * RegistryMutex be acquired before accessing it.
+ *
+ * Some of the terminology used in this module is a easy to mix up, so let's
+ * just be clear about it:
+ * - registry key/sub-key
+ * A registry key is sort of like the registry's equivalent of a
+ * directory. It can contain values, each of which is made up of a name
+ * and corresponding data. We may also refer to a "sub-key", meaning a
+ * registry key nested in a registry key.
+ * - cache key/entry key
+ * A cache key refers to the string that we use to look up a single
+ * element of cache entry data. Example: "CacheEntryVersion"
+ * - entry
+ * This refers to an entire record stored using Cache::Enqueue or retrieved
+ * using Cache::Dequeue. It consists of numerous cache keys and their
+ * corresponding data.
+ *
+ * The first version of this cache was problematic because of how hard it was to
+ * extend. This version attempts to overcome this. It first migrates all data
+ * out of the version 1 cache. This means that the stored ping data will not
+ * be accessible to out-of-date clients, but presumably they will eventually
+ * be updated or the up-to-date client that performed the migration will send
+ * the pings itself. Because the WDBA telemetry has no client ID, all analysis
+ * is stateless, so even if the other clients send some pings before the stored
+ * ones get sent, that's ok. The ordering isn't really important.
+ *
+ * This version of the cache attempts to correct the problem of how hard it was
+ * to extend the old cache. The biggest problem that the old cache had was that
+ * when it dequeued data it had to shift data, but it wouldn't shift keys that
+ * it didn't know about, causing them to become associated with the wrong cache
+ * entries.
+ *
+ * Version 2 of the cache will make 4 improvements to attempt to avoid problems
+ * like this in the future:
+ * 1. Each cache entry will get its own registry key. This will help to keep
+ * cache entries isolated from each other.
+ * 2. Each cache entry will include version data so that we know what cache
+ * keys to expect when we read it.
+ * 3. Rather than having to shift every entry every time we dequeue, we will
+ * implement a circular queue so that we just have to update what index
+ * currently represents the front
+ * 4. We will store the cache capacity in the cache so that we can expand the
+ * cache later, if we want, without breaking previous versions.
+ */
+class Cache {
+ public:
+ // cacheRegKey is the registry sub-key that the cache will be stored in. If
+ // null is passed (the default), we will use the default cache name. This is
+ // what ought to be used in production. When testing, we will pass a different
+ // key in so that our testing caches don't conflict with each other or with
+ // a possible production cache on the test machine.
+ explicit Cache(const wchar_t* cacheRegKey = nullptr);
+ ~Cache();
+
+ // The version of the cache (not to be confused with the version of the cache
+ // entries). This should only be incremented if we need to make breaking
+ // changes that require migration to a new cache location, like we did between
+ // versions 1 and 2. This value will be used as part of the sub-key that the
+ // cache is stored in (ex: "PingCache\version2").
+ static constexpr const uint32_t kVersion = 2;
+ // This value will be written into each entry. This allows us to know what
+ // cache keys to expect in the event that additional cache keys are added in
+ // later entry versions.
+ static constexpr const uint32_t kEntryVersion = 2;
+ static constexpr const uint32_t kDefaultCapacity = 2;
+ // We want to allow the cache to be expandable, but we don't really want it to
+ // be infinitely expandable. So we'll set an upper bound.
+ static constexpr const uint32_t kMaxCapacity = 100;
+ static constexpr const wchar_t* kDefaultPingCacheRegKey = L"PingCache";
+
+ // Used to read the version 1 cache entries during data migration. Full cache
+ // key names are formatted like: "<keyPrefix><baseKeyName><cacheIndex>"
+ // For example: "PingCacheNotificationType0"
+ static constexpr const wchar_t* kVersion1KeyPrefix = L"PingCache";
+ static constexpr const uint32_t kVersion1MaxSize = 2;
+
+ static constexpr const wchar_t* kCapacityRegName = L"Capacity";
+ static constexpr const wchar_t* kFrontRegName = L"Front";
+ static constexpr const wchar_t* kSizeRegName = L"Size";
+
+ // Cache Entry keys
+ static constexpr const wchar_t* kEntryVersionKey = L"CacheEntryVersion";
+ // Note that the next 3 must also match the base key names from version 1
+ // since we use them to construct those key names.
+ static constexpr const wchar_t* kNotificationTypeKey = L"NotificationType";
+ static constexpr const wchar_t* kNotificationShownKey = L"NotificationShown";
+ static constexpr const wchar_t* kNotificationActionKey =
+ L"NotificationAction";
+ static constexpr const wchar_t* kPrevNotificationActionKey =
+ L"PrevNotificationAction";
+
+ // The version key wasn't added until version 2, but we add it to the version
+ // 1 entries when migrating them to the cache.
+ static constexpr const uint32_t kInitialVersionEntryVersionKey = 1;
+ static constexpr const uint32_t kInitialVersionNotificationTypeKey = 1;
+ static constexpr const uint32_t kInitialVersionNotificationShownKey = 1;
+ static constexpr const uint32_t kInitialVersionNotificationActionKey = 1;
+ static constexpr const uint32_t kInitialVersionPrevNotificationActionKey = 2;
+
+ // We have two cache entry structs: one for the current version, and one
+ // generic one that can handle any version. There are a couple of reasons
+ // for this:
+ // - We only want to support writing the current version, but we want to
+ // support reading any version.
+ // - It makes things a bit nicer for the caller when Enqueue-ing, since
+ // they don't have to set the version or wrap values that were added
+ // later in a mozilla::Maybe.
+ // - It keeps us from having to worry about writing an invalid cache entry,
+ // such as one that claims to be version 2, but doesn't have
+ // prevNotificationAction.
+ // Note that the entry struct for the current version does not contain a
+ // version member value because we already know that its version is equal to
+ // Cache::kEntryVersion.
+ struct Entry {
+ std::string notificationType;
+ std::string notificationShown;
+ std::string notificationAction;
+ std::string prevNotificationAction;
+ };
+ struct VersionedEntry {
+ uint32_t entryVersion;
+ std::string notificationType;
+ std::string notificationShown;
+ std::string notificationAction;
+ mozilla::Maybe<std::string> prevNotificationAction;
+ };
+
+ using MaybeEntry = mozilla::Maybe<VersionedEntry>;
+ using MaybeEntryResult = mozilla::WindowsErrorResult<MaybeEntry>;
+
+ VoidResult Init();
+ VoidResult Enqueue(const Entry& entry);
+ MaybeEntryResult Dequeue();
+
+ private:
+ const std::wstring mCacheRegKey;
+
+ // We can't easily copy a VoidResult, so just store the raw HRESULT here.
+ mozilla::Maybe<HRESULT> mInitializeResult;
+ // How large the cache will grow before it starts rejecting new entries.
+ uint32_t mCapacity;
+ // The index of the first present cache entry.
+ uint32_t mFront;
+ // How many entries are present in the cache.
+ uint32_t mSize;
+
+ DwordResult EnsureDwordSetting(const wchar_t* regName, uint32_t defaultValue);
+ VoidResult SetupCache();
+ VoidResult MaybeMigrateVersion1();
+ std::wstring MakeEntryRegKeyName(uint32_t index);
+ VoidResult WriteEntryKeys(uint32_t index, const VersionedEntry& entry);
+ VoidResult DeleteEntry(uint32_t index);
+ VoidResult SetFront(uint32_t newFront);
+ VoidResult SetSize(uint32_t newSize);
+ VoidResult VersionedEnqueue(const VersionedEntry& entry);
+ VoidResult DiscardFront();
+ MaybeDwordResult ReadEntryKeyDword(const std::wstring& regKey,
+ const wchar_t* regName, bool expected);
+ MaybeStringResult ReadEntryKeyString(const std::wstring& regKey,
+ const wchar_t* regName, bool expected);
+};
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_CACHE_H__
diff --git a/toolkit/mozapps/defaultagent/DefaultAgent.cpp b/toolkit/mozapps/defaultagent/DefaultAgent.cpp
new file mode 100644
index 0000000000..2ebb5e466e
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/DefaultAgent.cpp
@@ -0,0 +1,491 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <windows.h>
+#include <shlwapi.h>
+#include <objbase.h>
+#include <string.h>
+#include <vector>
+
+#include "nsAutoRef.h"
+#include "nsDebug.h"
+#include "nsProxyRelease.h"
+#include "nsWindowsHelpers.h"
+#include "nsString.h"
+
+#include "common.h"
+#include "DefaultBrowser.h"
+#include "DefaultPDF.h"
+#include "EventLog.h"
+#include "Notification.h"
+#include "Policy.h"
+#include "Registry.h"
+#include "ScheduledTask.h"
+#include "ScheduledTaskRemove.h"
+#include "SetDefaultBrowser.h"
+#include "Telemetry.h"
+#include "xpcpublic.h"
+#include "mozilla/dom/Promise.h"
+#include "mozilla/ErrorResult.h"
+
+#include "DefaultAgent.h"
+
+// The AGENT_REGKEY_NAME is dependent on MOZ_APP_VENDOR and MOZ_APP_BASENAME,
+// so using those values in the mutex name prevents waiting on processes that
+// are using completely different data.
+#define REGISTRY_MUTEX_NAME \
+ L"" MOZ_APP_VENDOR MOZ_APP_BASENAME L"DefaultBrowserAgentRegistryMutex"
+// How long to wait on the registry mutex before giving up on it. This should
+// be short. Although the WDBA runs in the background, uninstallation happens
+// synchronously in the foreground.
+#define REGISTRY_MUTEX_TIMEOUT_MS (3 * 1000)
+
+namespace mozilla::default_agent {
+
+// This class is designed to prevent concurrency problems when accessing the
+// registry. It should be acquired before any usage of unprefixed registry
+// entries.
+class RegistryMutex {
+ private:
+ nsAutoHandle mMutex;
+ bool mLocked;
+
+ public:
+ RegistryMutex() : mMutex(nullptr), mLocked(false) {}
+ ~RegistryMutex() {
+ Release();
+ // nsAutoHandle will take care of closing the mutex's handle.
+ }
+
+ // Returns true on success, false on failure.
+ bool Acquire() {
+ if (mLocked) {
+ return true;
+ }
+
+ if (mMutex.get() == nullptr) {
+ // It seems like we would want to set the second parameter (bInitialOwner)
+ // to TRUE, but the documentation for CreateMutexW suggests that, because
+ // we aren't sure that the mutex doesn't already exist, we can't be sure
+ // whether we got ownership via this mechanism.
+ mMutex.own(CreateMutexW(nullptr, FALSE, REGISTRY_MUTEX_NAME));
+ if (mMutex.get() == nullptr) {
+ LOG_ERROR_MESSAGE(L"Couldn't open registry mutex: %#X", GetLastError());
+ return false;
+ }
+ }
+
+ DWORD mutexStatus =
+ WaitForSingleObject(mMutex.get(), REGISTRY_MUTEX_TIMEOUT_MS);
+ if (mutexStatus == WAIT_OBJECT_0) {
+ mLocked = true;
+ } else if (mutexStatus == WAIT_TIMEOUT) {
+ LOG_ERROR_MESSAGE(L"Timed out waiting for registry mutex");
+ } else if (mutexStatus == WAIT_ABANDONED) {
+ // This isn't really an error for us. No one else is using the registry.
+ // This status code means that we are supposed to check our data for
+ // consistency, but there isn't really anything we can fix here.
+ // This is an indication that an agent crashed though, which is clearly an
+ // error, so log an error message.
+ LOG_ERROR_MESSAGE(L"Found abandoned registry mutex. Continuing...");
+ mLocked = true;
+ } else {
+ // The only other documented status code is WAIT_FAILED. In the case that
+ // we somehow get some other code, that is also an error.
+ LOG_ERROR_MESSAGE(L"Failed to wait on registry mutex: %#X",
+ GetLastError());
+ }
+ return mLocked;
+ }
+
+ bool IsLocked() { return mLocked; }
+
+ void Release() {
+ if (mLocked) {
+ if (mMutex.get() == nullptr) {
+ LOG_ERROR_MESSAGE(L"Unexpectedly missing registry mutex");
+ return;
+ }
+ BOOL success = ReleaseMutex(mMutex.get());
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Failed to release registry mutex");
+ }
+ mLocked = false;
+ }
+ }
+};
+
+// Returns true if the registry value name given is one of the
+// install-directory-prefixed values used by the Windows Default Browser Agent.
+// ex: "C:\Program Files\Mozilla Firefox|PreviousDefault"
+// Returns true
+// ex: "InitialNotificationShown"
+// Returns false
+static bool IsPrefixedValueName(const wchar_t* valueName) {
+ // Prefixed value names use '|' as a delimiter. None of the
+ // non-install-directory-prefixed value names contain one.
+ return wcschr(valueName, L'|') != nullptr;
+}
+
+static void RemoveAllRegistryEntries() {
+ mozilla::UniquePtr<wchar_t[]> installPath = mozilla::GetFullBinaryPath();
+ if (!PathRemoveFileSpecW(installPath.get())) {
+ return;
+ }
+
+ HKEY rawRegKey = nullptr;
+ if (ERROR_SUCCESS !=
+ RegOpenKeyExW(HKEY_CURRENT_USER, AGENT_REGKEY_NAME, 0,
+ KEY_WRITE | KEY_QUERY_VALUE | KEY_WOW64_64KEY,
+ &rawRegKey)) {
+ return;
+ }
+ nsAutoRegKey regKey(rawRegKey);
+
+ DWORD maxValueNameLen = 0;
+ if (ERROR_SUCCESS != RegQueryInfoKeyW(regKey.get(), nullptr, nullptr, nullptr,
+ nullptr, nullptr, nullptr, nullptr,
+ &maxValueNameLen, nullptr, nullptr,
+ nullptr)) {
+ return;
+ }
+ // The length that RegQueryInfoKeyW returns is without a terminator.
+ maxValueNameLen += 1;
+
+ mozilla::UniquePtr<wchar_t[]> valueName =
+ mozilla::MakeUnique<wchar_t[]>(maxValueNameLen);
+
+ DWORD valueIndex = 0;
+ // Set this to true if we encounter values in this key that are prefixed with
+ // different install directories, indicating that this key is still in use
+ // by other installs.
+ bool keyStillInUse = false;
+
+ while (true) {
+ DWORD valueNameLen = maxValueNameLen;
+ LSTATUS ls =
+ RegEnumValueW(regKey.get(), valueIndex, valueName.get(), &valueNameLen,
+ nullptr, nullptr, nullptr, nullptr);
+ if (ls != ERROR_SUCCESS) {
+ break;
+ }
+
+ if (!wcsnicmp(valueName.get(), installPath.get(),
+ wcslen(installPath.get()))) {
+ RegDeleteValueW(regKey.get(), valueName.get());
+ // Only increment the index if we did not delete this value, because if
+ // we did then the indexes of all the values after that one just got
+ // decremented, meaning the index we already have now refers to a value
+ // that we haven't looked at yet.
+ } else {
+ valueIndex++;
+ if (IsPrefixedValueName(valueName.get())) {
+ // If this is not one of the unprefixed value names, it must be one of
+ // the install-directory prefixed values.
+ keyStillInUse = true;
+ }
+ }
+ }
+
+ regKey.reset();
+
+ // If no other installs are using this key, remove it now.
+ if (!keyStillInUse) {
+ // Use RegDeleteTreeW to remove the cache as well, which is in subkey.
+ RegDeleteTreeW(HKEY_CURRENT_USER, AGENT_REGKEY_NAME);
+ }
+}
+
+// This function adds a registry value with this format:
+// <install-dir>|Installed=1
+// RemoveAllRegistryEntries() determines whether the registry key is in use
+// by other installations by checking for install-directory-prefixed value
+// names. Although Firefox mirrors some preferences into install-directory-
+// prefixed values, the WDBA no longer uses any prefixed values. Adding this one
+// makes uninstallation work as expected slightly more reliably.
+static void WriteInstallationRegistryEntry() {
+ mozilla::WindowsErrorResult<mozilla::Ok> result =
+ RegistrySetValueBool(IsPrefixed::Prefixed, L"Installed", true);
+ if (result.isErr()) {
+ LOG_ERROR_MESSAGE(L"Failed to write installation registry entry: %#X",
+ result.unwrapErr().AsHResult());
+ }
+}
+
+// Returns false (without setting aResult) if reading last run time failed.
+static bool CheckIfAppRanRecently(bool* aResult) {
+ const ULONGLONG kTaskExpirationDays = 90;
+ const ULONGLONG kTaskExpirationSeconds = kTaskExpirationDays * 24 * 60 * 60;
+
+ MaybeQwordResult lastRunTimeResult =
+ RegistryGetValueQword(IsPrefixed::Prefixed, L"AppLastRunTime");
+ if (lastRunTimeResult.isErr()) {
+ return false;
+ }
+ mozilla::Maybe<ULONGLONG> lastRunTimeMaybe = lastRunTimeResult.unwrap();
+ if (!lastRunTimeMaybe.isSome()) {
+ return false;
+ }
+
+ ULONGLONG secondsSinceLastRunTime =
+ SecondsPassedSince(lastRunTimeMaybe.value());
+
+ *aResult = secondsSinceLastRunTime < kTaskExpirationSeconds;
+ return true;
+}
+
+// Use the macro to inject all of the definitions for nsISupports.
+NS_IMPL_ISUPPORTS(DefaultAgent, nsIDefaultAgent)
+
+NS_IMETHODIMP
+DefaultAgent::RegisterTask(const nsAString& aUniqueToken) {
+ // We aren't actually going to check whether we got the mutex here.
+ // Ideally we would acquire it since registration might migrate registry
+ // entries. But it is preferable to ignore a mutex wait timeout here
+ // because:
+ // 1. Otherwise the task doesn't get registered at all
+ // 2. If another installation's agent is holding the mutex, it either
+ // is far enough out of date that it doesn't yet use the migrated
+ // values, or it already did the migration for us.
+ RegistryMutex regMutex;
+ regMutex.Acquire();
+
+ WriteInstallationRegistryEntry();
+
+ HRESULT hr =
+ default_agent::RegisterTask(PromiseFlatString(aUniqueToken).get());
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP
+DefaultAgent::UpdateTask(const nsAString& aUniqueToken) {
+ // Not checking if we got the mutex for the same reason we didn't in
+ // register-task
+ RegistryMutex regMutex;
+ regMutex.Acquire();
+
+ WriteInstallationRegistryEntry();
+
+ HRESULT hr = default_agent::UpdateTask(PromiseFlatString(aUniqueToken).get());
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP
+DefaultAgent::UnregisterTask(const nsAString& aUniqueToken) {
+ HRESULT hr = RemoveTasks(PromiseFlatString(aUniqueToken).get(),
+ WhichTasks::WdbaTaskOnly);
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP
+DefaultAgent::Uninstall(const nsAString& aUniqueToken) {
+ // We aren't actually going to check whether we got the mutex here.
+ // Ideally we would acquire it since we are about to access the registry,
+ // so we would like to block simultaneous users of our registry key.
+ // But there are two reasons that it is preferable to ignore a mutex
+ // wait timeout here:
+ // 1. If we fail to uninstall our prefixed registry entries, the
+ // registry key containing them will never be removed, even when the
+ // last installation is uninstalled.
+ // 2. If we timed out waiting on the mutex, it implies that there are
+ // other installations. If there are other installations, there will
+ // be other prefixed registry entries. If there are other prefixed
+ // registry entries, we won't remove the whole key or touch the
+ // unprefixed entries during uninstallation. Therefore, we should
+ // be able to safely uninstall without stepping on anyone's toes.
+ RegistryMutex regMutex;
+ regMutex.Acquire();
+
+ RemoveAllRegistryEntries();
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+DefaultAgent::DoTask(const nsAString& aUniqueToken, const bool aForce) {
+ // Acquire() has a short timeout. Since this runs in the background, we
+ // could use a longer timeout in this situation. However, if another
+ // installation's agent is already running, it will update CurrentDefault,
+ // possibly send a ping, and possibly show a notification.
+ // Once all that has happened, there is no real reason to do it again. We
+ // only send one ping per day, so we aren't going to do that again. And
+ // the only time we ever show a second notification is 7 days after the
+ // first one, so we aren't going to do that again either.
+ // If the other process didn't take those actions, there is no reason that
+ // this process would take them.
+ // If the other process fails, this one will most likely fail for the same
+ // reason.
+ // So we'll just bail if we can't get the mutex quickly.
+ RegistryMutex regMutex;
+ if (!regMutex.Acquire()) {
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ // Check that Firefox ran recently, if not then stop here.
+ // Also stop if no timestamp was found, which most likely indicates
+ // that Firefox was not yet run.
+ bool ranRecently = false;
+ if (!aForce && (!CheckIfAppRanRecently(&ranRecently) || !ranRecently)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ DefaultBrowserResult defaultBrowserResult = GetDefaultBrowserInfo();
+ DefaultBrowserInfo browserInfo{};
+ if (defaultBrowserResult.isOk()) {
+ browserInfo = defaultBrowserResult.unwrap();
+ } else {
+ browserInfo.currentDefaultBrowser = Browser::Error;
+ browserInfo.previousDefaultBrowser = Browser::Error;
+ }
+
+ DefaultPdfResult defaultPdfResult = GetDefaultPdfInfo();
+ DefaultPdfInfo pdfInfo{};
+ if (defaultPdfResult.isOk()) {
+ pdfInfo = defaultPdfResult.unwrap();
+ } else {
+ pdfInfo.currentDefaultPdf = PDFHandler::Error;
+ }
+
+ NotificationActivities activitiesPerformed;
+ // We block while waiting for the notification which prevents STA thread
+ // callbacks from running as the event loop won't run. Moving notification
+ // handling to an MTA thread prevents this conflict.
+ activitiesPerformed = MaybeShowNotification(
+ browserInfo, PromiseFlatString(aUniqueToken).get(), aForce);
+
+ HRESULT hr = SendDefaultAgentPing(browserInfo, pdfInfo, activitiesPerformed);
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP
+DefaultAgent::AppRanRecently(bool* aRanRecently) {
+ bool ranRecently = false;
+ *aRanRecently = CheckIfAppRanRecently(&ranRecently) && ranRecently;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+DefaultAgent::GetDefaultBrowser(nsAString& aDefaultBrowser) {
+ Browser browser = default_agent::GetDefaultBrowser();
+ aDefaultBrowser = NS_ConvertUTF8toUTF16(GetStringForBrowser(browser));
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+DefaultAgent::GetReplacePreviousDefaultBrowser(
+ const nsAString& aDefaultBrowser, nsAString& aPreviousDefaultBrowser) {
+ Browser browser =
+ GetBrowserFromString(std::string(NS_ConvertUTF16toUTF8(aDefaultBrowser)));
+ Browser previousBrowser =
+ default_agent::GetReplacePreviousDefaultBrowser(browser);
+ aPreviousDefaultBrowser =
+ NS_ConvertUTF8toUTF16(GetStringForBrowser(previousBrowser));
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+DefaultAgent::GetDefaultPdfHandler(nsAString& aDefaultPdfHandler) {
+ PDFHandler pdf = default_agent::GetDefaultPdfInfo()
+ .unwrapOr({PDFHandler::Error})
+ .currentDefaultPdf;
+ aDefaultPdfHandler = NS_ConvertUTF8toUTF16(GetStringForPDFHandler(pdf));
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+DefaultAgent::SendPing(const nsAString& aDefaultBrowser,
+ const nsAString& aPreviousDefaultBrowser,
+ const nsAString& aDefaultPdfHandler,
+ const nsAString& aNotificationShown,
+ const nsAString& aNotificationAction) {
+ DefaultBrowserInfo browserInfo = {
+ GetBrowserFromString(std::string(NS_ConvertUTF16toUTF8(aDefaultBrowser))),
+ GetBrowserFromString(
+ std::string(NS_ConvertUTF16toUTF8(aPreviousDefaultBrowser)))};
+
+ DefaultPdfInfo pdfInfo = {GetPDFHandlerFromString(
+ std::string(NS_ConvertUTF16toUTF8(aDefaultPdfHandler)))};
+
+ // The JS implementation has never supported the "two notification flow",
+ // i.e., displaying a followup notification.
+ NotificationShown shown = GetNotificationShownFromString(aNotificationShown);
+ NotificationAction action =
+ GetNotificationActionFromString(aNotificationAction);
+ NotificationActivities activitiesPerformed = {NotificationType::Initial,
+ shown, action};
+
+ HRESULT hr = SendDefaultAgentPing(browserInfo, pdfInfo, activitiesPerformed);
+ return SUCCEEDED(hr) ? NS_OK : NS_ERROR_FAILURE;
+}
+
+NS_IMETHODIMP
+DefaultAgent::SetDefaultBrowserUserChoice(
+ const nsAString& aAumid, const nsTArray<nsString>& aExtraFileExtensions) {
+ return default_agent::SetDefaultBrowserUserChoice(
+ PromiseFlatString(aAumid).get(), aExtraFileExtensions);
+}
+
+NS_IMETHODIMP
+DefaultAgent::SetDefaultBrowserUserChoiceAsync(
+ const nsAString& aAumid, const nsTArray<nsString>& aExtraFileExtensions,
+ JSContext* aCx, dom::Promise** aPromise) {
+ if (!NS_IsMainThread()) {
+ return NS_ERROR_NOT_SAME_THREAD;
+ }
+
+ ErrorResult rv;
+ RefPtr<dom::Promise> promise =
+ dom::Promise::Create(xpc::CurrentNativeGlobal(aCx), rv);
+ if (MOZ_UNLIKELY(rv.Failed())) {
+ return rv.StealNSResult();
+ }
+
+ // A holder to pass the promise through the background task and back to
+ // the main thread when finished.
+ auto promiseHolder = MakeRefPtr<nsMainThreadPtrHolder<dom::Promise>>(
+ "SetDefaultBrowserUserChoiceAsync promise", promise);
+
+ nsresult result = NS_DispatchBackgroundTask(
+ NS_NewRunnableFunction(
+ "SetDefaultBrowserUserChoiceAsync",
+ // Make a local copy of the aAudmid parameter which is a reference
+ // which will go out of scope
+ [aumid = nsString(aAumid), promiseHolder = std::move(promiseHolder),
+ aExtraFileExtensions =
+ CopyableTArray<nsString>(aExtraFileExtensions)] {
+ nsresult rv = default_agent::SetDefaultBrowserUserChoice(
+ PromiseFlatString(aumid).get(), aExtraFileExtensions);
+
+ NS_DispatchToMainThread(NS_NewRunnableFunction(
+ "SetDefaultBrowserUserChoiceAsync callback",
+ [rv, promiseHolder = std::move(promiseHolder)] {
+ dom::Promise* promise = promiseHolder.get()->get();
+ if (NS_SUCCEEDED(rv)) {
+ promise->MaybeResolveWithUndefined();
+ } else {
+ promise->MaybeReject(rv);
+ }
+ }));
+ }),
+ NS_DISPATCH_EVENT_MAY_BLOCK);
+
+ promise.forget(aPromise);
+ return result;
+}
+
+NS_IMETHODIMP
+DefaultAgent::SetDefaultExtensionHandlersUserChoice(
+ const nsAString& aAumid, const nsTArray<nsString>& aFileExtensions) {
+ return default_agent::SetDefaultExtensionHandlersUserChoice(
+ PromiseFlatString(aAumid).get(), aFileExtensions);
+}
+
+NS_IMETHODIMP
+DefaultAgent::AgentDisabled(bool* aDisabled) {
+ *aDisabled = IsAgentDisabled();
+ return NS_OK;
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/DefaultAgent.h b/toolkit/mozapps/defaultagent/DefaultAgent.h
new file mode 100644
index 0000000000..518ac44afe
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/DefaultAgent.h
@@ -0,0 +1,28 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_DEFAULT_AGENT_H__
+#define __DEFAULT_BROWSER_AGENT_DEFAULT_AGENT_H__
+
+#include "nsIDefaultAgent.h"
+
+namespace mozilla::default_agent {
+
+class DefaultAgent final : public nsIDefaultAgent {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIDEFAULTAGENT
+
+ DefaultAgent() = default;
+
+ private:
+ // A private destructor must be declared.
+ ~DefaultAgent() = default;
+};
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_DEFAULT_AGENT_H__
diff --git a/toolkit/mozapps/defaultagent/DefaultBrowser.cpp b/toolkit/mozapps/defaultagent/DefaultBrowser.cpp
new file mode 100644
index 0000000000..87d3f62632
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/DefaultBrowser.cpp
@@ -0,0 +1,240 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "DefaultBrowser.h"
+
+#include <string>
+
+#include <shlobj.h>
+
+#include "EventLog.h"
+#include "Registry.h"
+
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/Unused.h"
+#include "mozilla/Try.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+namespace mozilla::default_agent {
+
+using BrowserResult = mozilla::WindowsErrorResult<Browser>;
+
+constexpr std::string_view kUnknownBrowserString = "";
+
+constexpr std::pair<std::string_view, Browser> kStringBrowserMap[]{
+ {"error", Browser::Error},
+ {kUnknownBrowserString, Browser::Unknown},
+ {"firefox", Browser::Firefox},
+ {"chrome", Browser::Chrome},
+ {"edge", Browser::EdgeWithEdgeHTML},
+ {"edge-chrome", Browser::EdgeWithBlink},
+ {"ie", Browser::InternetExplorer},
+ {"opera", Browser::Opera},
+ {"brave", Browser::Brave},
+ {"yandex", Browser::Yandex},
+ {"qq-browser", Browser::QQBrowser},
+ {"360-browser", Browser::_360Browser},
+ {"sogou", Browser::Sogou},
+ {"duckduckgo", Browser::DuckDuckGo},
+};
+
+static_assert(mozilla::ArrayLength(kStringBrowserMap) == kBrowserCount);
+
+std::string GetStringForBrowser(Browser browser) {
+ for (const auto& [mapString, mapBrowser] : kStringBrowserMap) {
+ if (browser == mapBrowser) {
+ return std::string{mapString};
+ }
+ }
+
+ return std::string(kUnknownBrowserString);
+}
+
+Browser GetBrowserFromString(const std::string& browserString) {
+ for (const auto& [mapString, mapBrowser] : kStringBrowserMap) {
+ if (browserString == mapString) {
+ return mapBrowser;
+ }
+ }
+
+ return Browser::Unknown;
+}
+
+BrowserResult TryGetDefaultBrowser() {
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, nullptr, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return BrowserResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ // Whatever is handling the HTTP protocol is effectively the default browser.
+ mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter> registeredApp;
+ {
+ wchar_t* rawRegisteredApp;
+ hr = pAAR->QueryCurrentDefault(L"http", AT_URLPROTOCOL, AL_EFFECTIVE,
+ &rawRegisteredApp);
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return BrowserResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ registeredApp = mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter>(
+ rawRegisteredApp);
+ }
+
+ // Get the application Friendly Name associated to the found ProgID. This is
+ // sized to be larger than any observed or expected friendly names. Long
+ // friendly names tend to be in the form `[Company] [Browser] [Variant]`
+ std::array<wchar_t, 256> friendlyName{};
+ DWORD friendlyNameLen = friendlyName.size();
+ hr = AssocQueryStringW(ASSOCF_NONE, ASSOCSTR_FRIENDLYAPPNAME,
+ registeredApp.get(), nullptr, friendlyName.data(),
+ &friendlyNameLen);
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return BrowserResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ // This maps a browser's Friendly Name prefix to an enum variant that we'll
+ // use to identify that browser in our telemetry ping (which is this
+ // function's return value).
+ constexpr std::pair<std::wstring_view, Browser> kFriendlyNamePrefixes[] = {
+ {L"Firefox", Browser::Firefox},
+ {L"Google Chrome", Browser::Chrome},
+ {L"Microsoft Edge", Browser::EdgeWithBlink},
+ {L"Internet Explorer", Browser::InternetExplorer},
+ {L"Opera", Browser::Opera},
+ {L"Brave", Browser::Brave},
+ {L"Yandex", Browser::Yandex},
+ {L"QQBrowser", Browser::QQBrowser},
+ // 360安全浏览器 UTF-16 encoding
+ {L"\u0033\u0036\u0030\u5b89\u5168\u6d4f\u89c8\u5668",
+ Browser::_360Browser},
+ // 搜狗高速浏览器 UTF-16 encoding
+ {L"\u641c\u72d7\u9ad8\u901f\u6d4f\u89c8\u5668", Browser::Sogou},
+ {L"DuckDuckGo", Browser::DuckDuckGo},
+ };
+
+ // We should have one prefix for every browser we track, minus exceptions
+ // listed below.
+ // Error - not a real browser.
+ // Unknown - not a real browser.
+ // EdgeWithEdgeHTML - duplicate friendly name with EdgeWithBlink with special
+ // handling below.
+ static_assert(mozilla::ArrayLength(kFriendlyNamePrefixes) ==
+ kBrowserCount - 3);
+
+ for (const auto& [prefix, browser] : kFriendlyNamePrefixes) {
+ // Find matching Friendly Name prefix.
+ if (!wcsnicmp(friendlyName.data(), prefix.data(), prefix.length())) {
+ if (browser == Browser::EdgeWithBlink) {
+ // Disambiguate EdgeWithEdgeHTML and EdgeWithBlink.
+ // The ProgID below is documented as having not changed while Edge was
+ // actively developed. It's assumed but unverified this is true in all
+ // cases (e.g. across locales).
+ //
+ // Note: at time of commit EdgeWithBlink from the Windows Store was a
+ // wrapper for Edge Installer instead of a package containing Edge,
+ // therefore the Default Browser associating ProgID was not in the form
+ // "AppX[hash]" as expected. It is unclear if the EdgeWithEdgeHTML and
+ // EdgeWithBlink ProgIDs would differ if the latter is changed into a
+ // package containing Edge.
+ constexpr std::wstring_view progIdEdgeHtml1{
+ L"AppXq0fevzme2pys62n3e0fbqa7peapykr8v"};
+ // Apparently there is at least one other ProgID used by EdgeHTML Edge.
+ constexpr std::wstring_view progIdEdgeHtml2{
+ L"AppXd4nrz8ff68srnhf9t5a8sbjyar1cr723"};
+
+ if (!wcsnicmp(registeredApp.get(), progIdEdgeHtml1.data(),
+ progIdEdgeHtml1.length()) ||
+ !wcsnicmp(registeredApp.get(), progIdEdgeHtml2.data(),
+ progIdEdgeHtml2.length())) {
+ return Browser::EdgeWithEdgeHTML;
+ }
+ }
+
+ return browser;
+ }
+ }
+
+ // The default browser is one that we don't know about.
+ return Browser::Unknown;
+}
+
+BrowserResult TryGetReplacePreviousDefaultBrowser(Browser currentDefault) {
+ // This function uses a registry value which stores the current default
+ // browser. It returns the data stored in that registry value and replaces the
+ // stored string with the current default browser string that was passed in.
+
+ std::string currentDefaultStr = GetStringForBrowser(currentDefault);
+ std::string previousDefault =
+ RegistryGetValueString(IsPrefixed::Unprefixed, L"CurrentDefault")
+ .unwrapOr(mozilla::Some(currentDefaultStr))
+ .valueOr(currentDefaultStr);
+
+ mozilla::Unused << RegistrySetValueString(
+ IsPrefixed::Unprefixed, L"CurrentDefault", currentDefaultStr.c_str());
+
+ return GetBrowserFromString(previousDefault);
+}
+
+DefaultBrowserResult GetDefaultBrowserInfo() {
+ DefaultBrowserInfo browserInfo;
+
+ MOZ_TRY_VAR(browserInfo.currentDefaultBrowser, TryGetDefaultBrowser());
+ MOZ_TRY_VAR(
+ browserInfo.previousDefaultBrowser,
+ TryGetReplacePreviousDefaultBrowser(browserInfo.currentDefaultBrowser));
+
+ return browserInfo;
+}
+
+// We used to prefix this key with the installation directory, but that causes
+// problems with our new "only one ping per day across installs" restriction.
+// To make sure all installations use consistent data, the value's name is
+// being migrated to a shared, non-prefixed name.
+// This function doesn't really do any error handling, because there isn't
+// really anything to be done if it fails.
+void MaybeMigrateCurrentDefault() {
+ const wchar_t* valueName = L"CurrentDefault";
+
+ MaybeStringResult valueResult =
+ RegistryGetValueString(IsPrefixed::Prefixed, valueName);
+ if (valueResult.isErr()) {
+ return;
+ }
+ mozilla::Maybe<std::string> maybeValue = valueResult.unwrap();
+ if (maybeValue.isNothing()) {
+ // No value to migrate
+ return;
+ }
+ std::string value = maybeValue.value();
+
+ mozilla::Unused << RegistryDeleteValue(IsPrefixed::Prefixed, valueName);
+
+ // Only migrate the value if no value is in the new location yet.
+ valueResult = RegistryGetValueString(IsPrefixed::Unprefixed, valueName);
+ if (valueResult.isErr()) {
+ return;
+ }
+ if (valueResult.unwrap().isNothing()) {
+ mozilla::Unused << RegistrySetValueString(IsPrefixed::Unprefixed, valueName,
+ value.c_str());
+ }
+}
+
+Browser GetDefaultBrowser() {
+ return TryGetDefaultBrowser().unwrapOr(Browser::Error);
+}
+Browser GetReplacePreviousDefaultBrowser(Browser currentBrowser) {
+ return TryGetReplacePreviousDefaultBrowser(currentBrowser)
+ .unwrapOr(Browser::Error);
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/DefaultBrowser.h b/toolkit/mozapps/defaultagent/DefaultBrowser.h
new file mode 100644
index 0000000000..f1b940959f
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/DefaultBrowser.h
@@ -0,0 +1,39 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_DEFAULT_BROWSER_H__
+#define __DEFAULT_BROWSER_DEFAULT_BROWSER_H__
+
+#include <string>
+
+#include "mozilla/DefineEnum.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+namespace mozilla::default_agent {
+
+MOZ_DEFINE_ENUM_CLASS(Browser,
+ (Error, Unknown, Firefox, Chrome, EdgeWithEdgeHTML,
+ EdgeWithBlink, InternetExplorer, Opera, Brave, Yandex,
+ QQBrowser, _360Browser, Sogou, DuckDuckGo));
+
+struct DefaultBrowserInfo {
+ Browser currentDefaultBrowser;
+ Browser previousDefaultBrowser;
+};
+
+using DefaultBrowserResult = mozilla::WindowsErrorResult<DefaultBrowserInfo>;
+
+DefaultBrowserResult GetDefaultBrowserInfo();
+Browser GetDefaultBrowser();
+Browser GetReplacePreviousDefaultBrowser(Browser currentBrowser);
+
+std::string GetStringForBrowser(Browser browser);
+Browser GetBrowserFromString(const std::string& browserString);
+void MaybeMigrateCurrentDefault();
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_DEFAULT_BROWSER_H__
diff --git a/toolkit/mozapps/defaultagent/DefaultPDF.cpp b/toolkit/mozapps/defaultagent/DefaultPDF.cpp
new file mode 100644
index 0000000000..e0a9f2e85a
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/DefaultPDF.cpp
@@ -0,0 +1,151 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "DefaultPDF.h"
+
+#include <string>
+
+#include <shlobj.h>
+#include <winerror.h>
+
+#include "EventLog.h"
+
+#include "mozilla/Buffer.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+#include "mozilla/Try.h"
+
+namespace mozilla::default_agent {
+
+constexpr std::string_view kUnknownPdfString = "";
+
+constexpr std::pair<std::string_view, PDFHandler> kStringPdfHandlerMap[]{
+ {"error", PDFHandler::Error},
+ {kUnknownPdfString, PDFHandler::Unknown},
+ {"Firefox", PDFHandler::Firefox},
+ {"Microsoft Edge", PDFHandler::MicrosoftEdge},
+ {"Google Chrome", PDFHandler::GoogleChrome},
+ {"Adobe Acrobat", PDFHandler::AdobeAcrobat},
+ {"WPS", PDFHandler::WPS},
+ {"Nitro", PDFHandler::Nitro},
+ {"Foxit", PDFHandler::Foxit},
+ {"PDF-XChange", PDFHandler::PDFXChange},
+ {"Avast", PDFHandler::AvastSecureBrowser},
+ {"Sumatra", PDFHandler::SumatraPDF},
+};
+
+static_assert(mozilla::ArrayLength(kStringPdfHandlerMap) == kPDFHandlerCount);
+
+std::string GetStringForPDFHandler(PDFHandler handler) {
+ for (const auto& [mapString, mapPdf] : kStringPdfHandlerMap) {
+ if (handler == mapPdf) {
+ return std::string{mapString};
+ }
+ }
+
+ return std::string(kUnknownPdfString);
+}
+
+PDFHandler GetPDFHandlerFromString(const std::string& pdfHandlerString) {
+ for (const auto& [mapString, mapPdfHandler] : kStringPdfHandlerMap) {
+ if (pdfHandlerString == mapString) {
+ return mapPdfHandler;
+ }
+ }
+
+ return PDFHandler::Unknown;
+}
+
+using PdfResult = mozilla::WindowsErrorResult<PDFHandler>;
+
+static PdfResult GetDefaultPdf() {
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, nullptr, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return PdfResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter> registeredApp;
+ {
+ wchar_t* rawRegisteredApp;
+ hr = pAAR->QueryCurrentDefault(L".pdf", AT_FILEEXTENSION, AL_EFFECTIVE,
+ &rawRegisteredApp);
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return PdfResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ registeredApp = mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter>{
+ rawRegisteredApp};
+ }
+
+ // Get the application Friendly Name associated to the found ProgID. This is
+ // sized to be larger than any observed or expected friendly names. Long
+ // friendly names tend to be in the form `[Company] [Viewer] [Variant]`
+ DWORD friendlyNameLen = 0;
+ hr = AssocQueryStringW(ASSOCF_NONE, ASSOCSTR_FRIENDLYAPPNAME,
+ registeredApp.get(), nullptr, nullptr,
+ &friendlyNameLen);
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return PdfResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ mozilla::Buffer<wchar_t> friendlyNameBuffer(friendlyNameLen);
+ hr = AssocQueryStringW(ASSOCF_NONE, ASSOCSTR_FRIENDLYAPPNAME,
+ registeredApp.get(), nullptr,
+ friendlyNameBuffer.Elements(), &friendlyNameLen);
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return PdfResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ constexpr std::pair<std::wstring_view, PDFHandler> kFriendlyNamePrefixes[] = {
+ {L"Firefox", PDFHandler::Firefox},
+ {L"Microsoft Edge", PDFHandler::MicrosoftEdge},
+ {L"Google Chrome", PDFHandler::GoogleChrome},
+ {L"Adobe", PDFHandler::AdobeAcrobat},
+ {L"Acrobat", PDFHandler::AdobeAcrobat},
+ {L"WPS", PDFHandler::WPS},
+ {L"Nitro", PDFHandler::Nitro},
+ {L"Foxit", PDFHandler::Foxit},
+ {L"PDF-XChange", PDFHandler::PDFXChange},
+ {L"Avast", PDFHandler::AvastSecureBrowser},
+ {L"Sumatra", PDFHandler::SumatraPDF},
+ };
+
+ // We should have one prefix for every PDF handler we track, with exceptions
+ // listed below.
+ // Error - removed; not a real pdf handler.
+ // Unknown - removed; not a real pdf handler.
+ // AdobeAcrobat - duplicate; `Adobe` and `Acrobat` prefixes are both seen in
+ // telemetry.
+ static_assert(mozilla::ArrayLength(kFriendlyNamePrefixes) ==
+ kPDFHandlerCount - 2 + 1);
+
+ PDFHandler resolvedHandler = PDFHandler::Unknown;
+ for (const auto& [knownHandlerSubstring, handlerEnum] :
+ kFriendlyNamePrefixes) {
+ if (!wcsnicmp(friendlyNameBuffer.Elements(), knownHandlerSubstring.data(),
+ knownHandlerSubstring.length())) {
+ resolvedHandler = handlerEnum;
+ break;
+ }
+ }
+
+ return resolvedHandler;
+}
+
+DefaultPdfResult GetDefaultPdfInfo() {
+ DefaultPdfInfo pdfInfo;
+ MOZ_TRY_VAR(pdfInfo.currentDefaultPdf, GetDefaultPdf());
+
+ return pdfInfo;
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/DefaultPDF.h b/toolkit/mozapps/defaultagent/DefaultPDF.h
new file mode 100644
index 0000000000..73afaa7025
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/DefaultPDF.h
@@ -0,0 +1,34 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 DEFAULT_BROWSER_DEFAULT_PDF_H__
+#define DEFAULT_BROWSER_DEFAULT_PDF_H__
+
+#include <string>
+
+#include "mozilla/DefineEnum.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+namespace mozilla::default_agent {
+
+MOZ_DEFINE_ENUM_CLASS(PDFHandler,
+ (Error, Unknown, Firefox, MicrosoftEdge, GoogleChrome,
+ AdobeAcrobat, WPS, Nitro, Foxit, PDFXChange,
+ AvastSecureBrowser, SumatraPDF));
+
+struct DefaultPdfInfo {
+ PDFHandler currentDefaultPdf;
+};
+
+using DefaultPdfResult = mozilla::WindowsErrorResult<DefaultPdfInfo>;
+
+DefaultPdfResult GetDefaultPdfInfo();
+std::string GetStringForPDFHandler(PDFHandler handler);
+PDFHandler GetPDFHandlerFromString(const std::string& pdfHandlerString);
+
+} // namespace mozilla::default_agent
+
+#endif // DEFAULT_BROWSER_DEFAULT_PDF_H__
diff --git a/toolkit/mozapps/defaultagent/EventLog.cpp b/toolkit/mozapps/defaultagent/EventLog.cpp
new file mode 100644
index 0000000000..eaac1161bb
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/EventLog.cpp
@@ -0,0 +1,11 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "EventLog.h"
+
+// This is an easy way to expose `MOZ_APP_DISPLAYNAME` to Rust code.
+const wchar_t* gWinEventLogSourceName =
+ L"" MOZ_APP_DISPLAYNAME " Default Browser Agent";
diff --git a/toolkit/mozapps/defaultagent/EventLog.h b/toolkit/mozapps/defaultagent/EventLog.h
new file mode 100644
index 0000000000..84b35010f8
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/EventLog.h
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_EVENT_LOG_H__
+#define __DEFAULT_BROWSER_AGENT_EVENT_LOG_H__
+
+#include "mozilla/Types.h"
+
+MOZ_BEGIN_EXTERN_C
+
+extern MOZ_EXPORT const wchar_t* gWinEventLogSourceName;
+
+MOZ_END_EXTERN_C
+
+#include "mozilla/WindowsEventLog.h"
+
+#define LOG_ERROR(hr) MOZ_WIN_EVENT_LOG_ERROR(gWinEventLogSourceName, hr)
+#define LOG_ERROR_MESSAGE(format, ...) \
+ MOZ_WIN_EVENT_LOG_ERROR_MESSAGE(gWinEventLogSourceName, format, __VA_ARGS__)
+
+#endif // __DEFAULT_BROWSER_AGENT_EVENT_LOG_H__
diff --git a/toolkit/mozapps/defaultagent/Notification.cpp b/toolkit/mozapps/defaultagent/Notification.cpp
new file mode 100644
index 0000000000..961e57c9b3
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Notification.cpp
@@ -0,0 +1,709 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "Notification.h"
+
+#include <shlwapi.h>
+#include <wchar.h>
+#include <windows.h>
+#include <winnt.h>
+
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/CmdLineAndEnvUtils.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/mscom/EnsureMTA.h"
+#include "mozilla/intl/FileSource.h"
+#include "mozilla/intl/Localization.h"
+#include "mozilla/ShellHeaderOnlyUtils.h"
+#include "mozilla/UniquePtr.h"
+#include "mozilla/Unused.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+#include "nsString.h"
+#include "nsTArray.h"
+#include "nsWindowsHelpers.h"
+#include "readstrings.h"
+#include "updatererrors.h"
+#include "WindowsDefaultBrowser.h"
+
+#include "common.h"
+#include "DefaultBrowser.h"
+#include "EventLog.h"
+#include "Registry.h"
+#include "SetDefaultBrowser.h"
+
+#include "wintoastlib.h"
+
+using mozilla::intl::Localization;
+
+#define SEVEN_DAYS_IN_SECONDS (7 * 24 * 60 * 60)
+
+// If the notification hasn't been activated or dismissed within 12 hours,
+// stop waiting for it.
+#define NOTIFICATION_WAIT_TIMEOUT_MS (12 * 60 * 60 * 1000)
+// If the mutex hasn't been released within a few minutes, something is wrong
+// and we should give up on it
+#define MUTEX_TIMEOUT_MS (10 * 60 * 1000)
+
+namespace mozilla::default_agent {
+
+bool FirefoxInstallIsEnglish();
+
+static bool SetInitialNotificationShown(bool wasShown) {
+ return !RegistrySetValueBool(IsPrefixed::Unprefixed,
+ L"InitialNotificationShown", wasShown)
+ .isErr();
+}
+
+static bool GetInitialNotificationShown() {
+ return RegistryGetValueBool(IsPrefixed::Unprefixed,
+ L"InitialNotificationShown")
+ .unwrapOr(mozilla::Some(false))
+ .valueOr(false);
+}
+
+static bool ResetInitialNotificationShown() {
+ return RegistryDeleteValue(IsPrefixed::Unprefixed,
+ L"InitialNotificationShown")
+ .isOk();
+}
+
+static bool SetFollowupNotificationShown(bool wasShown) {
+ return !RegistrySetValueBool(IsPrefixed::Unprefixed,
+ L"FollowupNotificationShown", wasShown)
+ .isErr();
+}
+
+static bool GetFollowupNotificationShown() {
+ return RegistryGetValueBool(IsPrefixed::Unprefixed,
+ L"FollowupNotificationShown")
+ .unwrapOr(mozilla::Some(false))
+ .valueOr(false);
+}
+
+static bool SetFollowupNotificationSuppressed(bool value) {
+ return !RegistrySetValueBool(IsPrefixed::Unprefixed,
+ L"FollowupNotificationSuppressed", value)
+ .isErr();
+}
+
+static bool GetFollowupNotificationSuppressed() {
+ return RegistryGetValueBool(IsPrefixed::Unprefixed,
+ L"FollowupNotificationSuppressed")
+ .unwrapOr(mozilla::Some(false))
+ .valueOr(false);
+}
+
+// Returns 0 if no value is set.
+static ULONGLONG GetFollowupNotificationRequestTime() {
+ return RegistryGetValueQword(IsPrefixed::Unprefixed, L"FollowupRequestTime")
+ .unwrapOr(mozilla::Some(0))
+ .valueOr(0);
+}
+
+// Returns false if no value is set.
+static bool GetPrefSetDefaultBrowserUserChoice() {
+ return RegistryGetValueBool(IsPrefixed::Prefixed,
+ L"SetDefaultBrowserUserChoice")
+ .unwrapOr(mozilla::Some(false))
+ .valueOr(false);
+}
+
+struct ToastStrings {
+ mozilla::UniquePtr<wchar_t[]> text1;
+ mozilla::UniquePtr<wchar_t[]> text2;
+ mozilla::UniquePtr<wchar_t[]> action1;
+ mozilla::UniquePtr<wchar_t[]> action2;
+ mozilla::UniquePtr<wchar_t[]> relImagePath;
+};
+
+struct Strings {
+ // Toast notification button text is hard to localize because it tends to
+ // overflow. Thus, we have 3 different toast notifications:
+ // - The initial notification, which includes a button with text like
+ // "Ask me later". Since we cannot easily localize this, we will display
+ // it only in English.
+ // - The followup notification, to be shown if the user clicked "Ask me
+ // later". Since we only have that button in English, we only need this
+ // notification in English.
+ // - The localized notification, which has much shorter button text to
+ // (hopefully) prevent overflow: just "Yes" and "No". Since we no longer
+ // have an "Ask me later" button, a followup localized notification is not
+ // needed.
+ ToastStrings initialToast;
+ ToastStrings followupToast;
+ ToastStrings localizedToast;
+
+ // Returned pointer points within this struct and should not be freed.
+ const ToastStrings* GetToastStrings(NotificationType whichToast,
+ bool englishStrings) const {
+ if (!englishStrings) {
+ return &localizedToast;
+ }
+ if (whichToast == NotificationType::Initial) {
+ return &initialToast;
+ }
+ return &followupToast;
+ }
+};
+
+// Gets all strings out of the relevant INI files.
+// Returns true on success, false on failure
+static bool GetStrings(Strings& strings) {
+ mozilla::UniquePtr<wchar_t[]> installPath;
+ bool success = GetInstallDirectory(installPath);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Failed to get install directory when getting strings");
+ return false;
+ }
+ nsTArray<nsCString> resIds = {"branding/brand.ftl"_ns,
+ "browser/backgroundtasks/defaultagent.ftl"_ns};
+ RefPtr<Localization> l10n = Localization::Create(resIds, true);
+ nsAutoCString daHeaderText, daBodyText, daYesButton, daNoButton;
+ mozilla::ErrorResult daRv;
+ l10n->FormatValueSync("default-browser-notification-header-text"_ns, {},
+ daHeaderText, daRv);
+ ENSURE_SUCCESS(daRv, false);
+ l10n->FormatValueSync("default-browser-notification-body-text"_ns, {},
+ daBodyText, daRv);
+ ENSURE_SUCCESS(daRv, false);
+ l10n->FormatValueSync("default-browser-notification-yes-button-text"_ns, {},
+ daYesButton, daRv);
+ ENSURE_SUCCESS(daRv, false);
+ l10n->FormatValueSync("default-browser-notification-no-button-text"_ns, {},
+ daNoButton, daRv);
+ ENSURE_SUCCESS(daRv, false);
+
+ NS_ConvertUTF8toUTF16 daHeaderTextW(daHeaderText), daBodyTextW(daBodyText),
+ daYesButtonW(daYesButton), daNoButtonW(daNoButton);
+ strings.localizedToast.text1 =
+ mozilla::MakeUnique<wchar_t[]>(daHeaderTextW.Length() + 1);
+ wcsncpy(strings.localizedToast.text1.get(), daHeaderTextW.get(),
+ daHeaderTextW.Length() + 1);
+ strings.localizedToast.text2 =
+ mozilla::MakeUnique<wchar_t[]>(daBodyTextW.Length() + 1);
+ wcsncpy(strings.localizedToast.text2.get(), daBodyTextW.get(),
+ daBodyTextW.Length() + 1);
+ strings.localizedToast.action1 =
+ mozilla::MakeUnique<wchar_t[]>(daYesButtonW.Length() + 1);
+ wcsncpy(strings.localizedToast.action1.get(), daYesButtonW.get(),
+ daYesButtonW.Length() + 1);
+ strings.localizedToast.action2 =
+ mozilla::MakeUnique<wchar_t[]>(daNoButtonW.Length() + 1);
+ wcsncpy(strings.localizedToast.action2.get(), daNoButtonW.get(),
+ daNoButtonW.Length() + 1);
+ const wchar_t* iniFormat = L"%s\\defaultagent.ini";
+ int bufferSize = _scwprintf(iniFormat, installPath.get());
+ ++bufferSize; // Extra character for terminating null
+ mozilla::UniquePtr<wchar_t[]> iniPath =
+ mozilla::MakeUnique<wchar_t[]>(bufferSize);
+ _snwprintf_s(iniPath.get(), bufferSize, _TRUNCATE, iniFormat,
+ installPath.get());
+
+ IniReader nonlocalizedReader(iniPath.get(), "Nonlocalized");
+ nonlocalizedReader.AddKey("InitialToastRelativeImagePath",
+ &strings.initialToast.relImagePath);
+ nonlocalizedReader.AddKey("FollowupToastRelativeImagePath",
+ &strings.followupToast.relImagePath);
+ nonlocalizedReader.AddKey("LocalizedToastRelativeImagePath",
+ &strings.localizedToast.relImagePath);
+ int result = nonlocalizedReader.Read();
+ if (result != OK) {
+ LOG_ERROR_MESSAGE(L"Unable to read non-localized strings: %d", result);
+ return false;
+ }
+
+ return true;
+}
+
+static mozilla::WindowsError LaunchFirefoxToHandleDefaultBrowserAgent() {
+ // Could also be `MOZ_APP_NAME.exe`, but there's no generality to be gained:
+ // the WDBA is Firefox-only.
+ FilePathResult firefoxPathResult = GetRelativeBinaryPath(L"firefox.exe");
+ if (firefoxPathResult.isErr()) {
+ return firefoxPathResult.unwrapErr();
+ }
+ std::wstring firefoxPath = firefoxPathResult.unwrap();
+
+ _bstr_t cmd = firefoxPath.c_str();
+ // Omit argv[0] because ShellExecute doesn't need it.
+ _variant_t args(L"-to-handle-default-browser-agent");
+ _variant_t operation(L"open");
+ _variant_t directory;
+ _variant_t showCmd(SW_SHOWNORMAL);
+
+ // To prevent inheriting environment variables from the background task, we
+ // run Firefox via Explorer instead of our own process. This mimics the
+ // implementation of the Windows Launcher Process.
+ auto result =
+ ShellExecuteByExplorer(cmd, args, operation, directory, showCmd);
+ NS_ENSURE_TRUE(result.isOk(), result.unwrapErr());
+
+ return mozilla::WindowsError::CreateSuccess();
+}
+
+/*
+ * Set the default browser.
+ *
+ * First check if we can directly write UserChoice, if so attempt that.
+ * If we can't write UserChoice, or if the attempt fails, fall back to
+ * showing the Default Apps page of Settings.
+ *
+ * @param aAumi The AUMI of the installation to set as default.
+ */
+static void SetDefaultBrowserFromNotification(const wchar_t* aumi) {
+ nsresult rv = NS_ERROR_FAILURE;
+ if (GetPrefSetDefaultBrowserUserChoice()) {
+ rv = SetDefaultBrowserUserChoice(aumi);
+ }
+
+ if (NS_SUCCEEDED(rv)) {
+ mozilla::Unused << LaunchFirefoxToHandleDefaultBrowserAgent();
+ } else {
+ LOG_ERROR_MESSAGE(L"Failed to SetDefaultBrowserUserChoice: %#X",
+ GetLastError());
+ LaunchModernSettingsDialogDefaultApps();
+ }
+}
+
+// This encapsulates the data that needs to be protected by a mutex because it
+// will be shared by the main thread and the handler thread.
+// To ensure the data is only written once, handlerDataHasBeenSet should be
+// initialized to false, then set to true when the handler writes data into the
+// structure.
+struct HandlerData {
+ NotificationActivities activitiesPerformed;
+ bool handlerDataHasBeenSet;
+};
+
+// The value that ToastHandler writes into should be a global. We can't control
+// when ToastHandler is called, and if this value isn't a global, ToastHandler
+// may be called and attempt to access this after it has been deconstructed.
+// Since this value is accessed by the handler thread and the main thread, it
+// is protected by a mutex (gHandlerMutex).
+// Since ShowNotification deconstructs the mutex, it might seem like once
+// ShowNotification exits, we can just rely on the inability to wait on an
+// invalid mutex to protect the deconstructed data, but it's possible that
+// we could deconstruct the mutex while the handler is holding it and is
+// already accessing the protected data.
+static HandlerData gHandlerReturnData;
+static HANDLE gHandlerMutex = INVALID_HANDLE_VALUE;
+
+class ToastHandler : public WinToastLib::IWinToastHandler {
+ private:
+ NotificationType mWhichNotification;
+ HANDLE mEvent;
+ const std::wstring mAumiStr;
+
+ public:
+ ToastHandler(NotificationType whichNotification, HANDLE event,
+ const wchar_t* aumi)
+ : mWhichNotification(whichNotification), mEvent(event), mAumiStr(aumi) {}
+
+ void FinishHandler(NotificationActivities& returnData) const {
+ SetReturnData(returnData);
+
+ BOOL success = SetEvent(mEvent);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Event could not be set: %#X", GetLastError());
+ }
+ }
+
+ void SetReturnData(NotificationActivities& toSet) const {
+ DWORD result = WaitForSingleObject(gHandlerMutex, MUTEX_TIMEOUT_MS);
+ if (result == WAIT_TIMEOUT) {
+ LOG_ERROR_MESSAGE(L"Unable to obtain mutex ownership");
+ return;
+ } else if (result == WAIT_FAILED) {
+ LOG_ERROR_MESSAGE(L"Failed to wait on mutex: %#X", GetLastError());
+ return;
+ } else if (result == WAIT_ABANDONED) {
+ LOG_ERROR_MESSAGE(L"Found abandoned mutex");
+ ReleaseMutex(gHandlerMutex);
+ return;
+ }
+
+ // Only set this data once
+ if (!gHandlerReturnData.handlerDataHasBeenSet) {
+ gHandlerReturnData.activitiesPerformed = toSet;
+ gHandlerReturnData.handlerDataHasBeenSet = true;
+ }
+
+ BOOL success = ReleaseMutex(gHandlerMutex);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Unable to release mutex ownership: %#X",
+ GetLastError());
+ }
+ }
+
+ void toastActivated() const override {
+ NotificationActivities activitiesPerformed;
+ activitiesPerformed.type = mWhichNotification;
+ activitiesPerformed.shown = NotificationShown::Shown;
+ activitiesPerformed.action = NotificationAction::ToastClicked;
+
+ // Notification strings are written to indicate the default browser is
+ // restored to Firefox when the notification body is clicked to prevent
+ // ambiguity when buttons aren't pressed.
+ SetDefaultBrowserFromNotification(mAumiStr.c_str());
+
+ FinishHandler(activitiesPerformed);
+ }
+
+ void toastActivated(int actionIndex) const override {
+ NotificationActivities activitiesPerformed;
+ activitiesPerformed.type = mWhichNotification;
+ activitiesPerformed.shown = NotificationShown::Shown;
+ // Override this below
+ activitiesPerformed.action = NotificationAction::NoAction;
+
+ if (actionIndex == 0) {
+ // "Make Firefox the default" button, on both the initial and followup
+ // notifications. "Yes" button on the localized notification.
+ activitiesPerformed.action = NotificationAction::MakeFirefoxDefaultButton;
+
+ SetDefaultBrowserFromNotification(mAumiStr.c_str());
+ } else if (actionIndex == 1) {
+ // Do nothing. As long as we don't call
+ // SetFollowupNotificationRequestTime, there will be no followup
+ // notification.
+ activitiesPerformed.action = NotificationAction::DismissedByButton;
+ }
+
+ FinishHandler(activitiesPerformed);
+ }
+
+ void toastDismissed(WinToastDismissalReason state) const override {
+ NotificationActivities activitiesPerformed;
+ activitiesPerformed.type = mWhichNotification;
+ activitiesPerformed.shown = NotificationShown::Shown;
+ // Override this below
+ activitiesPerformed.action = NotificationAction::NoAction;
+
+ if (state == WinToastDismissalReason::TimedOut) {
+ activitiesPerformed.action = NotificationAction::DismissedByTimeout;
+ } else if (state == WinToastDismissalReason::ApplicationHidden) {
+ activitiesPerformed.action =
+ NotificationAction::DismissedByApplicationHidden;
+ } else if (state == WinToastDismissalReason::UserCanceled) {
+ activitiesPerformed.action = NotificationAction::DismissedToActionCenter;
+ }
+
+ FinishHandler(activitiesPerformed);
+ }
+
+ void toastFailed() const override {
+ NotificationActivities activitiesPerformed;
+ activitiesPerformed.type = mWhichNotification;
+ activitiesPerformed.shown = NotificationShown::Error;
+ activitiesPerformed.action = NotificationAction::NoAction;
+
+ LOG_ERROR_MESSAGE(L"Toast notification failed to display");
+ FinishHandler(activitiesPerformed);
+ }
+};
+
+// This function blocks until the shown notification is activated or dismissed.
+static NotificationActivities ShowNotification(
+ NotificationType whichNotification, const wchar_t* aumi) {
+ // Initially set the value that will be returned to error. If the notification
+ // is shown successfully, we'll update it.
+ NotificationActivities activitiesPerformed = {whichNotification,
+ NotificationShown::Error,
+ NotificationAction::NoAction};
+
+ bool isEnglishInstall = FirefoxInstallIsEnglish();
+
+ Strings strings;
+ if (!GetStrings(strings)) {
+ return activitiesPerformed;
+ }
+ const ToastStrings* toastStrings =
+ strings.GetToastStrings(whichNotification, isEnglishInstall);
+
+ mozilla::mscom::EnsureMTA([&] {
+ using namespace WinToastLib;
+
+ if (!WinToast::isCompatible()) {
+ LOG_ERROR_MESSAGE(L"System is not compatible with WinToast");
+ return;
+ }
+ WinToast::instance()->setAppName(L"" MOZ_APP_DISPLAYNAME);
+ std::wstring aumiStr = aumi;
+ WinToast::instance()->setAppUserModelId(aumiStr);
+ WinToast::instance()->setShortcutPolicy(
+ WinToastLib::WinToast::SHORTCUT_POLICY_REQUIRE_NO_CREATE);
+ WinToast::WinToastError error;
+ if (!WinToast::instance()->initialize(&error)) {
+ LOG_ERROR_MESSAGE(WinToast::strerror(error).c_str());
+ return;
+ }
+
+ // This event object will let the handler notify us when it has handled the
+ // notification.
+ nsAutoHandle event(CreateEventW(nullptr, TRUE, FALSE, nullptr));
+ if (event.get() == nullptr) {
+ LOG_ERROR_MESSAGE(L"Unable to create event object: %#X", GetLastError());
+ return;
+ }
+
+ bool success = false;
+ if (whichNotification == NotificationType::Initial) {
+ success = SetInitialNotificationShown(true);
+ } else {
+ success = SetFollowupNotificationShown(true);
+ }
+ if (!success) {
+ // Return early in this case to prevent the notification from being shown
+ // on every run.
+ LOG_ERROR_MESSAGE(L"Unable to set notification as displayed");
+ return;
+ }
+
+ // We need the absolute image path, not the relative path.
+ mozilla::UniquePtr<wchar_t[]> installPath;
+ success = GetInstallDirectory(installPath);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Failed to get install directory for the image path");
+ return;
+ }
+ const wchar_t* absPathFormat = L"%s\\%s";
+ int bufferSize = _scwprintf(absPathFormat, installPath.get(),
+ toastStrings->relImagePath.get());
+ ++bufferSize; // Extra character for terminating null
+ mozilla::UniquePtr<wchar_t[]> absImagePath =
+ mozilla::MakeUnique<wchar_t[]>(bufferSize);
+ _snwprintf_s(absImagePath.get(), bufferSize, _TRUNCATE, absPathFormat,
+ installPath.get(), toastStrings->relImagePath.get());
+
+ // This is used to protect gHandlerReturnData.
+ gHandlerMutex = CreateMutexW(nullptr, TRUE, nullptr);
+ if (gHandlerMutex == nullptr) {
+ LOG_ERROR_MESSAGE(L"Unable to create mutex: %#X", GetLastError());
+ return;
+ }
+ // Automatically close this mutex when this function exits.
+ nsAutoHandle autoMutex(gHandlerMutex);
+ // No need to initialize gHandlerReturnData.activitiesPerformed, since it
+ // will be set by the handler. But we do need to initialize
+ // gHandlerReturnData.handlerDataHasBeenSet so the handler knows that no
+ // data has been set yet.
+ gHandlerReturnData.handlerDataHasBeenSet = false;
+ success = ReleaseMutex(gHandlerMutex);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Unable to release mutex ownership: %#X",
+ GetLastError());
+ }
+
+ // Finally ready to assemble the notification and dispatch it.
+ WinToastTemplate toastTemplate =
+ WinToastTemplate(WinToastTemplate::ImageAndText02);
+ toastTemplate.setTextField(toastStrings->text1.get(),
+ WinToastTemplate::FirstLine);
+ toastTemplate.setTextField(toastStrings->text2.get(),
+ WinToastTemplate::SecondLine);
+ toastTemplate.addAction(toastStrings->action1.get());
+ toastTemplate.addAction(toastStrings->action2.get());
+ toastTemplate.setImagePath(absImagePath.get());
+ toastTemplate.setScenario(WinToastTemplate::Scenario::Reminder);
+ ToastHandler* handler =
+ new ToastHandler(whichNotification, event.get(), aumi);
+ INT64 id = WinToast::instance()->showToast(toastTemplate, handler, &error);
+ if (id < 0) {
+ LOG_ERROR_MESSAGE(WinToast::strerror(error).c_str());
+ return;
+ }
+
+ DWORD result =
+ WaitForSingleObject(event.get(), NOTIFICATION_WAIT_TIMEOUT_MS);
+ // Don't return after these errors. Attempt to hide the notification.
+ if (result == WAIT_FAILED) {
+ LOG_ERROR_MESSAGE(L"Unable to wait on event object: %#X", GetLastError());
+ } else if (result == WAIT_TIMEOUT) {
+ LOG_ERROR_MESSAGE(L"Timed out waiting for event object");
+ } else {
+ result = WaitForSingleObject(gHandlerMutex, MUTEX_TIMEOUT_MS);
+ if (result == WAIT_TIMEOUT) {
+ LOG_ERROR_MESSAGE(L"Unable to obtain mutex ownership");
+ // activitiesPerformed is already set to error. No change needed.
+ } else if (result == WAIT_FAILED) {
+ LOG_ERROR_MESSAGE(L"Failed to wait on mutex: %#X", GetLastError());
+ // activitiesPerformed is already set to error. No change needed.
+ } else if (result == WAIT_ABANDONED) {
+ LOG_ERROR_MESSAGE(L"Found abandoned mutex");
+ ReleaseMutex(gHandlerMutex);
+ // activitiesPerformed is already set to error. No change needed.
+ } else {
+ // Mutex is being held. It is safe to access gHandlerReturnData.
+ // If gHandlerReturnData.handlerDataHasBeenSet is false, the handler
+ // never ran. Use the error value activitiesPerformed already contains.
+ if (gHandlerReturnData.handlerDataHasBeenSet) {
+ activitiesPerformed = gHandlerReturnData.activitiesPerformed;
+ }
+
+ success = ReleaseMutex(gHandlerMutex);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Unable to release mutex ownership: %#X",
+ GetLastError());
+ }
+ }
+ }
+
+ if (!WinToast::instance()->hideToast(id)) {
+ LOG_ERROR_MESSAGE(L"Failed to hide notification");
+ }
+ });
+ return activitiesPerformed;
+}
+
+// Previously this function checked that the Firefox build was using English.
+// This was checked because of the peculiar way we were localizing toast
+// notifications where we used a completely different set of strings in English.
+//
+// We've since unified the notification flows but need to clean up unused code
+// and config files - Bug 1826375.
+bool FirefoxInstallIsEnglish() { return false; }
+
+// If a notification is shown, this function will block until the notification
+// is activated or dismissed.
+// aumi is the App User Model ID.
+NotificationActivities MaybeShowNotification(
+ const DefaultBrowserInfo& browserInfo, const wchar_t* aumi, bool force) {
+ // Default to not showing a notification. Any other value will be returned
+ // directly from ShowNotification.
+ NotificationActivities activitiesPerformed = {NotificationType::Initial,
+ NotificationShown::NotShown,
+ NotificationAction::NoAction};
+
+ // Reset notification state machine, user setting default browser to Firefox
+ // is a strong signal that they intend to have it as the default browser.
+ if (browserInfo.currentDefaultBrowser == Browser::Firefox) {
+ ResetInitialNotificationShown();
+ }
+
+ bool initialNotificationShown = GetInitialNotificationShown();
+ if (!initialNotificationShown || force) {
+ if ((browserInfo.currentDefaultBrowser == Browser::EdgeWithBlink &&
+ browserInfo.previousDefaultBrowser == Browser::Firefox) ||
+ force) {
+ return ShowNotification(NotificationType::Initial, aumi);
+ }
+ return activitiesPerformed;
+ }
+ activitiesPerformed.type = NotificationType::Followup;
+
+ ULONGLONG followupNotificationRequestTime =
+ GetFollowupNotificationRequestTime();
+ bool followupNotificationRequested = followupNotificationRequestTime != 0;
+ bool followupNotificationShown = GetFollowupNotificationShown();
+ if (followupNotificationRequested && !followupNotificationShown &&
+ !GetFollowupNotificationSuppressed()) {
+ ULONGLONG secondsSinceRequestTime =
+ SecondsPassedSince(followupNotificationRequestTime);
+
+ if (secondsSinceRequestTime >= SEVEN_DAYS_IN_SECONDS) {
+ // If we go to show the followup notification and the user has already
+ // changed the default browser, permanently suppress the followup since
+ // it's no longer relevant.
+ if (browserInfo.currentDefaultBrowser == Browser::EdgeWithBlink) {
+ return ShowNotification(NotificationType::Followup, aumi);
+ } else {
+ SetFollowupNotificationSuppressed(true);
+ }
+ }
+ }
+ return activitiesPerformed;
+}
+
+std::string GetStringForNotificationType(NotificationType type) {
+ switch (type) {
+ case NotificationType::Initial:
+ return std::string("initial");
+ case NotificationType::Followup:
+ return std::string("followup");
+ }
+}
+
+std::string GetStringForNotificationShown(NotificationShown shown) {
+ switch (shown) {
+ case NotificationShown::NotShown:
+ return std::string("not-shown");
+ case NotificationShown::Shown:
+ return std::string("shown");
+ case NotificationShown::Error:
+ return std::string("error");
+ }
+}
+
+NotificationShown GetNotificationShownFromString(const nsAString& shown) {
+ if (shown == u"not-shown"_ns) {
+ return NotificationShown::NotShown;
+ } else if (shown == u"shown"_ns) {
+ return NotificationShown::Shown;
+ } else if (shown == u"error"_ns) {
+ return NotificationShown::Error;
+ } else {
+ // Catch all.
+ return NotificationShown::Error;
+ }
+}
+
+std::string GetStringForNotificationAction(NotificationAction action) {
+ switch (action) {
+ case NotificationAction::DismissedByTimeout:
+ return std::string("dismissed-by-timeout");
+ case NotificationAction::DismissedToActionCenter:
+ return std::string("dismissed-to-action-center");
+ case NotificationAction::DismissedByButton:
+ return std::string("dismissed-by-button");
+ case NotificationAction::DismissedByApplicationHidden:
+ return std::string("dismissed-by-application-hidden");
+ case NotificationAction::RemindMeLater:
+ return std::string("remind-me-later");
+ case NotificationAction::MakeFirefoxDefaultButton:
+ return std::string("make-firefox-default-button");
+ case NotificationAction::ToastClicked:
+ return std::string("toast-clicked");
+ case NotificationAction::NoAction:
+ return std::string("no-action");
+ }
+}
+
+NotificationAction GetNotificationActionFromString(const nsAString& action) {
+ if (action == u"dismissed-by-timeout"_ns) {
+ return NotificationAction::DismissedByTimeout;
+ } else if (action == u"dismissed-to-action-center"_ns) {
+ return NotificationAction::DismissedToActionCenter;
+ } else if (action == u"dismissed-by-button"_ns) {
+ return NotificationAction::DismissedByButton;
+ } else if (action == u"dismissed-by-application-hidden"_ns) {
+ return NotificationAction::DismissedByApplicationHidden;
+ } else if (action == u"remind-me-later"_ns) {
+ return NotificationAction::RemindMeLater;
+ } else if (action == u"make-firefox-default-button"_ns) {
+ return NotificationAction::MakeFirefoxDefaultButton;
+ } else if (action == u"toast-clicked"_ns) {
+ return NotificationAction::ToastClicked;
+ } else if (action == u"no-action"_ns) {
+ return NotificationAction::NoAction;
+ } else {
+ // Catch all.
+ return NotificationAction::NoAction;
+ }
+}
+
+void EnsureValidNotificationAction(std::string& actionString) {
+ if (actionString != "dismissed-by-timeout" &&
+ actionString != "dismissed-to-action-center" &&
+ actionString != "dismissed-by-button" &&
+ actionString != "dismissed-by-application-hidden" &&
+ actionString != "remind-me-later" &&
+ actionString != "make-firefox-default-button" &&
+ actionString != "toast-clicked" && actionString != "no-action") {
+ actionString = "no-action";
+ }
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/Notification.h b/toolkit/mozapps/defaultagent/Notification.h
new file mode 100644
index 0000000000..210c55f559
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Notification.h
@@ -0,0 +1,60 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_NOTIFICATION_H__
+#define __DEFAULT_BROWSER_NOTIFICATION_H__
+
+#include "DefaultBrowser.h"
+
+namespace mozilla::default_agent {
+
+enum class NotificationType {
+ Initial,
+ Followup,
+};
+
+enum class NotificationShown {
+ NotShown,
+ Shown,
+ Error,
+};
+
+enum class NotificationAction {
+ DismissedByTimeout,
+ DismissedToActionCenter,
+ DismissedByButton,
+ DismissedByApplicationHidden,
+ RemindMeLater,
+ MakeFirefoxDefaultButton,
+ ToastClicked,
+ NoAction, // Should not be used with NotificationShown::Shown
+};
+
+struct NotificationActivities {
+ NotificationType type;
+ NotificationShown shown;
+ NotificationAction action;
+};
+
+NotificationActivities MaybeShowNotification(
+ const DefaultBrowserInfo& browserInfo, const wchar_t* aumi, bool force);
+
+// These take enum values and get strings suitable for telemetry
+std::string GetStringForNotificationType(NotificationType type);
+std::string GetStringForNotificationShown(NotificationShown shown);
+NotificationShown GetNotificationShownFromString(const nsAString& shown);
+std::string GetStringForNotificationAction(NotificationAction action);
+NotificationAction GetNotificationActionFromString(const nsAString& action);
+
+// If actionString is a valid action string (i.e. corresponds to one of the
+// NotificationAction values), this function has no effect. If actionString is
+// not a valid action string, its value will be replaced with the string for
+// NotificationAction::NoAction.
+void EnsureValidNotificationAction(std::string& actionString);
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_NOTIFICATION_H__
diff --git a/toolkit/mozapps/defaultagent/Policy.cpp b/toolkit/mozapps/defaultagent/Policy.cpp
new file mode 100644
index 0000000000..f8efdf24a2
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Policy.cpp
@@ -0,0 +1,162 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "Policy.h"
+
+#include <windows.h>
+#include <shlwapi.h>
+#include <fstream>
+
+#include "common.h"
+#include "Registry.h"
+#include "UtfConvert.h"
+
+#include "json/json.h"
+#include "mozilla/HelperMacros.h"
+#include "mozilla/Maybe.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+// There is little logging or error handling in this file, because the file and
+// registry values we are reading here are normally absent, so never finding
+// anything that we look for at all would not be an error worth generating an
+// event log for.
+
+#define AGENT_POLICY_NAME "DisableDefaultBrowserAgent"
+#define TELEMETRY_POLICY_NAME "DisableTelemetry"
+
+// The Firefox policy engine hardcodes the string "Mozilla" in its registry
+// key accesses rather than using the configured vendor name, so we should do
+// the same here to be sure we're compatible with it.
+#define POLICY_REGKEY_NAME L"SOFTWARE\\Policies\\Mozilla\\" MOZ_APP_BASENAME
+
+namespace mozilla::default_agent {
+
+// This enum is the return type for the functions that check policy values.
+enum class PolicyState {
+ Enabled, // There is a policy explicitly set to enabled
+ Disabled, // There is a policy explicitly set to disabled
+ NoPolicy, // This policy isn't configured
+};
+
+static PolicyState FindPolicyInRegistry(HKEY rootKey,
+ const wchar_t* policyName) {
+ HKEY rawRegKey = nullptr;
+ RegOpenKeyExW(rootKey, POLICY_REGKEY_NAME, 0, KEY_READ, &rawRegKey);
+
+ nsAutoRegKey regKey(rawRegKey);
+
+ if (!regKey) {
+ return PolicyState::NoPolicy;
+ }
+
+ // If this key is empty and doesn't have any actual policies in it,
+ // treat that the same as the key not existing and return no result.
+ DWORD numSubKeys = 0, numValues = 0;
+ LSTATUS ls = RegQueryInfoKeyW(regKey.get(), nullptr, nullptr, nullptr,
+ &numSubKeys, nullptr, nullptr, &numValues,
+ nullptr, nullptr, nullptr, nullptr);
+ if (ls != ERROR_SUCCESS) {
+ return PolicyState::NoPolicy;
+ }
+
+ DWORD policyValue = UINT32_MAX;
+ DWORD policyValueSize = sizeof(policyValue);
+ ls = RegGetValueW(regKey.get(), nullptr, policyName, RRF_RT_REG_DWORD,
+ nullptr, &policyValue, &policyValueSize);
+
+ if (ls != ERROR_SUCCESS) {
+ return PolicyState::NoPolicy;
+ }
+ return policyValue == 0 ? PolicyState::Disabled : PolicyState::Enabled;
+}
+
+static PolicyState FindPolicyInFile(const char* policyName) {
+ mozilla::UniquePtr<wchar_t[]> thisBinaryPath = mozilla::GetFullBinaryPath();
+ if (!PathRemoveFileSpecW(thisBinaryPath.get())) {
+ return PolicyState::NoPolicy;
+ }
+
+ wchar_t policiesFilePath[MAX_PATH] = L"";
+ if (!PathCombineW(policiesFilePath, thisBinaryPath.get(), L"distribution")) {
+ return PolicyState::NoPolicy;
+ }
+
+ if (!PathAppendW(policiesFilePath, L"policies.json")) {
+ return PolicyState::NoPolicy;
+ }
+
+ // We need a narrow string-based std::ifstream because that's all jsoncpp can
+ // use; that means we need to supply it the file path as a narrow string.
+ Utf16ToUtf8Result policiesFilePathToUtf8 = Utf16ToUtf8(policiesFilePath);
+ if (policiesFilePathToUtf8.isErr()) {
+ return PolicyState::NoPolicy;
+ }
+ std::string policiesFilePathA = policiesFilePathToUtf8.unwrap();
+
+ Json::Value jsonRoot;
+ std::ifstream stream(policiesFilePathA);
+ Json::Reader().parse(stream, jsonRoot);
+
+ if (jsonRoot.isObject() && jsonRoot.isMember("Policies") &&
+ jsonRoot["Policies"].isObject()) {
+ if (jsonRoot["Policies"].isMember(policyName) &&
+ jsonRoot["Policies"][policyName].isBool()) {
+ return jsonRoot["Policies"][policyName].asBool() ? PolicyState::Enabled
+ : PolicyState::Disabled;
+ } else {
+ return PolicyState::NoPolicy;
+ }
+ }
+
+ return PolicyState::NoPolicy;
+}
+
+static PolicyState IsDisabledByPref(const wchar_t* prefRegValue) {
+ auto prefValueResult =
+ RegistryGetValueBool(IsPrefixed::Prefixed, prefRegValue);
+
+ if (prefValueResult.isErr()) {
+ return PolicyState::NoPolicy;
+ }
+ auto prefValue = prefValueResult.unwrap();
+ if (prefValue.isNothing()) {
+ return PolicyState::NoPolicy;
+ }
+ return prefValue.value() ? PolicyState::Enabled : PolicyState::Disabled;
+}
+
+// Everything we call from this function wants wide strings, except for jsoncpp,
+// which cannot work with them at all, so at some point we need both formats.
+// It's awkward to take both formats as individual arguments, but it would be
+// more awkward to take one and runtime convert it to the other, or to turn
+// this function into a macro so that the preprocessor can trigger the
+// conversion for us, so this is what we've got.
+static bool IsThingDisabled(const char* thing, const wchar_t* wideThing) {
+ // The logic here is intended to be the same as that used by Firefox's policy
+ // engine implementation; they should be kept in sync. We have added the pref
+ // check at the end though, since that's our own custom mechanism.
+ PolicyState state = FindPolicyInRegistry(HKEY_LOCAL_MACHINE, wideThing);
+ if (state == PolicyState::NoPolicy) {
+ state = FindPolicyInRegistry(HKEY_CURRENT_USER, wideThing);
+ }
+ if (state == PolicyState::NoPolicy) {
+ state = FindPolicyInFile(thing);
+ }
+ if (state == PolicyState::NoPolicy) {
+ state = IsDisabledByPref(wideThing);
+ }
+ return state == PolicyState::Enabled ? true : false;
+}
+
+bool IsAgentDisabled() {
+ return IsThingDisabled(AGENT_POLICY_NAME, L"" AGENT_POLICY_NAME);
+}
+
+bool IsTelemetryDisabled() {
+ return IsThingDisabled(TELEMETRY_POLICY_NAME, L"" TELEMETRY_POLICY_NAME);
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/Policy.h b/toolkit/mozapps/defaultagent/Policy.h
new file mode 100644
index 0000000000..2a07a94543
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Policy.h
@@ -0,0 +1,17 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_POLICY_H__
+#define __DEFAULT_BROWSER_AGENT_POLICY_H__
+
+namespace mozilla::default_agent {
+
+bool IsAgentDisabled();
+bool IsTelemetryDisabled();
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_POLICY_H__
diff --git a/toolkit/mozapps/defaultagent/Registry.cpp b/toolkit/mozapps/defaultagent/Registry.cpp
new file mode 100644
index 0000000000..a2153c50ac
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Registry.cpp
@@ -0,0 +1,330 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "Registry.h"
+
+#include <windows.h>
+#include <shlwapi.h>
+
+#include "common.h"
+#include "EventLog.h"
+#include "UtfConvert.h"
+
+#include "mozilla/Buffer.h"
+#include "mozilla/Try.h"
+
+namespace mozilla::default_agent {
+
+using WStringResult = mozilla::WindowsErrorResult<std::wstring>;
+
+static WStringResult MaybePrefixRegistryValueName(
+ IsPrefixed isPrefixed, const wchar_t* registryValueNameSuffix) {
+ if (isPrefixed == IsPrefixed::Unprefixed) {
+ std::wstring registryValueName = registryValueNameSuffix;
+ return registryValueName;
+ }
+
+ mozilla::UniquePtr<wchar_t[]> installPath = mozilla::GetFullBinaryPath();
+ if (!PathRemoveFileSpecW(installPath.get())) {
+ HRESULT hr = HRESULT_FROM_WIN32(ERROR_BAD_PATHNAME);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+ std::wstring registryValueName(installPath.get());
+ registryValueName.append(L"|");
+ registryValueName.append(registryValueNameSuffix);
+
+ return registryValueName;
+}
+
+// Creates a sub key of AGENT_REGKEY_NAME by appending the passed subKey. If
+// subKey is null, nothing is appended.
+static std::wstring MakeKeyName(const wchar_t* subKey) {
+ std::wstring keyName = AGENT_REGKEY_NAME;
+ if (subKey) {
+ keyName += L"\\";
+ keyName += subKey;
+ }
+ return keyName;
+}
+
+MaybeStringResult RegistryGetValueString(
+ IsPrefixed isPrefixed, const wchar_t* registryValueName,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Get the string size
+ DWORD wideDataSize = 0;
+ LSTATUS ls =
+ RegGetValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ RRF_RT_REG_SZ, nullptr, nullptr, &wideDataSize);
+ if (ls == ERROR_FILE_NOT_FOUND) {
+ return mozilla::Maybe<std::string>(mozilla::Nothing());
+ } else if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ // Convert bytes to characters. The extra character should be unnecessary, but
+ // addresses the possible rounding problem inherent with integer division.
+ DWORD charCount = (wideDataSize / sizeof(wchar_t)) + 1;
+
+ // Read the data from the registry into a wide string
+ mozilla::Buffer<wchar_t> wideData(charCount);
+ ls = RegGetValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ RRF_RT_REG_SZ, nullptr, wideData.Elements(), &wideDataSize);
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ // Convert to narrow string and return.
+ std::string narrowData;
+ MOZ_TRY_VAR(narrowData, Utf16ToUtf8(wideData.Elements()));
+
+ return mozilla::Some(narrowData);
+}
+
+VoidResult RegistrySetValueString(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const char* newValue,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Convert the value from a narrow string to a wide string
+ std::wstring wideValue;
+ MOZ_TRY_VAR(wideValue, Utf8ToUtf16(newValue));
+
+ // Store the value
+ LSTATUS ls = RegSetKeyValueW(HKEY_CURRENT_USER, keyName.c_str(),
+ valueName.c_str(), REG_SZ, wideValue.c_str(),
+ (wideValue.size() + 1) * sizeof(wchar_t));
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Ok();
+}
+
+MaybeBoolResult RegistryGetValueBool(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Read the integer value from the registry
+ DWORD value;
+ DWORD valueSize = sizeof(DWORD);
+ LSTATUS ls =
+ RegGetValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ RRF_RT_REG_DWORD, nullptr, &value, &valueSize);
+ if (ls == ERROR_FILE_NOT_FOUND) {
+ return mozilla::Maybe<bool>(mozilla::Nothing());
+ }
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Some(value != 0);
+}
+
+VoidResult RegistrySetValueBool(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName, bool newValue,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Write the value to the registry
+ DWORD value = newValue ? 1 : 0;
+ LSTATUS ls =
+ RegSetKeyValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ REG_DWORD, &value, sizeof(DWORD));
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Ok();
+}
+
+MaybeQwordResult RegistryGetValueQword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Read the integer value from the registry
+ ULONGLONG value;
+ DWORD valueSize = sizeof(ULONGLONG);
+ LSTATUS ls =
+ RegGetValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ RRF_RT_REG_QWORD, nullptr, &value, &valueSize);
+ if (ls == ERROR_FILE_NOT_FOUND) {
+ return mozilla::Maybe<ULONGLONG>(mozilla::Nothing());
+ }
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Some(value);
+}
+
+VoidResult RegistrySetValueQword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ ULONGLONG newValue,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Write the value to the registry
+ LSTATUS ls =
+ RegSetKeyValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ REG_QWORD, &newValue, sizeof(ULONGLONG));
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Ok();
+}
+
+MaybeDwordResult RegistryGetValueDword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Read the integer value from the registry
+ uint32_t value;
+ DWORD valueSize = sizeof(uint32_t);
+ LSTATUS ls =
+ RegGetValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ RRF_RT_DWORD, nullptr, &value, &valueSize);
+ if (ls == ERROR_FILE_NOT_FOUND) {
+ return mozilla::Maybe<uint32_t>(mozilla::Nothing());
+ }
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Some(value);
+}
+
+VoidResult RegistrySetValueDword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ uint32_t newValue,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ // Write the value to the registry
+ LSTATUS ls =
+ RegSetKeyValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str(),
+ REG_DWORD, &newValue, sizeof(uint32_t));
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Ok();
+}
+
+VoidResult RegistryDeleteValue(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey /* = nullptr */) {
+ // Get the full registry value name
+ WStringResult registryValueNameResult =
+ MaybePrefixRegistryValueName(isPrefixed, registryValueName);
+ if (registryValueNameResult.isErr()) {
+ return mozilla::Err(registryValueNameResult.unwrapErr());
+ }
+ std::wstring valueName = registryValueNameResult.unwrap();
+
+ std::wstring keyName = MakeKeyName(subKey);
+
+ LSTATUS ls =
+ RegDeleteKeyValueW(HKEY_CURRENT_USER, keyName.c_str(), valueName.c_str());
+ if (ls != ERROR_SUCCESS) {
+ HRESULT hr = HRESULT_FROM_WIN32(ls);
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return mozilla::Ok();
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/Registry.h b/toolkit/mozapps/defaultagent/Registry.h
new file mode 100644
index 0000000000..26bea1ae72
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Registry.h
@@ -0,0 +1,100 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_REGISTRY_H__
+#define __DEFAULT_BROWSER_AGENT_REGISTRY_H__
+
+#include <cstdint>
+#include <windows.h>
+
+#include "mozilla/Maybe.h"
+#include "mozilla/Result.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+namespace mozilla::default_agent {
+
+// Indicates whether or not a registry value name is prefixed with the install
+// directory path (Prefixed), or not (Unprefixed). Prefixing a registry value
+// name with the install directory makes that value specific to this
+// installation's default browser agent.
+enum class IsPrefixed {
+ Prefixed,
+ Unprefixed,
+};
+
+// The result of an operation only, containing no other data on success.
+using VoidResult = mozilla::WindowsErrorResult<mozilla::Ok>;
+
+using MaybeString = mozilla::Maybe<std::string>;
+using MaybeStringResult = mozilla::WindowsErrorResult<MaybeString>;
+// Get a string from the registry. If necessary, value name prefixing will be
+// performed automatically.
+// Strings are stored as wide strings, but are converted to narrow UTF-8 before
+// being returned.
+MaybeStringResult RegistryGetValueString(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey = nullptr);
+
+// Set a string in the registry. If necessary, value name prefixing will be
+// performed automatically.
+// Strings are converted to wide strings for registry storage.
+VoidResult RegistrySetValueString(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const char* newValue,
+ const wchar_t* subKey = nullptr);
+
+using MaybeBoolResult = mozilla::WindowsErrorResult<mozilla::Maybe<bool>>;
+// Get a bool from the registry.
+// Bools are stored as a single DWORD, with 0 meaning false and any other value
+// meaning true.
+MaybeBoolResult RegistryGetValueBool(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey = nullptr);
+
+// Set a bool in the registry. If necessary, value name prefixing will be
+// performed automatically.
+// Bools are stored as a single DWORD, with 0 meaning false and any other value
+// meaning true.
+VoidResult RegistrySetValueBool(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName, bool newValue,
+ const wchar_t* subKey = nullptr);
+
+using MaybeQwordResult = mozilla::WindowsErrorResult<mozilla::Maybe<ULONGLONG>>;
+// Get a QWORD (ULONGLONG) from the registry. If necessary, value name prefixing
+// will be performed automatically.
+MaybeQwordResult RegistryGetValueQword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey = nullptr);
+
+// Get a QWORD (ULONGLONG) in the registry. If necessary, value name prefixing
+// will be performed automatically.
+VoidResult RegistrySetValueQword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ ULONGLONG newValue,
+ const wchar_t* subKey = nullptr);
+
+using MaybeDword = mozilla::Maybe<uint32_t>;
+using MaybeDwordResult = mozilla::WindowsErrorResult<MaybeDword>;
+// Get a DWORD (uint32_t) from the registry. If necessary, value name prefixing
+// will be performed automatically.
+MaybeDwordResult RegistryGetValueDword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey = nullptr);
+
+// Get a DWORD (uint32_t) in the registry. If necessary, value name prefixing
+// will be performed automatically.
+VoidResult RegistrySetValueDword(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ uint32_t newValue,
+ const wchar_t* subKey = nullptr);
+
+VoidResult RegistryDeleteValue(IsPrefixed isPrefixed,
+ const wchar_t* registryValueName,
+ const wchar_t* subKey = nullptr);
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_REGISTRY_H__
diff --git a/toolkit/mozapps/defaultagent/ScheduledTask.cpp b/toolkit/mozapps/defaultagent/ScheduledTask.cpp
new file mode 100644
index 0000000000..a9cd647c03
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/ScheduledTask.cpp
@@ -0,0 +1,328 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "ScheduledTask.h"
+#include "ScheduledTaskRemove.h"
+
+#include <string>
+#include <time.h>
+
+#include <comutil.h>
+#include <taskschd.h>
+
+#include "readstrings.h"
+#include "updatererrors.h"
+#include "EventLog.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/ScopeExit.h"
+#include "mozilla/UniquePtr.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+#include "WindowsDefaultBrowser.h"
+
+#include "DefaultBrowser.h"
+
+#include "mozilla/ErrorResult.h"
+#include "mozilla/intl/Localization.h"
+#include "nsString.h"
+#include "nsTArray.h"
+using mozilla::intl::Localization;
+
+namespace mozilla::default_agent {
+
+// The task scheduler requires its time values to come in the form of a string
+// in the format YYYY-MM-DDTHH:MM:SSZ. This format string is used to get that
+// out of the C library wcsftime function.
+const wchar_t* kTimeFormat = L"%Y-%m-%dT%H:%M:%SZ";
+// The expanded time string should always be this length, for example:
+// 2020-02-12T16:59:32Z
+const size_t kTimeStrMaxLen = 20;
+
+#define ENSURE(x) \
+ if (FAILED(hr = (x))) { \
+ LOG_ERROR(hr); \
+ return hr; \
+ }
+
+bool GetTaskDescription(mozilla::UniquePtr<wchar_t[]>& description) {
+ mozilla::UniquePtr<wchar_t[]> installPath;
+ bool success = GetInstallDirectory(installPath);
+ if (!success) {
+ LOG_ERROR_MESSAGE(L"Failed to get install directory");
+ return false;
+ }
+ nsTArray<nsCString> resIds = {"branding/brand.ftl"_ns,
+ "browser/backgroundtasks/defaultagent.ftl"_ns};
+ RefPtr<Localization> l10n = Localization::Create(resIds, true);
+ nsAutoCString daTaskDesc;
+ mozilla::ErrorResult rv;
+ l10n->FormatValueSync("default-browser-agent-task-description"_ns, {},
+ daTaskDesc, rv);
+ if (rv.Failed()) {
+ LOG_ERROR_MESSAGE(L"Failed to read task description");
+ return false;
+ }
+ NS_ConvertUTF8toUTF16 daTaskDescW(daTaskDesc);
+ description = mozilla::MakeUnique<wchar_t[]>(daTaskDescW.Length() + 1);
+ wcsncpy(description.get(), daTaskDescW.get(), daTaskDescW.Length() + 1);
+ return true;
+}
+
+HRESULT RegisterTask(const wchar_t* uniqueToken,
+ BSTR startTime /* = nullptr */) {
+ // Do data migration during the task installation. This might seem like it
+ // belongs in UpdateTask, but we want to be able to call
+ // RemoveTasks();
+ // RegisterTask();
+ // and still have data migration happen. Also, UpdateTask calls this function,
+ // so migration will still get run in that case.
+ MaybeMigrateCurrentDefault();
+
+ // Make sure we don't try to register a task that already exists.
+ RemoveTasks(uniqueToken, WhichTasks::WdbaTaskOnly);
+
+ // If we create a folder and then fail to create the task, we need to
+ // remember to delete the folder so that whatever set of permissions it ends
+ // up with doesn't interfere with trying to create the task again later, and
+ // so that we don't just leave an empty folder behind.
+ bool createdFolder = false;
+
+ HRESULT hr = S_OK;
+ RefPtr<ITaskService> scheduler;
+ ENSURE(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER,
+ IID_ITaskService, getter_AddRefs(scheduler)));
+
+ ENSURE(scheduler->Connect(VARIANT{}, VARIANT{}, VARIANT{}, VARIANT{}));
+
+ RefPtr<ITaskFolder> rootFolder;
+ BStrPtr rootFolderBStr = BStrPtr(SysAllocString(L"\\"));
+ ENSURE(
+ scheduler->GetFolder(rootFolderBStr.get(), getter_AddRefs(rootFolder)));
+
+ RefPtr<ITaskFolder> taskFolder;
+ BStrPtr vendorBStr = BStrPtr(SysAllocString(kTaskVendor));
+ if (FAILED(rootFolder->GetFolder(vendorBStr.get(),
+ getter_AddRefs(taskFolder)))) {
+ hr = rootFolder->CreateFolder(vendorBStr.get(), VARIANT{},
+ getter_AddRefs(taskFolder));
+
+ if (SUCCEEDED(hr)) {
+ createdFolder = true;
+ } else if (hr == HRESULT_FROM_WIN32(ERROR_ALREADY_EXISTS)) {
+ // `CreateFolder` doesn't assign to the out pointer on
+ // `ERROR_ALREADY_EXISTS`, so try to get the folder again. This behavior
+ // is undocumented but was verified in a debugger.
+ HRESULT priorHr = hr;
+ hr = rootFolder->GetFolder(vendorBStr.get(), getter_AddRefs(taskFolder));
+
+ if (FAILED(hr)) {
+ LOG_ERROR(priorHr);
+ LOG_ERROR(hr);
+ return hr;
+ }
+ } else {
+ LOG_ERROR(hr);
+ return hr;
+ }
+ }
+
+ auto cleanupFolder =
+ mozilla::MakeScopeExit([&hr, createdFolder, &rootFolder, &vendorBStr] {
+ if (createdFolder && FAILED(hr)) {
+ // If this fails, we can't really handle that intelligently, so
+ // don't even bother to check the return code.
+ rootFolder->DeleteFolder(vendorBStr.get(), 0);
+ }
+ });
+
+ RefPtr<ITaskDefinition> newTask;
+ ENSURE(scheduler->NewTask(0, getter_AddRefs(newTask)));
+
+ mozilla::UniquePtr<wchar_t[]> description;
+ if (!GetTaskDescription(description)) {
+ return E_FAIL;
+ }
+ BStrPtr descriptionBstr = BStrPtr(SysAllocString(description.get()));
+
+ RefPtr<IRegistrationInfo> taskRegistration;
+ ENSURE(newTask->get_RegistrationInfo(getter_AddRefs(taskRegistration)));
+ ENSURE(taskRegistration->put_Description(descriptionBstr.get()));
+
+ RefPtr<ITaskSettings> taskSettings;
+ ENSURE(newTask->get_Settings(getter_AddRefs(taskSettings)));
+ ENSURE(taskSettings->put_DisallowStartIfOnBatteries(VARIANT_FALSE));
+ ENSURE(taskSettings->put_MultipleInstances(TASK_INSTANCES_IGNORE_NEW));
+ ENSURE(taskSettings->put_StartWhenAvailable(VARIANT_TRUE));
+ ENSURE(taskSettings->put_StopIfGoingOnBatteries(VARIANT_FALSE));
+ // This cryptic string means "12 hours 5 minutes". So, if the task runs for
+ // longer than that, the process will be killed, because that should never
+ // happen. See
+ // https://docs.microsoft.com/en-us/windows/win32/taskschd/tasksettings-executiontimelimit
+ // for a detailed explanation of these strings.
+ BStrPtr execTimeLimitBStr = BStrPtr(SysAllocString(L"PT12H5M"));
+ ENSURE(taskSettings->put_ExecutionTimeLimit(execTimeLimitBStr.get()));
+
+ RefPtr<IRegistrationInfo> regInfo;
+ ENSURE(newTask->get_RegistrationInfo(getter_AddRefs(regInfo)));
+
+ ENSURE(regInfo->put_Author(vendorBStr.get()));
+
+ RefPtr<ITriggerCollection> triggers;
+ ENSURE(newTask->get_Triggers(getter_AddRefs(triggers)));
+
+ RefPtr<ITrigger> newTrigger;
+ ENSURE(triggers->Create(TASK_TRIGGER_DAILY, getter_AddRefs(newTrigger)));
+
+ RefPtr<IDailyTrigger> dailyTrigger;
+ ENSURE(newTrigger->QueryInterface(IID_IDailyTrigger,
+ getter_AddRefs(dailyTrigger)));
+
+ if (startTime) {
+ ENSURE(dailyTrigger->put_StartBoundary(startTime));
+ } else {
+ // The time that the task is scheduled to run at every day is taken from the
+ // time in the trigger's StartBoundary property. We'll set this to the
+ // current time, on the theory that the time at which we're being installed
+ // is a time that the computer is likely to be on other days. If our
+ // theory is wrong and the computer is offline at the scheduled time, then
+ // because we've set StartWhenAvailable above, the task will run whenever
+ // it wakes up. Since our task is entirely in the background and doesn't use
+ // a lot of resources, we're not concerned about it bothering the user if it
+ // runs while they're actively using this computer.
+ time_t now_t = time(nullptr);
+ // Subtract a minute from the current time, to avoid "winning" a potential
+ // race with the scheduler that might have it start the task immediately
+ // after we register it, if we finish doing that and then it evaluates the
+ // trigger during the same second. We haven't seen this happen in practice,
+ // but there's no documented guarantee that it won't, so let's be sure.
+ now_t -= 60;
+
+ tm now_tm;
+ errno_t errno_rv = gmtime_s(&now_tm, &now_t);
+ if (errno_rv != 0) {
+ // The C runtime has a (private) function to convert Win32 error codes to
+ // errno values, but there's nothing that goes the other way, and it
+ // isn't worth including one here for something that's this unlikely to
+ // fail anyway. So just return a generic error.
+ hr = HRESULT_FROM_WIN32(ERROR_INVALID_TIME);
+ LOG_ERROR(hr);
+ return hr;
+ }
+
+ mozilla::UniquePtr<wchar_t[]> timeStr =
+ mozilla::MakeUnique<wchar_t[]>(kTimeStrMaxLen + 1);
+
+ if (wcsftime(timeStr.get(), kTimeStrMaxLen + 1, kTimeFormat, &now_tm) ==
+ 0) {
+ hr = E_NOT_SUFFICIENT_BUFFER;
+ LOG_ERROR(hr);
+ return hr;
+ }
+
+ BStrPtr startTimeBStr = BStrPtr(SysAllocString(timeStr.get()));
+ ENSURE(dailyTrigger->put_StartBoundary(startTimeBStr.get()));
+ }
+
+ ENSURE(dailyTrigger->put_DaysInterval(1));
+
+ RefPtr<IActionCollection> actions;
+ ENSURE(newTask->get_Actions(getter_AddRefs(actions)));
+
+ RefPtr<IAction> action;
+ ENSURE(actions->Create(TASK_ACTION_EXEC, getter_AddRefs(action)));
+
+ RefPtr<IExecAction> execAction;
+ ENSURE(action->QueryInterface(IID_IExecAction, getter_AddRefs(execAction)));
+
+ // Register proxy instead of Firefox background task.
+ mozilla::UniquePtr<wchar_t[]> installPath = mozilla::GetFullBinaryPath();
+ if (!PathRemoveFileSpecW(installPath.get())) {
+ return E_FAIL;
+ }
+ std::wstring proxyPath(installPath.get());
+ proxyPath += L"\\default-browser-agent.exe";
+
+ BStrPtr binaryPathBStr = BStrPtr(SysAllocString(proxyPath.c_str()));
+ ENSURE(execAction->put_Path(binaryPathBStr.get()));
+
+ std::wstring taskArgs = L"do-task \"";
+ taskArgs += uniqueToken;
+ taskArgs += L"\"";
+ BStrPtr argsBStr = BStrPtr(SysAllocString(taskArgs.c_str()));
+ ENSURE(execAction->put_Arguments(argsBStr.get()));
+
+ std::wstring taskName(kTaskName);
+ taskName += uniqueToken;
+ BStrPtr taskNameBStr = BStrPtr(SysAllocString(taskName.c_str()));
+
+ RefPtr<IRegisteredTask> registeredTask;
+ ENSURE(taskFolder->RegisterTaskDefinition(
+ taskNameBStr.get(), newTask, TASK_CREATE_OR_UPDATE, VARIANT{}, VARIANT{},
+ TASK_LOGON_INTERACTIVE_TOKEN, VARIANT{}, getter_AddRefs(registeredTask)));
+
+ return hr;
+}
+
+HRESULT UpdateTask(const wchar_t* uniqueToken) {
+ RefPtr<ITaskService> scheduler;
+ HRESULT hr = S_OK;
+ ENSURE(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER,
+ IID_ITaskService, getter_AddRefs(scheduler)));
+
+ ENSURE(scheduler->Connect(VARIANT{}, VARIANT{}, VARIANT{}, VARIANT{}));
+
+ RefPtr<ITaskFolder> taskFolder;
+ BStrPtr folderBStr = BStrPtr(SysAllocString(kTaskVendor));
+
+ if (FAILED(
+ scheduler->GetFolder(folderBStr.get(), getter_AddRefs(taskFolder)))) {
+ // If our folder doesn't exist, create it and the task.
+ return RegisterTask(uniqueToken);
+ }
+
+ std::wstring taskName(kTaskName);
+ taskName += uniqueToken;
+ BStrPtr taskNameBStr = BStrPtr(SysAllocString(taskName.c_str()));
+
+ RefPtr<IRegisteredTask> task;
+ if (FAILED(taskFolder->GetTask(taskNameBStr.get(), getter_AddRefs(task)))) {
+ // If our task doesn't exist at all, just create one.
+ return RegisterTask(uniqueToken);
+ }
+
+ // If we have a task registered already, we need to recreate it because
+ // something might have changed that we need to update. But we don't
+ // want to restart the schedule from now, because that might mean the
+ // task never runs at all for e.g. Nightly. So create a new task, but
+ // first get and preserve the existing trigger.
+ RefPtr<ITaskDefinition> definition;
+ if (FAILED(task->get_Definition(getter_AddRefs(definition)))) {
+ // This task is broken, make a new one.
+ return RegisterTask(uniqueToken);
+ }
+
+ RefPtr<ITriggerCollection> triggerList;
+ if (FAILED(definition->get_Triggers(getter_AddRefs(triggerList)))) {
+ // This task is broken, make a new one.
+ return RegisterTask(uniqueToken);
+ }
+
+ RefPtr<ITrigger> trigger;
+ if (FAILED(triggerList->get_Item(1, getter_AddRefs(trigger)))) {
+ // This task is broken, make a new one.
+ return RegisterTask(uniqueToken);
+ }
+
+ BSTR startTimeBstr;
+ if (FAILED(trigger->get_StartBoundary(&startTimeBstr))) {
+ // This task is broken, make a new one.
+ return RegisterTask(uniqueToken);
+ }
+ BStrPtr startTime(startTimeBstr);
+
+ return RegisterTask(uniqueToken, startTime.get());
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/ScheduledTask.h b/toolkit/mozapps/defaultagent/ScheduledTask.h
new file mode 100644
index 0000000000..a3709823ad
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/ScheduledTask.h
@@ -0,0 +1,23 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_SCHEDULED_TASK_H__
+#define __DEFAULT_BROWSER_AGENT_SCHEDULED_TASK_H__
+
+#include <windows.h>
+#include <wtypes.h>
+
+namespace mozilla::default_agent {
+
+// uniqueToken should be a string unique to the installation, so that a
+// separate task can be created for each installation. Typically this will be
+// the install hash string.
+HRESULT RegisterTask(const wchar_t* uniqueToken, BSTR startTime = nullptr);
+HRESULT UpdateTask(const wchar_t* uniqueToken);
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_SCHEDULED_TASK_H__
diff --git a/toolkit/mozapps/defaultagent/ScheduledTaskRemove.cpp b/toolkit/mozapps/defaultagent/ScheduledTaskRemove.cpp
new file mode 100644
index 0000000000..e672a813e3
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/ScheduledTaskRemove.cpp
@@ -0,0 +1,126 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "ScheduledTaskRemove.h"
+
+#include <string>
+
+#include <comutil.h>
+#include <taskschd.h>
+
+#include "EventLog.h"
+#include "mozilla/RefPtr.h"
+
+namespace mozilla::default_agent {
+
+#define ENSURE(x) \
+ if (FAILED(hr = (x))) { \
+ LOG_ERROR(hr); \
+ return hr; \
+ }
+
+bool EndsWith(const wchar_t* string, const wchar_t* suffix) {
+ size_t string_len = wcslen(string);
+ size_t suffix_len = wcslen(suffix);
+ if (suffix_len > string_len) {
+ return false;
+ }
+ const wchar_t* substring = string + string_len - suffix_len;
+ return wcscmp(substring, suffix) == 0;
+}
+
+HRESULT RemoveTasks(const wchar_t* uniqueToken, WhichTasks tasksToRemove) {
+ if (!uniqueToken || wcslen(uniqueToken) == 0) {
+ return E_INVALIDARG;
+ }
+
+ RefPtr<ITaskService> scheduler;
+ HRESULT hr = S_OK;
+ ENSURE(CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER,
+ IID_ITaskService, getter_AddRefs(scheduler)));
+
+ ENSURE(scheduler->Connect(VARIANT{}, VARIANT{}, VARIANT{}, VARIANT{}));
+
+ RefPtr<ITaskFolder> taskFolder;
+ BStrPtr folderBStr(SysAllocString(kTaskVendor));
+
+ hr = scheduler->GetFolder(folderBStr.get(), getter_AddRefs(taskFolder));
+ if (FAILED(hr)) {
+ if (hr == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND)) {
+ // Don't return an error code if our folder doesn't exist,
+ // because that just means it's been removed already.
+ return S_OK;
+ } else {
+ return hr;
+ }
+ }
+
+ RefPtr<IRegisteredTaskCollection> tasksInFolder;
+ ENSURE(taskFolder->GetTasks(TASK_ENUM_HIDDEN, getter_AddRefs(tasksInFolder)));
+
+ LONG numTasks = 0;
+ ENSURE(tasksInFolder->get_Count(&numTasks));
+
+ std::wstring WdbaTaskName(kTaskName);
+ WdbaTaskName += uniqueToken;
+
+ // This will be set to the last error that we encounter while deleting tasks.
+ // This allows us to keep attempting to remove the remaining tasks, even if
+ // we encounter an error, while still preserving what error we encountered so
+ // we can return it from this function.
+ HRESULT deleteResult = S_OK;
+ // Set to true if we intentionally skip any tasks.
+ bool tasksSkipped = false;
+
+ for (LONG i = 0; i < numTasks; ++i) {
+ RefPtr<IRegisteredTask> task;
+ // IRegisteredTaskCollection's are 1-indexed.
+ hr = tasksInFolder->get_Item(_variant_t(i + 1), getter_AddRefs(task));
+ if (FAILED(hr)) {
+ deleteResult = hr;
+ continue;
+ }
+
+ BSTR taskName;
+ hr = task->get_Name(&taskName);
+ if (FAILED(hr)) {
+ deleteResult = hr;
+ continue;
+ }
+ // Automatically free taskName when we are done with it.
+ BStrPtr uniqueTaskName(taskName);
+
+ if (tasksToRemove == WhichTasks::WdbaTaskOnly) {
+ if (WdbaTaskName.compare(taskName) != 0) {
+ tasksSkipped = true;
+ continue;
+ }
+ } else { // tasksToRemove == WhichTasks::AllTasksForInstallation
+ if (!EndsWith(taskName, uniqueToken)) {
+ tasksSkipped = true;
+ continue;
+ }
+ }
+
+ hr = taskFolder->DeleteTask(taskName, 0 /* flags */);
+ if (FAILED(hr)) {
+ deleteResult = hr;
+ }
+ }
+
+ // If we successfully removed all the tasks, delete the folder too.
+ if (!tasksSkipped && SUCCEEDED(deleteResult)) {
+ RefPtr<ITaskFolder> rootFolder;
+ BStrPtr rootFolderBStr = BStrPtr(SysAllocString(L"\\"));
+ ENSURE(
+ scheduler->GetFolder(rootFolderBStr.get(), getter_AddRefs(rootFolder)));
+ ENSURE(rootFolder->DeleteFolder(folderBStr.get(), 0));
+ }
+
+ return deleteResult;
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/ScheduledTaskRemove.h b/toolkit/mozapps/defaultagent/ScheduledTaskRemove.h
new file mode 100644
index 0000000000..17fd75d5e1
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/ScheduledTaskRemove.h
@@ -0,0 +1,37 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_SCHEDULED_TASK_REMOVE_H__
+#define __DEFAULT_BROWSER_AGENT_SCHEDULED_TASK_REMOVE_H__
+
+#include <windows.h>
+#include <wtypes.h>
+
+#include <oleauto.h>
+
+#include "mozilla/UniquePtr.h"
+
+namespace mozilla::default_agent {
+
+struct SysFreeStringDeleter {
+ void operator()(BSTR aPtr) { ::SysFreeString(aPtr); }
+};
+using BStrPtr = mozilla::UniquePtr<OLECHAR, SysFreeStringDeleter>;
+
+static const wchar_t* kTaskVendor = L"" MOZ_APP_VENDOR;
+// kTaskName should have the unique token appended before being used.
+static const wchar_t* kTaskName =
+ L"" MOZ_APP_DISPLAYNAME " Default Browser Agent ";
+
+enum class WhichTasks {
+ WdbaTaskOnly,
+ AllTasksForInstallation,
+};
+HRESULT RemoveTasks(const wchar_t* uniqueToken, WhichTasks tasksToRemove);
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_SCHEDULED_TASK_REMOVE_H__
diff --git a/toolkit/mozapps/defaultagent/SetDefaultBrowser.cpp b/toolkit/mozapps/defaultagent/SetDefaultBrowser.cpp
new file mode 100644
index 0000000000..8bc0889e67
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/SetDefaultBrowser.cpp
@@ -0,0 +1,347 @@
+/* -*- 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 <windows.h>
+#include <appmodel.h>
+#include <shlobj.h> // for SHChangeNotify and IApplicationAssociationRegistration
+#include <functional>
+#include <timeapi.h>
+
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/CmdLineAndEnvUtils.h"
+#include "mozilla/RefPtr.h"
+#include "mozilla/UniquePtr.h"
+#include "mozilla/WindowsVersion.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+#include "WindowsUserChoice.h"
+#include "nsThreadUtils.h"
+
+#include "EventLog.h"
+#include "SetDefaultBrowser.h"
+
+namespace mozilla::default_agent {
+
+/*
+ * The implementation for setting extension handlers by writing UserChoice.
+ *
+ * This is used by both SetDefaultBrowserUserChoice and
+ * SetDefaultExtensionHandlersUserChoice.
+ *
+ * @param aAumi The AUMI of the installation to set as default.
+ *
+ * @param aSid Current user's string SID
+ *
+ * @param aExtraFileExtensions Optional array of extra file association pairs to
+ * set as default, like `[ ".pdf", "FirefoxPDF" ]`.
+ *
+ * @returns NS_OK All associations set and checked
+ * successfully.
+ * NS_ERROR_WDBA_REJECTED UserChoice was set, but checking the default
+ * did not return our ProgID.
+ * NS_ERROR_FAILURE Failed to set at least one association.
+ */
+static nsresult SetDefaultExtensionHandlersUserChoiceImpl(
+ const wchar_t* aAumi, const wchar_t* const aSid,
+ const nsTArray<nsString>& aFileExtensions);
+
+static bool AddMillisecondsToSystemTime(SYSTEMTIME& aSystemTime,
+ ULONGLONG aIncrementMS) {
+ FILETIME fileTime;
+ ULARGE_INTEGER fileTimeInt;
+ if (!::SystemTimeToFileTime(&aSystemTime, &fileTime)) {
+ return false;
+ }
+ fileTimeInt.LowPart = fileTime.dwLowDateTime;
+ fileTimeInt.HighPart = fileTime.dwHighDateTime;
+
+ // FILETIME is in units of 100ns.
+ fileTimeInt.QuadPart += aIncrementMS * 1000 * 10;
+
+ fileTime.dwLowDateTime = fileTimeInt.LowPart;
+ fileTime.dwHighDateTime = fileTimeInt.HighPart;
+ SYSTEMTIME tmpSystemTime;
+ if (!::FileTimeToSystemTime(&fileTime, &tmpSystemTime)) {
+ return false;
+ }
+
+ aSystemTime = tmpSystemTime;
+ return true;
+}
+
+// Compare two SYSTEMTIMEs as FILETIME after clearing everything
+// below minutes.
+static bool CheckEqualMinutes(SYSTEMTIME aSystemTime1,
+ SYSTEMTIME aSystemTime2) {
+ aSystemTime1.wSecond = 0;
+ aSystemTime1.wMilliseconds = 0;
+
+ aSystemTime2.wSecond = 0;
+ aSystemTime2.wMilliseconds = 0;
+
+ FILETIME fileTime1;
+ FILETIME fileTime2;
+ if (!::SystemTimeToFileTime(&aSystemTime1, &fileTime1) ||
+ !::SystemTimeToFileTime(&aSystemTime2, &fileTime2)) {
+ return false;
+ }
+
+ return (fileTime1.dwLowDateTime == fileTime2.dwLowDateTime) &&
+ (fileTime1.dwHighDateTime == fileTime2.dwHighDateTime);
+}
+
+static bool SetUserChoiceRegistry(const wchar_t* aExt, const wchar_t* aProgID,
+ mozilla::UniquePtr<wchar_t[]> aHash) {
+ auto assocKeyPath = GetAssociationKeyPath(aExt);
+ if (!assocKeyPath) {
+ return false;
+ }
+
+ LSTATUS ls;
+ HKEY rawAssocKey;
+ ls = ::RegOpenKeyExW(HKEY_CURRENT_USER, assocKeyPath.get(), 0,
+ KEY_READ | KEY_WRITE, &rawAssocKey);
+ if (ls != ERROR_SUCCESS) {
+ LOG_ERROR(HRESULT_FROM_WIN32(ls));
+ return false;
+ }
+ nsAutoRegKey assocKey(rawAssocKey);
+
+ // When Windows creates this key, it is read-only (Deny Set Value), so we need
+ // to delete it first.
+ // We don't set any similar special permissions.
+ ls = ::RegDeleteKeyW(assocKey.get(), L"UserChoice");
+ if (ls != ERROR_SUCCESS) {
+ LOG_ERROR(HRESULT_FROM_WIN32(ls));
+ return false;
+ }
+
+ HKEY rawUserChoiceKey;
+ ls = ::RegCreateKeyExW(assocKey.get(), L"UserChoice", 0, nullptr,
+ 0 /* options */, KEY_READ | KEY_WRITE,
+ 0 /* security attributes */, &rawUserChoiceKey,
+ nullptr);
+ if (ls != ERROR_SUCCESS) {
+ LOG_ERROR(HRESULT_FROM_WIN32(ls));
+ return false;
+ }
+ nsAutoRegKey userChoiceKey(rawUserChoiceKey);
+
+ DWORD progIdByteCount = (::lstrlenW(aProgID) + 1) * sizeof(wchar_t);
+ ls = ::RegSetValueExW(userChoiceKey.get(), L"ProgID", 0, REG_SZ,
+ reinterpret_cast<const unsigned char*>(aProgID),
+ progIdByteCount);
+ if (ls != ERROR_SUCCESS) {
+ LOG_ERROR(HRESULT_FROM_WIN32(ls));
+ return false;
+ }
+
+ DWORD hashByteCount = (::lstrlenW(aHash.get()) + 1) * sizeof(wchar_t);
+ ls = ::RegSetValueExW(userChoiceKey.get(), L"Hash", 0, REG_SZ,
+ reinterpret_cast<const unsigned char*>(aHash.get()),
+ hashByteCount);
+ if (ls != ERROR_SUCCESS) {
+ LOG_ERROR(HRESULT_FROM_WIN32(ls));
+ return false;
+ }
+
+ return true;
+}
+
+/*
+ * Set an association with a UserChoice key
+ *
+ * Removes the old key, creates a new one with ProgID and Hash set to
+ * enable a new asociation.
+ *
+ * @param aExt File type or protocol to associate
+ * @param aSid Current user's string SID
+ * @param aProgID ProgID to use for the asociation
+ * @param inMsix Are we running from in an msix package?
+ *
+ * @return true if successful, false on error.
+ */
+static bool SetUserChoice(const wchar_t* aExt, const wchar_t* aSid,
+ const wchar_t* aProgID, bool inMsix) {
+ if (inMsix) {
+ LOG_ERROR_MESSAGE(L"SetUserChoice does not work on MSIX builds.");
+ return false;
+ }
+
+ SYSTEMTIME hashTimestamp;
+ ::GetSystemTime(&hashTimestamp);
+ auto hash = GenerateUserChoiceHash(aExt, aSid, aProgID, hashTimestamp);
+ if (!hash) {
+ return false;
+ }
+
+ // The hash changes at the end of each minute, so check that the hash should
+ // be the same by the time we're done writing.
+ const ULONGLONG kWriteTimingThresholdMilliseconds = 1000;
+ // Generating the hash could have taken some time, so start from now.
+ SYSTEMTIME writeEndTimestamp;
+ ::GetSystemTime(&writeEndTimestamp);
+ if (!AddMillisecondsToSystemTime(writeEndTimestamp,
+ kWriteTimingThresholdMilliseconds)) {
+ return false;
+ }
+ if (!CheckEqualMinutes(hashTimestamp, writeEndTimestamp)) {
+ LOG_ERROR_MESSAGE(
+ L"Hash is too close to expiration, sleeping until next hash.");
+ ::Sleep(kWriteTimingThresholdMilliseconds * 2);
+
+ // For consistency, use the current time.
+ ::GetSystemTime(&hashTimestamp);
+ hash = GenerateUserChoiceHash(aExt, aSid, aProgID, hashTimestamp);
+ if (!hash) {
+ return false;
+ }
+ }
+
+ // We're outside of an MSIX package and can use the Win32 Registry API.
+ return SetUserChoiceRegistry(aExt, aProgID, std::move(hash));
+}
+
+static bool VerifyUserDefault(const wchar_t* aExt, const wchar_t* aProgID) {
+ RefPtr<IApplicationAssociationRegistration> pAAR;
+ HRESULT hr = ::CoCreateInstance(
+ CLSID_ApplicationAssociationRegistration, nullptr, CLSCTX_INPROC,
+ IID_IApplicationAssociationRegistration, getter_AddRefs(pAAR));
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return false;
+ }
+
+ wchar_t* rawRegisteredApp;
+ bool isProtocol = aExt[0] != L'.';
+ // Note: Checks AL_USER instead of AL_EFFECTIVE.
+ hr = pAAR->QueryCurrentDefault(aExt,
+ isProtocol ? AT_URLPROTOCOL : AT_FILEEXTENSION,
+ AL_USER, &rawRegisteredApp);
+ if (FAILED(hr)) {
+ if (hr == HRESULT_FROM_WIN32(ERROR_NO_ASSOCIATION)) {
+ LOG_ERROR_MESSAGE(L"UserChoice ProgID %s for %s was rejected", aProgID,
+ aExt);
+ } else {
+ LOG_ERROR(hr);
+ }
+ return false;
+ }
+ mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter> registeredApp(
+ rawRegisteredApp);
+
+ if (::CompareStringOrdinal(registeredApp.get(), -1, aProgID, -1, FALSE) !=
+ CSTR_EQUAL) {
+ LOG_ERROR_MESSAGE(
+ L"Default was %s after writing ProgID %s to UserChoice for %s",
+ registeredApp.get(), aProgID, aExt);
+ return false;
+ }
+
+ return true;
+}
+
+nsresult SetDefaultBrowserUserChoice(
+ const wchar_t* aAumi, const nsTArray<nsString>& aExtraFileExtensions) {
+ // Verify that the implementation of UserChoice hashing has not changed by
+ // computing the current default hash and comparing with the existing value.
+ if (!CheckBrowserUserChoiceHashes()) {
+ LOG_ERROR_MESSAGE(L"UserChoice Hash mismatch");
+ return NS_ERROR_WDBA_HASH_CHECK;
+ }
+
+ if (!mozilla::IsWin10CreatorsUpdateOrLater()) {
+ LOG_ERROR_MESSAGE(L"UserChoice hash matched, but Windows build is too old");
+ return NS_ERROR_WDBA_BUILD;
+ }
+
+ auto sid = GetCurrentUserStringSid();
+ if (!sid) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsTArray<nsString> browserDefaults = {
+ u"https"_ns, u"FirefoxURL"_ns, u"http"_ns, u"FirefoxURL"_ns,
+ u".html"_ns, u"FirefoxHTML"_ns, u".htm"_ns, u"FirefoxHTML"_ns};
+
+ browserDefaults.AppendElements(aExtraFileExtensions);
+
+ nsresult rv = SetDefaultExtensionHandlersUserChoiceImpl(aAumi, sid.get(),
+ browserDefaults);
+ if (!NS_SUCCEEDED(rv)) {
+ LOG_ERROR_MESSAGE(L"Failed setting default with %s", aAumi);
+ }
+
+ // Notify shell to refresh icons
+ ::SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr);
+
+ return rv;
+}
+
+nsresult SetDefaultExtensionHandlersUserChoice(
+ const wchar_t* aAumi, const nsTArray<nsString>& aFileExtensions) {
+ auto sid = GetCurrentUserStringSid();
+ if (!sid) {
+ return NS_ERROR_FAILURE;
+ }
+
+ nsresult rv = SetDefaultExtensionHandlersUserChoiceImpl(aAumi, sid.get(),
+ aFileExtensions);
+ if (!NS_SUCCEEDED(rv)) {
+ LOG_ERROR_MESSAGE(L"Failed setting default with %s", aAumi);
+ }
+
+ // Notify shell to refresh icons
+ ::SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr);
+
+ return rv;
+}
+
+nsresult SetDefaultExtensionHandlersUserChoiceImpl(
+ const wchar_t* aAumi, const wchar_t* const aSid,
+ const nsTArray<nsString>& aFileExtensions) {
+ UINT32 pfnLen = 0;
+ bool inMsix =
+ GetCurrentPackageFullName(&pfnLen, nullptr) != APPMODEL_ERROR_NO_PACKAGE;
+
+ if (inMsix) {
+ // MSIX packages can not meaningfully modify the registry keys related to
+ // default handlers
+ return NS_ERROR_FAILURE;
+ }
+
+ for (size_t i = 0; i + 1 < aFileExtensions.Length(); i += 2) {
+ const wchar_t* extraFileExtension = aFileExtensions[i].get();
+ const wchar_t* extraProgIDRoot = aFileExtensions[i + 1].get();
+ // Formatting the ProgID here prevents using this helper to target arbitrary
+ // ProgIDs.
+ mozilla::UniquePtr<wchar_t[]> extraProgID;
+ if (inMsix) {
+ nsresult rv = GetMsixProgId(extraFileExtension, extraProgID);
+ if (NS_FAILED(rv)) {
+ LOG_ERROR_MESSAGE(L"Failed to retrieve MSIX progID for %s",
+ extraFileExtension);
+ return rv;
+ }
+ } else {
+ extraProgID = FormatProgID(extraProgIDRoot, aAumi);
+ if (!CheckProgIDExists(extraProgID.get())) {
+ LOG_ERROR_MESSAGE(L"ProgID %s not found", extraProgID.get());
+ return NS_ERROR_WDBA_NO_PROGID;
+ }
+ }
+
+ if (!SetUserChoice(extraFileExtension, aSid, extraProgID.get(), inMsix)) {
+ return NS_ERROR_FAILURE;
+ }
+
+ if (!VerifyUserDefault(extraFileExtension, extraProgID.get())) {
+ return NS_ERROR_WDBA_REJECTED;
+ }
+ }
+
+ return NS_OK;
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/SetDefaultBrowser.h b/toolkit/mozapps/defaultagent/SetDefaultBrowser.h
new file mode 100644
index 0000000000..bb33365058
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/SetDefaultBrowser.h
@@ -0,0 +1,66 @@
+/* -*- 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 DEFAULT_BROWSER_SET_DEFAULT_BROWSER_H__
+#define DEFAULT_BROWSER_SET_DEFAULT_BROWSER_H__
+
+#include "nsStringFwd.h"
+#include "nsArray.h"
+#include <functional>
+
+namespace mozilla::default_agent {
+
+/*
+ * Set the default browser by writing the UserChoice registry keys.
+ *
+ * This sets the associations for https, http, .html, and .htm, and
+ * optionally for additional extra file extensions.
+ *
+ * When the agent is run with set-default-browser-user-choice,
+ * the exit code is the result of this function.
+ *
+ * @param aAumi The AUMI of the installation to set as default.
+ *
+ * @param aExtraFileExtensions Optional array of extra file association pairs to
+ * set as default, like `[ ".pdf", "FirefoxPDF" ]`.
+ *
+ * @return NS_OK All associations set and checked
+ * successfully.
+ * NS_ERROR_WDBA_NO_PROGID The ProgID classes had not been registered.
+ * NS_ERROR_WDBA_HASH_CHECK The existing UserChoice Hash could not be
+ * verified.
+ * NS_ERROR_WDBA_REJECTED UserChoice was set, but checking the default
+ * did not return our ProgID.
+ * NS_ERROR_WDBA_BUILD The existing UserChoice Hash was verified,
+ * but we're on an older, unsupported Windows
+ * build, so do not attempt to update the
+ * UserChoice hash.
+ * NS_ERROR_FAILURE other failure
+ */
+nsresult SetDefaultBrowserUserChoice(
+ const wchar_t* aAumi,
+ const nsTArray<nsString>& aExtraFileExtensions = nsTArray<nsString>());
+
+/*
+ * Set the default extension handlers for the given file extensions by writing
+ * the UserChoice registry keys.
+ *
+ * @param aAumi The AUMI of the installation to set as default.
+ *
+ * @param aExtraFileExtensions Optional array of extra file association pairs to
+ * set as default, like `[ ".pdf", "FirefoxPDF" ]`.
+ *
+ * @returns NS_OK All associations set and checked
+ * successfully.
+ * NS_ERROR_WDBA_REJECTED UserChoice was set, but checking the default
+ * did not return our ProgID.
+ * NS_ERROR_FAILURE Failed to set at least one association.
+ */
+nsresult SetDefaultExtensionHandlersUserChoice(
+ const wchar_t* aAumi, const nsTArray<nsString>& aFileExtensions);
+
+} // namespace mozilla::default_agent
+
+#endif // DEFAULT_BROWSER_SET_DEFAULT_BROWSER_H__
diff --git a/toolkit/mozapps/defaultagent/Telemetry.cpp b/toolkit/mozapps/defaultagent/Telemetry.cpp
new file mode 100644
index 0000000000..0b71fb7949
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Telemetry.cpp
@@ -0,0 +1,585 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "Telemetry.h"
+
+#include <fstream>
+#include <string>
+
+#include <windows.h>
+
+#include <knownfolders.h>
+#include <shlobj_core.h>
+
+#include "common.h"
+#include "Cache.h"
+#include "EventLog.h"
+#include "Notification.h"
+#include "Policy.h"
+#include "UtfConvert.h"
+#include "Registry.h"
+
+#include "json/json.h"
+#include "mozilla/ArrayUtils.h"
+#include "mozilla/CmdLineAndEnvUtils.h"
+#include "mozilla/glean/GleanMetrics.h"
+#include "mozilla/glean/GleanPings.h"
+#include "mozilla/HelperMacros.h"
+#include "mozilla/UniquePtr.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+#include "nsStringFwd.h"
+
+#define TELEMETRY_BASE_URL "https://incoming.telemetry.mozilla.org/submit"
+#define TELEMETRY_NAMESPACE "default-browser-agent"
+#define TELEMETRY_PING_VERSION "1"
+#define TELEMETRY_PING_DOCTYPE "default-browser"
+
+// This is almost the complete URL, just needs a UUID appended.
+#define TELEMETRY_PING_URL \
+ TELEMETRY_BASE_URL "/" TELEMETRY_NAMESPACE "/" TELEMETRY_PING_DOCTYPE \
+ "/" TELEMETRY_PING_VERSION "/"
+
+// We only want to send one ping per day. However, this is slightly less than 24
+// hours so that we have a little bit of wiggle room on our task, which is also
+// supposed to run every 24 hours.
+#define MINIMUM_PING_PERIOD_SEC ((23 * 60 * 60) + (45 * 60))
+
+#define PREV_NOTIFICATION_ACTION_REG_NAME L"PrevNotificationAction"
+
+#if !defined(RRF_SUBKEY_WOW6464KEY)
+# define RRF_SUBKEY_WOW6464KEY 0x00010000
+#endif // !defined(RRF_SUBKEY_WOW6464KEY)
+
+namespace mozilla::default_agent {
+
+using TelemetryFieldResult = mozilla::WindowsErrorResult<std::string>;
+using BoolResult = mozilla::WindowsErrorResult<bool>;
+
+// This function was copied from the implementation of
+// nsITelemetry::isOfficialTelemetry, currently found in the file
+// toolkit/components/telemetry/core/Telemetry.cpp.
+static bool IsOfficialTelemetry() {
+#if defined(MOZILLA_OFFICIAL) && defined(MOZ_TELEMETRY_REPORTING) && \
+ !defined(DEBUG)
+ return true;
+#else
+ return false;
+#endif
+}
+
+static TelemetryFieldResult GetOSVersion() {
+ OSVERSIONINFOEXW osv = {sizeof(osv)};
+ if (::GetVersionExW(reinterpret_cast<OSVERSIONINFOW*>(&osv))) {
+ std::ostringstream oss;
+ oss << osv.dwMajorVersion << "." << osv.dwMinorVersion << "."
+ << osv.dwBuildNumber;
+
+ if (osv.dwMajorVersion == 10 && osv.dwMinorVersion == 0) {
+ // Get the "Update Build Revision" (UBR) value
+ DWORD ubrValue;
+ DWORD ubrValueLen = sizeof(ubrValue);
+ LSTATUS ubrOk =
+ ::RegGetValueW(HKEY_LOCAL_MACHINE,
+ L"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion",
+ L"UBR", RRF_RT_DWORD | RRF_SUBKEY_WOW6464KEY, nullptr,
+ &ubrValue, &ubrValueLen);
+ if (ubrOk == ERROR_SUCCESS) {
+ oss << "." << ubrValue;
+ }
+ }
+
+ return oss.str();
+ }
+
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return TelemetryFieldResult(mozilla::WindowsError::FromHResult(hr));
+}
+
+static TelemetryFieldResult GetOSLocale() {
+ wchar_t localeName[LOCALE_NAME_MAX_LENGTH] = L"";
+ if (!GetUserDefaultLocaleName(localeName, LOCALE_NAME_MAX_LENGTH)) {
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return TelemetryFieldResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ // We'll need the locale string in UTF-8 to be able to submit it.
+ Utf16ToUtf8Result narrowLocaleName = Utf16ToUtf8(localeName);
+
+ return narrowLocaleName.unwrapOr("");
+}
+
+static FilePathResult GetPingFilePath(std::wstring& uuid) {
+ wchar_t* rawAppDataPath;
+ HRESULT hr = SHGetKnownFolderPath(FOLDERID_RoamingAppData, 0, nullptr,
+ &rawAppDataPath);
+ if (FAILED(hr)) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter> appDataPath(
+ rawAppDataPath);
+
+ // The Path* functions don't set LastError, but this is the only thing that
+ // can really cause them to fail, so if they ever do we assume this is why.
+ hr = HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER);
+
+ wchar_t pingFilePath[MAX_PATH] = L"";
+ if (!PathCombineW(pingFilePath, appDataPath.get(), L"" MOZ_APP_VENDOR)) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ if (!PathAppendW(pingFilePath, L"" MOZ_APP_BASENAME)) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ if (!PathAppendW(pingFilePath, L"Pending Pings")) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ if (!PathAppendW(pingFilePath, uuid.c_str())) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return std::wstring(pingFilePath);
+}
+
+// Sends Firefox Desktop telemetry ping. Note: this is sent in parallel to Glean
+// telemetry.
+static mozilla::WindowsError SendDesktopTelemetryPing(
+ const std::string defaultBrowser, const std::string previousDefaultBrowser,
+ const std::string defaultPdf, const std::string osVersion,
+ const std::string prevOSVersion, const std::string osLocale,
+ const std::string notificationType, const std::string notificationShown,
+ const std::string notificationAction,
+ const std::string prevNotificationAction) {
+ // Fill in the ping JSON object.
+ Json::Value ping;
+ ping["build_channel"] = MOZ_STRINGIFY(MOZ_UPDATE_CHANNEL);
+ ping["build_version"] = MOZILLA_VERSION;
+ ping["default_browser"] = defaultBrowser;
+ ping["previous_default_browser"] = previousDefaultBrowser;
+ ping["default_pdf_viewer_raw"] = defaultPdf;
+ ping["os_version"] = osVersion;
+ ping["previous_os_version"] = prevOSVersion;
+ ping["os_locale"] = osLocale;
+ ping["notification_type"] = notificationType;
+ ping["notification_shown"] = notificationShown;
+ ping["notification_action"] = notificationAction;
+ ping["previous_notification_action"] = prevNotificationAction;
+
+ // Stringify the JSON.
+ Json::StreamWriterBuilder jsonStream;
+ jsonStream["indentation"] = "";
+ std::string pingStr = Json::writeString(jsonStream, ping);
+
+ // Generate a UUID for the ping.
+ FilePathResult uuidResult = GenerateUUIDStr();
+ if (uuidResult.isErr()) {
+ return uuidResult.unwrapErr();
+ }
+ std::wstring uuid = uuidResult.unwrap();
+
+ // Write the JSON string to a file. Use the UUID in the file name so that if
+ // multiple instances of this task are running they'll have their own files.
+ FilePathResult pingFilePathResult = GetPingFilePath(uuid);
+ if (pingFilePathResult.isErr()) {
+ return pingFilePathResult.unwrapErr();
+ }
+ std::wstring pingFilePath = pingFilePathResult.unwrap();
+
+ {
+ std::ofstream outFile(pingFilePath);
+ outFile << pingStr;
+ if (outFile.fail()) {
+ // We have no way to get a specific error code out of a file stream
+ // other than to catch an exception, so substitute a generic error code.
+ HRESULT hr = HRESULT_FROM_WIN32(ERROR_IO_DEVICE);
+ LOG_ERROR(hr);
+ return mozilla::WindowsError::FromHResult(hr);
+ }
+ }
+
+ // Hand the file off to pingsender to submit.
+ FilePathResult pingsenderPathResult =
+ GetRelativeBinaryPath(L"pingsender.exe");
+ if (pingsenderPathResult.isErr()) {
+ return pingsenderPathResult.unwrapErr();
+ }
+ std::wstring pingsenderPath = pingsenderPathResult.unwrap();
+
+ std::wstring url(L"" TELEMETRY_PING_URL);
+ url.append(uuid);
+
+ const wchar_t* pingsenderArgs[] = {pingsenderPath.c_str(), url.c_str(),
+ pingFilePath.c_str()};
+ mozilla::UniquePtr<wchar_t[]> pingsenderCmdLine(
+ mozilla::MakeCommandLine(mozilla::ArrayLength(pingsenderArgs),
+ const_cast<wchar_t**>(pingsenderArgs)));
+
+ PROCESS_INFORMATION pi;
+ STARTUPINFOW si = {sizeof(si)};
+ si.dwFlags = STARTF_USESHOWWINDOW;
+ si.wShowWindow = SW_HIDE;
+ if (!::CreateProcessW(pingsenderPath.c_str(), pingsenderCmdLine.get(),
+ nullptr, nullptr, FALSE,
+ DETACHED_PROCESS | NORMAL_PRIORITY_CLASS, nullptr,
+ nullptr, &si, &pi)) {
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return mozilla::WindowsError::FromHResult(hr);
+ }
+
+ CloseHandle(pi.hThread);
+ CloseHandle(pi.hProcess);
+
+ return mozilla::WindowsError::CreateSuccess();
+}
+
+// This function checks if a ping has already been sent today. If one has not,
+// it assumes that we are about to send one and sets a registry entry that will
+// cause this function to return true for the next day.
+// This function uses unprefixed registry entries, so a RegistryMutex should be
+// held before calling.
+static BoolResult GetPingAlreadySentToday() {
+ const wchar_t* valueName = L"LastPingSentAt";
+ MaybeQwordResult readResult =
+ RegistryGetValueQword(IsPrefixed::Unprefixed, valueName);
+ if (readResult.isErr()) {
+ HRESULT hr = readResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to read registry: %#X", hr);
+ return BoolResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ mozilla::Maybe<ULONGLONG> maybeValue = readResult.unwrap();
+ ULONGLONG now = GetCurrentTimestamp();
+ if (maybeValue.isSome()) {
+ ULONGLONG lastPingTime = maybeValue.value();
+ if (SecondsPassedSince(lastPingTime, now) < MINIMUM_PING_PERIOD_SEC) {
+ return true;
+ }
+ }
+
+ mozilla::WindowsErrorResult<mozilla::Ok> writeResult =
+ RegistrySetValueQword(IsPrefixed::Unprefixed, valueName, now);
+ if (writeResult.isErr()) {
+ HRESULT hr = readResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to write registry: %#X", hr);
+ return BoolResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ return false;
+}
+
+// This both retrieves a value from the registry and writes new data
+// (currentDefault) to the same value. If there is no value stored, the value
+// passed for prevDefault will be converted to a string and returned instead.
+//
+// Although we already store and retrieve a cached previous default browser
+// value elsewhere, it may be updated when we don't send a ping. The value we
+// retrieve here will only be updated when we are sending a ping to ensure
+// that pings don't miss a default browser transition.
+static TelemetryFieldResult GetAndUpdatePreviousDefaultBrowser(
+ const std::string& currentDefault, Browser prevDefault) {
+ const wchar_t* registryValueName = L"PingCurrentDefault";
+
+ MaybeStringResult readResult =
+ RegistryGetValueString(IsPrefixed::Unprefixed, registryValueName);
+ if (readResult.isErr()) {
+ HRESULT hr = readResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to read registry: %#X", hr);
+ return TelemetryFieldResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ mozilla::Maybe<std::string> maybeValue = readResult.unwrap();
+ std::string oldCurrentDefault;
+ if (maybeValue.isSome()) {
+ oldCurrentDefault = maybeValue.value();
+ } else {
+ oldCurrentDefault = GetStringForBrowser(prevDefault);
+ }
+
+ mozilla::WindowsErrorResult<mozilla::Ok> writeResult = RegistrySetValueString(
+ IsPrefixed::Unprefixed, registryValueName, currentDefault.c_str());
+ if (writeResult.isErr()) {
+ HRESULT hr = writeResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to write registry: %#X", hr);
+ return TelemetryFieldResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ return oldCurrentDefault;
+}
+
+// This both retrieves a value from the registry and writes new data
+// (`currentOSVersion`) to the same value. If there is no value stored,
+// `currentOSVersion` is returned instead.
+//
+// The value we retrieve here will only be updated when we are sending a ping to
+// ensure that pings don't miss a Windows OS version transition.
+static TelemetryFieldResult GetAndUpdatePreviousOSVersion(
+ const std::string& currentOSVersion) {
+ const wchar_t* registryValueName = L"PingCurrentOSVersion";
+
+ MaybeStringResult readResult =
+ RegistryGetValueString(IsPrefixed::Unprefixed, registryValueName);
+ if (readResult.isErr()) {
+ HRESULT hr = readResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to read registry: %#X", hr);
+ return TelemetryFieldResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ mozilla::Maybe<std::string> maybeValue = readResult.unwrap();
+ std::string oldOSVersion = maybeValue.valueOr(currentOSVersion);
+
+ mozilla::WindowsErrorResult<mozilla::Ok> writeResult = RegistrySetValueString(
+ IsPrefixed::Unprefixed, registryValueName, currentOSVersion.c_str());
+ if (writeResult.isErr()) {
+ HRESULT hr = writeResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to write registry: %#X", hr);
+ return TelemetryFieldResult(mozilla::WindowsError::FromHResult(hr));
+ }
+ return oldOSVersion;
+}
+
+// If notifications actions occurred, we want to make sure a ping gets sent for
+// them. If we aren't sending a ping right now, we want to cache the ping values
+// for the next time the ping is sent.
+// The values passed will only be cached if actions were actually taken
+// (i.e. not when notificationShown == "not-shown")
+HRESULT MaybeCache(Cache& cache, const std::string& notificationType,
+ const std::string& notificationShown,
+ const std::string& notificationAction,
+ const std::string& prevNotificationAction) {
+ std::string notShown =
+ GetStringForNotificationShown(NotificationShown::NotShown);
+ if (notificationShown == notShown) {
+ return S_OK;
+ }
+
+ Cache::Entry entry{
+ .notificationType = notificationType,
+ .notificationShown = notificationShown,
+ .notificationAction = notificationAction,
+ .prevNotificationAction = prevNotificationAction,
+ };
+ VoidResult result = cache.Enqueue(entry);
+ if (result.isErr()) {
+ return result.unwrapErr().AsHResult();
+ }
+ return S_OK;
+}
+
+// This function retrieves values cached by MaybeCache. If any values were
+// loaded from the cache, the values passed in to this function are passed to
+// MaybeCache so that they are not lost. If there are no values in the cache,
+// the values passed will not be changed.
+// Values retrieved from the cache will also be removed from it.
+HRESULT MaybeSwapForCached(Cache& cache, std::string& notificationType,
+ std::string& notificationShown,
+ std::string& notificationAction,
+ std::string& prevNotificationAction) {
+ Cache::MaybeEntryResult result = cache.Dequeue();
+ if (result.isErr()) {
+ HRESULT hr = result.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Failed to read cache: %#X", hr);
+ return hr;
+ }
+ Cache::MaybeEntry maybeEntry = result.unwrap();
+ if (maybeEntry.isNothing()) {
+ return S_OK;
+ }
+
+ MaybeCache(cache, notificationType, notificationShown, notificationAction,
+ prevNotificationAction);
+ notificationType = maybeEntry.value().notificationType;
+ notificationShown = maybeEntry.value().notificationShown;
+ notificationAction = maybeEntry.value().notificationAction;
+ if (maybeEntry.value().prevNotificationAction.isSome()) {
+ prevNotificationAction = maybeEntry.value().prevNotificationAction.value();
+ } else {
+ prevNotificationAction =
+ GetStringForNotificationAction(NotificationAction::NoAction);
+ }
+ return S_OK;
+}
+
+HRESULT ReadPreviousNotificationAction(std::string& prevAction) {
+ MaybeStringResult maybePrevActionResult = RegistryGetValueString(
+ IsPrefixed::Unprefixed, PREV_NOTIFICATION_ACTION_REG_NAME);
+ if (maybePrevActionResult.isErr()) {
+ HRESULT hr = maybePrevActionResult.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to read prev action from registry: %#X", hr);
+ return hr;
+ }
+ mozilla::Maybe<std::string> maybePrevAction = maybePrevActionResult.unwrap();
+ if (maybePrevAction.isNothing()) {
+ prevAction = GetStringForNotificationAction(NotificationAction::NoAction);
+ } else {
+ prevAction = maybePrevAction.value();
+ // There's no good reason why there should be an invalid value stored here.
+ // But it's also not worth aborting the whole ping over. This function will
+ // silently change it to "no-action" if the value isn't valid to prevent us
+ // from sending unexpected telemetry values.
+ EnsureValidNotificationAction(prevAction);
+ }
+ return S_OK;
+}
+
+// Writes the previous notification action to the registry, but only if a
+// notification was shown.
+HRESULT MaybeWritePreviousNotificationAction(
+ const NotificationActivities& activitiesPerformed) {
+ if (activitiesPerformed.shown != NotificationShown::Shown) {
+ return S_OK;
+ }
+ std::string notificationAction =
+ GetStringForNotificationAction(activitiesPerformed.action);
+ mozilla::WindowsErrorResult<mozilla::Ok> result = RegistrySetValueString(
+ IsPrefixed::Unprefixed, PREV_NOTIFICATION_ACTION_REG_NAME,
+ notificationAction.c_str());
+ if (result.isErr()) {
+ HRESULT hr = result.unwrapErr().AsHResult();
+ LOG_ERROR_MESSAGE(L"Unable to write prev action to registry: %#X", hr);
+ return hr;
+ }
+ return S_OK;
+}
+
+// Sends Firefox Desktop and Glean telemetry for the Default Agent in parallel.
+HRESULT SendDefaultAgentPing(
+ const DefaultBrowserInfo& browserInfo, const DefaultPdfInfo& pdfInfo,
+ const NotificationActivities& activitiesPerformed) {
+ std::string currentDefaultBrowser =
+ GetStringForBrowser(browserInfo.currentDefaultBrowser);
+ std::string currentDefaultPdf =
+ GetStringForPDFHandler(pdfInfo.currentDefaultPdf);
+ std::string notificationType =
+ GetStringForNotificationType(activitiesPerformed.type);
+ std::string notificationShown =
+ GetStringForNotificationShown(activitiesPerformed.shown);
+ std::string notificationAction =
+ GetStringForNotificationAction(activitiesPerformed.action);
+
+ TelemetryFieldResult osVersionResult = GetOSVersion();
+ if (osVersionResult.isErr()) {
+ return osVersionResult.unwrapErr().AsHResult();
+ }
+ std::string osVersion = osVersionResult.unwrap();
+
+ TelemetryFieldResult osLocaleResult = GetOSLocale();
+ if (osLocaleResult.isErr()) {
+ return osLocaleResult.unwrapErr().AsHResult();
+ }
+ std::string osLocale = osLocaleResult.unwrap();
+
+ std::string prevNotificationAction;
+ HRESULT hr = ReadPreviousNotificationAction(prevNotificationAction);
+ if (FAILED(hr)) {
+ return hr;
+ }
+ // Intentionally discard the result of this write. There's no real reason
+ // to abort sending the ping in the error case and it already wrote an error
+ // message. So there isn't really anything to do at this point.
+ MaybeWritePreviousNotificationAction(activitiesPerformed);
+
+ Cache cache;
+
+ // Do not send the ping if we are not an official telemetry-enabled build;
+ // don't even generate the ping in fact, because if we write the file out
+ // then some other build might find it later and decide to submit it.
+ if (!IsOfficialTelemetry() || IsTelemetryDisabled()) {
+ return MaybeCache(cache, notificationType, notificationShown,
+ notificationAction, prevNotificationAction);
+ }
+
+ // Glean notification pings are handled asynchronously from system defaults
+ // pings; caching is unnecessary as we need not adhere to the system default
+ // ping's 24 hour cadence.
+ if (activitiesPerformed.shown != NotificationShown::NotShown) {
+ mozilla::glean::notification::show_success.Set(activitiesPerformed.shown ==
+ NotificationShown::Shown);
+ if (activitiesPerformed.shown == NotificationShown::Shown) {
+ mozilla::glean::notification::action.Set(
+ nsDependentCString(notificationAction.c_str()));
+ }
+ }
+
+ // Pings are limited to one per day (across all installations), so check if we
+ // already sent one today.
+ // This will also set a registry entry indicating that the last ping was
+ // just sent, to prevent another one from being sent today. We'll do this
+ // now even though we haven't sent the ping yet. After this check, we send
+ // a ping unconditionally. The only exception is for errors, and any error
+ // that we get now will probably be hit every time.
+ // Because unsent pings attempted with pingsender can get automatically
+ // re-sent later, we don't even want to try again on transient network
+ // failures.
+ hr = [&]() {
+ BoolResult pingAlreadySentResult = GetPingAlreadySentToday();
+ if (pingAlreadySentResult.isErr()) {
+ return pingAlreadySentResult.unwrapErr().AsHResult();
+ }
+ bool pingAlreadySent = pingAlreadySentResult.unwrap();
+ if (pingAlreadySent) {
+ return MaybeCache(cache, notificationType, notificationShown,
+ notificationAction, prevNotificationAction);
+ }
+
+ hr = MaybeSwapForCached(cache, notificationType, notificationShown,
+ notificationAction, prevNotificationAction);
+ if (FAILED(hr)) {
+ return hr;
+ }
+
+ // Don't update the registry's default browser data until we are sure we
+ // want to send a ping. Otherwise it could be updated to reflect a ping we
+ // never sent. Same logic for witnessing Windows updates, but they're less
+ // valuable, so try (and potentially fail) those first.
+ TelemetryFieldResult previousOSVersionResult =
+ GetAndUpdatePreviousOSVersion(osVersion);
+ if (previousOSVersionResult.isErr()) {
+ return previousOSVersionResult.unwrapErr().AsHResult();
+ }
+ std::string prevOSVersion = previousOSVersionResult.unwrap();
+
+ mozilla::glean::system::os_version.Set(
+ nsDependentCString(osVersion.c_str()));
+ mozilla::glean::system::previous_os_version.Set(
+ nsDependentCString(prevOSVersion.c_str()));
+
+ TelemetryFieldResult previousDefaultBrowserResult =
+ GetAndUpdatePreviousDefaultBrowser(currentDefaultBrowser,
+ browserInfo.previousDefaultBrowser);
+ if (previousDefaultBrowserResult.isErr()) {
+ return previousDefaultBrowserResult.unwrapErr().AsHResult();
+ }
+ std::string previousDefaultBrowser = previousDefaultBrowserResult.unwrap();
+
+ mozilla::glean::system_default::browser.Set(
+ nsDependentCString(currentDefaultBrowser.c_str()));
+ // Glean telemetry doesn't use registry cached ping values for
+ // notifications, so we shouldn't use the registry cached values for the
+ // previous default browser either.
+ std::string uncachedPreviousDefaultBrowser =
+ GetStringForBrowser(browserInfo.previousDefaultBrowser);
+ mozilla::glean::system_default::previous_browser.Set(
+ nsDependentCString(uncachedPreviousDefaultBrowser.c_str()));
+ mozilla::glean::system_default::pdf_handler.Set(
+ nsDependentCString(currentDefaultPdf.c_str()));
+
+ return SendDesktopTelemetryPing(
+ currentDefaultBrowser, previousDefaultBrowser, currentDefaultPdf,
+ osVersion, prevOSVersion, osLocale, notificationType,
+ notificationShown, notificationAction, prevNotificationAction)
+ .AsHResult();
+ }();
+
+ mozilla::glean_pings::DefaultAgent.Submit("daily_ping"_ns);
+
+ return hr;
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/Telemetry.h b/toolkit/mozapps/defaultagent/Telemetry.h
new file mode 100644
index 0000000000..028746b6f1
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/Telemetry.h
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_TELEMETRY_H__
+#define __DEFAULT_BROWSER_TELEMETRY_H__
+
+#include <windows.h>
+
+#include "DefaultBrowser.h"
+#include "DefaultPDF.h"
+#include "Notification.h"
+
+namespace mozilla::default_agent {
+
+HRESULT SendDefaultAgentPing(const DefaultBrowserInfo& browserInfo,
+ const DefaultPdfInfo& pdfInfo,
+ const NotificationActivities& activitiesPerformed);
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_TELEMETRY_H__
diff --git a/toolkit/mozapps/defaultagent/UtfConvert.cpp b/toolkit/mozapps/defaultagent/UtfConvert.cpp
new file mode 100644
index 0000000000..2259a2db6f
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/UtfConvert.cpp
@@ -0,0 +1,59 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "UtfConvert.h"
+
+#include <string>
+
+#include "EventLog.h"
+
+#include "mozilla/Buffer.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+namespace mozilla::default_agent {
+
+Utf16ToUtf8Result Utf16ToUtf8(const wchar_t* const utf16) {
+ int utf8Len =
+ WideCharToMultiByte(CP_UTF8, 0, utf16, -1, nullptr, 0, nullptr, nullptr);
+ if (utf8Len == 0) {
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return Utf16ToUtf8Result(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ mozilla::Buffer<char> utf8(utf8Len);
+ int bytesWritten = WideCharToMultiByte(CP_UTF8, 0, utf16, -1, utf8.Elements(),
+ utf8.Length(), nullptr, nullptr);
+ if (bytesWritten == 0) {
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return Utf16ToUtf8Result(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return std::string(utf8.Elements());
+}
+
+Utf8ToUtf16Result Utf8ToUtf16(const char* const utf8) {
+ int utf16Len = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0);
+ if (utf16Len == 0) {
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ mozilla::Buffer<wchar_t> utf16(utf16Len);
+ int charsWritten = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, utf16.Elements(),
+ utf16.Length());
+ if (charsWritten == 0) {
+ HRESULT hr = HRESULT_FROM_WIN32(GetLastError());
+ LOG_ERROR(hr);
+ return mozilla::Err(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return std::wstring(utf16.Elements());
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/UtfConvert.h b/toolkit/mozapps/defaultagent/UtfConvert.h
new file mode 100644
index 0000000000..8cfbb9ad0b
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/UtfConvert.h
@@ -0,0 +1,24 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 DEFAULT_BROWSER_UTF_CONVERT_H__
+#define DEFAULT_BROWSER_UTF_CONVERT_H__
+
+#include <string>
+
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+namespace mozilla::default_agent {
+
+using Utf16ToUtf8Result = mozilla::WindowsErrorResult<std::string>;
+using Utf8ToUtf16Result = mozilla::WindowsErrorResult<std::wstring>;
+
+Utf16ToUtf8Result Utf16ToUtf8(const wchar_t* const utf16);
+Utf8ToUtf16Result Utf8ToUtf16(const char* const utf8);
+
+} // namespace mozilla::default_agent
+
+#endif // DEFAULT_BROWSER_UTF_CONVERT_H__
diff --git a/toolkit/mozapps/defaultagent/WindowsMutex.cpp b/toolkit/mozapps/defaultagent/WindowsMutex.cpp
new file mode 100644
index 0000000000..804c3e6d75
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/WindowsMutex.cpp
@@ -0,0 +1,103 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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/Logging.h"
+
+#include "WindowsMutex.h"
+
+namespace mozilla::default_agent {
+
+using mozilla::LogLevel;
+
+static LazyLogModule gWindowsMutexLog("WindowsMutex");
+
+NS_IMPL_ISUPPORTS(WindowsMutexFactory, nsIWindowsMutexFactory)
+
+NS_IMETHODIMP
+WindowsMutexFactory::CreateMutex(const nsAString& aName,
+ nsIWindowsMutex** aWindowsMutex) {
+ nsAutoHandle mutex;
+ auto name = PromiseFlatString(aName);
+
+ mutex.own(CreateMutexW(nullptr, FALSE, name.get()));
+ if (mutex.get() == nullptr) {
+ MOZ_LOG(gWindowsMutexLog, LogLevel::Error,
+ ("Couldn't open mutex \"%s\": %#lX",
+ NS_ConvertUTF16toUTF8(name).get(), GetLastError()));
+ return NS_ERROR_NOT_AVAILABLE;
+ }
+
+ RefPtr<WindowsMutex> nsMutex = new WindowsMutex(name, mutex);
+ nsMutex.forget(aWindowsMutex);
+ return NS_OK;
+}
+
+WindowsMutex::WindowsMutex(const nsString& aName, nsAutoHandle& aMutex)
+ : mName(NS_ConvertUTF16toUTF8(aName)), mLocked(false) {
+ mMutex.steal(aMutex);
+}
+
+WindowsMutex::~WindowsMutex() {
+ Unlock();
+ // nsAutoHandle will take care of closing the mutex's handle.
+}
+
+NS_IMPL_ISUPPORTS(WindowsMutex, nsIWindowsMutex)
+
+NS_IMETHODIMP
+WindowsMutex::TryLock() {
+ // This object may be used on the main thread, so don't wait if it's
+ // not signaled.
+ DWORD mutexStatus = WaitForSingleObject(mMutex.get(), 0);
+ if (mutexStatus == WAIT_OBJECT_0) {
+ mLocked = true;
+ } else if (mutexStatus == WAIT_TIMEOUT) {
+ MOZ_LOG(gWindowsMutexLog, LogLevel::Warning,
+ ("Timed out waiting for mutex \"%s\"", mName.get()));
+ } else if (mutexStatus == WAIT_ABANDONED) {
+ // This status code means that we are supposed to check our data for
+ // consistency as the last locking process didn't signal intentional
+ // unlocking which might indicate it crashed mid-operation. Current uses of
+ // this `WindowsMutex` don't need to worry about corruption of the locked
+ // object, if needed the `nsIWindowsMutex` interface should be extended.
+ MOZ_LOG(gWindowsMutexLog, LogLevel::Warning,
+ ("Found abandoned mutex \"%s\". Continuing...", mName.get()));
+ mLocked = true;
+ } else {
+ // The only other documented status code is WAIT_FAILED. In the case that
+ // we somehow get some other code, that is also an error.
+ MOZ_LOG(gWindowsMutexLog, LogLevel::Error,
+ ("Failed to wait on mutex: mName: %s, error %#lX", mName.get(),
+ GetLastError()));
+ }
+ return mLocked ? NS_OK : NS_ERROR_NOT_AVAILABLE;
+}
+
+NS_IMETHODIMP
+WindowsMutex::IsLocked(bool* aLocked) {
+ *aLocked = mLocked;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+WindowsMutex::Unlock() {
+ nsresult rv = NS_OK;
+
+ if (mLocked) {
+ BOOL success = ReleaseMutex(mMutex.get());
+ if (!success) {
+ MOZ_LOG(gWindowsMutexLog, LogLevel::Error,
+ ("Failed to release mutex \"%s\"", mName.get()));
+ rv = NS_ERROR_UNEXPECTED;
+ }
+
+ mLocked = false;
+ }
+
+ return rv;
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/WindowsMutex.h b/toolkit/mozapps/defaultagent/WindowsMutex.h
new file mode 100644
index 0000000000..5e8b32314c
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/WindowsMutex.h
@@ -0,0 +1,45 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_DEFAULT_AGENT_MUTEX_H__
+#define __DEFAULT_BROWSER_AGENT_DEFAULT_AGENT_MUTEX_H__
+
+#include "nsString.h"
+#include "nsWindowsHelpers.h"
+
+#include "nsIWindowsMutex.h"
+
+namespace mozilla::default_agent {
+
+class WindowsMutexFactory final : public nsIWindowsMutexFactory {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIWINDOWSMUTEXFACTORY
+
+ WindowsMutexFactory() = default;
+
+ private:
+ ~WindowsMutexFactory() = default;
+};
+
+class WindowsMutex final : public nsIWindowsMutex {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIWINDOWSMUTEX
+
+ WindowsMutex(const nsString& aName, nsAutoHandle& aMutex);
+
+ private:
+ nsAutoHandle mMutex;
+ nsCString mName;
+ bool mLocked;
+
+ ~WindowsMutex();
+};
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_DEFAULT_AGENT_MUTEX_H__
diff --git a/toolkit/mozapps/defaultagent/common.cpp b/toolkit/mozapps/defaultagent/common.cpp
new file mode 100644
index 0000000000..0e660d1207
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/common.cpp
@@ -0,0 +1,85 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "common.h"
+
+#include "EventLog.h"
+
+#include <windows.h>
+
+namespace mozilla::default_agent {
+
+ULONGLONG GetCurrentTimestamp() {
+ FILETIME filetime;
+ GetSystemTimeAsFileTime(&filetime);
+ ULARGE_INTEGER integerTime;
+ integerTime.u.LowPart = filetime.dwLowDateTime;
+ integerTime.u.HighPart = filetime.dwHighDateTime;
+ return integerTime.QuadPart;
+}
+
+// Passing a zero as the second argument (or omitting it) causes the function
+// to get the current time rather than using a passed value.
+ULONGLONG SecondsPassedSince(ULONGLONG initialTime,
+ ULONGLONG currentTime /* = 0 */) {
+ if (currentTime == 0) {
+ currentTime = GetCurrentTimestamp();
+ }
+ // Since this is returning an unsigned value, let's make sure we don't try to
+ // return anything negative
+ if (initialTime >= currentTime) {
+ return 0;
+ }
+
+ // These timestamps are expressed in 100-nanosecond intervals
+ return (currentTime - initialTime) / 10 // To microseconds
+ / 1000 // To milliseconds
+ / 1000; // To seconds
+}
+
+FilePathResult GenerateUUIDStr() {
+ UUID uuid;
+ RPC_STATUS status = UuidCreate(&uuid);
+ if (status != RPC_S_OK) {
+ HRESULT hr = MAKE_HRESULT(1, FACILITY_RPC, status);
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ // 39 == length of a UUID string including braces and NUL.
+ wchar_t guidBuf[39] = {};
+ if (StringFromGUID2(uuid, guidBuf, 39) != 39) {
+ LOG_ERROR(HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER));
+ return FilePathResult(
+ mozilla::WindowsError::FromWin32Error(ERROR_INSUFFICIENT_BUFFER));
+ }
+
+ // Remove the curly braces.
+ return std::wstring(guidBuf + 1, guidBuf + 37);
+}
+
+FilePathResult GetRelativeBinaryPath(const wchar_t* suffix) {
+ // The Path* functions don't set LastError, but this is the only thing that
+ // can really cause them to fail, so if they ever do we assume this is why.
+ HRESULT hr = HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER);
+
+ mozilla::UniquePtr<wchar_t[]> thisBinaryPath = mozilla::GetFullBinaryPath();
+ if (!PathRemoveFileSpecW(thisBinaryPath.get())) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ wchar_t relativePath[MAX_PATH] = L"";
+
+ if (!PathCombineW(relativePath, thisBinaryPath.get(), suffix)) {
+ LOG_ERROR(hr);
+ return FilePathResult(mozilla::WindowsError::FromHResult(hr));
+ }
+
+ return std::wstring(relativePath);
+}
+
+} // namespace mozilla::default_agent
diff --git a/toolkit/mozapps/defaultagent/common.h b/toolkit/mozapps/defaultagent/common.h
new file mode 100644
index 0000000000..ddd0ca6a67
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/common.h
@@ -0,0 +1,29 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 __DEFAULT_BROWSER_AGENT_COMMON_H__
+#define __DEFAULT_BROWSER_AGENT_COMMON_H__
+
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+#define AGENT_REGKEY_NAME \
+ L"SOFTWARE\\" MOZ_APP_VENDOR "\\" MOZ_APP_BASENAME "\\Default Browser Agent"
+
+namespace mozilla::default_agent {
+
+ULONGLONG GetCurrentTimestamp();
+// Passing a zero as the second argument (or omitting it) causes the function
+// to get the current time rather than using a passed value.
+ULONGLONG SecondsPassedSince(ULONGLONG initialTime, ULONGLONG currentTime = 0);
+
+using FilePathResult = mozilla::WindowsErrorResult<std::wstring>;
+FilePathResult GenerateUUIDStr();
+
+FilePathResult GetRelativeBinaryPath(const wchar_t* suffix);
+
+} // namespace mozilla::default_agent
+
+#endif // __DEFAULT_BROWSER_AGENT_COMMON_H__
diff --git a/toolkit/mozapps/defaultagent/components.conf b/toolkit/mozapps/defaultagent/components.conf
new file mode 100644
index 0000000000..62b5055d56
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/components.conf
@@ -0,0 +1,21 @@
+# -*- 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/.
+
+if buildconfig.substs['CC_TYPE'] in ('msvc', 'clang-cl'):
+ Classes = [
+ {
+ 'cid': '{edc38cb5-b6f6-4aeb-bd45-7be8e00fc364}',
+ 'contract_ids': ['@mozilla.org/default-agent;1'],
+ 'type': 'mozilla::default_agent::DefaultAgent',
+ 'headers': ['mozilla/DefaultAgent.h'],
+ },
+ {
+ 'cid': '{d54fe2b7-438f-4629-9706-1acda5b51088}',
+ 'contract_ids': ['@mozilla.org/windows-mutex-factory;1'],
+ 'type': 'mozilla::default_agent::WindowsMutexFactory',
+ 'headers': ['mozilla/WindowsMutex.h'],
+ },
+ ]
diff --git a/toolkit/mozapps/defaultagent/defaultagent.ini b/toolkit/mozapps/defaultagent/defaultagent.ini
new file mode 100644
index 0000000000..9300b20c46
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/defaultagent.ini
@@ -0,0 +1,9 @@
+; This Source Code Form is subject to the terms of the Mozilla Public
+; License, v. 2.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 in the UTF-8 encoding
+[Nonlocalized]
+InitialToastRelativeImagePath=browser/VisualElements/VisualElements_150.png
+FollowupToastRelativeImagePath=browser/VisualElements/VisualElements_150.png
+LocalizedToastRelativeImagePath=browser/VisualElements/VisualElements_150.png
diff --git a/toolkit/mozapps/defaultagent/docs/index.rst b/toolkit/mozapps/defaultagent/docs/index.rst
new file mode 100644
index 0000000000..f977f234cf
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/docs/index.rst
@@ -0,0 +1,49 @@
+=====================
+Default Browser Agent
+=====================
+
+The Default Browser Agent is a Windows-only scheduled task which runs in the background to collect and submit data about the browser that the user has set as their OS default (that is, the browser that will be invoked by the operating system to open web links that the user clicks on in other programs). Its purpose is to help Mozilla understand user's default browser choices and, in the future, to engage with users at a time when they may not be actively running Firefox.
+
+For information about the specific data that the agent sends, see :doc:`the ping documentation </toolkit/components/telemetry/data/default-browser-ping>`.
+
+
+Scheduled Task
+==============
+
+The agent runs as a `Windows scheduled task <https://docs.microsoft.com/en-us/windows/win32/taskschd/about-the-task-scheduler>`_. The scheduled task proxy executable invokes the Firefox ``BackgroundTask_defaultagent`` which executes all of the agent's primary functions; all of its other functions relate to managing the task. The Windows installer is responsible for creating (and the uninstaller for removing) the agent's task entry, but the code for actually doing this resides in the agent itself, and the installers simply call it using dedicated command line parameters (``register-task`` and ``uninstall``). The :doc:`PostUpdate </browser/installer/windows/installer/Helper>` code also calls the agent to update any properties of an existing task registration that need to be updated, or to create one during an application update if none exists.
+
+The tasks are normal entries in the Windows Task Scheduler, managed using `its Win32 API <https://docs.microsoft.com/en-us/windows/win32/api/_taskschd/>`_. They're created in a tasks folder called "Mozilla" (or whatever the application's vendor name is), and there's one for each installation of Firefox (or other Mozilla application). The task is set to run automatically every 24 hours starting at the time it's registered (with the first run being 24 hours after that), or the nearest time after that the computer is awake. The task is configured with one action, which is to run the agent binary with the command line parameter ``do-task``, the command that invokes the actual agent functionality.
+
+The default browser agent needs to run as some OS-level user, as opposed to, say, ``LOCAL SERVICE``, in order to read the user's default browser setting. Therefore, the default browser agent runs as the user that ran the Firefox installer (although always without elevation, whether the installer had it or not).
+
+
+Remote Disablement
+------------------
+
+The default browser agent can be remotely disabled and (re-)enabled. Each time the scheduled task runs it queries `Firefox Remote Settings <https://remote-settings.readthedocs.io/en/latest/>`_ to determine if the agent has been remotely disabled or (re-)enabled.
+
+If the default browser agent is disabled by policy, remote disablement will not be checked. However, the notification functionality of the agent is distinct from the telemetry functionality of the agent, and remote disablement must apply to both functions. Therefore, even if the user has opted out of sending telemetry (by policy or by preference), the agent must check for remote disablement. For a user who is currently opted out of telemetry, they will not be opted in due to the default browser agent being remotely (re-)enabled.
+
+
+Data Management
+===============
+
+The default browser agent has to be able to work with settings at several different levels: a Firefox profile, an OS user, a Firefox installation, and the entire system. This need creates an information architecture mismatch between all of those things, mostly because no Firefox profile is available to the agent while it's running; it's not really feasible to either directly use or to clone Firefox's profile selection functionality, and even if we could select a profile, whatever code we might use to actually work with it would have the same problems. So, in order to allow for controlling the agent from Firefox, certain settings are mirrored from Firefox to a location where the agent can read them. Since the agent operates in the context only of an OS-level user, that means that in this situation a single OS-level user who uses multiple Firefox profiles may be able to observe the agent's settings changing as the different profiles race to be the active mirror, without them knowingly taking any action.
+
+
+Pref Reflection
+---------------
+
+The agent needs to be able to read (but not set) values that have their canonical representation in the form of Firefox prefs. This means those pref values have to be copied out to a place where the agent can read them. The Windows registry was chosen as that place; it's easier to use than a file, and we already have keys there which are reserved by Firefox. Specifically, the subkey used for these prefs is ``HKEY_CURRENT_USER\Software\[app vendor name]\[app name]\Default Browser Agent\``. During Firefox startup, the values of the prefs that control the agent are reflected to this key, and those values are updated whenever the prefs change after that.
+
+The list of reflected prefs includes the global telemetry opt-out pref ``datareporting.healthreport.uploadEnabled`` and a pref called ``default-browser-agent.enabled``, which can enable or disable the entire agent. The agent checks these registry-reflected pref values when its scheduled task runs, they do not actually prevent the scheduled task from running.
+
+Enterprise policies also exist to perform the same functions as these prefs. These work the same way as all other Firefox policies and `the documentation for those <https://mozilla.github.io/policy-templates/>`_ explains how to use them.
+
+In addition, the following Firefox Remote Settings pref is reflected: ``services.settings.server``. It is the service endpoint to consult for remote-disablement.
+
+
+Default Browser Setting
+-----------------------
+
+The agent is responsible for reporting both the user's current default browser and their previous default browser. Nothing in the operating system records past associations, so the agent must do this for itself. First, it gets the current default browser by calling `IApplicationAssociationRegistration::QueryCurrentDefault <https://docs.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-iapplicationassociationregistration-querycurrentdefault>`_ for the ``http`` protocol. It then checks that against a value stored in its own registry key and, if those are different, it knows that the default browser has changed, and records the new and old defaults.
diff --git a/toolkit/mozapps/defaultagent/metrics.yaml b/toolkit/mozapps/defaultagent/metrics.yaml
new file mode 100644
index 0000000000..355a80d7e6
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/metrics.yaml
@@ -0,0 +1,208 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Toolkit :: Default Browser Agent'
+
+system:
+ os_version:
+ type: string
+ description: >
+ The current Windows OS version, usually as a dotted quad ("x.y.z.w") with
+ Windows Update Build Revision (UBR), but potentially as a dotted triple
+ ("x.y.z") without UBR.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1850149
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1850149
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - default-agent
+
+ previous_os_version:
+ type: string
+ description: >
+ The Windows OS version before it was changed to the current setting. The
+ possible values are the same as for `system.os_version`.
+
+ The OS does not keep track of the previous OS version, so the agent
+ records this information itself. That means that it will be inaccurate
+ until the first time the default is changed after the agent task begins
+ running. Before then, the value of `previous_os_version` will be the same
+ as `os_version`.
+
+ This value is updated every time the Default Agent runs, so when the
+ default browser is first changed the values for `os_version` and
+ `previous_os_version` will be different. But on subsequent executions of
+ the Default Agent, the two values will be the same.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1850149
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1850149
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - default-agent
+
+system_default:
+ browser:
+ type: string
+ description: >
+ Which browser is currently set as the system default web browser. This is
+ simply a string with the name of the browser binned to a fixed set of
+ known browsers.
+
+ Possible values currently include the following (from
+ [DefaultBrowser.cpp](https://searchfox.org/mozilla-central/source/toolkit/mozapps/defaultagent/DefaultBrowser.cpp)):
+ * "error"
+ * "" (unknown)
+ * "firefox"
+ * "chrome"
+ * "edge"
+ * "edge-chrome"
+ * "ie"
+ * "opera"
+ * "brave"
+ * "yandex"
+ * "qq-browser"
+ * "360-browser"
+ * "sogou"
+ * "duckduckgo"
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1838755
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1621293
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - default-agent
+
+ previous_browser:
+ type: string
+ description: >
+ Which browser was set as the system default before it was changed to the
+ current setting. The possible values are the same as for
+ `system_default.browser`.
+
+ The OS does not keep track of previous default settings, so the agent
+ records this information itself. That means that it will be inaccurate
+ until the first time the default is changed after the agent task begins
+ running. Before then, the value of `previous_browser` will be the same
+ as `browser`.
+
+ This value is updated every time the Default Agent runs, so when the
+ default browser is first changed the values for `browser` and
+ `previous_browser` will be different. But on subsequent executions of
+ the Default Agent, the two values will be the same.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1838755
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1621293
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - default-agent
+
+ pdf_handler:
+ type: string
+ description: >
+ Which pdf handler is currently set as the system default handler. This is
+ simply a string with the name of the handler binned to a fixed set of
+ known handlers.
+
+ Possible values currently include the following (from
+ [DefaultPDF.cpp](https://searchfox.org/mozilla-central/source/toolkit/mozapps/defaultagent/DefaultPDF.cpp)):
+ * "Error"
+ * "" (unknown)
+ * "Firefox"
+ * "Microsoft Edge"
+ * "Google Chrome"
+ * "Adobe Acrobat"
+ * "WPS"
+ * "Nitro"
+ * "Foxit"
+ * "PDF-XChange"
+ * "Avast"
+ * "Sumatra"
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1756900
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1756900
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - default-agent
+
+notification:
+ show_success:
+ type: boolean
+ description: >
+ Whether a notification was shown or not. Possible value include "shown" and "error".
+ notification_emails:
+ - install-update@mozilla.com
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1838755
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1621293
+ expires: never
+ send_in_pings:
+ - default-agent
+
+ action:
+ type: string
+ description: >
+ The action that the user took in response to the notification. Possible
+ values currently include the following:
+ * “dismissed-by-timeout”
+ * “dismissed-to-action-center”
+ * “dismissed-by-button”
+ * “dismissed-by-application-hidden”
+ * “make-firefox-default-button”
+ * “toast-clicked”
+
+ Many of the values correspond to buttons on the notification and should be
+ pretty self explanatory, but a few are less so.
+ * “dismissed-to-action-center” will be used if the user clicks the arrow in
+ the top right corner of the notification to dismiss it to the
+ action center.
+ * “dismissed-by-application-hidden” is provided because that is a method of
+ dismissal that the notification API could give but, in practice, should
+ never be seen.
+ * “dismissed-by-timeout” indicates that the user did not interact with the
+ notification and it timed out.
+ notification_emails:
+ - install-update@mozilla.com
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1838755
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1621293
+ expires: never
+ send_in_pings:
+ - default-agent
diff --git a/toolkit/mozapps/defaultagent/module.ver b/toolkit/mozapps/defaultagent/module.ver
new file mode 100644
index 0000000000..92b692b62c
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/module.ver
@@ -0,0 +1 @@
+WIN32_MODULE_DESCRIPTION=@MOZ_APP_DISPLAYNAME@ Default Browser Agent
diff --git a/toolkit/mozapps/defaultagent/moz.build b/toolkit/mozapps/defaultagent/moz.build
new file mode 100644
index 0000000000..86b68c6371
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/moz.build
@@ -0,0 +1,113 @@
+# -*- 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/.
+
+SPHINX_TREES["default-browser-agent"] = "docs"
+
+DIRS += ["proxy"]
+
+UNIFIED_SOURCES += [
+ "Cache.cpp",
+ "common.cpp",
+ "DefaultAgent.cpp",
+ "DefaultBrowser.cpp",
+ "DefaultPDF.cpp",
+ "EventLog.cpp",
+ "Policy.cpp",
+ "Registry.cpp",
+ "ScheduledTask.cpp",
+ "ScheduledTaskRemove.cpp",
+ "SetDefaultBrowser.cpp",
+ "Telemetry.cpp",
+ "UtfConvert.cpp",
+ "WindowsMutex.cpp",
+]
+
+SOURCES += [
+ "/third_party/WinToast/wintoastlib.cpp",
+ "/toolkit/mozapps/update/common/readstrings.cpp",
+ "Notification.cpp",
+]
+
+# Suppress warnings from third-party code.
+SOURCES["/third_party/WinToast/wintoastlib.cpp"].flags += [
+ "-Wno-implicit-fallthrough",
+ "-Wno-nonportable-include-path", # Needed for wintoastlib.h including "Windows.h"
+]
+SOURCES["Notification.cpp"].flags += [
+ "-Wno-nonportable-include-path", # Needed for wintoastlib.h including "Windows.h"
+]
+
+EXPORTS.mozilla += [
+ "DefaultAgent.h",
+ "WindowsMutex.h",
+]
+
+USE_LIBS += [
+ "jsoncpp",
+]
+
+LOCAL_INCLUDES += [
+ "/browser/components/shell/",
+ "/other-licenses/nsis/Contrib/CityHash/cityhash",
+ "/third_party/WinToast",
+ "/toolkit/components/jsoncpp/include",
+ "/toolkit/mozapps/update/common",
+]
+
+OS_LIBS += [
+ "advapi32",
+ "bcrypt",
+ "comsupp",
+ "crypt32",
+ "kernel32",
+ "netapi32",
+ "ole32",
+ "oleaut32",
+ "rpcrt4",
+ "shell32",
+ "shlwapi",
+ "taskschd",
+ "userenv",
+ "wininet",
+ "ws2_32",
+ "ntdll",
+]
+
+XPIDL_SOURCES += [
+ "nsIDefaultAgent.idl",
+ "nsIWindowsMutex.idl",
+]
+
+XPIDL_MODULE = "default-agent"
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"]
+
+# If defines are added to this list that are required by the Cache,
+# SetDefaultBrowser, or their dependencies (Registry, EventLog, common),
+# tests/gtest/moz.build will need to be updated as well.
+for var in ("MOZ_APP_BASENAME", "MOZ_APP_DISPLAYNAME", "MOZ_APP_VENDOR"):
+ DEFINES[var] = '"%s"' % CONFIG[var]
+
+DEFINES["UNICODE"] = True
+DEFINES["_UNICODE"] = True
+
+FINAL_TARGET_FILES += ["defaultagent.ini"]
+
+FINAL_LIBRARY = "xul"
+
+if CONFIG["ENABLE_TESTS"]:
+ DIRS += ["tests/gtest"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Default Browser Agent")
+
+EXTRA_JS_MODULES.backgroundtasks += [
+ "BackgroundTask_defaultagent.sys.mjs",
+]
diff --git a/toolkit/mozapps/defaultagent/nsIDefaultAgent.idl b/toolkit/mozapps/defaultagent/nsIDefaultAgent.idl
new file mode 100644
index 0000000000..7e78e1b30d
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/nsIDefaultAgent.idl
@@ -0,0 +1,167 @@
+/* -*- Mode: IDL; 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 "nsISupports.idl"
+
+[scriptable, uuid(edc38cb5-b6f6-4aeb-bd45-7be8e00fc364)]
+interface nsIDefaultAgent : nsISupports
+{
+ /**
+ * Create a Windows scheduled task that will launch this binary with the
+ * do-task command every 24 hours, starting from 24 hours after register-task
+ * is run.
+ *
+ * @param {AString} aUniqueToken
+ * A unique identifier for this installation; typically the install path
+ * hash that's used for the update directory, the AppUserModelID, and
+ * other related purposes.
+ */
+ void registerTask(in AString aUniqueToken);
+
+ /**
+ * Update an existing task registration, without changing its schedule. This
+ * should be called during updates of the application, in case this program
+ * has been updated and any of the task parameters have changed.
+ *
+ * @param {AString} aUniqueToken
+ * A unique identifier for this installation; the same one provided when
+ * the task was registered.
+ */
+ void updateTask(in AString aUniqueToken);
+
+ /**
+ * Removes the previously created task. The unique token argument is required
+ * and should be the same one that was passed in when the task was registered.
+ *
+ * @param {AString} aUniqueToken
+ * A unique identifier for this installation; the same one provided when
+ * the task was registered.
+ */
+ void unregisterTask(in AString aUniqueToken);
+
+ /**
+ * Removes the previously created task, and also removes all registry entries
+ * running the task may have created.
+ *
+ * @param {AString} aUniqueToken
+ * A unique identifier for this installation; the same one provided when
+ * the task was registered.
+ */
+ void uninstall(in AString aUniqueToken);
+
+ /**
+ * Actually performs the default agent task, which currently means generating
+ * and sending our telemetry ping and possibly showing a notification to the
+ * user if their browser has switched from Firefox to Edge with Blink.
+ *
+ * @param {AString} aUniqueToken
+ * A unique identifier for this installation; the same one provided when
+ * the task was registered.
+ * @param {boolean} aForce
+ * For debugging, forces the task to run even if it has run in the last
+ * 24 hours, and forces the notification to show.
+ */
+ void doTask(in AString aUniqueToken, in boolean aForce);
+
+ /**
+ * Checks that the main app ran recently.
+ *
+ * @return {boolean} true if the app ran recently.
+ */
+ boolean appRanRecently();
+
+ /**
+ * Returns a string for the default browser if known, binned to known browsers.
+ *
+ * @return {AString}
+ * The current default browser.
+ */
+ AString getDefaultBrowser();
+
+ /**
+ * Gets and replaces the previously found default browser from the registry.
+ *
+ * @param {AString} aCurrentBrowser
+ * The current known browser to save to the registry.
+ * @return {AString}
+ * The previous known browser from the registry.
+ */
+ AString getReplacePreviousDefaultBrowser(in AString aCurrentBrowser);
+
+ /**
+ * Returns a string for the default PDF handler if known, binned to known
+ * PDF handlers.
+ *
+ * @return {AString}
+ * The previous default PDF handler.
+ */
+ AString getDefaultPdfHandler();
+
+ /**
+ * Sends a Default Agent telemetry ping.
+ *
+ * @param {AString} aCurrentBrowser
+ * The current known browser.
+ * @param {AString} aPreviousBrowser
+ * The previous known browser.
+ * @param {AString} aPdfHandler
+ * The current known PDF handler.
+ * @param {AString} aNotificationShown
+ * If the notification was or wasn't shown. See
+ * `toolkit/mozapps/defaultagent/Notification.h` for valid values.
+ * @param {AString} aNotificationAction
+ * The notification action taken by the user. See
+ * `toolkit/mozapps/defaultagent/Notification.h` for valid values.
+ *
+ */
+ void sendPing(in AString aCurrentBrowser, in AString aPreviousBrowser, in AString aPdfHandler, in AString aNotificationShown, in AString aNotificationAction);
+
+ /**
+ * Set the default browser and optionally additional file extensions via the
+ * UserChoice registry keys.
+ *
+ * @param {AString} aAumid
+ * Suffix to be appended to ProgIDs when registering system defaults.
+ * @param {Array<AString>} aExtraFileExtensions
+ * Additional optional file extensions to register specified as argument
+ * pairs: the first element is the file extension, the second element is
+ * the root of a ProgID, which will be suffixed with `-{aAumid}`.
+ */
+ void setDefaultBrowserUserChoice(in AString aAumid, in Array<AString> aExtraFileExtensions);
+
+ /**
+ * Set the default browser and optionally additional file extensions via the
+ * UserChoice registry keys, asynchronously. Does the actual work on a
+ * background thread.
+ *
+ * @param {AString} aAumid
+ * Suffix to be appended to ProgIDs when registering system defaults.
+ * @param {Array<AString>} aExtraFileExtensions
+ * Additional optional file extensions to register specified as argument
+ * pairs: the first element is the file extension, the second element is
+ * the root of a ProgID, which will be suffixed with `-{aAumid}`.
+ */
+ [implicit_jscontext]
+ Promise setDefaultBrowserUserChoiceAsync(in AString aAumid, in Array<AString> aExtraFileExtensions);
+
+ /**
+ * Sets file extensions via the UserChoice registry keys.
+ *
+ * @param {AString} aAumid
+ * Suffix to be appended to ProgIDs when registering system defaults.
+ * @param {Array<AString>} aExtraFileExtensions
+ * File extensions to register specified as argument pairs: the first
+ * element is the file extension, the second element is the root of a
+ * ProgID, which will be suffixed with `-{aAumid}`.
+ */
+ void setDefaultExtensionHandlersUserChoice(in AString aAumid, in Array<AString> aFileExtensions);
+
+ /**
+ * Checks if the default agent has been disabled.
+ *
+ * @return {boolean} true if the default agent is disabled.
+ */
+ boolean agentDisabled();
+};
diff --git a/toolkit/mozapps/defaultagent/nsIWindowsMutex.idl b/toolkit/mozapps/defaultagent/nsIWindowsMutex.idl
new file mode 100644
index 0000000000..69090aa764
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/nsIWindowsMutex.idl
@@ -0,0 +1,62 @@
+/* -*- Mode: IDL; 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 "nsISupports.idl"
+
+/**
+* Interact with Windows named mutexes.
+*
+* Generally you don't want a Windows named mutex, you want one of the many Gecko
+* locking primitives. But when you do want cross-application instance or even
+* cross-installation coordination, a Windows named mutex might be an
+* appropriate tool.
+*/
+[scriptable, uuid(26f09999-c26e-4b72-8747-5adaefa0914c)]
+interface nsIWindowsMutex : nsISupports
+{
+ /**
+ * Locks the mutex.
+ *
+ * Note that this will not block waiting to lock. It attempts to lock the mutex
+ * and if it can't immediately, NS_ERROR_NOT_AVAILABLE will be thrown.
+ *
+ * This function succeeds when an abandoned mutex is found, therefore is
+ * inappropriate for use if an abandoned mutex might imply the locked resource
+ * is in a corrupt state.
+ *
+ * @throws NS_ERROR_NOT_AVAILABLE
+ * If unable to lock the mutex.
+ */
+ void tryLock();
+
+ /**
+ * Returns whether the mutex is locked.
+ *
+ * @return {boolean} true if locked, false if unlocked.
+ */
+ bool isLocked();
+
+ /**
+ * Unlocks the mutex.
+ * @throws NS_ERROR_UNEXPECTED
+ * If unable to release mutex.
+ */
+ void unlock();
+};
+
+[scriptable, uuid(d54fe2b7-438f-4629-9706-1acda5b51088)]
+interface nsIWindowsMutexFactory : nsISupports {
+ /**
+ * Creates a Windows named mutex.
+ *
+ * @param {AString} aName
+ * The system-wide name of the mutex.
+ * @return {nsIWindowsMutex}
+ * The created Windows mutex.
+ * @throws NS_ERROR_NOT_AVAILABLE
+ * If unable to create mutex.
+ */
+ nsIWindowsMutex createMutex(in AString aName);
+};
diff --git a/toolkit/mozapps/defaultagent/pings.yaml b/toolkit/mozapps/defaultagent/pings.yaml
new file mode 100644
index 0000000000..5e607f8c8f
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/pings.yaml
@@ -0,0 +1,42 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+---
+$schema: moz://mozilla.org/schemas/glean/pings/2-0-0
+
+default-agent:
+ description: >
+ This opt-out ping is sent from the Default Agent, which is a Windows-only
+ Firefox Background Task that is registered during Firefox installation with
+ the Windows scheduled tasks system so that it runs automatically every 24
+ hours, whether Firefox is running or not.
+
+ Opting out of telemetry is handled via the pref value being copied to the
+ registry so that the Default Agent can read it without needing to work with
+ profiles. Relevant policies are consulted as well. The agent also has its own
+ pref, `default-agent.enabled`, which if set to false disables all agent
+ functionality, including generating this ping.
+
+ Each installation of Firefox has its own copy of the agent and its own
+ scheduled task which shares a common `LastPingSentAt` user registry key with
+ other installations. Installations race to send a single ping per 24 hour
+ window per installing user. If multiple operating system-level users are all
+ using one copy of Firefox, only one scheduled task will have been created and
+ only one ping will be sent, even though the users might have different
+ default browser settings. If multiple users have installed Firefox then each
+ installing user will have a scheduled task and ping.
+
+ Additional information for the Default Agent can be found in the
+ [Default Browser Agent docs](https://firefox-source-docs.mozilla.org/toolkit/mozapps/defaultagent/default-browser-agent/index.html).
+ include_client_id: false
+ send_if_empty: false
+ reasons:
+ daily_ping: |
+ The ping was sent as part of the daily scheduled Default Agent run.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1838755
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1621293
+ notification_emails:
+ - install-update@mozilla.com
diff --git a/toolkit/mozapps/defaultagent/proxy/Makefile.in b/toolkit/mozapps/defaultagent/proxy/Makefile.in
new file mode 100644
index 0000000000..dadff2846b
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/proxy/Makefile.in
@@ -0,0 +1,16 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 binary should never open a console window in release builds, because
+# it's going to run in the background when the user may not expect it, and
+# we don't want a console window to just appear out of nowhere on them.
+# For debug builds though, it's okay to use the existing MOZ_WINCONSOLE value.
+ifndef MOZ_DEBUG
+MOZ_WINCONSOLE = 0
+endif
+
+# Rebuild if the resources or manifest change.
+EXTRA_DEPS += $(srcdir)/default-browser-agent.exe.manifest
+
+include $(topsrcdir)/config/rules.mk
diff --git a/toolkit/mozapps/defaultagent/proxy/default-browser-agent.exe.manifest b/toolkit/mozapps/defaultagent/proxy/default-browser-agent.exe.manifest
new file mode 100644
index 0000000000..ceb2839697
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/proxy/default-browser-agent.exe.manifest
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="DefaultBrowserAgent"
+ type="win32"
+/>
+<description>Default Browser Agent</description>
+<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:security>
+ <ms_asmv3:requestedPrivileges>
+ <ms_asmv3:requestedExecutionLevel level="asInvoker" uiAccess="false" />
+ </ms_asmv3:requestedPrivileges>
+ </ms_asmv3:security>
+</ms_asmv3:trustInfo>
+<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+</compatibility>
+<ms_asmv3:application xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
+ <dpiAware>True/PM</dpiAware>
+ <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
+ </ms_asmv3:windowsSettings>
+</ms_asmv3:application>
+</assembly>
diff --git a/toolkit/mozapps/defaultagent/proxy/main.cpp b/toolkit/mozapps/defaultagent/proxy/main.cpp
new file mode 100644
index 0000000000..53efdcb9ae
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/proxy/main.cpp
@@ -0,0 +1,118 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <windows.h>
+#include <shlwapi.h>
+#include <objbase.h>
+#include <string.h>
+#include <filesystem>
+
+#include "../ScheduledTask.h"
+#include "../ScheduledTaskRemove.h"
+#include "mozilla/CmdLineAndEnvUtils.h"
+
+using namespace mozilla::default_agent;
+
+// See BackgroundTask_defaultagent.sys.mjs for arguments.
+int wmain(int argc, wchar_t** argv) {
+ // Firefox deescalates process permissions, so handle task unscheduling step
+ // here instead of the Firefox Background Tasks to ensure cleanup for other
+ // users. See Bug 1710143.
+ if (!wcscmp(argv[1], L"uninstall")) {
+ if (argc < 3 || !argv[2]) {
+ return E_INVALIDARG;
+ }
+
+ HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
+ if (FAILED(hr)) {
+ return hr;
+ }
+
+ RemoveTasks(argv[2], WhichTasks::AllTasksForInstallation);
+
+ CoUninitialize();
+
+ // Background Task handles remainder of uninstall.
+ }
+
+ std::vector<wchar_t> path(MAX_PATH, 0);
+ DWORD charsWritten = GetModuleFileNameW(nullptr, path.data(), path.size());
+
+ // GetModuleFileNameW returns the count of characters written including null
+ // when truncated, excluding null otherwise. Therefore the count will always
+ // be less than the buffer size when not truncated.
+ while (charsWritten == path.size()) {
+ path.resize(path.size() * 2, 0);
+ charsWritten = GetModuleFileNameW(nullptr, path.data(), path.size());
+ }
+
+ if (charsWritten == 0) {
+ return E_UNEXPECTED;
+ }
+
+ std::filesystem::path programPath = path.data();
+ programPath = programPath.parent_path();
+ programPath += L"\\" MOZ_APP_NAME L".exe";
+
+ std::vector<const wchar_t*> childArgv;
+ childArgv.push_back(programPath.c_str());
+ childArgv.push_back(L"--backgroundtask");
+ childArgv.push_back(L"defaultagent");
+ // Skip argv[0], path to this exectuable.
+ for (int i = 1; i < argc; i++) {
+ childArgv.push_back(argv[i]);
+ }
+
+ auto cmdLine = mozilla::MakeCommandLine(childArgv.size(), childArgv.data());
+
+ STARTUPINFOW si = {};
+ si.cb = sizeof(STARTUPINFOW);
+ PROCESS_INFORMATION pi = {};
+
+ // Runs `{program path} --backgoundtask defaultagent`.
+ CreateProcessW(programPath.c_str(), cmdLine.get(), nullptr, nullptr, false,
+ DETACHED_PROCESS | NORMAL_PRIORITY_CLASS, nullptr, nullptr,
+ &si, &pi);
+
+ // Wait until process exists so uninstalling doesn't interrupt the background
+ // task cleaning registry entries.
+ DWORD exitCode;
+ if (WaitForSingleObject(pi.hProcess, INFINITE) == WAIT_OBJECT_0 &&
+ ::GetExitCodeProcess(pi.hProcess, &exitCode)) {
+ // Match EXIT_CODE in BackgroundTasksManager.sys.mjs and
+ // BackgroundTask_defaultagent.sys.mjs.
+ enum EXIT_CODE {
+ SUCCESS = 0,
+ NOT_FOUND = 2,
+ EXCEPTION = 3,
+ TIMEOUT = 4,
+ DISABLED_BY_POLICY = 11,
+ INVALID_ARGUMENT = 12,
+ MUTEX_NOT_LOCKABLE = 13,
+ };
+
+ switch (exitCode) {
+ case SUCCESS:
+ return S_OK;
+ case NOT_FOUND:
+ return E_UNEXPECTED;
+ case EXCEPTION:
+ return E_FAIL;
+ case TIMEOUT:
+ return E_FAIL;
+ case DISABLED_BY_POLICY:
+ return HRESULT_FROM_WIN32(ERROR_ACCESS_DISABLED_BY_POLICY);
+ case INVALID_ARGUMENT:
+ return E_INVALIDARG;
+ case MUTEX_NOT_LOCKABLE:
+ return HRESULT_FROM_WIN32(ERROR_SHARING_VIOLATION);
+ default:
+ return E_UNEXPECTED;
+ }
+ }
+
+ return E_UNEXPECTED;
+}
diff --git a/toolkit/mozapps/defaultagent/proxy/moz.build b/toolkit/mozapps/defaultagent/proxy/moz.build
new file mode 100644
index 0000000000..40d7655dfa
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/proxy/moz.build
@@ -0,0 +1,68 @@
+# -*- 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/.
+
+Program("default-browser-agent")
+
+SPHINX_TREES["default-browser-agent"] = "docs"
+
+UNIFIED_SOURCES += [
+ "../EventLog.cpp",
+ "../ScheduledTaskRemove.cpp",
+ "main.cpp",
+]
+
+SOURCES += [
+ "/browser/components/shell/WindowsDefaultBrowser.cpp",
+ "/other-licenses/nsis/Contrib/CityHash/cityhash/city.cpp",
+ "/toolkit/mozapps/update/common/readstrings.cpp",
+]
+
+LOCAL_INCLUDES += [
+ "../",
+ "/browser/components/shell/",
+ "/mfbt/",
+ "/other-licenses/nsis/Contrib/CityHash/cityhash",
+ "/toolkit/mozapps/update/common/",
+]
+
+OS_LIBS += [
+ "advapi32",
+ "comsupp",
+ "netapi32",
+ "ole32",
+ "oleaut32",
+ "shell32",
+ "shlwapi",
+ "taskschd",
+]
+
+DEFINES["NS_NO_XPCOM"] = True
+DEFINES["IMPL_MFBT"] = True
+
+DEFINES["UNICODE"] = True
+DEFINES["_UNICODE"] = True
+
+for var in (
+ "MOZ_APP_NAME",
+ "MOZ_APP_DISPLAYNAME",
+ "MOZ_APP_VENDOR",
+ "MOZ_APP_BASENAME",
+):
+ DEFINES[var] = '"%s"' % CONFIG[var]
+
+# We need STL headers that aren't allowed when wrapping is on (at least
+# <filesystem>, and possibly others).
+DisableStlWrapping()
+
+# We need this to be able to use wmain as the entry point on MinGW;
+# otherwise it will try to use WinMain.
+if CONFIG["CC_TYPE"] == "clang-cl":
+ WIN32_EXE_LDFLAGS += ["-ENTRY:wmainCRTStartup"]
+else:
+ WIN32_EXE_LDFLAGS += ["-municode"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Default Browser Agent")
diff --git a/toolkit/mozapps/defaultagent/tests/gtest/CacheTest.cpp b/toolkit/mozapps/defaultagent/tests/gtest/CacheTest.cpp
new file mode 100644
index 0000000000..892be6b2f7
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/tests/gtest/CacheTest.cpp
@@ -0,0 +1,301 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "gtest/gtest.h"
+
+#include <string>
+
+#include "Cache.h"
+#include "common.h"
+#include "Registry.h"
+#include "UtfConvert.h"
+
+#include "mozilla/Result.h"
+#include "mozilla/UniquePtr.h"
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+using namespace mozilla::default_agent;
+
+class WDBACacheTest : public ::testing::Test {
+ protected:
+ std::wstring mCacheRegKey;
+
+ void SetUp() override {
+ // Create a unique registry key to put the cache in for each test.
+ const ::testing::TestInfo* const testInfo =
+ ::testing::UnitTest::GetInstance()->current_test_info();
+ Utf8ToUtf16Result testCaseResult = Utf8ToUtf16(testInfo->test_case_name());
+ ASSERT_TRUE(testCaseResult.isOk());
+ mCacheRegKey = testCaseResult.unwrap();
+
+ Utf8ToUtf16Result testNameResult = Utf8ToUtf16(testInfo->name());
+ ASSERT_TRUE(testNameResult.isOk());
+ mCacheRegKey += L'.';
+ mCacheRegKey += testNameResult.unwrap();
+
+ FilePathResult uuidResult = GenerateUUIDStr();
+ ASSERT_TRUE(uuidResult.isOk());
+ mCacheRegKey += L'.';
+ mCacheRegKey += uuidResult.unwrap();
+ }
+
+ void TearDown() override {
+ // It seems like the TearDown probably doesn't run if SetUp doesn't
+ // succeed, but I can't find any documentation saying that. And we don't
+ // want to accidentally clobber the entirety of AGENT_REGKEY_NAME.
+ if (!mCacheRegKey.empty()) {
+ std::wstring regKey = AGENT_REGKEY_NAME;
+ regKey += L'\\';
+ regKey += mCacheRegKey;
+ RegDeleteTreeW(HKEY_CURRENT_USER, regKey.c_str());
+ }
+ }
+};
+
+TEST_F(WDBACacheTest, BasicFunctionality) {
+ Cache cache(mCacheRegKey.c_str());
+ VoidResult result = cache.Init();
+ ASSERT_TRUE(result.isOk());
+
+ // Test that the cache starts empty
+ Cache::MaybeEntryResult entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ Cache::MaybeEntry entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isNothing());
+
+ // Test that the cache stops accepting items when it is full.
+ ASSERT_EQ(Cache::kDefaultCapacity, 2U);
+ Cache::Entry toWrite = Cache::Entry{
+ .notificationType = "string1",
+ .notificationShown = "string2",
+ .notificationAction = "string3",
+ .prevNotificationAction = "string4",
+ };
+ result = cache.Enqueue(toWrite);
+ ASSERT_TRUE(result.isOk());
+ toWrite = Cache::Entry{
+ .notificationType = "string5",
+ .notificationShown = "string6",
+ .notificationAction = "string7",
+ .prevNotificationAction = "string8",
+ };
+ result = cache.Enqueue(toWrite);
+ ASSERT_TRUE(result.isOk());
+ toWrite = Cache::Entry{
+ .notificationType = "string9",
+ .notificationShown = "string10",
+ .notificationAction = "string11",
+ .prevNotificationAction = "string12",
+ };
+ result = cache.Enqueue(toWrite);
+ ASSERT_TRUE(result.isErr());
+
+ // Read the two cache entries back out and test that they match the expected
+ // values.
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 2U);
+ ASSERT_EQ(entry.value().notificationType, "string1");
+ ASSERT_EQ(entry.value().notificationShown, "string2");
+ ASSERT_EQ(entry.value().notificationAction, "string3");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isSome());
+ ASSERT_EQ(entry.value().prevNotificationAction.value(), "string4");
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 2U);
+ ASSERT_EQ(entry.value().notificationType, "string5");
+ ASSERT_EQ(entry.value().notificationShown, "string6");
+ ASSERT_EQ(entry.value().notificationAction, "string7");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isSome());
+ ASSERT_EQ(entry.value().prevNotificationAction.value(), "string8");
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isNothing());
+}
+
+TEST_F(WDBACacheTest, Version1Migration) {
+ // Set up 2 version 1 cache entries
+ VoidResult result = RegistrySetValueString(
+ IsPrefixed::Unprefixed, L"PingCacheNotificationType0", "string1");
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ L"PingCacheNotificationShown0", "string2");
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ L"PingCacheNotificationAction0", "string3");
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ L"PingCacheNotificationType1", "string4");
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ L"PingCacheNotificationShown1", "string5");
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ L"PingCacheNotificationAction1", "string6");
+ ASSERT_TRUE(result.isOk());
+
+ Cache cache(mCacheRegKey.c_str());
+ result = cache.Init();
+ ASSERT_TRUE(result.isOk());
+
+ Cache::MaybeEntryResult entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ Cache::MaybeEntry entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 1U);
+ ASSERT_EQ(entry.value().notificationType, "string1");
+ ASSERT_EQ(entry.value().notificationShown, "string2");
+ ASSERT_EQ(entry.value().notificationAction, "string3");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isNothing());
+
+ // Insert a new item to test coexistence of different versions
+ Cache::Entry toWrite = Cache::Entry{
+ .notificationType = "string7",
+ .notificationShown = "string8",
+ .notificationAction = "string9",
+ .prevNotificationAction = "string10",
+ };
+ result = cache.Enqueue(toWrite);
+ ASSERT_TRUE(result.isOk());
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 1U);
+ ASSERT_EQ(entry.value().notificationType, "string4");
+ ASSERT_EQ(entry.value().notificationShown, "string5");
+ ASSERT_EQ(entry.value().notificationAction, "string6");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isNothing());
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 2U);
+ ASSERT_EQ(entry.value().notificationType, "string7");
+ ASSERT_EQ(entry.value().notificationShown, "string8");
+ ASSERT_EQ(entry.value().notificationAction, "string9");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isSome());
+ ASSERT_EQ(entry.value().prevNotificationAction.value(), "string10");
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isNothing());
+}
+
+TEST_F(WDBACacheTest, ForwardsCompatibility) {
+ // Set up a cache that might have been made by a future version with a larger
+ // capacity set and more keys per entry.
+ std::wstring settingsKey = mCacheRegKey + L"\\version2";
+ VoidResult result = RegistrySetValueDword(
+ IsPrefixed::Unprefixed, Cache::kCapacityRegName, 8, settingsKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ // We're going to insert the future version's entry at index 6 so there's
+ // space for 1 more before we loop back to index 0. Then we are going to
+ // enqueue 2 new values to test that this works properly.
+ result = RegistrySetValueDword(IsPrefixed::Unprefixed, Cache::kFrontRegName,
+ 6, settingsKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueDword(IsPrefixed::Unprefixed, Cache::kSizeRegName, 1,
+ settingsKey.c_str());
+ ASSERT_TRUE(result.isOk());
+
+ // Insert an entry as if it was inserted by a future version
+ std::wstring entryRegKey = settingsKey + L"\\6";
+ result =
+ RegistrySetValueDword(IsPrefixed::Unprefixed, Cache::kEntryVersionKey,
+ 9999, entryRegKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ Cache::kNotificationTypeKey, "string1",
+ entryRegKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ Cache::kNotificationShownKey, "string2",
+ entryRegKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ Cache::kNotificationActionKey, "string3",
+ entryRegKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed,
+ Cache::kPrevNotificationActionKey, "string4",
+ entryRegKey.c_str());
+ ASSERT_TRUE(result.isOk());
+ result = RegistrySetValueString(IsPrefixed::Unprefixed, L"UnknownFutureKey",
+ "string5", entryRegKey.c_str());
+ ASSERT_TRUE(result.isOk());
+
+ Cache cache(mCacheRegKey.c_str());
+ result = cache.Init();
+ ASSERT_TRUE(result.isOk());
+
+ // Insert 2 new items to test that these features work with a different
+ // capacity.
+ Cache::Entry toWrite = Cache::Entry{
+ .notificationType = "string6",
+ .notificationShown = "string7",
+ .notificationAction = "string8",
+ .prevNotificationAction = "string9",
+ };
+ result = cache.Enqueue(toWrite);
+ ASSERT_TRUE(result.isOk());
+ toWrite = Cache::Entry{
+ .notificationType = "string10",
+ .notificationShown = "string11",
+ .notificationAction = "string12",
+ .prevNotificationAction = "string13",
+ };
+ result = cache.Enqueue(toWrite);
+ ASSERT_TRUE(result.isOk());
+
+ // Read cache and verify the output
+ Cache::MaybeEntryResult entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ Cache::MaybeEntry entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 9999U);
+ ASSERT_EQ(entry.value().notificationType, "string1");
+ ASSERT_EQ(entry.value().notificationShown, "string2");
+ ASSERT_EQ(entry.value().notificationAction, "string3");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isSome());
+ ASSERT_EQ(entry.value().prevNotificationAction.value(), "string4");
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 2U);
+ ASSERT_EQ(entry.value().notificationType, "string6");
+ ASSERT_EQ(entry.value().notificationShown, "string7");
+ ASSERT_EQ(entry.value().notificationAction, "string8");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isSome());
+ ASSERT_EQ(entry.value().prevNotificationAction.value(), "string9");
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isSome());
+ ASSERT_EQ(entry.value().entryVersion, 2U);
+ ASSERT_EQ(entry.value().notificationType, "string10");
+ ASSERT_EQ(entry.value().notificationShown, "string11");
+ ASSERT_EQ(entry.value().notificationAction, "string12");
+ ASSERT_TRUE(entry.value().prevNotificationAction.isSome());
+ ASSERT_EQ(entry.value().prevNotificationAction.value(), "string13");
+
+ entryResult = cache.Dequeue();
+ ASSERT_TRUE(entryResult.isOk());
+ entry = entryResult.unwrap();
+ ASSERT_TRUE(entry.isNothing());
+}
diff --git a/toolkit/mozapps/defaultagent/tests/gtest/SetDefaultBrowserTest.cpp b/toolkit/mozapps/defaultagent/tests/gtest/SetDefaultBrowserTest.cpp
new file mode 100644
index 0000000000..7c491184d9
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/tests/gtest/SetDefaultBrowserTest.cpp
@@ -0,0 +1,55 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "gtest/gtest.h"
+
+#include <windows.h>
+#include "mozilla/UniquePtr.h"
+#include "WindowsUserChoice.h"
+
+#include "SetDefaultBrowser.h"
+
+using namespace mozilla::default_agent;
+
+TEST(SetDefaultBrowserUserChoice, Hash)
+{
+ // Hashes set by System Settings on 64-bit Windows 10 Pro 20H2 (19042.928).
+ const wchar_t* sid = L"S-1-5-21-636376821-3290315252-1794850287-1001";
+
+ // length mod 8 = 0
+ EXPECT_STREQ(
+ GenerateUserChoiceHash(L"https", sid, L"FirefoxURL-308046B0AF4A39CB",
+ (SYSTEMTIME){2021, 4, 1, 19, 23, 7, 56, 506})
+ .get(),
+ L"uzpIsMVyZ1g=");
+
+ // length mod 8 = 2 (confirm that the incomplete last block is dropped)
+ EXPECT_STREQ(
+ GenerateUserChoiceHash(L".html", sid, L"FirefoxHTML-308046B0AF4A39CB",
+ (SYSTEMTIME){2021, 4, 1, 19, 23, 7, 56, 519})
+ .get(),
+ L"7fjRtUPASlc=");
+
+ // length mod 8 = 4
+ EXPECT_STREQ(
+ GenerateUserChoiceHash(L"https", sid, L"MSEdgeHTM",
+ (SYSTEMTIME){2021, 4, 1, 19, 23, 3, 48, 119})
+ .get(),
+ L"Fz0kA3Ymmps=");
+
+ // length mod 8 = 6
+ EXPECT_STREQ(
+ GenerateUserChoiceHash(L".html", sid, L"ChromeHTML",
+ (SYSTEMTIME){2021, 4, 1, 19, 23, 6, 3, 628})
+ .get(),
+ L"R5TD9LGJ5Xw=");
+
+ // non-ASCII
+ EXPECT_STREQ(
+ GenerateUserChoiceHash(L".html", sid, L"FirefoxHTML-ÀBÇDË😀†",
+ (SYSTEMTIME){2021, 4, 2, 20, 0, 38, 55, 101})
+ .get(),
+ L"F3NsK3uNv5E=");
+}
diff --git a/toolkit/mozapps/defaultagent/tests/gtest/moz.build b/toolkit/mozapps/defaultagent/tests/gtest/moz.build
new file mode 100644
index 0000000000..07fa68228c
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/tests/gtest/moz.build
@@ -0,0 +1,33 @@
+# -*- Mode: python; c-basic-offset: 4; 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 https://mozilla.org/MPL/2.0/.
+
+Library("DefaultAgentTest")
+
+UNIFIED_SOURCES += [
+ "CacheTest.cpp",
+ "SetDefaultBrowserTest.cpp",
+]
+
+LOCAL_INCLUDES += [
+ "/browser/components/shell/",
+ "/toolkit/mozapps/defaultagent",
+]
+
+OS_LIBS += [
+ "advapi32",
+ "bcrypt",
+ "crypt32",
+ "kernel32",
+ "rpcrt4",
+]
+
+DEFINES["UNICODE"] = True
+DEFINES["_UNICODE"] = True
+
+for var in ("MOZ_APP_BASENAME", "MOZ_APP_DISPLAYNAME", "MOZ_APP_VENDOR"):
+ DEFINES[var] = '"%s"' % CONFIG[var]
+
+FINAL_LIBRARY = "xul-gtest"
diff --git a/toolkit/mozapps/defaultagent/tests/xpcshell/test_windows_mutex.js b/toolkit/mozapps/defaultagent/tests/xpcshell/test_windows_mutex.js
new file mode 100644
index 0000000000..ac020ae9f2
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/tests/xpcshell/test_windows_mutex.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Multiple instances of a named mutex on Windows can lock on the same thread, so
+// we have to run each test across at least two distinct threads. Running on a
+// separate process achieves the same.
+do_load_child_test_harness();
+
+let parentFactory = Cc["@mozilla.org/windows-mutex-factory;1"].createInstance(
+ Ci.nsIWindowsMutexFactory
+);
+
+function promiseCommand(aCommand) {
+ // Exceptions don't propogate to the process that called `sendCommand` nor
+ // tigger a test failure, so wrap the command to ensure we fail appropriately.
+ let wrappedCommand = `try {${aCommand}} catch(e) {Assert.ok(false, "Error running command received in child process. Note the passed in function must be self-contained. Error: \${e.toString()}");}`;
+ return new Promise(resolve => sendCommand(wrappedCommand, resolve));
+}
+
+// This is passed as a string to a child process, thus must be self-contained.
+function assertLockOkOnChild(aName, aTestString) {
+ if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) {
+ Assert.ok(false, `${assertLockOkOnChild.name} run on child process.`);
+ }
+
+ let childFactory = Cc["@mozilla.org/windows-mutex-factory;1"].createInstance(
+ Ci.nsIWindowsMutexFactory
+ );
+
+ let lockingMutex = childFactory.createMutex(aName);
+
+ info(`Locking mutex for subtest "${aTestString}"`);
+ lockingMutex.tryLock();
+ try {
+ Assert.ok(lockingMutex.isLocked(), aTestString);
+ } finally {
+ lockingMutex.unlock();
+ }
+}
+
+// This is passed as a string to a child process, thus must be self-contained.
+function assertLockThrowsOnChild(aName, aTestString) {
+ if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT) {
+ Assert.ok(false, `${assertLockThrowsOnChild.name} run on child process.`);
+ }
+
+ let childFactory = Cc["@mozilla.org/windows-mutex-factory;1"].createInstance(
+ Ci.nsIWindowsMutexFactory
+ );
+
+ let blockedMutex = childFactory.createMutex(aName);
+
+ info(`Locking mutex for subtest "${aTestString}"`);
+ Assert.throws(blockedMutex.tryLock, /NS_ERROR_NOT_AVAILABLE/, aTestString);
+ Assert.ok(!blockedMutex.isLocked(), "Not locked after error.");
+}
+
+add_task(async function test_lock_blocks() {
+ const kTestMutexName = Services.uuid.generateUUID().toString();
+ let lockingMutex = parentFactory.createMutex(kTestMutexName);
+
+ Assert.ok(!lockingMutex.isLocked(), "Reported unlocked before locking.");
+
+ info(`Locking mutex named "${kTestMutexName}"`);
+ lockingMutex.tryLock();
+ try {
+ Assert.ok(lockingMutex.isLocked(), "Reported locked after locking.");
+
+ await promiseCommand(
+ `(${assertLockThrowsOnChild.toString()})("${kTestMutexName}", "Concurrent attempts to lock identically named mutex throws.");`
+ );
+ } finally {
+ lockingMutex.unlock();
+ }
+});
+
+add_task(async function test_unlock_unblocks() {
+ const kTestMutexName = Services.uuid.generateUUID().toString();
+ let lockingMutex = parentFactory.createMutex(kTestMutexName);
+
+ info(`Locking mutex named "${kTestMutexName}"`);
+ lockingMutex.tryLock();
+ lockingMutex.unlock();
+
+ Assert.ok(!lockingMutex.isLocked(), "Reported unlocked after unlocking.");
+
+ await promiseCommand(
+ `(${assertLockOkOnChild.toString()})("${kTestMutexName}", "Locked previously unlocked mutex.");`
+ );
+});
+
+add_task(async function test_names_dont_conflict() {
+ const kTestMutexName = Services.uuid.generateUUID().toString();
+ let mutex1 = parentFactory.createMutex(kTestMutexName);
+
+ info(`Locking mutex named "${kTestMutexName}"`);
+ mutex1.tryLock();
+ try {
+ await promiseCommand(
+ `(${assertLockOkOnChild.toString()})(Services.uuid.generateUUID().toString(), "Differently named mutexes don't conflict");`
+ );
+ } finally {
+ mutex1.unlock();
+ }
+});
+
+add_task(async function test_relock_when_locked() {
+ const kTestMutexName = Services.uuid.generateUUID().toString();
+ let mutex = parentFactory.createMutex(kTestMutexName);
+
+ mutex.tryLock();
+ try {
+ Assert.ok(() => mutex.tryLock(), "Relocking locked mutex succeeds.");
+ Assert.ok(
+ mutex.isLocked(),
+ "Reported locked after relocking locked mutex."
+ );
+ } finally {
+ mutex.unlock();
+ }
+});
+
+add_task(async function test_unlock_without_lock() {
+ const kTestMutexName = Services.uuid.generateUUID().toString();
+ let mutex = parentFactory.createMutex(kTestMutexName);
+
+ mutex.unlock();
+ Assert.ok(
+ !mutex.isLocked(),
+ "Reported unlocked after unnecessarily unlocking mutex."
+ );
+
+ mutex.tryLock();
+ try {
+ Assert.ok(
+ mutex.isLocked(),
+ "Reported locked after locking unnecessarily unlocked mutex."
+ );
+ } finally {
+ mutex.unlock();
+ }
+});
diff --git a/toolkit/mozapps/defaultagent/tests/xpcshell/xpcshell.toml b/toolkit/mozapps/defaultagent/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..df1992fb13
--- /dev/null
+++ b/toolkit/mozapps/defaultagent/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,4 @@
+[DEFAULT]
+run-if = ["os == 'win'"]
+
+["test_windows_mutex.js"]
diff --git a/toolkit/mozapps/downloads/DownloadLastDir.sys.mjs b/toolkit/mozapps/downloads/DownloadLastDir.sys.mjs
new file mode 100644
index 0000000000..da0439dcc5
--- /dev/null
+++ b/toolkit/mozapps/downloads/DownloadLastDir.sys.mjs
@@ -0,0 +1,254 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/*
+ * The behavior implemented by gDownloadLastDir is documented here.
+ *
+ * In normal browsing sessions, gDownloadLastDir uses the browser.download.lastDir
+ * preference to store the last used download directory. The first time the user
+ * switches into the private browsing mode, the last download directory is
+ * preserved to the pref value, but if the user switches to another directory
+ * during the private browsing mode, that directory is not stored in the pref,
+ * and will be merely kept in memory. When leaving the private browsing mode,
+ * this in-memory value will be discarded, and the last download directory
+ * will be reverted to the pref value.
+ *
+ * Both the pref and the in-memory value will be cleared when clearing the
+ * browsing history. This effectively changes the last download directory
+ * to the default download directory on each platform.
+ *
+ * If passed a URI, the last used directory is also stored with that URI in the
+ * content preferences database. This can be disabled by setting the pref
+ * browser.download.lastDir.savePerSite to false.
+ */
+
+const LAST_DIR_PREF = "browser.download.lastDir";
+const SAVE_PER_SITE_PREF = LAST_DIR_PREF + ".savePerSite";
+const nsIFile = Ci.nsIFile;
+
+import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "cps2",
+ "@mozilla.org/content-pref/service;1",
+ "nsIContentPrefService2"
+);
+
+let nonPrivateLoadContext = Cu.createLoadContext();
+let privateLoadContext = Cu.createPrivateLoadContext();
+
+var observer = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "last-pb-context-exited":
+ gDownloadLastDirFile = null;
+ break;
+ case "browser:purge-session-history":
+ gDownloadLastDirFile = null;
+ if (Services.prefs.prefHasUserValue(LAST_DIR_PREF)) {
+ Services.prefs.clearUserPref(LAST_DIR_PREF);
+ }
+ // Ensure that purging session history causes both the session-only PB cache
+ // and persistent prefs to be cleared.
+ let promises = [
+ new Promise(resolve =>
+ lazy.cps2.removeByName(LAST_DIR_PREF, nonPrivateLoadContext, {
+ handleCompletion: resolve,
+ })
+ ),
+ new Promise(resolve =>
+ lazy.cps2.removeByName(LAST_DIR_PREF, privateLoadContext, {
+ handleCompletion: resolve,
+ })
+ ),
+ ];
+ // This is for testing purposes.
+ if (aSubject && typeof subject == "object") {
+ aSubject.promise = Promise.all(promises);
+ }
+ break;
+ }
+ },
+};
+
+Services.obs.addObserver(observer, "last-pb-context-exited", true);
+Services.obs.addObserver(observer, "browser:purge-session-history", true);
+
+function readLastDirPref() {
+ try {
+ return Services.prefs.getComplexValue(LAST_DIR_PREF, nsIFile);
+ } catch (e) {
+ return null;
+ }
+}
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isContentPrefEnabled",
+ SAVE_PER_SITE_PREF,
+ true
+);
+
+var gDownloadLastDirFile = readLastDirPref();
+
+export class DownloadLastDir {
+ // aForcePrivate is only used when aWindow is null.
+ constructor(aWindow, aForcePrivate) {
+ let isPrivate = false;
+ if (aWindow === null) {
+ isPrivate =
+ aForcePrivate || PrivateBrowsingUtils.permanentPrivateBrowsing;
+ } else {
+ let loadContext = aWindow.docShell.QueryInterface(Ci.nsILoadContext);
+ isPrivate = loadContext.usePrivateBrowsing;
+ }
+
+ // We always use a fake load context because we may not have one (i.e.,
+ // in the aWindow == null case) and because the load context associated
+ // with aWindow may disappear by the time we need it. This approach is
+ // safe because we only care about the private browsing state. All the
+ // rest of the load context isn't of interest to the content pref service.
+ this.fakeContext = isPrivate ? privateLoadContext : nonPrivateLoadContext;
+ }
+
+ isPrivate() {
+ return this.fakeContext.usePrivateBrowsing;
+ }
+
+ // compat shims
+ get file() {
+ return this.#getLastFile();
+ }
+ set file(val) {
+ this.setFile(null, val);
+ }
+
+ cleanupPrivateFile() {
+ gDownloadLastDirFile = null;
+ }
+
+ #getLastFile() {
+ if (gDownloadLastDirFile && !gDownloadLastDirFile.exists()) {
+ gDownloadLastDirFile = null;
+ }
+
+ if (this.isPrivate()) {
+ if (!gDownloadLastDirFile) {
+ gDownloadLastDirFile = readLastDirPref();
+ }
+ return gDownloadLastDirFile;
+ }
+ return readLastDirPref();
+ }
+
+ async getFileAsync(aURI) {
+ let plainPrefFile = this.#getLastFile();
+ if (!aURI || !lazy.isContentPrefEnabled) {
+ return plainPrefFile;
+ }
+
+ return new Promise(resolve => {
+ lazy.cps2.getByDomainAndName(
+ this.#cpsGroupFromURL(aURI),
+ LAST_DIR_PREF,
+ this.fakeContext,
+ {
+ _result: null,
+ handleResult(aResult) {
+ this._result = aResult;
+ },
+ handleCompletion(aReason) {
+ let file = plainPrefFile;
+ if (
+ aReason == Ci.nsIContentPrefCallback2.COMPLETE_OK &&
+ this._result instanceof Ci.nsIContentPref
+ ) {
+ try {
+ file = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ file.initWithPath(this._result.value);
+ } catch (e) {
+ file = plainPrefFile;
+ }
+ }
+ resolve(file);
+ },
+ }
+ );
+ });
+ }
+
+ setFile(aURI, aFile) {
+ if (aURI && lazy.isContentPrefEnabled) {
+ if (aFile instanceof Ci.nsIFile) {
+ lazy.cps2.set(
+ this.#cpsGroupFromURL(aURI),
+ LAST_DIR_PREF,
+ aFile.path,
+ this.fakeContext
+ );
+ } else {
+ lazy.cps2.removeByDomainAndName(
+ this.#cpsGroupFromURL(aURI),
+ LAST_DIR_PREF,
+ this.fakeContext
+ );
+ }
+ }
+ if (this.isPrivate()) {
+ if (aFile instanceof Ci.nsIFile) {
+ gDownloadLastDirFile = aFile.clone();
+ } else {
+ gDownloadLastDirFile = null;
+ }
+ } else if (aFile instanceof Ci.nsIFile) {
+ Services.prefs.setComplexValue(LAST_DIR_PREF, nsIFile, aFile);
+ } else if (Services.prefs.prefHasUserValue(LAST_DIR_PREF)) {
+ Services.prefs.clearUserPref(LAST_DIR_PREF);
+ }
+ }
+
+ /**
+ * Pre-processor to extract a domain name to be used with the content-prefs
+ * service. This specially handles data and file URIs so that the download
+ * dirs are recalled in a more consistent way:
+ * - all file:/// URIs share the same folder
+ * - data: URIs share a folder per mime-type. If a mime-type is not
+ * specified text/plain is assumed.
+ * - blob: URIs share the same folder as their origin. This is done by
+ * ContentPrefs already, so we just let the url fall-through.
+ * In any other case the original URL is returned as a string and ContentPrefs
+ * will do its usual parsing.
+ *
+ * @param {string|nsIURI|URL} url The URL to parse
+ * @returns {string} the domain name to use, or the original url.
+ */
+ #cpsGroupFromURL(url) {
+ if (typeof url == "string") {
+ url = new URL(url);
+ } else if (url instanceof Ci.nsIURI) {
+ url = URL.fromURI(url);
+ }
+ if (!URL.isInstance(url)) {
+ return url;
+ }
+ if (url.protocol == "data:") {
+ return url.href.match(/^data:[^;,]*/i)[0].replace(/:$/, ":text/plain");
+ }
+ if (url.protocol == "file:") {
+ return "file:///";
+ }
+ return url.href;
+ }
+}
diff --git a/toolkit/mozapps/downloads/DownloadUtils.sys.mjs b/toolkit/mozapps/downloads/DownloadUtils.sys.mjs
new file mode 100644
index 0000000000..3bf97f3c9e
--- /dev/null
+++ b/toolkit/mozapps/downloads/DownloadUtils.sys.mjs
@@ -0,0 +1,616 @@
+/* vim: sw=2 ts=2 sts=2 expandtab filetype=javascript
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This module provides the DownloadUtils object which contains useful methods
+ * for downloads such as displaying file sizes, transfer times, and download
+ * locations.
+ *
+ * List of methods:
+ *
+ * [string status, double newLast]
+ * getDownloadStatus(int aCurrBytes, [optional] int aMaxBytes,
+ * [optional] double aSpeed, [optional] double aLastSec)
+ *
+ * string progress
+ * getTransferTotal(int aCurrBytes, [optional] int aMaxBytes)
+ *
+ * [string timeLeft, double newLast]
+ * getTimeLeft(double aSeconds, [optional] double aLastSec)
+ *
+ * [string dateCompact, string dateComplete]
+ * getReadableDates(Date aDate, [optional] Date aNow)
+ *
+ * [string displayHost, string fullHost]
+ * getURIHost(string aURIString)
+ *
+ * [string convertedBytes, string units]
+ * convertByteUnits(int aBytes)
+ *
+ * [int time, string units, int subTime, string subUnits]
+ * convertTimeUnits(double aSecs)
+ */
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+
+const BYTE_UNITS = [
+ "download-utils-bytes",
+ "download-utils-kilobyte",
+ "download-utils-megabyte",
+ "download-utils-gigabyte",
+];
+
+const TIME_UNITS = [
+ "download-utils-short-seconds",
+ "download-utils-short-minutes",
+ "download-utils-short-hours",
+ "download-utils-short-days",
+];
+
+// These are the maximum values for seconds, minutes, hours corresponding
+// with TIME_UNITS without the last item
+const TIME_SIZES = [60, 60, 24];
+
+var localeNumberFormatCache = new Map();
+function getLocaleNumberFormat(fractionDigits) {
+ if (!localeNumberFormatCache.has(fractionDigits)) {
+ localeNumberFormatCache.set(
+ fractionDigits,
+ new Services.intl.NumberFormat(undefined, {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })
+ );
+ }
+ return localeNumberFormatCache.get(fractionDigits);
+}
+
+const l10n = new Localization(["toolkit/downloads/downloadUtils.ftl"], true);
+
+// Keep track of at most this many second/lastSec pairs so that multiple calls
+// to getTimeLeft produce the same time left
+const kCachedLastMaxSize = 10;
+var gCachedLast = [];
+
+export var DownloadUtils = {
+ /**
+ * Generate a full status string for a download given its current progress,
+ * total size, speed, last time remaining
+ *
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @param [optional] aSpeed
+ * Current transfer rate in bytes/sec or -1 for unknown
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A pair: [download status text, new value of "last seconds"]
+ */
+ getDownloadStatus: function DU_getDownloadStatus(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ ) {
+ let [transfer, timeLeft, newLast, normalizedSpeed] =
+ this._deriveTransferRate(aCurrBytes, aMaxBytes, aSpeed, aLastSec);
+
+ let [rate, unit] = DownloadUtils.convertByteUnits(normalizedSpeed);
+
+ let status;
+ if (rate === "Infinity") {
+ // Infinity download speed doesn't make sense. Show a localized phrase instead.
+ status = l10n.formatValueSync("download-utils-status-infinite-rate", {
+ transfer,
+ timeLeft,
+ });
+ } else {
+ status = l10n.formatValueSync("download-utils-status", {
+ transfer,
+ rate,
+ unit,
+ timeLeft,
+ });
+ }
+ return [status, newLast];
+ },
+
+ /**
+ * Generate a status string for a download given its current progress,
+ * total size, speed, last time remaining. The status string contains the
+ * time remaining, as well as the total bytes downloaded. Unlike
+ * getDownloadStatus, it does not include the rate of download.
+ *
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @param [optional] aSpeed
+ * Current transfer rate in bytes/sec or -1 for unknown
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A pair: [download status text, new value of "last seconds"]
+ */
+ getDownloadStatusNoRate: function DU_getDownloadStatusNoRate(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ ) {
+ let [transfer, timeLeft, newLast] = this._deriveTransferRate(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ );
+
+ let status = l10n.formatValueSync("download-utils-status-no-rate", {
+ transfer,
+ timeLeft,
+ });
+ return [status, newLast];
+ },
+
+ /**
+ * Helper function that returns a transfer string, a time remaining string,
+ * and a new value of "last seconds".
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @param [optional] aSpeed
+ * Current transfer rate in bytes/sec or -1 for unknown
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A triple: [amount transferred string, time remaining string,
+ * new value of "last seconds"]
+ */
+ _deriveTransferRate: function DU__deriveTransferRate(
+ aCurrBytes,
+ aMaxBytes,
+ aSpeed,
+ aLastSec
+ ) {
+ if (aMaxBytes == null) {
+ aMaxBytes = -1;
+ }
+ if (aSpeed == null) {
+ aSpeed = -1;
+ }
+ if (aLastSec == null) {
+ aLastSec = Infinity;
+ }
+
+ // Calculate the time remaining if we have valid values
+ let seconds =
+ aSpeed > 0 && aMaxBytes > 0 ? (aMaxBytes - aCurrBytes) / aSpeed : -1;
+
+ let transfer = DownloadUtils.getTransferTotal(aCurrBytes, aMaxBytes);
+ let [timeLeft, newLast] = DownloadUtils.getTimeLeft(seconds, aLastSec);
+ return [transfer, timeLeft, newLast, aSpeed];
+ },
+
+ /**
+ * Generate the transfer progress string to show the current and total byte
+ * size. Byte units will be as large as possible and the same units for
+ * current and max will be suppressed for the former.
+ *
+ * @param aCurrBytes
+ * Number of bytes transferred so far
+ * @param [optional] aMaxBytes
+ * Total number of bytes or -1 for unknown
+ * @return The transfer progress text
+ */
+ getTransferTotal: function DU_getTransferTotal(aCurrBytes, aMaxBytes) {
+ if (aMaxBytes == null) {
+ aMaxBytes = -1;
+ }
+
+ let [progress, progressUnits] = DownloadUtils.convertByteUnits(aCurrBytes);
+ let [total, totalUnits] = DownloadUtils.convertByteUnits(aMaxBytes);
+
+ // Figure out which byte progress string to display
+ let name;
+ if (aMaxBytes < 0) {
+ name = "download-utils-transfer-no-total";
+ } else if (progressUnits == totalUnits) {
+ name = "download-utils-transfer-same-units";
+ } else {
+ name = "download-utils-transfer-diff-units";
+ }
+
+ return l10n.formatValueSync(name, {
+ progress,
+ progressUnits,
+ total,
+ totalUnits,
+ });
+ },
+
+ /**
+ * Generate a "time left" string given an estimate on the time left and the
+ * last time. The extra time is used to give a better estimate on the time to
+ * show. Both the time values are doubles instead of integers to help get
+ * sub-second accuracy for current and future estimates.
+ *
+ * @param aSeconds
+ * Current estimate on number of seconds left for the download
+ * @param [optional] aLastSec
+ * Last time remaining in seconds or Infinity for unknown
+ * @return A pair: [time left text, new value of "last seconds"]
+ */
+ getTimeLeft: function DU_getTimeLeft(aSeconds, aLastSec) {
+ let nf = new Services.intl.NumberFormat();
+ if (aLastSec == null) {
+ aLastSec = Infinity;
+ }
+
+ if (aSeconds < 0) {
+ return [l10n.formatValueSync("download-utils-time-unknown"), aLastSec];
+ }
+
+ // Try to find a cached lastSec for the given second
+ aLastSec = gCachedLast.reduce(
+ (aResult, aItem) => (aItem[0] == aSeconds ? aItem[1] : aResult),
+ aLastSec
+ );
+
+ // Add the current second/lastSec pair unless we have too many
+ gCachedLast.push([aSeconds, aLastSec]);
+ if (gCachedLast.length > kCachedLastMaxSize) {
+ gCachedLast.shift();
+ }
+
+ // Apply smoothing only if the new time isn't a huge change -- e.g., if the
+ // new time is more than half the previous time; this is useful for
+ // downloads that start/resume slowly
+ if (aSeconds > aLastSec / 2) {
+ // Apply hysteresis to favor downward over upward swings
+ // 30% of down and 10% of up (exponential smoothing)
+ let diff = aSeconds - aLastSec;
+ aSeconds = aLastSec + (diff < 0 ? 0.3 : 0.1) * diff;
+
+ // If the new time is similar, reuse something close to the last seconds,
+ // but subtract a little to provide forward progress
+ let diffPct = (diff / aLastSec) * 100;
+ if (Math.abs(diff) < 5 || Math.abs(diffPct) < 5) {
+ aSeconds = aLastSec - (diff < 0 ? 0.4 : 0.2);
+ }
+ }
+
+ // Decide what text to show for the time
+ let timeLeft;
+ if (aSeconds < 4) {
+ // Be friendly in the last few seconds
+ timeLeft = l10n.formatValueSync("download-utils-time-few-seconds");
+ } else {
+ // Convert the seconds into its two largest units to display
+ let [time1, unit1, time2, unit2] =
+ DownloadUtils.convertTimeUnits(aSeconds);
+
+ const pair1 = l10n.formatValueSync("download-utils-time-pair", {
+ time: nf.format(time1),
+ unit: unit1,
+ });
+
+ // Only show minutes for under 1 hour unless there's a few minutes left;
+ // or the second pair is 0.
+ if ((aSeconds < 3600 && time1 >= 4) || time2 == 0) {
+ timeLeft = l10n.formatValueSync("download-utils-time-left-single", {
+ time: pair1,
+ });
+ } else {
+ // We've got 2 pairs of times to display
+ const pair2 = l10n.formatValueSync("download-utils-time-pair", {
+ time: nf.format(time2),
+ unit: unit2,
+ });
+ timeLeft = l10n.formatValueSync("download-utils-time-left-double", {
+ time1: pair1,
+ time2: pair2,
+ });
+ }
+ }
+
+ return [timeLeft, aSeconds];
+ },
+
+ /**
+ * Converts a Date object to two readable formats, one compact, one complete.
+ * The compact format is relative to the current date, and is not an accurate
+ * representation. For example, only the time is displayed for today. The
+ * complete format always includes both the date and the time, excluding the
+ * seconds, and is often shown when hovering the cursor over the compact
+ * representation.
+ *
+ * @param aDate
+ * Date object representing the date and time to format. It is assumed
+ * that this value represents a past date.
+ * @param [optional] aNow
+ * Date object representing the current date and time. The real date
+ * and time of invocation is used if this parameter is omitted.
+ * @return A pair: [compact text, complete text]
+ */
+ getReadableDates: function DU_getReadableDates(aDate, aNow) {
+ if (!aNow) {
+ aNow = new Date();
+ }
+
+ // Figure out when today begins
+ let today = new Date(aNow.getFullYear(), aNow.getMonth(), aNow.getDate());
+
+ let dateTimeCompact;
+ let dateTimeFull;
+
+ // Figure out if the time is from today, yesterday, this week, etc.
+ if (aDate >= today) {
+ let dts = new Services.intl.DateTimeFormat(undefined, {
+ timeStyle: "short",
+ });
+ dateTimeCompact = dts.format(aDate);
+ } else if (today - aDate < MS_PER_DAY) {
+ // After yesterday started, show yesterday
+ dateTimeCompact = l10n.formatValueSync("download-utils-yesterday");
+ } else if (today - aDate < 6 * MS_PER_DAY) {
+ // After last week started, show day of week
+ dateTimeCompact = aDate.toLocaleDateString(undefined, {
+ weekday: "long",
+ });
+ } else {
+ // Show month/day
+ dateTimeCompact = aDate.toLocaleString(undefined, {
+ month: "long",
+ day: "numeric",
+ });
+ }
+
+ const dtOptions = { dateStyle: "long", timeStyle: "short" };
+ dateTimeFull = new Services.intl.DateTimeFormat(
+ undefined,
+ dtOptions
+ ).format(aDate);
+
+ return [dateTimeCompact, dateTimeFull];
+ },
+
+ /**
+ * Get the appropriate display host string for a URI string depending on if
+ * the URI has an eTLD + 1, is an IP address, a local file, or other protocol
+ *
+ * @param aURIString
+ * The URI string to try getting an eTLD + 1, etc.
+ * @return A pair: [display host for the URI string, full host name]
+ */
+ getURIHost: function DU_getURIHost(aURIString) {
+ let idnService = Cc["@mozilla.org/network/idn-service;1"].getService(
+ Ci.nsIIDNService
+ );
+
+ // Get a URI that knows about its components
+ let uri;
+ try {
+ uri = Services.io.newURI(aURIString);
+ } catch (ex) {
+ return ["", ""];
+ }
+
+ // Get the inner-most uri for schemes like jar:
+ if (uri instanceof Ci.nsINestedURI) {
+ uri = uri.innermostURI;
+ }
+
+ if (uri.scheme == "blob") {
+ let origin = new URL(uri.spec).origin;
+ // Origin can be "null" for blob URIs from a sandbox.
+ if (origin != "null") {
+ // `newURI` can throw (like for null) and throwing here breaks...
+ // a lot of stuff. So let's avoid doing that in case there are other
+ // edgecases we're missing here.
+ try {
+ uri = Services.io.newURI(origin);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+
+ let fullHost;
+ try {
+ // Get the full host name; some special URIs fail (data: jar:)
+ fullHost = uri.host;
+ } catch (e) {
+ fullHost = "";
+ }
+
+ let displayHost;
+ try {
+ // This might fail if it's an IP address or doesn't have more than 1 part
+ let baseDomain = Services.eTLD.getBaseDomain(uri);
+
+ // Convert base domain for display; ignore the isAscii out param
+ displayHost = idnService.convertToDisplayIDN(baseDomain, {});
+ } catch (e) {
+ // Default to the host name
+ displayHost = fullHost;
+ }
+
+ // Check if we need to show something else for the host
+ if (uri.scheme == "file") {
+ // Display special text for file protocol
+ displayHost = l10n.formatValueSync("download-utils-done-file-scheme");
+ fullHost = displayHost;
+ } else if (!displayHost.length) {
+ // Got nothing; show the scheme (data: about: moz-icon:)
+ displayHost = l10n.formatValueSync("download-utils-done-scheme", {
+ scheme: uri.scheme,
+ });
+ fullHost = displayHost;
+ } else if (uri.port != -1) {
+ // Tack on the port if it's not the default port
+ let port = ":" + uri.port;
+ displayHost += port;
+ fullHost += port;
+ }
+
+ return [displayHost, fullHost];
+ },
+
+ /**
+ * Converts a number of bytes to the appropriate unit that results in an
+ * internationalized number that needs fewer than 4 digits.
+ *
+ * @param aBytes
+ * Number of bytes to convert
+ * @return A pair: [new value with 3 sig. figs., its unit]
+ */
+ convertByteUnits: function DU_convertByteUnits(aBytes) {
+ let unitIndex = 0;
+
+ // Convert to next unit if it needs 4 digits (after rounding), but only if
+ // we know the name of the next unit
+ while (aBytes >= 999.5 && unitIndex < BYTE_UNITS.length - 1) {
+ aBytes /= 1024;
+ unitIndex++;
+ }
+
+ // Get rid of insignificant bits by truncating to 1 or 0 decimal points
+ // 0 -> 0; 1.2 -> 1.2; 12.3 -> 12.3; 123.4 -> 123; 234.5 -> 235
+ // added in bug 462064: (unitIndex != 0) makes sure that no decimal digit for bytes appears when aBytes < 100
+ let fractionDigits = aBytes > 0 && aBytes < 100 && unitIndex != 0 ? 1 : 0;
+
+ // Don't try to format Infinity values using NumberFormat.
+ if (aBytes === Infinity) {
+ aBytes = "Infinity";
+ } else {
+ aBytes = getLocaleNumberFormat(fractionDigits).format(aBytes);
+ }
+
+ return [aBytes, l10n.formatValueSync(BYTE_UNITS[unitIndex])];
+ },
+
+ /**
+ * Converts a number of seconds to the two largest units. Time values are
+ * whole numbers, and units have the correct plural/singular form.
+ *
+ * @param aSecs
+ * Seconds to convert into the appropriate 2 units
+ * @return 4-item array [first value, its unit, second value, its unit]
+ */
+ convertTimeUnits: function DU_convertTimeUnits(aSecs) {
+ let time = aSecs;
+ let scale = 1;
+ let unitIndex = 0;
+
+ // Keep converting to the next unit while we have units left and the
+ // current one isn't the largest unit possible
+ while (unitIndex < TIME_SIZES.length && time >= TIME_SIZES[unitIndex]) {
+ time /= TIME_SIZES[unitIndex];
+ scale *= TIME_SIZES[unitIndex];
+ unitIndex++;
+ }
+
+ let value = convertTimeUnitsValue(time);
+ let units = convertTimeUnitsUnits(value, unitIndex);
+
+ let extra = aSecs - value * scale;
+ let nextIndex = unitIndex - 1;
+
+ // Convert the extra time to the next largest unit
+ for (let index = 0; index < nextIndex; index++) {
+ extra /= TIME_SIZES[index];
+ }
+
+ let value2 = convertTimeUnitsValue(extra);
+ let units2 = convertTimeUnitsUnits(value2, nextIndex);
+
+ return [value, units, value2, units2];
+ },
+
+ /**
+ * Converts a number of seconds to "downloading file opens in X" status.
+ * @param aSeconds
+ * Seconds to convert into the time format.
+ * @return status object, example:
+ * status = {
+ * l10n: {
+ * id: "downloading-file-opens-in-minutes-and-seconds",
+ * args: { minutes: 2, seconds: 30 },
+ * },
+ * };
+ */
+ getFormattedTimeStatus: function DU_getFormattedTimeStatus(aSeconds) {
+ aSeconds = Math.floor(aSeconds);
+ let l10n;
+ if (!isFinite(aSeconds) || aSeconds < 0) {
+ l10n = {
+ id: "downloading-file-opens-in-some-time-2",
+ };
+ } else if (aSeconds < 60) {
+ l10n = {
+ id: "downloading-file-opens-in-seconds-2",
+ args: { seconds: aSeconds },
+ };
+ } else if (aSeconds < 3600) {
+ let minutes = Math.floor(aSeconds / 60);
+ let seconds = aSeconds % 60;
+ l10n = seconds
+ ? {
+ args: { seconds, minutes },
+ id: "downloading-file-opens-in-minutes-and-seconds-2",
+ }
+ : { args: { minutes }, id: "downloading-file-opens-in-minutes-2" };
+ } else {
+ let hours = Math.floor(aSeconds / 3600);
+ let minutes = Math.floor((aSeconds % 3600) / 60);
+ l10n = {
+ args: { hours, minutes },
+ id: "downloading-file-opens-in-hours-and-minutes-2",
+ };
+ }
+ return { l10n };
+ },
+};
+
+/**
+ * Private helper for convertTimeUnits that gets the display value of a time
+ *
+ * @param aTime
+ * Time value for display
+ * @return An integer value for the time rounded down
+ */
+function convertTimeUnitsValue(aTime) {
+ return Math.floor(aTime);
+}
+
+/**
+ * Private helper for convertTimeUnits that gets the display units of a time
+ *
+ * @param timeValue
+ * Time value for display
+ * @param aIndex
+ * Index into gStr.timeUnits for the appropriate unit
+ * @return The appropriate plural form of the unit for the time
+ */
+function convertTimeUnitsUnits(timeValue, aIndex) {
+ // Negative index would be an invalid unit, so just give empty
+ if (aIndex < 0) {
+ return "";
+ }
+
+ return l10n.formatValueSync(TIME_UNITS[aIndex], { timeValue });
+}
+
+/**
+ * Private helper function to log errors to the error console and command line
+ *
+ * @param aMsg
+ * Error message to log or an array of strings to concat
+ */
+// function log(aMsg) {
+// let msg = "DownloadUtils.jsm: " + (aMsg.join ? aMsg.join("") : aMsg);
+// Services.console.logStringMessage(msg);
+// dump(msg + "\n");
+// }
diff --git a/toolkit/mozapps/downloads/HelperAppDlg.sys.mjs b/toolkit/mozapps/downloads/HelperAppDlg.sys.mjs
new file mode 100644
index 0000000000..66f77d38e4
--- /dev/null
+++ b/toolkit/mozapps/downloads/HelperAppDlg.sys.mjs
@@ -0,0 +1,1349 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { BrowserUtils } from "resource://gre/modules/BrowserUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ EnableDelayHelper: "resource://gre/modules/PromptUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gReputationService",
+ "@mozilla.org/reputationservice/application-reputation-service;1",
+ Ci.nsIApplicationReputationService
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gMIMEService",
+ "@mozilla.org/mime;1",
+ Ci.nsIMIMEService
+);
+
+import { Integration } from "resource://gre/modules/Integration.sys.mjs";
+
+Integration.downloads.defineESModuleGetter(
+ lazy,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+// /////////////////////////////////////////////////////////////////////////////
+// // Helper Functions
+
+/**
+ * Determines if a given directory is able to be used to download to.
+ *
+ * @param aDirectory
+ * The directory to check.
+ * @return true if we can use the directory, false otherwise.
+ */
+function isUsableDirectory(aDirectory) {
+ return (
+ aDirectory.exists() && aDirectory.isDirectory() && aDirectory.isWritable()
+ );
+}
+
+// Web progress listener so we can detect errors while mLauncher is
+// streaming the data to a temporary file.
+function nsUnknownContentTypeDialogProgressListener(aHelperAppDialog) {
+ this.helperAppDlg = aHelperAppDialog;
+}
+
+nsUnknownContentTypeDialogProgressListener.prototype = {
+ // nsIWebProgressListener methods.
+ // Look for error notifications and display alert to user.
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (aStatus != Cr.NS_OK) {
+ // Display error alert (using text supplied by back-end).
+ // FIXME this.dialog is undefined?
+ Services.prompt.alert(this.dialog, this.helperAppDlg.mTitle, aMessage);
+ // Close the dialog.
+ this.helperAppDlg.onCancel();
+ if (this.helperAppDlg.mDialog) {
+ this.helperAppDlg.mDialog.close();
+ }
+ }
+ },
+
+ // Ignore onProgressChange, onProgressChange64, onStateChange, onLocationChange, onSecurityChange, onContentBlockingEvent and onRefreshAttempted notifications.
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {},
+
+ onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {},
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {},
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {},
+
+ onSecurityChange(aWebProgress, aRequest, aState) {},
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {},
+
+ onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
+ return true;
+ },
+};
+
+// /////////////////////////////////////////////////////////////////////////////
+// // nsUnknownContentTypeDialog
+
+/* This file implements the nsIHelperAppLauncherDialog interface.
+ *
+ * The implementation consists of a JavaScript "class" named nsUnknownContentTypeDialog,
+ * comprised of:
+ * - a JS constructor function
+ * - a prototype providing all the interface methods and implementation stuff
+ */
+
+const PREF_BD_USEDOWNLOADDIR = "browser.download.useDownloadDir";
+const nsITimer = Ci.nsITimer;
+
+import * as downloadModule from "resource://gre/modules/DownloadLastDir.sys.mjs";
+import { DownloadPaths } from "resource://gre/modules/DownloadPaths.sys.mjs";
+
+import { DownloadUtils } from "resource://gre/modules/DownloadUtils.sys.mjs";
+import { Downloads } from "resource://gre/modules/Downloads.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+
+/* ctor
+ */
+export function nsUnknownContentTypeDialog() {
+ // Initialize data properties.
+ this.mLauncher = null;
+ this.mContext = null;
+ this.mReason = null;
+ this.chosenApp = null;
+ this.givenDefaultApp = false;
+ this.updateSelf = true;
+ this.mTitle = "";
+}
+
+nsUnknownContentTypeDialog.prototype = {
+ classID: Components.ID("{F68578EB-6EC2-4169-AE19-8C6243F0ABE1}"),
+
+ nsIMIMEInfo: Ci.nsIMIMEInfo,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIHelperAppLauncherDialog",
+ "nsITimerCallback",
+ ]),
+
+ // ---------- nsIHelperAppLauncherDialog methods ----------
+
+ // show: Open XUL dialog using window watcher. Since the dialog is not
+ // modal, it needs to be a top level window and the way to open
+ // one of those is via that route).
+ show(aLauncher, aContext, aReason) {
+ this.mLauncher = aLauncher;
+ this.mContext = aContext;
+ this.mReason = aReason;
+
+ // Cache some information in case this context goes away:
+ try {
+ let parent = aContext.getInterface(Ci.nsIDOMWindow);
+ this._mDownloadDir = new downloadModule.DownloadLastDir(parent);
+ } catch (ex) {
+ console.error(
+ "Missing window information when showing nsIHelperAppLauncherDialog:",
+ ex
+ );
+ }
+
+ const nsITimer = Ci.nsITimer;
+ this._showTimer = Cc["@mozilla.org/timer;1"].createInstance(nsITimer);
+ this._showTimer.initWithCallback(this, 0, nsITimer.TYPE_ONE_SHOT);
+ },
+
+ // When opening from new tab, if tab closes while dialog is opening,
+ // (which is a race condition on the XUL file being cached and the timer
+ // in nsExternalHelperAppService), the dialog gets a blur and doesn't
+ // activate the OK button. So we wait a bit before doing opening it.
+ reallyShow() {
+ try {
+ let docShell = this.mContext.getInterface(Ci.nsIDocShell);
+ let rootWin = docShell.browsingContext.topChromeWindow;
+ this.mDialog = Services.ww.openWindow(
+ rootWin,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ null,
+ "chrome,centerscreen,titlebar,dialog=yes,dependent",
+ null
+ );
+ } catch (ex) {
+ // The containing window may have gone away. Break reference
+ // cycles and stop doing the download.
+ this.mLauncher.cancel(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+
+ // Hook this object to the dialog.
+ this.mDialog.dialog = this;
+
+ // Hook up utility functions.
+ this.getSpecialFolderKey = this.mDialog.getSpecialFolderKey;
+
+ // Watch for error notifications.
+ var progressListener = new nsUnknownContentTypeDialogProgressListener(this);
+ this.mLauncher.setWebProgressListener(progressListener);
+ },
+
+ //
+ // displayBadPermissionAlert()
+ //
+ // Diplay an alert panel about the bad permission of folder/directory.
+ //
+ displayBadPermissionAlert() {
+ let bundle = Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/unknownContentType.properties"
+ );
+
+ Services.prompt.alert(
+ this.dialog,
+ bundle.GetStringFromName("badPermissions.title"),
+ bundle.GetStringFromName("badPermissions")
+ );
+ },
+
+ promptForSaveToFileAsync(
+ aLauncher,
+ aContext,
+ aDefaultFileName,
+ aSuggestedFileExtension,
+ aForcePrompt
+ ) {
+ var result = null;
+
+ this.mLauncher = aLauncher;
+
+ let bundle = Services.strings.createBundle(
+ "chrome://mozapps/locale/downloads/unknownContentType.properties"
+ );
+
+ let parent;
+ let gDownloadLastDir;
+ try {
+ parent = aContext.getInterface(Ci.nsIDOMWindow);
+ } catch (ex) {}
+
+ if (parent) {
+ gDownloadLastDir = new downloadModule.DownloadLastDir(parent);
+ } else {
+ // Use the cached download info, but pick an arbitrary parent window
+ // because the original one is definitely gone (and nsIFilePicker doesn't like
+ // a null parent):
+ gDownloadLastDir = this._mDownloadDir;
+ for (let someWin of Services.wm.getEnumerator("")) {
+ // We need to make sure we don't end up with this dialog, because otherwise
+ // that's going to go away when the user clicks "Save", and that breaks the
+ // windows file picker that's supposed to show up if we let the user choose
+ // where to save files...
+ if (someWin != this.mDialog) {
+ parent = someWin;
+ }
+ }
+ if (!parent) {
+ console.error(
+ "No candidate parent windows were found for the save filepicker." +
+ "This should never happen."
+ );
+ }
+ }
+
+ (async () => {
+ if (!aForcePrompt) {
+ // Check to see if the user wishes to auto save to the default download
+ // folder without prompting. Note that preference might not be set.
+ let autodownload = Services.prefs.getBoolPref(
+ PREF_BD_USEDOWNLOADDIR,
+ false
+ );
+
+ if (autodownload) {
+ // Retrieve the user's default download directory
+ let preferredDir = await Downloads.getPreferredDownloadsDirectory();
+ let defaultFolder = new FileUtils.File(preferredDir);
+
+ try {
+ if (aDefaultFileName) {
+ result = this.validateLeafName(
+ defaultFolder,
+ aDefaultFileName,
+ aSuggestedFileExtension
+ );
+ }
+ } catch (ex) {
+ // When the default download directory is write-protected,
+ // prompt the user for a different target file.
+ }
+
+ // Check to make sure we have a valid directory, otherwise, prompt
+ if (result) {
+ // This path is taken when we have a writable default download directory.
+ aLauncher.saveDestinationAvailable(result);
+ return;
+ }
+ }
+ }
+
+ // Use file picker to show dialog.
+ var nsIFilePicker = Ci.nsIFilePicker;
+ var picker =
+ Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ var windowTitle = bundle.GetStringFromName("saveDialogTitle");
+ picker.init(parent, windowTitle, nsIFilePicker.modeSave);
+ if (aDefaultFileName) {
+ picker.defaultString = this.getFinalLeafName(aDefaultFileName);
+ }
+
+ if (aSuggestedFileExtension) {
+ // aSuggestedFileExtension includes the period, so strip it
+ picker.defaultExtension = aSuggestedFileExtension.substring(1);
+ } else {
+ try {
+ picker.defaultExtension = this.mLauncher.MIMEInfo.primaryExtension;
+ } catch (ex) {}
+ }
+
+ var wildCardExtension = "*";
+ if (aSuggestedFileExtension) {
+ wildCardExtension += aSuggestedFileExtension;
+ picker.appendFilter(
+ this.mLauncher.MIMEInfo.description,
+ wildCardExtension
+ );
+ }
+
+ picker.appendFilters(nsIFilePicker.filterAll);
+
+ // Default to lastDir if it is valid, otherwise use the user's default
+ // downloads directory. getPreferredDownloadsDirectory should always
+ // return a valid directory path, so we can safely default to it.
+ let preferredDir = await Downloads.getPreferredDownloadsDirectory();
+ picker.displayDirectory = new FileUtils.File(preferredDir);
+
+ gDownloadLastDir.getFileAsync(aLauncher.source).then(lastDir => {
+ if (lastDir && isUsableDirectory(lastDir)) {
+ picker.displayDirectory = lastDir;
+ }
+
+ picker.open(returnValue => {
+ if (returnValue == nsIFilePicker.returnCancel) {
+ // null result means user cancelled.
+ aLauncher.saveDestinationAvailable(null);
+ return;
+ }
+
+ // Be sure to save the directory the user chose through the Save As...
+ // dialog as the new browser.download.dir since the old one
+ // didn't exist.
+ result = picker.file;
+
+ if (result) {
+ let allowOverwrite = false;
+ try {
+ // If we're overwriting, avoid renaming our file, and assume
+ // overwriting it does the right thing.
+ if (
+ result.exists() &&
+ this.getFinalLeafName(result.leafName, "", true) ==
+ result.leafName
+ ) {
+ allowOverwrite = true;
+ }
+ } catch (ex) {
+ // As it turns out, the failure to remove the file, for example due to
+ // permission error, will be handled below eventually somehow.
+ }
+
+ var newDir = result.parent.QueryInterface(Ci.nsIFile);
+
+ // Do not store the last save directory as a pref inside the private browsing mode
+ gDownloadLastDir.setFile(aLauncher.source, newDir);
+
+ try {
+ result = this.validateLeafName(
+ newDir,
+ result.leafName,
+ null,
+ allowOverwrite,
+ true
+ );
+ } catch (ex) {
+ // When the chosen download directory is write-protected,
+ // display an informative error message.
+ // In all cases, download will be stopped.
+
+ if (ex.result == Cr.NS_ERROR_FILE_ACCESS_DENIED) {
+ this.displayBadPermissionAlert();
+ aLauncher.saveDestinationAvailable(null);
+ return;
+ }
+ }
+ }
+ // Don't pop up the downloads panel redundantly.
+ aLauncher.saveDestinationAvailable(result, true);
+ });
+ });
+ })().catch(console.error);
+ },
+
+ getFinalLeafName(aLeafName, aFileExt, aAfterFilePicker) {
+ return (
+ DownloadPaths.sanitize(aLeafName, {
+ compressWhitespaces: !aAfterFilePicker,
+ allowInvalidFilenames: aAfterFilePicker,
+ }) || "unnamed" + (aFileExt ? "." + aFileExt : "")
+ );
+ },
+
+ /**
+ * Ensures that a local folder/file combination does not already exist in
+ * the file system (or finds such a combination with a reasonably similar
+ * leaf name), creates the corresponding file, and returns it.
+ *
+ * @param aLocalFolder
+ * the folder where the file resides
+ * @param aLeafName
+ * the string name of the file (may be empty if no name is known,
+ * in which case a name will be chosen)
+ * @param aFileExt
+ * the extension of the file, if one is known; this will be ignored
+ * if aLeafName is non-empty
+ * @param aAllowExisting
+ * if set to true, avoid creating a unique file.
+ * @param aAfterFilePicker
+ * if set to true, this was a file entered by the user from a file picker.
+ * @return nsIFile
+ * the created file
+ * @throw an error such as permission doesn't allow creation of
+ * file, etc.
+ */
+ validateLeafName(
+ aLocalFolder,
+ aLeafName,
+ aFileExt,
+ aAllowExisting = false,
+ aAfterFilePicker = false
+ ) {
+ if (!(aLocalFolder && isUsableDirectory(aLocalFolder))) {
+ throw new Components.Exception(
+ "Destination directory non-existing or permission error",
+ Cr.NS_ERROR_FILE_ACCESS_DENIED
+ );
+ }
+
+ aLeafName = this.getFinalLeafName(aLeafName, aFileExt, aAfterFilePicker);
+ aLocalFolder.append(aLeafName);
+
+ if (!aAllowExisting) {
+ // The following assignment can throw an exception, but
+ // is now caught properly in the caller of validateLeafName.
+ var validatedFile = DownloadPaths.createNiceUniqueFile(aLocalFolder);
+ } else {
+ validatedFile = aLocalFolder;
+ }
+
+ return validatedFile;
+ },
+
+ // ---------- implementation methods ----------
+
+ // initDialog: Fill various dialog fields with initial content.
+ initDialog() {
+ // Put file name in window title.
+ var suggestedFileName = this.mLauncher.suggestedFileName;
+
+ this.mDialog.document.addEventListener("dialogaccept", this);
+ this.mDialog.document.addEventListener("dialogcancel", this);
+
+ let url = this.mLauncher.source;
+
+ if (url instanceof Ci.nsINestedURI) {
+ url = url.innermostURI;
+ }
+
+ let iconPath = "goat";
+ let fname = "";
+ if (suggestedFileName) {
+ fname = iconPath = suggestedFileName;
+ } else if (url instanceof Ci.nsIURL) {
+ // A url, use file name from it.
+ fname = iconPath = url.fileName;
+ } else if (["data", "blob"].includes(url.scheme)) {
+ // The path is useless for these, so use a reasonable default.
+ let { MIMEType } = this.mLauncher.MIMEInfo;
+ fname = lazy.gMIMEService.getValidFileName(null, MIMEType, url, 0);
+ } else {
+ fname = url.pathQueryRef;
+ }
+
+ this.mSourcePath = url.prePath;
+ // Some URIs do not implement nsIURL, so we can't just QI.
+ if (url instanceof Ci.nsIURL) {
+ this.mSourcePath += url.directory;
+ } else {
+ // Don't make the url excessively long (e.g. for data URIs)
+ // (this doesn't use a temp var to avoid copying a potentially
+ // several mb-long string)
+ this.mSourcePath +=
+ url.pathQueryRef.length > 500
+ ? url.pathQueryRef.substring(0, 500) + "\u2026"
+ : url.pathQueryRef;
+ }
+
+ var displayName = fname.replace(/ +/g, " ");
+
+ this.mTitle = this.dialogElement("strings").getFormattedString("title", [
+ displayName,
+ ]);
+ this.mDialog.document.title = this.mTitle;
+
+ // Put content type, filename and location into intro.
+ this.initIntro(url, displayName);
+
+ var iconString =
+ "moz-icon://" +
+ iconPath +
+ "?size=16&contentType=" +
+ this.mLauncher.MIMEInfo.MIMEType;
+ this.dialogElement("contentTypeImage").setAttribute("src", iconString);
+
+ let dialog = this.mDialog.document.getElementById("unknownContentType");
+
+ // if always-save and is-executable and no-handler
+ // then set up simple ui
+ var mimeType = this.mLauncher.MIMEInfo.MIMEType;
+ let isPlain = mimeType == "text/plain";
+
+ this.isExemptExecutableExtension =
+ Services.policies.isExemptExecutableExtension(
+ url.spec,
+ fname?.split(".").at(-1)
+ );
+
+ var shouldntRememberChoice =
+ mimeType == "application/octet-stream" ||
+ mimeType == "application/x-msdownload" ||
+ (this.mLauncher.targetFileIsExecutable &&
+ !this.isExemptExecutableExtension) ||
+ // Do not offer to remember text/plain mimetype choices if the file
+ // isn't actually a 'plain' text file.
+ (isPlain && lazy.gReputationService.isBinary(suggestedFileName));
+ if (
+ (shouldntRememberChoice && !this.openWithDefaultOK()) ||
+ Services.prefs.getBoolPref("browser.download.forbid_open_with")
+ ) {
+ // hide featured choice
+ this.dialogElement("normalBox").collapsed = true;
+ // show basic choice
+ this.dialogElement("basicBox").collapsed = false;
+ // change button labels and icons; use "save" icon for the accept
+ // button since it's the only action possible
+ let acceptButton = dialog.getButton("accept");
+ acceptButton.label = this.dialogElement("strings").getString(
+ "unknownAccept.label"
+ );
+ acceptButton.setAttribute("icon", "save");
+ dialog.getButton("cancel").label = this.dialogElement(
+ "strings"
+ ).getString("unknownCancel.label");
+ // hide other handler
+ this.dialogElement("openHandler").collapsed = true;
+ // set save as the selected option
+ this.dialogElement("mode").selectedItem = this.dialogElement("save");
+ } else {
+ this.initInteractiveControls();
+
+ // Initialize "always ask me" box. This should always be disabled
+ // and set to true for the ambiguous type application/octet-stream.
+ // We don't also check for application/x-msdownload here since we
+ // want users to be able to autodownload .exe files.
+ var rememberChoice = this.dialogElement("rememberChoice");
+
+ // Just because we have a content-type of application/octet-stream
+ // here doesn't actually mean that the content is of that type. Many
+ // servers default to sending text/plain for file types they don't know
+ // about. To account for this, the uriloader does some checking to see
+ // if a file sent as text/plain contains binary characters, and if so (*)
+ // it morphs the content-type into application/octet-stream so that
+ // the file can be properly handled. Since this is not generic binary
+ // data, rather, a data format that the system probably knows about,
+ // we don't want to use the content-type provided by this dialog's
+ // opener, as that's the generic application/octet-stream that the
+ // uriloader has passed, rather we want to ask the MIME Service.
+ // This is so we don't needlessly disable the "autohandle" checkbox.
+
+ if (shouldntRememberChoice) {
+ rememberChoice.checked = false;
+ rememberChoice.hidden = true;
+ } else {
+ rememberChoice.checked =
+ !this.mLauncher.MIMEInfo.alwaysAskBeforeHandling &&
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.handleInternally;
+ }
+ this.toggleRememberChoice(rememberChoice);
+ }
+
+ this.mDialog.setTimeout(function () {
+ this.dialog.postShowCallback();
+ }, 0);
+
+ this.delayHelper = new lazy.EnableDelayHelper({
+ disableDialog: () => {
+ dialog.getButton("accept").disabled = true;
+ },
+ enableDialog: () => {
+ dialog.getButton("accept").disabled = false;
+ },
+ focusTarget: this.mDialog,
+ });
+ },
+
+ notify(aTimer) {
+ if (aTimer == this._showTimer) {
+ if (!this.mDialog) {
+ this.reallyShow();
+ }
+ // The timer won't release us, so we have to release it.
+ this._showTimer = null;
+ } else if (aTimer == this._saveToDiskTimer) {
+ // Since saveToDisk may open a file picker and therefore block this routine,
+ // we should only call it once the dialog is closed.
+ this.mLauncher.promptForSaveDestination();
+ this._saveToDiskTimer = null;
+ }
+ },
+
+ postShowCallback() {
+ this.mDialog.sizeToContent();
+
+ // Set initial focus
+ this.dialogElement("mode").focus();
+ },
+
+ initIntro(url, displayName) {
+ this.dialogElement("location").value = displayName;
+ this.dialogElement("location").setAttribute("tooltiptext", displayName);
+
+ // if mSourcePath is a local file, then let's use the pretty path name
+ // instead of an ugly url...
+ let pathString;
+ if (url instanceof Ci.nsIFileURL) {
+ try {
+ // Getting .file might throw, or .parent could be null
+ pathString = url.file.parent.path;
+ } catch (ex) {}
+ }
+
+ if (!pathString) {
+ pathString = BrowserUtils.formatURIForDisplay(url, {
+ showInsecureHTTP: true,
+ });
+ }
+
+ // Set the location text, which is separate from the intro text so it can be cropped
+ var location = this.dialogElement("source");
+ location.value = pathString;
+ location.setAttribute("tooltiptext", this.mSourcePath);
+
+ // Show the type of file.
+ var type = this.dialogElement("type");
+ var mimeInfo = this.mLauncher.MIMEInfo;
+
+ // 1. Try to use the pretty description of the type, if one is available.
+ var typeString = mimeInfo.description;
+
+ if (typeString == "") {
+ // 2. If there is none, use the extension to identify the file, e.g. "ZIP file"
+ var primaryExtension = "";
+ try {
+ primaryExtension = mimeInfo.primaryExtension;
+ } catch (ex) {}
+ if (primaryExtension != "") {
+ typeString = this.dialogElement("strings").getFormattedString(
+ "fileType",
+ [primaryExtension.toUpperCase()]
+ );
+ }
+ // 3. If we can't even do that, just give up and show the MIME type.
+ else {
+ typeString = mimeInfo.MIMEType;
+ }
+ }
+ // When the length is unknown, contentLength would be -1
+ if (this.mLauncher.contentLength >= 0) {
+ let [size, unit] = DownloadUtils.convertByteUnits(
+ this.mLauncher.contentLength
+ );
+ type.value = this.dialogElement("strings").getFormattedString(
+ "orderedFileSizeWithType",
+ [typeString, size, unit]
+ );
+ } else {
+ type.value = typeString;
+ }
+ },
+
+ // Returns true if opening the default application makes sense.
+ openWithDefaultOK() {
+ // The checking is different on Windows...
+ if (AppConstants.platform == "win") {
+ // Windows presents some special cases.
+ // We need to prevent use of "system default" when the file is
+ // executable (so the user doesn't launch nasty programs downloaded
+ // from the web), and, enable use of "system default" if it isn't
+ // executable (because we will prompt the user for the default app
+ // in that case).
+
+ // Default is Ok if the file isn't executable (and vice-versa).
+ return (
+ !this.mLauncher.targetFileIsExecutable ||
+ this.isExemptExecutableExtension
+ );
+ }
+ // On other platforms, default is Ok if there is a default app.
+ // Note that nsIMIMEInfo providers need to ensure that this holds true
+ // on each platform.
+ return this.mLauncher.MIMEInfo.hasDefaultHandler;
+ },
+
+ // Set "default" application description field.
+ initDefaultApp() {
+ // Use description, if we can get one.
+ var desc = this.mLauncher.MIMEInfo.defaultDescription;
+ if (desc) {
+ var defaultApp = this.dialogElement("strings").getFormattedString(
+ "defaultApp",
+ [desc]
+ );
+ this.dialogElement("defaultHandler").label = defaultApp;
+ } else {
+ this.dialogElement("modeDeck").setAttribute("selectedIndex", "1");
+ // Hide the default handler item too, in case the user picks a
+ // custom handler at a later date which triggers the menulist to show.
+ this.dialogElement("defaultHandler").hidden = true;
+ }
+ },
+
+ getPath(aFile) {
+ if (AppConstants.platform == "macosx") {
+ return aFile.leafName || aFile.path;
+ }
+ return aFile.path;
+ },
+
+ initInteractiveControls() {
+ var modeGroup = this.dialogElement("mode");
+
+ // We don't let users open .exe files or random binary data directly
+ // from the browser at the moment because of security concerns.
+ var openWithDefaultOK = this.openWithDefaultOK();
+ var mimeType = this.mLauncher.MIMEInfo.MIMEType;
+ var openHandler = this.dialogElement("openHandler");
+ if (
+ (this.mLauncher.targetFileIsExecutable &&
+ !this.isExemptExecutableExtension) ||
+ ((mimeType == "application/octet-stream" ||
+ mimeType == "application/x-msdos-program" ||
+ mimeType == "application/x-msdownload") &&
+ !openWithDefaultOK)
+ ) {
+ this.dialogElement("open").disabled = true;
+ openHandler.disabled = true;
+ openHandler.selectedItem = null;
+ modeGroup.selectedItem = this.dialogElement("save");
+ return;
+ }
+
+ // Fill in helper app info, if there is any.
+ try {
+ this.chosenApp =
+ this.mLauncher.MIMEInfo.preferredApplicationHandler.QueryInterface(
+ Ci.nsILocalHandlerApp
+ );
+ } catch (e) {
+ this.chosenApp = null;
+ }
+ // Initialize "default application" field.
+ this.initDefaultApp();
+
+ var otherHandler = this.dialogElement("otherHandler");
+
+ // Fill application name textbox.
+ if (
+ this.chosenApp &&
+ this.chosenApp.executable &&
+ this.chosenApp.executable.path
+ ) {
+ otherHandler.setAttribute(
+ "path",
+ this.getPath(this.chosenApp.executable)
+ );
+
+ otherHandler.label = this.getFileDisplayName(this.chosenApp.executable);
+ otherHandler.hidden = false;
+ }
+
+ openHandler.selectedIndex = 0;
+ var defaultOpenHandler = this.dialogElement("defaultHandler");
+
+ if (this.shouldShowInternalHandlerOption()) {
+ this.dialogElement("handleInternally").hidden = false;
+ }
+
+ if (
+ this.mLauncher.MIMEInfo.preferredAction ==
+ this.nsIMIMEInfo.useSystemDefault
+ ) {
+ // Open (using system default).
+ modeGroup.selectedItem = this.dialogElement("open");
+ } else if (
+ this.mLauncher.MIMEInfo.preferredAction == this.nsIMIMEInfo.useHelperApp
+ ) {
+ // Open with given helper app.
+ modeGroup.selectedItem = this.dialogElement("open");
+ openHandler.selectedItem =
+ otherHandler && !otherHandler.hidden
+ ? otherHandler
+ : defaultOpenHandler;
+ } else if (
+ !this.dialogElement("handleInternally").hidden &&
+ this.mLauncher.MIMEInfo.preferredAction ==
+ this.nsIMIMEInfo.handleInternally
+ ) {
+ // Handle internally
+ modeGroup.selectedItem = this.dialogElement("handleInternally");
+ } else {
+ // Save to disk.
+ modeGroup.selectedItem = this.dialogElement("save");
+ }
+
+ // If we don't have a "default app" then disable that choice.
+ if (!openWithDefaultOK) {
+ var isSelected = defaultOpenHandler.selected;
+
+ // Disable that choice.
+ defaultOpenHandler.hidden = true;
+ // If that's the default, then switch to "save to disk."
+ if (isSelected) {
+ openHandler.selectedIndex = 1;
+ if (this.dialogElement("open").selected) {
+ modeGroup.selectedItem = this.dialogElement("save");
+ }
+ }
+ }
+
+ otherHandler.nextSibling.hidden =
+ otherHandler.nextSibling.nextSibling.hidden = false;
+ this.updateOKButton();
+ },
+
+ // Returns the user-selected application
+ helperAppChoice() {
+ return this.chosenApp;
+ },
+
+ get saveToDisk() {
+ return this.dialogElement("save").selected;
+ },
+
+ get useOtherHandler() {
+ return (
+ this.dialogElement("open").selected &&
+ this.dialogElement("openHandler").selectedIndex == 1
+ );
+ },
+
+ get useSystemDefault() {
+ return (
+ this.dialogElement("open").selected &&
+ this.dialogElement("openHandler").selectedIndex == 0
+ );
+ },
+
+ get handleInternally() {
+ return this.dialogElement("handleInternally").selected;
+ },
+
+ toggleRememberChoice(aCheckbox) {
+ this.dialogElement("settingsChange").hidden = !aCheckbox.checked;
+ this.mDialog.sizeToContent();
+ },
+
+ openHandlerCommand() {
+ var openHandler = this.dialogElement("openHandler");
+ if (openHandler.selectedItem.id == "choose") {
+ this.chooseApp();
+ } else {
+ openHandler.setAttribute(
+ "lastSelectedItemID",
+ openHandler.selectedItem.id
+ );
+ }
+ },
+
+ updateOKButton() {
+ var ok = false;
+ if (this.dialogElement("save").selected) {
+ // This is always OK.
+ ok = true;
+ } else if (this.dialogElement("open").selected) {
+ switch (this.dialogElement("openHandler").selectedIndex) {
+ case 0:
+ // No app need be specified in this case.
+ ok = true;
+ break;
+ case 1:
+ // only enable the OK button if we have a default app to use or if
+ // the user chose an app....
+ ok =
+ this.chosenApp ||
+ /\S/.test(this.dialogElement("otherHandler").getAttribute("path"));
+ break;
+ }
+ }
+
+ // Enable Ok button if ok to press.
+ let dialog = this.mDialog.document.getElementById("unknownContentType");
+ dialog.getButton("accept").disabled = !ok;
+ },
+
+ // Returns true iff the user-specified helper app has been modified.
+ appChanged() {
+ return (
+ this.helperAppChoice() !=
+ this.mLauncher.MIMEInfo.preferredApplicationHandler
+ );
+ },
+
+ updateMIMEInfo() {
+ let { MIMEInfo } = this.mLauncher;
+
+ // Don't erase the preferred choice being internal handler
+ // -- this dialog is often the result of the handler fallback
+ // (e.g. Content-Disposition was set as attachment) and we don't
+ // want to inadvertently cause that to always show the dialog if
+ // users don't want that behaviour.
+
+ // Note: this is the same condition as the one in initDialog
+ // which avoids ticking the checkbox. The user can still change
+ // the action by ticking the checkbox, or by using the prefs to
+ // manually select always ask (at which point `areAlwaysOpeningInternally`
+ // will be false, which means `discardUpdate` will be false, which means
+ // we'll store the last-selected option even if the filetype's pref is
+ // set to always ask).
+ let areAlwaysOpeningInternally =
+ MIMEInfo.preferredAction == Ci.nsIMIMEInfo.handleInternally &&
+ !MIMEInfo.alwaysAskBeforeHandling;
+ let discardUpdate =
+ areAlwaysOpeningInternally &&
+ !this.dialogElement("rememberChoice").checked;
+
+ var needUpdate = false;
+ // If current selection differs from what's in the mime info object,
+ // then we need to update.
+ if (this.saveToDisk) {
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction != this.nsIMIMEInfo.saveToDisk;
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction = this.nsIMIMEInfo.saveToDisk;
+ }
+ } else if (this.useSystemDefault) {
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.useSystemDefault;
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction =
+ this.nsIMIMEInfo.useSystemDefault;
+ }
+ } else if (this.useOtherHandler) {
+ // For "open with", we need to check both preferred action and whether the user chose
+ // a new app.
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.useHelperApp || this.appChanged();
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction = this.nsIMIMEInfo.useHelperApp;
+ // App may have changed - Update application
+ var app = this.helperAppChoice();
+ this.mLauncher.MIMEInfo.preferredApplicationHandler = app;
+ }
+ } else if (this.handleInternally) {
+ needUpdate =
+ this.mLauncher.MIMEInfo.preferredAction !=
+ this.nsIMIMEInfo.handleInternally;
+ if (needUpdate) {
+ this.mLauncher.MIMEInfo.preferredAction =
+ this.nsIMIMEInfo.handleInternally;
+ }
+ }
+ // We will also need to update if the "always ask" flag has changed.
+ needUpdate =
+ needUpdate ||
+ this.mLauncher.MIMEInfo.alwaysAskBeforeHandling !=
+ !this.dialogElement("rememberChoice").checked;
+
+ // One last special case: If the input "always ask" flag was false, then we always
+ // update. In that case we are displaying the helper app dialog for the first
+ // time for this mime type and we need to store the user's action in the handler service
+ // (whether that action has changed or not; if it didn't change, then we need
+ // to store the "always ask" flag so the helper app dialog will or won't display
+ // next time, per the user's selection).
+ needUpdate = needUpdate || !this.mLauncher.MIMEInfo.alwaysAskBeforeHandling;
+
+ // Make sure mime info has updated setting for the "always ask" flag.
+ this.mLauncher.MIMEInfo.alwaysAskBeforeHandling =
+ !this.dialogElement("rememberChoice").checked;
+
+ return needUpdate && !discardUpdate;
+ },
+
+ // See if the user changed things, and if so, store this mime type in the
+ // handler service.
+ updateHelperAppPref() {
+ var handlerInfo = this.mLauncher.MIMEInfo;
+ var hs = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ hs.store(handlerInfo);
+ },
+
+ onOK(aEvent) {
+ // Verify typed app path, if necessary.
+ if (this.useOtherHandler) {
+ var helperApp = this.helperAppChoice();
+ if (
+ !helperApp ||
+ !helperApp.executable ||
+ !helperApp.executable.exists()
+ ) {
+ // Show alert and try again.
+ var bundle = this.dialogElement("strings");
+ var msg = bundle.getFormattedString("badApp", [
+ this.dialogElement("otherHandler").getAttribute("path"),
+ ]);
+ Services.prompt.alert(
+ this.mDialog,
+ bundle.getString("badApp.title"),
+ msg
+ );
+
+ // Disable the OK button.
+ let dialog = this.mDialog.document.getElementById("unknownContentType");
+ dialog.getButton("accept").disabled = true;
+ this.dialogElement("mode").focus();
+
+ // Clear chosen application.
+ this.chosenApp = null;
+
+ // Leave dialog up.
+ aEvent.preventDefault();
+ }
+ }
+
+ // Remove our web progress listener (a progress dialog will be
+ // taking over).
+ this.mLauncher.setWebProgressListener(null);
+
+ // saveToDisk and setDownloadToLaunch can return errors in
+ // certain circumstances (e.g. The user clicks cancel in the
+ // "Save to Disk" dialog. In those cases, we don't want to
+ // update the helper application preferences in the RDF file.
+ try {
+ var needUpdate = this.updateMIMEInfo();
+
+ if (this.dialogElement("save").selected) {
+ // see @notify
+ // we cannot use opener's setTimeout, see bug 420405
+ this._saveToDiskTimer =
+ Cc["@mozilla.org/timer;1"].createInstance(nsITimer);
+ this._saveToDiskTimer.initWithCallback(this, 0, nsITimer.TYPE_ONE_SHOT);
+ } else {
+ let uri = this.mLauncher.source;
+ // Launch local files immediately without downloading them:
+ if (uri instanceof Ci.nsIFileURL) {
+ this.mLauncher.launchLocalFile();
+ } else {
+ this.mLauncher.setDownloadToLaunch(this.handleInternally, null);
+ }
+ }
+
+ // Update user pref for this mime type (if necessary). We do not
+ // store anything in the mime type preferences for the ambiguous
+ // type application/octet-stream. We do NOT do this for
+ // application/x-msdownload since we want users to be able to
+ // autodownload these to disk.
+ if (
+ needUpdate &&
+ this.mLauncher.MIMEInfo.MIMEType != "application/octet-stream"
+ ) {
+ this.updateHelperAppPref();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ this.onUnload();
+ },
+
+ onCancel() {
+ // Remove our web progress listener.
+ this.mLauncher.setWebProgressListener(null);
+
+ // Cancel app launcher.
+ try {
+ this.mLauncher.cancel(Cr.NS_BINDING_ABORTED);
+ } catch (e) {
+ console.error(e);
+ }
+
+ this.onUnload();
+ },
+
+ onUnload() {
+ this.mDialog.document.removeEventListener("dialogaccept", this);
+ this.mDialog.document.removeEventListener("dialogcancel", this);
+
+ // Unhook dialog from this object.
+ this.mDialog.dialog = null;
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "dialogaccept":
+ this.onOK(aEvent);
+ break;
+ case "dialogcancel":
+ this.onCancel();
+ break;
+ }
+ },
+
+ dialogElement(id) {
+ return this.mDialog.document.getElementById(id);
+ },
+
+ // Retrieve the pretty description from the file
+ getFileDisplayName: function getFileDisplayName(file) {
+ if (AppConstants.platform == "win") {
+ if (file instanceof Ci.nsILocalFileWin) {
+ try {
+ return file.getVersionInfoField("FileDescription");
+ } catch (e) {}
+ }
+ } else if (AppConstants.platform == "macosx") {
+ if (file instanceof Ci.nsILocalFileMac) {
+ try {
+ return file.bundleDisplayName;
+ } catch (e) {}
+ }
+ }
+ return file.leafName;
+ },
+
+ finishChooseApp() {
+ if (this.chosenApp) {
+ // Show the "handler" menulist since we have a (user-specified)
+ // application now.
+ this.dialogElement("modeDeck").setAttribute("selectedIndex", "0");
+
+ // Update dialog.
+ var otherHandler = this.dialogElement("otherHandler");
+ otherHandler.removeAttribute("hidden");
+ otherHandler.setAttribute(
+ "path",
+ this.getPath(this.chosenApp.executable)
+ );
+ if (AppConstants.platform == "win") {
+ otherHandler.label = this.getFileDisplayName(this.chosenApp.executable);
+ } else {
+ otherHandler.label = this.chosenApp.name;
+ }
+ this.dialogElement("openHandler").selectedIndex = 1;
+ this.dialogElement("openHandler").setAttribute(
+ "lastSelectedItemID",
+ "otherHandler"
+ );
+
+ this.dialogElement("mode").selectedItem = this.dialogElement("open");
+ } else {
+ var openHandler = this.dialogElement("openHandler");
+ var lastSelectedID = openHandler.getAttribute("lastSelectedItemID");
+ if (!lastSelectedID) {
+ lastSelectedID = "defaultHandler";
+ }
+ openHandler.selectedItem = this.dialogElement(lastSelectedID);
+ }
+ },
+ // chooseApp: Open file picker and prompt user for application.
+ chooseApp() {
+ if (AppConstants.platform == "win") {
+ // Protect against the lack of an extension
+ var fileExtension = "";
+ try {
+ fileExtension = this.mLauncher.MIMEInfo.primaryExtension;
+ } catch (ex) {}
+
+ // Try to use the pretty description of the type, if one is available.
+ var typeString = this.mLauncher.MIMEInfo.description;
+
+ if (!typeString) {
+ // If there is none, use the extension to
+ // identify the file, e.g. "ZIP file"
+ if (fileExtension) {
+ typeString = this.dialogElement("strings").getFormattedString(
+ "fileType",
+ [fileExtension.toUpperCase()]
+ );
+ } else {
+ // If we can't even do that, just give up and show the MIME type.
+ typeString = this.mLauncher.MIMEInfo.MIMEType;
+ }
+ }
+
+ var params = {};
+ params.title = this.dialogElement("strings").getString(
+ "chooseAppFilePickerTitle"
+ );
+ params.description = typeString;
+ params.filename = this.mLauncher.suggestedFileName;
+ params.mimeInfo = this.mLauncher.MIMEInfo;
+ params.handlerApp = null;
+
+ this.mDialog.openDialog(
+ "chrome://global/content/appPicker.xhtml",
+ null,
+ "chrome,modal,centerscreen,titlebar,dialog=yes",
+ params
+ );
+
+ if (
+ params.handlerApp &&
+ params.handlerApp.executable &&
+ params.handlerApp.executable.isFile()
+ ) {
+ // Remember the file they chose to run.
+ this.chosenApp = params.handlerApp;
+ }
+ } else if ("@mozilla.org/applicationchooser;1" in Cc) {
+ var nsIApplicationChooser = Ci.nsIApplicationChooser;
+ var appChooser = Cc["@mozilla.org/applicationchooser;1"].createInstance(
+ nsIApplicationChooser
+ );
+ appChooser.init(
+ this.mDialog,
+ this.dialogElement("strings").getString("chooseAppFilePickerTitle")
+ );
+ var contentTypeDialogObj = this;
+ let appChooserCallback = function appChooserCallback_done(aResult) {
+ if (aResult) {
+ contentTypeDialogObj.chosenApp = aResult.QueryInterface(
+ Ci.nsILocalHandlerApp
+ );
+ }
+ contentTypeDialogObj.finishChooseApp();
+ };
+ appChooser.open(this.mLauncher.MIMEInfo.MIMEType, appChooserCallback);
+ // The finishChooseApp is called from appChooserCallback
+ return;
+ } else {
+ var nsIFilePicker = Ci.nsIFilePicker;
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ fp.init(
+ this.mDialog,
+ this.dialogElement("strings").getString("chooseAppFilePickerTitle"),
+ nsIFilePicker.modeOpen
+ );
+
+ fp.appendFilters(nsIFilePicker.filterApps);
+
+ fp.open(aResult => {
+ if (aResult == nsIFilePicker.returnOK && fp.file) {
+ // Remember the file they chose to run.
+ var localHandlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ localHandlerApp.executable = fp.file;
+ this.chosenApp = localHandlerApp;
+ }
+ this.finishChooseApp();
+ });
+ // The finishChooseApp is called from fp.open() callback
+ return;
+ }
+
+ this.finishChooseApp();
+ },
+
+ shouldShowInternalHandlerOption() {
+ let browsingContext = this.mDialog.BrowsingContext.get(
+ this.mLauncher.browsingContextId
+ );
+ let primaryExtension = "";
+ try {
+ // The primaryExtension getter may throw if there are no
+ // known extensions for this mimetype.
+ primaryExtension = this.mLauncher.MIMEInfo.primaryExtension;
+ } catch (e) {}
+
+ // Only available for PDF files when pdf.js is enabled.
+ // Skip if the current window uses the resource scheme, to avoid
+ // showing the option when using the Download button in pdf.js.
+ if (primaryExtension == "pdf") {
+ return (
+ !(
+ this.mLauncher.source.schemeIs("blob") ||
+ this.mLauncher.source.equalsExceptRef(
+ browsingContext.currentWindowGlobal.documentURI
+ )
+ ) &&
+ !Services.prefs.getBoolPref("pdfjs.disabled", true) &&
+ Services.prefs.getBoolPref(
+ "browser.helperApps.showOpenOptionForPdfJS",
+ false
+ )
+ );
+ }
+
+ return (
+ Services.prefs.getBoolPref(
+ "browser.helperApps.showOpenOptionForViewableInternally",
+ false
+ ) &&
+ lazy.DownloadIntegration.shouldViewDownloadInternally(
+ this.mLauncher.MIMEInfo.MIMEType,
+ primaryExtension
+ )
+ );
+ },
+
+ // Turn this on to get debugging messages.
+ debug: false,
+
+ // Dump text (if debug is on).
+ dump(text) {
+ if (this.debug) {
+ dump(text);
+ }
+ },
+};
diff --git a/toolkit/mozapps/downloads/components.conf b/toolkit/mozapps/downloads/components.conf
new file mode 100644
index 0000000000..8446fb2150
--- /dev/null
+++ b/toolkit/mozapps/downloads/components.conf
@@ -0,0 +1,14 @@
+# -*- 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': '{F68578EB-6EC2-4169-AE19-8C6243F0ABE1}',
+ 'contract_ids': ['@mozilla.org/helperapplauncherdialog;1'],
+ 'esModule': 'resource://gre/modules/HelperAppDlg.sys.mjs',
+ 'constructor': 'nsUnknownContentTypeDialog',
+ },
+]
diff --git a/toolkit/mozapps/downloads/content/unknownContentType.xhtml b/toolkit/mozapps/downloads/content/unknownContentType.xhtml
new file mode 100644
index 0000000000..47cf9d3117
--- /dev/null
+++ b/toolkit/mozapps/downloads/content/unknownContentType.xhtml
@@ -0,0 +1,104 @@
+<?xml version="1.0"?>
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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="unknownContentTypeWindow"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ onload="dialog.initDialog();" onunload="if (dialog) dialog.onCancel();"
+#ifdef XP_WIN
+ style="min-width: 36em;"
+#else
+ style="min-width: 34em;"
+#endif
+ screenX="" screenY=""
+ persist="screenX screenY"
+ aria-describedby="intro location whichIs type from source unknownPrompt">
+<linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <html:link
+ rel="stylesheet"
+ href="chrome://mozapps/skin/downloads/unknownContentType.css"
+ />
+
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="toolkit/global/unknownContentType.ftl"/>
+</linkset>
+<dialog id="unknownContentType">
+
+ <stringbundle id="strings" src="chrome://mozapps/locale/downloads/unknownContentType.properties"/>
+
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <vbox flex="1" id="container">
+ <description id="intro" data-l10n-id="unknowncontenttype-intro"></description>
+ <separator class="thin"/>
+ <hbox align="start" class="small-indent">
+ <image id="contentTypeImage"/>
+ <vbox flex="1">
+ <description id="location" class="plain" crop="start" flex="1"/>
+ <separator class="thin"/>
+ <hbox align="center">
+ <label id="whichIs" data-l10n-id="unknowncontenttype-which-is"/>
+ <html:input id="type" class="plain" readonly="readonly" noinitialfocus="true"/>
+ </hbox>
+ <hbox align="center">
+ <label data-l10n-id="unknowncontenttype-from" id="from"/>
+ <description id="source" class="plain" crop="start" flex="1"/>
+ </hbox>
+ </vbox>
+ </hbox>
+
+ <separator class="thin"/>
+
+ <hbox align="center" id="basicBox" collapsed="true">
+ <label id="unknownPrompt" data-l10n-id="unknowncontenttype-prompt" flex="1"/>
+ </hbox>
+
+ <vbox flex="1" id="normalBox">
+ <separator/>
+ <label control="mode" class="header" data-l10n-id="unknowncontenttype-action-question"/>
+ <radiogroup id="mode" class="small-indent">
+ <radio id="handleInternally" hidden="true" data-l10n-id="unknowncontenttype-handleinternally"/>
+
+ <hbox>
+ <radio id="open" data-l10n-id="unknowncontenttype-open-with"/>
+ <deck id="modeDeck" flex="1">
+ <hbox id="openHandlerBox" flex="1" align="center">
+ <menulist id="openHandler" flex="1" native="true">
+ <menupopup id="openHandlerPopup" oncommand="dialog.openHandlerCommand();">
+ <menuitem id="defaultHandler" default="true" crop="end"/>
+ <menuitem id="otherHandler" hidden="true" crop="start"/>
+ <menuseparator/>
+ <menuitem id="choose" data-l10n-id="unknowncontenttype-other"/>
+ </menupopup>
+ </menulist>
+ </hbox>
+ <hbox flex="1" align="center">
+ <button id="chooseButton" oncommand="dialog.chooseApp();"
+ data-l10n-id="unknowncontenttype-choose-handler"/>
+ </hbox>
+ </deck>
+ </hbox>
+
+ <radio id="save" data-l10n-id="unknowncontenttype-save-file"/>
+ </radiogroup>
+ <separator class="thin"/>
+ <hbox class="small-indent">
+ <checkbox id="rememberChoice" data-l10n-id="unknowncontenttype-remember-choice"
+ oncommand="dialog.toggleRememberChoice(event.target);"
+ native="true"/>
+ </hbox>
+
+ <separator/>
+
+ <description id="settingsChange" hidden="true" data-l10n-id="unknowncontenttype-settingschange"/>
+
+ <separator class="thin"/>
+ </vbox>
+ </vbox>
+</dialog>
+</window>
diff --git a/toolkit/mozapps/downloads/jar.mn b/toolkit/mozapps/downloads/jar.mn
new file mode 100644
index 0000000000..7caa2f9668
--- /dev/null
+++ b/toolkit/mozapps/downloads/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 mozapps %content/mozapps/
+* content/mozapps/downloads/unknownContentType.xhtml (content/unknownContentType.xhtml)
diff --git a/toolkit/mozapps/downloads/moz.build b/toolkit/mozapps/downloads/moz.build
new file mode 100644
index 0000000000..2ac210ca68
--- /dev/null
+++ b/toolkit/mozapps/downloads/moz.build
@@ -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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Downloads API")
+
+TEST_DIRS += ["tests"]
+
+EXTRA_JS_MODULES += [
+ "DownloadLastDir.sys.mjs",
+ "DownloadUtils.sys.mjs",
+ "HelperAppDlg.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/mozapps/downloads/tests/browser/browser.toml b/toolkit/mozapps/downloads/tests/browser/browser.toml
new file mode 100644
index 0000000000..eddb8b84f1
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser.toml
@@ -0,0 +1,33 @@
+[DEFAULT]
+support-files = [
+ "unknownContentType_dialog_layout_data.pif",
+ "unknownContentType_dialog_layout_data.pif^headers^",
+ "unknownContentType_dialog_layout_data.txt",
+ "unknownContentType_dialog_layout_data.txt^headers^",
+ "head.js",
+]
+
+["browser_save_wrongextension.js"]
+
+["browser_unknownContentType_blob.js"]
+
+["browser_unknownContentType_delayedbutton.js"]
+skip-if = [
+ "os == 'linux' && bits == 64 && debug", # Bug 1747285
+ "os == 'linux' && fission && tsan", # Bug 1747285
+]
+
+["browser_unknownContentType_dialog_layout.js"]
+
+["browser_unknownContentType_extension.js"]
+support-files = [
+ "unknownContentType.EXE",
+ "unknownContentType.EXE^headers^",
+]
+
+["browser_unknownContentType_policy.js"]
+run-if = ["os == 'win'"] # jnlp file are not considered executable on macOS or Linux
+support-files = [
+ "example.jnlp",
+ "example.jnlp^headers^",
+]
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js b/toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js
new file mode 100644
index 0000000000..83e668076a
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_save_wrongextension.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+let url =
+ "data:text/html,<a id='link' href='http://localhost:8000/thefile.js'>Link</a>";
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+let httpServer = null;
+
+add_task(async function test() {
+ const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+ );
+
+ httpServer = new HttpServer();
+ httpServer.start(8000);
+
+ function handleRequest(aRequest, aResponse) {
+ aResponse.setStatusLine(aRequest.httpVersion, 200);
+ aResponse.setHeader("Content-Type", "text/plain");
+ aResponse.write("Some Text");
+ }
+ httpServer.registerPathHandler("/thefile.js", handleRequest);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(document, "popupshown");
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShownPromise;
+
+ let tempDir = createTemporarySaveDirectory();
+ let destFile;
+
+ MockFilePicker.displayDirectory = tempDir;
+ MockFilePicker.showCallback = fp => {
+ let fileName = fp.defaultString;
+ destFile = tempDir.clone();
+ destFile.append(fileName);
+
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ };
+
+ let transferCompletePromise = new Promise(resolve => {
+ mockTransferCallback = resolve;
+ mockTransferRegisterer.register();
+ });
+
+ registerCleanupFunction(function () {
+ mockTransferRegisterer.unregister();
+ tempDir.remove(true);
+ });
+
+ document.getElementById("context-savelink").doCommand();
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ document,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+
+ await transferCompletePromise;
+
+ is(destFile.leafName, "thefile.js", "filename extension is not modified");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async () => {
+ MockFilePicker.cleanup();
+ await new Promise(resolve => httpServer.stop(resolve));
+});
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+function createTemporarySaveDirectory() {
+ let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ return saveDir;
+}
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js
new file mode 100644
index 0000000000..7b3900f46f
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_blob.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+
+async function promiseDownloadFinished(list) {
+ return new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ info("Download changed!");
+ if (download.succeeded || download.error) {
+ info("Download succeeded or errored");
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+}
+
+/**
+ * Check that both in the download "what do you want to do with this file"
+ * dialog and in the about:downloads download list, we represent blob URL
+ * download sources using the principal (origin) that generated the blob.
+ */
+add_task(async function test_check_blob_origin_representation() {
+ forcePromptForFiles("text/plain", "txt");
+
+ await check_blob_origin(
+ "https://example.org/1",
+ "example.org",
+ "example.org"
+ );
+ await check_blob_origin(
+ "data:text/html,<body>Some Text<br>",
+ "(data)",
+ "blob"
+ );
+});
+
+async function check_blob_origin(pageURL, expectedSource, expectedListOrigin) {
+ await BrowserTestUtils.withNewTab(pageURL, async browser => {
+ // Ensure we wait for the download to finish:
+ let downloadList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadPromise = promiseDownloadFinished(downloadList);
+
+ // Wait for the download prompting dialog
+ let dialogPromise = BrowserTestUtils.domWindowOpenedAndLoaded(
+ null,
+ win => win.document.documentURI == UCT_URI
+ );
+
+ // create and click an <a download> link to a txt file.
+ await SpecialPowers.spawn(browser, [], () => {
+ // Use `eval` to get a blob URL scoped to content, so that content is
+ // actually allowed to open it and so we can check the origin is correct.
+ let url = content.eval(`
+ window.foo = new Blob(["Hello"], {type: "text/plain"});
+ URL.createObjectURL(window.foo)`);
+ let link = content.document.createElement("a");
+ link.href = url;
+ link.textContent = "Click me, click me, me me me";
+ link.download = "my-file.txt";
+ content.document.body.append(link);
+ link.click();
+ });
+
+ // Check what we display in the dialog
+ let dialogWin = await dialogPromise;
+ let source = dialogWin.document.getElementById("source");
+ is(
+ source.value,
+ expectedSource,
+ "Should list origin as source if available."
+ );
+
+ // Close the dialog
+ let closedPromise = BrowserTestUtils.windowClosed(dialogWin);
+ // Ensure we're definitely saving (otherwise this depends on mime service
+ // defaults):
+ dialogWin.document.getElementById("save").click();
+ let dialogNode = dialogWin.document.querySelector("dialog");
+ dialogNode.getButton("accept").disabled = false;
+ dialogNode.acceptDialog();
+ await closedPromise;
+
+ // Wait for the download to finish and ensure it is cleared up.
+ let download = await downloadPromise;
+ registerCleanupFunction(async () => {
+ let target = download.target.path;
+ await download.finalize();
+ await IOUtils.remove(target);
+ });
+
+ // Check that the same download is displayed correctly in about:downloads.
+ await BrowserTestUtils.withNewTab("about:downloads", async dlBrowser => {
+ let doc = dlBrowser.contentDocument;
+ let listNode = doc.getElementById("downloadsListBox");
+ await BrowserTestUtils.waitForMutationCondition(
+ listNode,
+ { childList: true, subtree: true, attributeFilter: ["value"] },
+ () =>
+ listNode.firstElementChild
+ ?.querySelector(".downloadDetailsNormal")
+ ?.getAttribute("value")
+ );
+ let download = listNode.firstElementChild;
+ let detailString = download.querySelector(".downloadDetailsNormal").value;
+ Assert.stringContains(
+ detailString,
+ expectedListOrigin,
+ "Should list origin in download list if available."
+ );
+ });
+ });
+}
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js
new file mode 100644
index 0000000000..06a5a9d238
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_delayedbutton.js
@@ -0,0 +1,96 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+const LOAD_URI =
+ "http://mochi.test:8888/browser/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt";
+
+const DIALOG_DELAY =
+ Services.prefs.getIntPref("security.dialog_enable_delay") + 200;
+
+let UCTObserver = {
+ opened: Promise.withResolvers(),
+ closed: Promise.withResolvers(),
+
+ observe(aSubject, aTopic, aData) {
+ let win = aSubject;
+
+ switch (aTopic) {
+ case "domwindowopened":
+ win.addEventListener(
+ "load",
+ function onLoad(event) {
+ // Let the dialog initialize
+ SimpleTest.executeSoon(function () {
+ UCTObserver.opened.resolve(win);
+ });
+ },
+ { once: true }
+ );
+ break;
+
+ case "domwindowclosed":
+ if (win.location == UCT_URI) {
+ this.closed.resolve();
+ }
+ break;
+ }
+ },
+};
+
+function waitDelay(delay) {
+ return new Promise((resolve, reject) => {
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ window.setTimeout(resolve, delay);
+ });
+}
+
+add_task(async function test_unknownContentType_delayedbutton() {
+ info("Starting browser_unknownContentType_delayedbutton.js...");
+ forcePromptForFiles("text/plain", "txt");
+
+ Services.ww.registerNotification(UCTObserver);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: LOAD_URI,
+ waitForLoad: false,
+ waitForStateStop: true,
+ },
+ async function () {
+ let uctWindow = await UCTObserver.opened.promise;
+ let dialog = uctWindow.document.getElementById("unknownContentType");
+ let ok = dialog.getButton("accept");
+
+ SimpleTest.is(ok.disabled, true, "button started disabled");
+
+ await waitDelay(DIALOG_DELAY);
+
+ SimpleTest.is(ok.disabled, false, "button was enabled");
+
+ let focusOutOfDialog = SimpleTest.promiseFocus(window);
+ window.focus();
+ await focusOutOfDialog;
+
+ SimpleTest.is(ok.disabled, true, "button was disabled");
+
+ let focusOnDialog = SimpleTest.promiseFocus(uctWindow);
+ uctWindow.focus();
+ await focusOnDialog;
+
+ SimpleTest.is(ok.disabled, true, "button remained disabled");
+
+ await waitDelay(DIALOG_DELAY);
+ SimpleTest.is(ok.disabled, false, "button re-enabled after delay");
+
+ dialog.cancelDialog();
+ await UCTObserver.closed.promise;
+
+ Services.ww.unregisterNotification(UCTObserver);
+ uctWindow = null;
+ UCTObserver = null;
+ }
+ );
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js
new file mode 100644
index 0000000000..6755c1aadb
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_dialog_layout.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/*
+ * The unknownContentType popup can have two different layouts depending on
+ * whether a helper application can be selected or not.
+ * This tests that both layouts have correct collapsed elements.
+ */
+
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+
+let tests = [
+ {
+ // This URL will trigger the simple UI, where only the Save an Cancel buttons are available
+ url: "http://mochi.test:8888/browser/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif",
+ elements: {
+ basicBox: { collapsed: false },
+ normalBox: { collapsed: true },
+ },
+ },
+ {
+ // This URL will trigger the full UI
+ url: "http://mochi.test:8888/browser/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt",
+ elements: {
+ basicBox: { collapsed: true },
+ normalBox: { collapsed: false },
+ },
+ },
+];
+
+add_task(async function test_unknownContentType_dialog_layout() {
+ forcePromptForFiles("text/plain", "txt");
+ forcePromptForFiles("application/octet-stream", "pif");
+
+ for (let test of tests) {
+ let UCTObserver = {
+ opened: Promise.withResolvers(),
+ closed: Promise.withResolvers(),
+
+ observe(aSubject, aTopic, aData) {
+ let win = aSubject;
+
+ switch (aTopic) {
+ case "domwindowopened":
+ win.addEventListener(
+ "load",
+ function onLoad(event) {
+ // Let the dialog initialize
+ SimpleTest.executeSoon(function () {
+ UCTObserver.opened.resolve(win);
+ });
+ },
+ { once: true }
+ );
+ break;
+
+ case "domwindowclosed":
+ if (win.location == UCT_URI) {
+ this.closed.resolve();
+ }
+ break;
+ }
+ },
+ };
+
+ Services.ww.registerNotification(UCTObserver);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: test.url,
+ waitForLoad: false,
+ waitForStateStop: true,
+ },
+ async function () {
+ let uctWindow = await UCTObserver.opened.promise;
+
+ for (let [id, props] of Object.entries(test.elements)) {
+ let elem = uctWindow.dialog.dialogElement(id);
+ for (let [prop, value] of Object.entries(props)) {
+ SimpleTest.is(
+ elem[prop],
+ value,
+ "Element with id " +
+ id +
+ " has property " +
+ prop +
+ " set to " +
+ value
+ );
+ }
+ }
+ let focusOnDialog = SimpleTest.promiseFocus(uctWindow);
+ uctWindow.focus();
+ await focusOnDialog;
+
+ uctWindow.document.getElementById("unknownContentType").cancelDialog();
+ uctWindow = null;
+ Services.ww.unregisterNotification(UCTObserver);
+ }
+ );
+ }
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js
new file mode 100644
index 0000000000..1bb836c1d8
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_extension.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+/**
+ * Check that case-sensitivity doesn't cause us to duplicate
+ * file name extensions.
+ */
+add_task(async function test_download_filename_extension() {
+ forcePromptForFiles("application/octet-stream", "exe");
+ let windowObserver = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "unknownContentType.EXE",
+ waitForLoad: false,
+ });
+ let win = await windowObserver;
+
+ let list = await Downloads.getList(Downloads.ALL);
+ let downloadFinishedPromise = new Promise(resolve => {
+ list.addView({
+ onDownloadChanged(download) {
+ if (download.stopped) {
+ list.removeView(this);
+ resolve(download);
+ }
+ },
+ });
+ });
+
+ let dialog = win.document.querySelector("dialog");
+ dialog.getButton("accept").removeAttribute("disabled");
+ dialog.acceptDialog();
+ let download = await downloadFinishedPromise;
+ // We cannot assume that the filename didn't change.
+ let filename = PathUtils.filename(download.target.path);
+ Assert.ok(
+ filename.indexOf(".") == filename.lastIndexOf("."),
+ "Should not duplicate extension"
+ );
+ Assert.ok(filename.endsWith(".EXE"), "Should not change extension");
+ await list.remove(download);
+ BrowserTestUtils.removeTab(tab);
+ try {
+ await IOUtils.remove(download.target.path);
+ } catch (ex) {
+ // Ignore errors in removing the file, the system may keep it locked and
+ // it's not a critical issue.
+ info("Failed to remove the file " + ex);
+ }
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js
new file mode 100644
index 0000000000..ccb0d957bb
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/browser_unknownContentType_policy.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+/**
+ * Check that policy allows certain extensions to be launched.
+ */
+add_task(async function test_download_jnlp_policy() {
+ forcePromptForFiles("application/x-java-jnlp-file", "jnlp");
+ let windowObserver = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "example.jnlp",
+ waitForLoad: false,
+ });
+ let win = await windowObserver;
+
+ let dialog = win.document.querySelector("dialog");
+ let normalBox = win.document.getElementById("normalBox");
+ let basicBox = win.document.getElementById("basicBox");
+ is(normalBox.collapsed, !AppConstants.IS_ESR);
+ is(basicBox.collapsed, AppConstants.IS_ESR);
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(tab);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExemptDomainFileTypePairsFromFileTypeDownloadWarnings: [
+ {
+ file_extension: "jnlp",
+ domains: ["example.com"],
+ },
+ ],
+ },
+ });
+
+ windowObserver = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: TEST_PATH + "example.jnlp",
+ waitForLoad: false,
+ });
+ win = await windowObserver;
+
+ dialog = win.document.querySelector("dialog");
+ normalBox = win.document.getElementById("normalBox");
+ basicBox = win.document.getElementById("basicBox");
+ is(normalBox.collapsed, false);
+ is(basicBox.collapsed, true);
+ dialog.cancelDialog();
+ BrowserTestUtils.removeTab(tab);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {},
+ });
+});
diff --git a/toolkit/mozapps/downloads/tests/browser/example.jnlp b/toolkit/mozapps/downloads/tests/browser/example.jnlp
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/example.jnlp
diff --git a/toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^ b/toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^
new file mode 100644
index 0000000000..fac0de2095
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/example.jnlp^headers^
@@ -0,0 +1 @@
+Content-Type: application/x-java-jnlp-file
diff --git a/toolkit/mozapps/downloads/tests/browser/head.js b/toolkit/mozapps/downloads/tests/browser/head.js
new file mode 100644
index 0000000000..a5536e95b2
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/head.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function forcePromptForFiles(mime, extension) {
+ const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"].getService(
+ Ci.nsIHandlerService
+ );
+ const mimeSvc = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
+
+ let txtHandlerInfo = mimeSvc.getFromTypeAndExtension(mime, extension);
+ txtHandlerInfo.preferredAction = Ci.nsIHandlerInfo.alwaysAsk;
+ txtHandlerInfo.alwaysAskBeforeHandling = true;
+ handlerSvc.store(txtHandlerInfo);
+ registerCleanupFunction(() => handlerSvc.remove(txtHandlerInfo));
+}
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^ b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^
new file mode 100644
index 0000000000..09b22facc0
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType.EXE^headers^
@@ -0,0 +1 @@
+Content-Type: application/octet-stream
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif
new file mode 100644
index 0000000000..9353d13126
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif
@@ -0,0 +1 @@
+Dummy content for unknownContentType_dialog_layout_data.pif
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^
new file mode 100644
index 0000000000..09b22facc0
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.pif^headers^
@@ -0,0 +1 @@
+Content-Type: application/octet-stream
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt
new file mode 100644
index 0000000000..77e7195596
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt
@@ -0,0 +1 @@
+Dummy content for unknownContentType_dialog_layout_data.txt
diff --git a/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^
new file mode 100644
index 0000000000..2a3c472e26
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/browser/unknownContentType_dialog_layout_data.txt^headers^
@@ -0,0 +1,2 @@
+Content-Type: text/plain
+Content-Disposition: attachment
diff --git a/toolkit/mozapps/downloads/tests/moz.build b/toolkit/mozapps/downloads/tests/moz.build
new file mode 100644
index 0000000000..e274053906
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/moz.build
@@ -0,0 +1,8 @@
+# -*- 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.toml"]
+BROWSER_CHROME_MANIFESTS += ["browser/browser.toml"]
diff --git a/toolkit/mozapps/downloads/tests/unit/head_downloads.js b/toolkit/mozapps/downloads/tests/unit/head_downloads.js
new file mode 100644
index 0000000000..f3178decef
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/head_downloads.js
@@ -0,0 +1,5 @@
+registerCleanupFunction(function () {
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+});
diff --git a/toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js b/toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js
new file mode 100644
index 0000000000..956601c71e
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_DownloadUtils.js
@@ -0,0 +1,398 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+const gDecimalSymbol = Number(5.4).toLocaleString().match(/\D/);
+function _(str) {
+ return str.replace(/\./g, gDecimalSymbol);
+}
+
+function testConvertByteUnits(aBytes, aValue, aUnit) {
+ let [value, unit] = DownloadUtils.convertByteUnits(aBytes);
+ Assert.equal(value, aValue);
+ Assert.equal(unit, aUnit);
+}
+
+function testTransferTotal(aCurrBytes, aMaxBytes, aTransfer) {
+ let transfer = DownloadUtils.getTransferTotal(aCurrBytes, aMaxBytes);
+ Assert.equal(transfer, aTransfer);
+}
+
+// Get the em-dash character because typing it directly here doesn't work :(
+var gDash = DownloadUtils.getDownloadStatus(0)[0].match(/left (.) 0 bytes/)[1];
+
+var gVals = [
+ 0,
+ 100,
+ 2345,
+ 55555,
+ 982341,
+ 23194134,
+ 1482,
+ 58,
+ 9921949201,
+ 13498132,
+ Infinity,
+];
+
+function testStatus(aFunc, aCurr, aMore, aRate, aTest) {
+ dump("Status Test: " + [aCurr, aMore, aRate, aTest] + "\n");
+ let curr = gVals[aCurr];
+ let max = curr + gVals[aMore];
+ let speed = gVals[aRate];
+
+ let [status, last] = aFunc(curr, max, speed);
+
+ if (0) {
+ dump(
+ "testStatus(" +
+ aCurr +
+ ", " +
+ aMore +
+ ", " +
+ aRate +
+ ', ["' +
+ status.replace(gDash, "--") +
+ '", ' +
+ last.toFixed(3) +
+ "]);\n"
+ );
+ }
+
+ // Make sure the status text matches
+ Assert.equal(status, _(aTest[0].replace(/--/, gDash)));
+
+ // Make sure the lastSeconds matches
+ if (last == Infinity) {
+ Assert.equal(last, aTest[1]);
+ } else {
+ Assert.ok(Math.abs(last - aTest[1]) < 0.1);
+ }
+}
+
+function testFormattedTimeStatus(aSec, aExpected) {
+ dump("Formatted Time Status Test: [" + aSec + "]\n");
+
+ let status = DownloadUtils.getFormattedTimeStatus(aSec);
+ dump("Formatted Time Status Test Returns: (" + status.l10n.id + ")\n");
+
+ Assert.equal(status.l10n.id, aExpected);
+}
+
+function testURI(aURI, aDisp, aHost) {
+ dump("URI Test: " + [aURI, aDisp, aHost] + "\n");
+
+ let [disp, host] = DownloadUtils.getURIHost(aURI);
+
+ // Make sure we have the right display host and full host
+ Assert.equal(disp, aDisp);
+ Assert.equal(host, aHost);
+}
+
+function testGetReadableDates(aDate, aCompactValue) {
+ const now = new Date(2000, 11, 31, 11, 59, 59);
+
+ let [dateCompact] = DownloadUtils.getReadableDates(aDate, now);
+ Assert.equal(dateCompact, aCompactValue);
+}
+
+function testAllGetReadableDates() {
+ // This test cannot depend on the current date and time, or the date format.
+ // It depends on being run with the English localization, however.
+ const today_11_30 = new Date(2000, 11, 31, 11, 30, 15);
+ const today_12_30 = new Date(2000, 11, 31, 12, 30, 15);
+ const yesterday_11_30 = new Date(2000, 11, 30, 11, 30, 15);
+ const yesterday_12_30 = new Date(2000, 11, 30, 12, 30, 15);
+ const twodaysago = new Date(2000, 11, 29, 11, 30, 15);
+ const sixdaysago = new Date(2000, 11, 25, 11, 30, 15);
+ const sevendaysago = new Date(2000, 11, 24, 11, 30, 15);
+
+ let cDtf = Services.intl.DateTimeFormat;
+
+ testGetReadableDates(
+ today_11_30,
+ new cDtf(undefined, { timeStyle: "short" }).format(today_11_30)
+ );
+ testGetReadableDates(
+ today_12_30,
+ new cDtf(undefined, { timeStyle: "short" }).format(today_12_30)
+ );
+
+ testGetReadableDates(yesterday_11_30, "Yesterday");
+ testGetReadableDates(yesterday_12_30, "Yesterday");
+ testGetReadableDates(
+ twodaysago,
+ twodaysago.toLocaleDateString(undefined, { weekday: "long" })
+ );
+ testGetReadableDates(
+ sixdaysago,
+ sixdaysago.toLocaleDateString(undefined, { weekday: "long" })
+ );
+ testGetReadableDates(
+ sevendaysago,
+ sevendaysago.toLocaleDateString(undefined, { month: "long" }) +
+ " " +
+ sevendaysago.getDate().toString().padStart(2, "0")
+ );
+
+ let [, dateTimeFull] = DownloadUtils.getReadableDates(today_11_30);
+
+ const dtOptions = { dateStyle: "long", timeStyle: "short" };
+ Assert.equal(
+ dateTimeFull,
+ new cDtf(undefined, dtOptions).format(today_11_30)
+ );
+}
+
+function run_test() {
+ testConvertByteUnits(-1, "-1", "bytes");
+ testConvertByteUnits(1, _("1"), "bytes");
+ testConvertByteUnits(42, _("42"), "bytes");
+ testConvertByteUnits(123, _("123"), "bytes");
+ testConvertByteUnits(1024, _("1.0"), "KB");
+ testConvertByteUnits(8888, _("8.7"), "KB");
+ testConvertByteUnits(59283, _("57.9"), "KB");
+ testConvertByteUnits(640000, _("625"), "KB");
+ testConvertByteUnits(1048576, _("1.0"), "MB");
+ testConvertByteUnits(307232768, _("293"), "MB");
+ testConvertByteUnits(1073741824, _("1.0"), "GB");
+
+ testTransferTotal(1, 1, _("1 of 1 bytes"));
+ testTransferTotal(234, 4924, _("234 bytes of 4.8 KB"));
+ testTransferTotal(94923, 233923, _("92.7 of 228 KB"));
+ testTransferTotal(4924, 94923, _("4.8 of 92.7 KB"));
+ testTransferTotal(2342, 294960345, _("2.3 KB of 281 MB"));
+ testTransferTotal(234, undefined, _("234 bytes"));
+ testTransferTotal(4889023, undefined, _("4.7 MB"));
+
+ if (0) {
+ // Help find some interesting test cases
+ let r = () => Math.floor(Math.random() * 10);
+ for (let i = 0; i < 100; i++) {
+ testStatus(r(), r(), r());
+ }
+ }
+
+ // First, test with rates, via getDownloadStatus...
+ let statusFunc = DownloadUtils.getDownloadStatus.bind(DownloadUtils);
+
+ testStatus(statusFunc, 2, 1, 7, [
+ "A few seconds left -- 2.3 of 2.4 KB (58 bytes/sec)",
+ 1.724,
+ ]);
+ testStatus(statusFunc, 1, 2, 6, [
+ "A few seconds left -- 100 bytes of 2.4 KB (1.4 KB/sec)",
+ 1.582,
+ ]);
+ testStatus(statusFunc, 4, 3, 9, [
+ "A few seconds left -- 959 KB of 1.0 MB (12.9 MB/sec)",
+ 0.004,
+ ]);
+ testStatus(statusFunc, 2, 3, 8, [
+ "A few seconds left -- 2.3 of 56.5 KB (9.2 GB/sec)",
+ 0.0,
+ ]);
+
+ testStatus(statusFunc, 8, 4, 3, [
+ "17s left -- 9.2 of 9.2 GB (54.3 KB/sec)",
+ 17.682,
+ ]);
+ testStatus(statusFunc, 1, 3, 2, [
+ "23s left -- 100 bytes of 54.4 KB (2.3 KB/sec)",
+ 23.691,
+ ]);
+ testStatus(statusFunc, 9, 3, 2, [
+ "23s left -- 12.9 of 12.9 MB (2.3 KB/sec)",
+ 23.691,
+ ]);
+ testStatus(statusFunc, 5, 6, 7, [
+ "25s left -- 22.1 of 22.1 MB (58 bytes/sec)",
+ 25.552,
+ ]);
+
+ testStatus(statusFunc, 3, 9, 3, [
+ "4m left -- 54.3 KB of 12.9 MB (54.3 KB/sec)",
+ 242.969,
+ ]);
+ testStatus(statusFunc, 2, 3, 1, [
+ "9m left -- 2.3 of 56.5 KB (100 bytes/sec)",
+ 555.55,
+ ]);
+ testStatus(statusFunc, 4, 3, 7, [
+ "15m left -- 959 KB of 1.0 MB (58 bytes/sec)",
+ 957.845,
+ ]);
+ testStatus(statusFunc, 5, 3, 7, [
+ "15m left -- 22.1 of 22.2 MB (58 bytes/sec)",
+ 957.845,
+ ]);
+
+ testStatus(statusFunc, 1, 9, 2, [
+ "1h 35m left -- 100 bytes of 12.9 MB (2.3 KB/sec)",
+ 5756.133,
+ ]);
+ testStatus(statusFunc, 2, 9, 6, [
+ "2h 31m left -- 2.3 KB of 12.9 MB (1.4 KB/sec)",
+ 9108.051,
+ ]);
+ testStatus(statusFunc, 2, 4, 1, [
+ "2h 43m left -- 2.3 of 962 KB (100 bytes/sec)",
+ 9823.41,
+ ]);
+ testStatus(statusFunc, 6, 4, 7, [
+ "4h 42m left -- 1.4 of 961 KB (58 bytes/sec)",
+ 16936.914,
+ ]);
+
+ testStatus(statusFunc, 6, 9, 1, [
+ "1d 13h left -- 1.4 KB of 12.9 MB (100 bytes/sec)",
+ 134981.32,
+ ]);
+ testStatus(statusFunc, 3, 8, 3, [
+ "2d 1h left -- 54.3 KB of 9.2 GB (54.3 KB/sec)",
+ 178596.872,
+ ]);
+ testStatus(statusFunc, 1, 8, 6, [
+ "77d 11h left -- 100 bytes of 9.2 GB (1.4 KB/sec)",
+ 6694972.47,
+ ]);
+ testStatus(statusFunc, 6, 8, 7, [
+ "1,979d 22h left -- 1.4 KB of 9.2 GB (58 bytes/sec)",
+ 171068089.672,
+ ]);
+
+ testStatus(statusFunc, 0, 0, 5, [
+ "Unknown time left -- 0 of 0 bytes (22.1 MB/sec)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 0, 6, 0, [
+ "Unknown time left -- 0 bytes of 1.4 KB (0 bytes/sec)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 6, 6, 0, [
+ "Unknown time left -- 1.4 of 2.9 KB (0 bytes/sec)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 8, 5, 0, [
+ "Unknown time left -- 9.2 of 9.3 GB (0 bytes/sec)",
+ Infinity,
+ ]);
+
+ // With rate equal to Infinity
+ testStatus(statusFunc, 0, 0, 10, [
+ "Unknown time left -- 0 of 0 bytes (Really fast)",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 1, 2, 10, [
+ "A few seconds left -- 100 bytes of 2.4 KB (Really fast)",
+ 0,
+ ]);
+
+ // Now test without rates, via getDownloadStatusNoRate.
+ statusFunc = DownloadUtils.getDownloadStatusNoRate.bind(DownloadUtils);
+
+ testStatus(statusFunc, 2, 1, 7, [
+ "A few seconds left -- 2.3 of 2.4 KB",
+ 1.724,
+ ]);
+ testStatus(statusFunc, 1, 2, 6, [
+ "A few seconds left -- 100 bytes of 2.4 KB",
+ 1.582,
+ ]);
+ testStatus(statusFunc, 4, 3, 9, [
+ "A few seconds left -- 959 KB of 1.0 MB",
+ 0.004,
+ ]);
+ testStatus(statusFunc, 2, 3, 8, [
+ "A few seconds left -- 2.3 of 56.5 KB",
+ 0.0,
+ ]);
+
+ testStatus(statusFunc, 8, 4, 3, ["17s left -- 9.2 of 9.2 GB", 17.682]);
+ testStatus(statusFunc, 1, 3, 2, ["23s left -- 100 bytes of 54.4 KB", 23.691]);
+ testStatus(statusFunc, 9, 3, 2, ["23s left -- 12.9 of 12.9 MB", 23.691]);
+ testStatus(statusFunc, 5, 6, 7, ["25s left -- 22.1 of 22.1 MB", 25.552]);
+
+ testStatus(statusFunc, 3, 9, 3, ["4m left -- 54.3 KB of 12.9 MB", 242.969]);
+ testStatus(statusFunc, 2, 3, 1, ["9m left -- 2.3 of 56.5 KB", 555.55]);
+ testStatus(statusFunc, 4, 3, 7, ["15m left -- 959 KB of 1.0 MB", 957.845]);
+ testStatus(statusFunc, 5, 3, 7, ["15m left -- 22.1 of 22.2 MB", 957.845]);
+
+ testStatus(statusFunc, 1, 9, 2, [
+ "1h 35m left -- 100 bytes of 12.9 MB",
+ 5756.133,
+ ]);
+ testStatus(statusFunc, 2, 9, 6, [
+ "2h 31m left -- 2.3 KB of 12.9 MB",
+ 9108.051,
+ ]);
+ testStatus(statusFunc, 2, 4, 1, ["2h 43m left -- 2.3 of 962 KB", 9823.41]);
+ testStatus(statusFunc, 6, 4, 7, ["4h 42m left -- 1.4 of 961 KB", 16936.914]);
+
+ testStatus(statusFunc, 6, 9, 1, [
+ "1d 13h left -- 1.4 KB of 12.9 MB",
+ 134981.32,
+ ]);
+ testStatus(statusFunc, 3, 8, 3, [
+ "2d 1h left -- 54.3 KB of 9.2 GB",
+ 178596.872,
+ ]);
+ testStatus(statusFunc, 1, 8, 6, [
+ "77d 11h left -- 100 bytes of 9.2 GB",
+ 6694972.47,
+ ]);
+ testStatus(statusFunc, 6, 8, 7, [
+ "1,979d 22h left -- 1.4 KB of 9.2 GB",
+ 171068089.672,
+ ]);
+
+ testStatus(statusFunc, 0, 0, 5, [
+ "Unknown time left -- 0 of 0 bytes",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 0, 6, 0, [
+ "Unknown time left -- 0 bytes of 1.4 KB",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 6, 6, 0, [
+ "Unknown time left -- 1.4 of 2.9 KB",
+ Infinity,
+ ]);
+ testStatus(statusFunc, 8, 5, 0, [
+ "Unknown time left -- 9.2 of 9.3 GB",
+ Infinity,
+ ]);
+
+ testFormattedTimeStatus(-1, "downloading-file-opens-in-some-time-2");
+ // Passing in null will return a status of file-opens-in-seconds, as Math.floor(null) = 0
+ testFormattedTimeStatus(null, "downloading-file-opens-in-seconds-2");
+ testFormattedTimeStatus(0, "downloading-file-opens-in-seconds-2");
+ testFormattedTimeStatus(30, "downloading-file-opens-in-seconds-2");
+
+ testURI("http://www.mozilla.org/", "mozilla.org", "www.mozilla.org");
+ testURI(
+ "http://www.city.mikasa.hokkaido.jp/",
+ "city.mikasa.hokkaido.jp",
+ "www.city.mikasa.hokkaido.jp"
+ );
+ testURI("data:text/html,Hello World", "data resource", "data resource");
+ testURI(
+ "jar:http://www.mozilla.com/file!/magic",
+ "mozilla.com",
+ "www.mozilla.com"
+ );
+ testURI("file:///C:/Cool/Stuff/", "local file", "local file");
+ // Don't test for moz-icon if we don't have a protocol handler for it (e.g. b2g):
+ if ("@mozilla.org/network/protocol;1?name=moz-icon" in Cc) {
+ testURI("moz-icon:file:///test.extension", "local file", "local file");
+ testURI("moz-icon://.extension", "moz-icon resource", "moz-icon resource");
+ }
+ testURI("about:config", "about resource", "about resource");
+ testURI("invalid.uri", "", "");
+
+ testAllGetReadableDates();
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js b/toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js
new file mode 100644
index 0000000000..786da28b75
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_lowMinutes.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 448344 to make sure when we're in low minutes, we show both minutes
+ * and seconds; but continue to show only minutes when we have plenty.
+ */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+/**
+ * Print some debug message to the console. All arguments will be printed,
+ * separated by spaces.
+ *
+ * @param [arg0, arg1, arg2, ...]
+ * Any number of arguments to print out
+ * @usage _("Hello World") -> prints "Hello World"
+ * @usage _(1, 2, 3) -> prints "1 2 3"
+ */
+var _ = function (some, debug, text, to) {
+ print(Array.from(arguments).join(" "));
+};
+
+_("Make an array of time lefts and expected string to be shown for that time");
+var expectedTimes = [
+ [1.1, "A few seconds left", "under 4sec -> few"],
+ [2.5, "A few seconds left", "under 4sec -> few"],
+ [3.9, "A few seconds left", "under 4sec -> few"],
+ [5.3, "5s left", "truncate seconds"],
+ [1.1 * 60, "1m 6s left", "under 4min -> show sec"],
+ [2.5 * 60, "2m 30s left", "under 4min -> show sec"],
+ [3.9 * 60, "3m 54s left", "under 4min -> show sec"],
+ [5.3 * 60, "5m left", "over 4min -> only show min"],
+ [1.1 * 3600, "1h 6m left", "over 1hr -> show min/sec"],
+ [2.5 * 3600, "2h 30m left", "over 1hr -> show min/sec"],
+ [3.9 * 3600, "3h 54m left", "over 1hr -> show min/sec"],
+ [5.3 * 3600, "5h 18m left", "over 1hr -> show min/sec"],
+];
+_(expectedTimes.join("\n"));
+
+function run_test() {
+ expectedTimes.forEach(function ([time, expectStatus, comment]) {
+ _("Running test with time", time);
+ _("Test comment:", comment);
+ let [status, last] = DownloadUtils.getTimeLeft(time);
+
+ _("Got status:", status, "last:", last);
+ _("Expecting..", expectStatus);
+ Assert.equal(status, expectStatus);
+
+ _();
+ });
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js b/toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js
new file mode 100644
index 0000000000..d60baee447
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_syncedDownloadUtils.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test bug 420482 by making sure multiple consumers of DownloadUtils gets the
+ * same time remaining time if they provide the same time left but a different
+ * "last time".
+ */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+function run_test() {
+ // Simulate having multiple downloads requesting time left
+ let downloadTimes = {};
+ for (let time of [1, 30, 60, 3456, 9999]) {
+ downloadTimes[time] = DownloadUtils.getTimeLeft(time)[0];
+ }
+
+ // Pretend we're a download status bar also asking for a time left, but we're
+ // using a different "last sec". We need to make sure we get the same time.
+ let lastSec = 314;
+ for (let [time, text] of Object.entries(downloadTimes)) {
+ Assert.equal(DownloadUtils.getTimeLeft(time, lastSec)[0], text);
+ }
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js b/toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js
new file mode 100644
index 0000000000..eb512d93ab
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/test_unspecified_arguments.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Make sure passing null and nothing to various variable-arg DownloadUtils
+ * methods provide the same result.
+ */
+
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+function run_test() {
+ Assert.equal(
+ DownloadUtils.getDownloadStatus(1000, null, null, null) + "",
+ DownloadUtils.getDownloadStatus(1000) + ""
+ );
+ Assert.equal(
+ DownloadUtils.getDownloadStatus(1000, null, null) + "",
+ DownloadUtils.getDownloadStatus(1000, null) + ""
+ );
+
+ Assert.equal(
+ DownloadUtils.getTransferTotal(1000, null) + "",
+ DownloadUtils.getTransferTotal(1000) + ""
+ );
+
+ Assert.equal(
+ DownloadUtils.getTimeLeft(1000, null) + "",
+ DownloadUtils.getTimeLeft(1000) + ""
+ );
+}
diff --git a/toolkit/mozapps/downloads/tests/unit/xpcshell.toml b/toolkit/mozapps/downloads/tests/unit/xpcshell.toml
new file mode 100644
index 0000000000..7f1d488a3c
--- /dev/null
+++ b/toolkit/mozapps/downloads/tests/unit/xpcshell.toml
@@ -0,0 +1,10 @@
+[DEFAULT]
+head = "head_downloads.js"
+
+["test_DownloadUtils.js"]
+
+["test_lowMinutes.js"]
+
+["test_syncedDownloadUtils.js"]
+
+["test_unspecified_arguments.js"]
diff --git a/toolkit/mozapps/extensions/.eslintrc.js b/toolkit/mozapps/extensions/.eslintrc.js
new file mode 100644
index 0000000000..2e1909b8fb
--- /dev/null
+++ b/toolkit/mozapps/extensions/.eslintrc.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 = {
+ rules: {
+ // Warn about cyclomatic complexity in functions.
+ // XXX Bug 1326071 - This should be reduced down - probably to 20 or to
+ // be removed & synced with the mozilla/recommended value.
+ complexity: ["error", { max: 68 }],
+
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "all",
+ },
+ ],
+ },
+ overrides: [
+ {
+ files: "test/xpcshell/head*.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/toolkit/mozapps/extensions/AbuseReporter.sys.mjs b/toolkit/mozapps/extensions/AbuseReporter.sys.mjs
new file mode 100644
index 0000000000..0d8df5c9f3
--- /dev/null
+++ b/toolkit/mozapps/extensions/AbuseReporter.sys.mjs
@@ -0,0 +1,703 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const PREF_ABUSE_REPORT_URL = "extensions.abuseReport.url";
+const PREF_AMO_DETAILS_API_URL = "extensions.abuseReport.amoDetailsURL";
+
+// Name associated with the report dialog window.
+const DIALOG_WINDOW_NAME = "addons-abuse-report-dialog";
+
+// Maximum length of the string properties sent to the API endpoint.
+const MAX_STRING_LENGTH = 255;
+
+// Minimum time between report submissions (in ms).
+const MIN_MS_BETWEEN_SUBMITS = 30000;
+
+// The addon types currently supported by the integrated abuse report panel.
+const SUPPORTED_ADDON_TYPES = [
+ "extension",
+ "theme",
+ "sitepermission",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+];
+
+// An expanded set of addon types supported when the abuse report hosted on AMO is enabled
+// (based on the "extensions.abuseReport.amoFormEnabled" pref).
+const AMO_SUPPORTED_ADDON_TYPES = [...SUPPORTED_ADDON_TYPES, "dictionary"];
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ClientID: "resource://gre/modules/ClientID.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "ABUSE_REPORT_URL",
+ PREF_ABUSE_REPORT_URL
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "AMO_DETAILS_API_URL",
+ PREF_AMO_DETAILS_API_URL
+);
+
+// Whether the abuse report feature should open a form hosted by the url
+// derived from the one set on the extensions.abuseReport.amoFormURL pref
+// or use the abuse report panel integrated in Firefox.
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "ABUSE_REPORT_AMO_FORM_ENABLED",
+ "extensions.abuseReport.amoFormEnabled",
+ true
+);
+
+const PRIVATE_REPORT_PROPS = Symbol("privateReportProps");
+
+const ERROR_TYPES = Object.freeze([
+ "ERROR_ABORTED_SUBMIT",
+ "ERROR_ADDON_NOTFOUND",
+ "ERROR_CLIENT",
+ "ERROR_NETWORK",
+ "ERROR_UNKNOWN",
+ "ERROR_RECENT_SUBMIT",
+ "ERROR_SERVER",
+ "ERROR_AMODETAILS_NOTFOUND",
+ "ERROR_AMODETAILS_FAILURE",
+]);
+
+export class AbuseReportError extends Error {
+ constructor(errorType, errorInfo = undefined) {
+ if (!ERROR_TYPES.includes(errorType)) {
+ throw new Error(`Unknown AbuseReportError type "${errorType}"`);
+ }
+
+ let message = errorInfo ? `${errorType} - ${errorInfo}` : errorType;
+
+ super(message);
+ this.name = "AbuseReportError";
+ this.errorType = errorType;
+ this.errorInfo = errorInfo;
+ }
+}
+
+/**
+ * Create an error info string from a fetch response object.
+ *
+ * @param {Response} response
+ * A fetch response object to convert into an errorInfo string.
+ *
+ * @returns {Promise<string>}
+ * The errorInfo string to be included in an AbuseReportError.
+ */
+async function responseToErrorInfo(response) {
+ return JSON.stringify({
+ status: response.status,
+ responseText: await response.text().catch(err => ""),
+ });
+}
+
+/**
+ * A singleton object used to create new AbuseReport instances for a given addonId
+ * and enforce a minium amount of time between two report submissions .
+ */
+export const AbuseReporter = {
+ _lastReportTimestamp: null,
+
+ get amoFormEnabled() {
+ return lazy.ABUSE_REPORT_AMO_FORM_ENABLED;
+ },
+
+ getAMOFormURL({ addonId }) {
+ return Services.urlFormatter
+ .formatURLPref("extensions.abuseReport.amoFormURL")
+ .replace(/%addonID%/g, addonId);
+ },
+
+ // Error types.
+ updateLastReportTimestamp() {
+ this._lastReportTimestamp = Date.now();
+ },
+
+ getTimeFromLastReport() {
+ const currentTimestamp = Date.now();
+ if (this._lastReportTimestamp > currentTimestamp) {
+ // Reset the last report timestamp if it is in the future.
+ this._lastReportTimestamp = null;
+ }
+
+ if (!this._lastReportTimestamp) {
+ return Infinity;
+ }
+
+ return currentTimestamp - this._lastReportTimestamp;
+ },
+
+ isSupportedAddonType(addonType) {
+ if (this.amoFormEnabled) {
+ return AMO_SUPPORTED_ADDON_TYPES.includes(addonType);
+ }
+ return SUPPORTED_ADDON_TYPES.includes(addonType);
+ },
+
+ /**
+ * Create an AbuseReport instance, given the addonId and a reportEntryPoint.
+ *
+ * @param {string} addonId
+ * The id of the addon to create the report instance for.
+ * @param {object} options
+ * @param {string} options.reportEntryPoint
+ * An identifier that represent the entry point for the report flow.
+ *
+ * @returns {Promise<AbuseReport>}
+ * Returns a promise that resolves to an instance of the AbuseReport
+ * class, which represent an ongoing report.
+ */
+ async createAbuseReport(addonId, { reportEntryPoint } = {}) {
+ let addon = await lazy.AddonManager.getAddonByID(addonId);
+
+ if (!addon) {
+ // The addon isn't installed, query the details from the AMO API endpoint.
+ addon = await this.queryAMOAddonDetails(addonId, reportEntryPoint);
+ }
+
+ if (!addon) {
+ lazy.AMTelemetry.recordReportEvent({
+ addonId,
+ errorType: "ERROR_ADDON_NOTFOUND",
+ reportEntryPoint,
+ });
+ throw new AbuseReportError("ERROR_ADDON_NOTFOUND");
+ }
+
+ const reportData = await this.getReportData(addon);
+
+ return new AbuseReport({
+ addon,
+ reportData,
+ reportEntryPoint,
+ });
+ },
+
+ /**
+ * Retrieves the addon details from the AMO API endpoint, used to create
+ * abuse reports on non-installed addon-ons.
+ *
+ * For the addon details that may be translated (e.g. addon name, description etc.)
+ * the function will try to retrieve the string localized in the same locale used
+ * by Gecko (and fallback to "en-US" if that locale is unavailable).
+ *
+ * The addon creator properties are set to the first author available.
+ *
+ * @param {string} addonId
+ * The id of the addon to retrieve the details available on AMO.
+ * @param {string} reportEntryPoint
+ * The entry point for the report flow (to be included in the telemetry
+ * recorded in case of failures).
+ *
+ * @returns {Promise<AMOAddonDetails|null>}
+ * Returns a promise that resolves to an AMOAddonDetails object,
+ * which has the subset of the AddonWrapper properties which are
+ * needed by the abuse report panel or the report data sent to
+ * the abuse report API endpoint), or null if it fails to
+ * retrieve the details from AMO.
+ *
+ * @typedef {object} AMOAddonDetails
+ * @prop {string} id
+ * @prop {string} name
+ * @prop {string} version
+ * @prop {string} description
+ * @prop {string} type
+ * @prop {string} iconURL
+ * @prop {string} homepageURL
+ * @prop {string} supportURL
+ * @prop {AMOAddonCreator} creator
+ * @prop {boolean} isRecommended
+ * @prop {number} signedState=AddonManager.SIGNEDSTATE_UNKNOWN
+ * @prop {object} installTelemetryInfo={ source: "not_installed" }
+ *
+ * @typedef {object} AMOAddonCreator
+ * @prop {string} name
+ * @prop {string} url
+ */
+ async queryAMOAddonDetails(addonId, reportEntryPoint) {
+ let details;
+ try {
+ // This should be the API endpoint documented at:
+ // https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#detail
+ details = await fetch(`${lazy.AMO_DETAILS_API_URL}/${addonId}`, {
+ credentials: "omit",
+ referrerPolicy: "no-referrer",
+ headers: { "Content-Type": "application/json" },
+ }).then(async response => {
+ if (response.status === 200) {
+ return response.json();
+ }
+
+ let errorInfo = await responseToErrorInfo(response).catch(
+ err => undefined
+ );
+
+ if (response.status === 404) {
+ // Record a different telemetry event for 404 errors.
+ throw new AbuseReportError("ERROR_AMODETAILS_NOTFOUND", errorInfo);
+ }
+
+ throw new AbuseReportError("ERROR_AMODETAILS_FAILURE", errorInfo);
+ });
+ } catch (err) {
+ // Log the original error in the browser console.
+ Cu.reportError(err);
+
+ lazy.AMTelemetry.recordReportEvent({
+ addonId,
+ errorType: err.errorType || "ERROR_AMODETAILS_FAILURE",
+ reportEntryPoint,
+ });
+
+ return null;
+ }
+
+ const locale = Services.locale.appLocaleAsBCP47;
+
+ // Get a string value from a translated value
+ // (https://addons-server.readthedocs.io/en/latest/topics/api/overview.html#api-overview-translations)
+ const getTranslatedValue = value => {
+ if (typeof value === "string") {
+ return value;
+ }
+ return value && (value[locale] || value["en-US"]);
+ };
+
+ const getAuthorField = fieldName =>
+ details.authors && details.authors[0] && details.authors[0][fieldName];
+
+ // Normalize type "statictheme" (which is the type used on the AMO API side)
+ // into "theme" (because it is the type we use and expect on the Firefox side
+ // for this addon type).
+ const addonType = details.type === "statictheme" ? "theme" : details.type;
+
+ return {
+ id: addonId,
+ name: getTranslatedValue(details.name),
+ version: details.current_version.version,
+ description: getTranslatedValue(details.summary),
+ type: addonType,
+ iconURL: details.icon_url,
+ homepageURL: getTranslatedValue(details.homepage),
+ supportURL: getTranslatedValue(details.support_url),
+ // Set the addon creator to the first author in the AMO details.
+ creator: {
+ name: getAuthorField("name"),
+ url: getAuthorField("url"),
+ },
+ isRecommended: details.is_recommended,
+ // Set signed state to unknown because it isn't installed.
+ signedState: lazy.AddonManager.SIGNEDSTATE_UNKNOWN,
+ // Set the installTelemetryInfo.source to "not_installed".
+ installTelemetryInfo: { source: "not_installed" },
+ };
+ },
+
+ /**
+ * Helper function that retrieves from an addon object all the data to send
+ * as part of the submission request, besides the `reason`, `message` which are
+ * going to be received from the submit method of the report object returned
+ * by `createAbuseReport`.
+ * (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html)
+ *
+ * @param {AddonWrapper} addon
+ * The addon object to collect the detail from.
+ *
+ * @return {object}
+ * An object that contains the collected details.
+ */
+ async getReportData(addon) {
+ const truncateString = text =>
+ typeof text == "string" ? text.slice(0, MAX_STRING_LENGTH) : text;
+
+ // Normalize addon_install_source and addon_install_method values
+ // as expected by the server API endpoint. Returns null if the
+ // value is not a string.
+ const normalizeValue = text =>
+ typeof text == "string"
+ ? text.toLowerCase().replace(/[- :]/g, "_")
+ : null;
+
+ const installInfo = addon.installTelemetryInfo || {};
+
+ const data = {
+ addon: addon.id,
+ addon_version: addon.version,
+ addon_name: truncateString(addon.name),
+ addon_summary: truncateString(addon.description),
+ addon_install_origin:
+ addon.sourceURI && truncateString(addon.sourceURI.spec),
+ install_date: addon.installDate && addon.installDate.toISOString(),
+ addon_install_source: normalizeValue(installInfo.source),
+ addon_install_source_url:
+ installInfo.sourceURL && truncateString(installInfo.sourceURL),
+ addon_install_method: normalizeValue(installInfo.method),
+ };
+
+ switch (addon.signedState) {
+ case lazy.AddonManager.SIGNEDSTATE_BROKEN:
+ data.addon_signature = "broken";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_UNKNOWN:
+ data.addon_signature = "unknown";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_MISSING:
+ data.addon_signature = "missing";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_PRELIMINARY:
+ data.addon_signature = "preliminary";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_SIGNED:
+ data.addon_signature = "signed";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_SYSTEM:
+ data.addon_signature = "system";
+ break;
+ case lazy.AddonManager.SIGNEDSTATE_PRIVILEGED:
+ data.addon_signature = "privileged";
+ break;
+ default:
+ data.addon_signature = `unknown: ${addon.signedState}`;
+ }
+
+ // Set "curated" as addon_signature on recommended addons
+ // (addon.isRecommended internally checks that the addon is also
+ // signed correctly).
+ if (addon.isRecommended) {
+ data.addon_signature = "curated";
+ }
+
+ data.client_id = await lazy.ClientID.getClientIdHash();
+
+ data.app = Services.appinfo.name.toLowerCase();
+ data.appversion = Services.appinfo.version;
+ data.lang = Services.locale.appLocaleAsBCP47;
+ data.operating_system = AppConstants.platform;
+ data.operating_system_version = Services.sysinfo.getProperty("version");
+
+ return data;
+ },
+
+ /**
+ * Helper function that returns a reference to a report dialog window
+ * already opened (if any).
+ *
+ * @returns {Window?}
+ */
+ getOpenDialog() {
+ return Services.ww.getWindowByName(DIALOG_WINDOW_NAME);
+ },
+
+ /**
+ * Helper function that opens an abuse report form in a new dialog window.
+ *
+ * @param {string} addonId
+ * The addonId being reported.
+ * @param {string} reportEntryPoint
+ * The entry point from which the user has triggered the abuse report
+ * flow.
+ * @param {XULElement} browser
+ * The browser element (if any) that is opening the report window.
+ *
+ * @return {Promise<AbuseReportDialog>}
+ * Returns an AbuseReportDialog object, rejects if it fails to open
+ * the dialog.
+ *
+ * @typedef {object} AbuseReportDialog
+ * An object that represents the abuse report dialog.
+ * @prop {function} close
+ * A method that closes the report dialog (used by the caller
+ * to close the dialog when the user chooses to close the window
+ * that started the abuse report flow).
+ * @prop {Promise<AbuseReport|undefined>} promiseReport
+ * A promise resolved to an AbuseReport instance if the report should
+ * be submitted, or undefined if the user has cancelled the report.
+ * Rejects if it fails to create an AbuseReport instance or to open
+ * the abuse report window.
+ */
+ async openDialog(addonId, reportEntryPoint, browser) {
+ const chromeWin = browser && browser.ownerGlobal;
+ if (!chromeWin) {
+ throw new Error("Abuse Reporter dialog cancelled, opener tab closed");
+ }
+
+ const dialogWin = this.getOpenDialog();
+
+ if (dialogWin) {
+ // If an abuse report dialog is already open, cancel the
+ // previous report flow and start a new one.
+ const { deferredReport, promiseReport } =
+ dialogWin.arguments[0].wrappedJSObject;
+ deferredReport.resolve({ userCancelled: true });
+ await promiseReport;
+ }
+
+ const report = await AbuseReporter.createAbuseReport(addonId, {
+ reportEntryPoint,
+ });
+
+ if (!SUPPORTED_ADDON_TYPES.includes(report.addon.type)) {
+ throw new Error(
+ `Addon type "${report.addon.type}" is not currently supported by the integrated abuse reporting feature`
+ );
+ }
+
+ const params = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+
+ const dialogInit = {
+ report,
+ openWebLink(url) {
+ chromeWin.openWebLinkIn(url, "tab", {
+ relatedToCurrent: true,
+ });
+ },
+ };
+
+ params.appendElement(dialogInit);
+
+ let win;
+ function closeDialog() {
+ if (win && !win.closed) {
+ win.close();
+ }
+ }
+
+ const promiseReport = new Promise((resolve, reject) => {
+ dialogInit.deferredReport = { resolve, reject };
+ }).then(
+ ({ userCancelled }) => {
+ closeDialog();
+ return userCancelled ? undefined : report;
+ },
+ err => {
+ Cu.reportError(
+ `Unexpected abuse report panel error: ${err} :: ${err.stack}`
+ );
+ closeDialog();
+ return Promise.reject({
+ message: "Unexpected abuse report panel error",
+ });
+ }
+ );
+
+ const promiseReportPanel = new Promise((resolve, reject) => {
+ dialogInit.deferredReportPanel = { resolve, reject };
+ });
+
+ dialogInit.promiseReport = promiseReport;
+ dialogInit.promiseReportPanel = promiseReportPanel;
+
+ win = Services.ww.openWindow(
+ chromeWin,
+ "chrome://mozapps/content/extensions/abuse-report-frame.html",
+ DIALOG_WINDOW_NAME,
+ // Set the dialog window options (including a reasonable initial
+ // window height size, eventually adjusted by the panel once it
+ // has been rendered its content).
+ "dialog,centerscreen,height=700",
+ params
+ );
+
+ return {
+ close: closeDialog,
+ promiseReport,
+
+ // Properties used in tests
+ promiseReportPanel,
+ window: win,
+ };
+ },
+};
+
+/**
+ * Represents an ongoing abuse report. Instances of this class are created
+ * by the `AbuseReporter.createAbuseReport` method.
+ *
+ * This object is used by the reporting UI panel and message bars to:
+ *
+ * - get an errorType in case of a report creation error (e.g. because of a
+ * previously submitted report)
+ * - get the addon details used inside the reporting panel
+ * - submit the abuse report (and re-submit if a previous submission failed
+ * and the user choose to retry to submit it again)
+ * - abort an ongoing submission
+ *
+ * @param {object} options
+ * @param {AddonWrapper|null} options.addon
+ * AddonWrapper instance for the extension/theme being reported.
+ * (May be null if the extension has not been found).
+ * @param {object|null} options.reportData
+ * An object which contains addon and environment details to send as part of a submission
+ * (may be null if the report has a createErrorType).
+ * @param {string} options.reportEntryPoint
+ * A string that identify how the report has been triggered.
+ */
+class AbuseReport {
+ constructor({ addon, createErrorType, reportData, reportEntryPoint }) {
+ this[PRIVATE_REPORT_PROPS] = {
+ aborted: false,
+ abortController: new AbortController(),
+ addon,
+ reportData,
+ reportEntryPoint,
+ // message and reason are initially null, and then set by the panel
+ // using the related set method.
+ message: null,
+ reason: null,
+ };
+ }
+
+ recordTelemetry(errorType) {
+ const { addon, reportEntryPoint } = this;
+ lazy.AMTelemetry.recordReportEvent({
+ addonId: addon.id,
+ addonType: addon.type,
+ errorType,
+ reportEntryPoint,
+ });
+ }
+
+ /**
+ * Submit the current report, given a reason and a message.
+ *
+ * @returns {Promise<void>}
+ * Resolves once the report has been successfully submitted.
+ * It rejects with an AbuseReportError if the report couldn't be
+ * submitted for a known reason (or another Error type otherwise).
+ */
+ async submit() {
+ const {
+ aborted,
+ abortController,
+ message,
+ reason,
+ reportData,
+ reportEntryPoint,
+ } = this[PRIVATE_REPORT_PROPS];
+
+ // Record telemetry event and throw an AbuseReportError.
+ const rejectReportError = async (errorType, { response } = {}) => {
+ this.recordTelemetry(errorType);
+
+ // Leave errorInfo empty if there is no response or fails to
+ // be converted into an error info object.
+ const errorInfo = response
+ ? await responseToErrorInfo(response).catch(err => undefined)
+ : undefined;
+
+ throw new AbuseReportError(errorType, errorInfo);
+ };
+
+ if (aborted) {
+ // Report aborted before being actually submitted.
+ return rejectReportError("ERROR_ABORTED_SUBMIT");
+ }
+
+ // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS.
+ let msFromLastReport = AbuseReporter.getTimeFromLastReport();
+ if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) {
+ return rejectReportError("ERROR_RECENT_SUBMIT");
+ }
+
+ let response;
+ try {
+ response = await fetch(lazy.ABUSE_REPORT_URL, {
+ signal: abortController.signal,
+ method: "POST",
+ credentials: "omit",
+ referrerPolicy: "no-referrer",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ ...reportData,
+ report_entry_point: reportEntryPoint,
+ message,
+ reason,
+ }),
+ });
+ } catch (err) {
+ if (err.name === "AbortError") {
+ return rejectReportError("ERROR_ABORTED_SUBMIT");
+ }
+ Cu.reportError(err);
+ return rejectReportError("ERROR_NETWORK");
+ }
+
+ if (response.ok && response.status >= 200 && response.status < 400) {
+ // Ensure that the response is also a valid json format.
+ try {
+ await response.json();
+ } catch (err) {
+ this.recordTelemetry("ERROR_UNKNOWN");
+ throw err;
+ }
+ AbuseReporter.updateLastReportTimestamp();
+ this.recordTelemetry();
+ return undefined;
+ }
+
+ if (response.status >= 400 && response.status < 500) {
+ return rejectReportError("ERROR_CLIENT", { response });
+ }
+
+ if (response.status >= 500 && response.status < 600) {
+ return rejectReportError("ERROR_SERVER", { response });
+ }
+
+ // We got an unexpected HTTP status code.
+ return rejectReportError("ERROR_UNKNOWN", { response });
+ }
+
+ /**
+ * Abort the report submission.
+ */
+ abort() {
+ const { abortController } = this[PRIVATE_REPORT_PROPS];
+ abortController.abort();
+ this[PRIVATE_REPORT_PROPS].aborted = true;
+ }
+
+ get addon() {
+ return this[PRIVATE_REPORT_PROPS].addon;
+ }
+
+ get reportEntryPoint() {
+ return this[PRIVATE_REPORT_PROPS].reportEntryPoint;
+ }
+
+ /**
+ * Set the open message (called from the panel when the user submit the report)
+ *
+ * @parm {string} message
+ * An optional string which contains a description for the reported issue.
+ */
+ setMessage(message) {
+ this[PRIVATE_REPORT_PROPS].message = message;
+ }
+
+ /**
+ * Set the report reason (called from the panel when the user submit the report)
+ *
+ * @parm {string} reason
+ * String identifier for the report reason.
+ */
+ setReason(reason) {
+ this[PRIVATE_REPORT_PROPS].reason = reason;
+ }
+}
diff --git a/toolkit/mozapps/extensions/AddonContentPolicy.cpp b/toolkit/mozapps/extensions/AddonContentPolicy.cpp
new file mode 100644
index 0000000000..983935f7c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonContentPolicy.cpp
@@ -0,0 +1,371 @@
+/* -*- 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 "AddonContentPolicy.h"
+
+#include "mozilla/dom/nsCSPContext.h"
+#include "nsCOMPtr.h"
+#include "nsComponentManagerUtils.h"
+#include "nsIContent.h"
+#include "mozilla/BasePrincipal.h"
+#include "mozilla/Components.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/intl/Localization.h"
+#include "nsIEffectiveTLDService.h"
+#include "nsIUUIDGenerator.h"
+#include "nsIURI.h"
+#include "nsNetCID.h"
+#include "nsNetUtil.h"
+
+using namespace mozilla;
+using namespace mozilla::intl;
+
+/* Enforces content policies for WebExtension scopes. Currently:
+ *
+ * - Checks custom content security policies for sufficiently stringent
+ * script-src and other script-related directives.
+ * - We also used to validate object-src similarly to script-src, but that was
+ * dropped because NPAPI plugins are no longer supported (see bug 1766881).
+ */
+
+AddonContentPolicy::AddonContentPolicy() = default;
+
+AddonContentPolicy::~AddonContentPolicy() = default;
+
+NS_IMPL_ISUPPORTS(AddonContentPolicy, nsIAddonContentPolicy)
+
+// CSP Validation:
+
+static const char* allowedSchemes[] = {"blob", "filesystem", nullptr};
+
+static const char* allowedHostSchemes[] = {"http", "https", "moz-extension",
+ nullptr};
+
+/**
+ * Validates a CSP directive to ensure that it is sufficiently stringent.
+ * In particular, ensures that:
+ *
+ * - No remote sources are allowed other than from https: schemes
+ *
+ * - No remote sources specify host wildcards for generic domains
+ * (*.blogspot.com, *.com, *)
+ *
+ * - All remote sources and local extension sources specify a host
+ *
+ * - No scheme sources are allowed other than blob:, filesystem:,
+ * moz-extension:, and https:
+ *
+ * - No keyword sources are allowed other than 'none', 'self', 'unsafe-eval',
+ * and hash sources.
+ *
+ * Manifest V3 limits CSP for extension_pages, the script-src and
+ * worker-src directives may only be the following:
+ * - self
+ * - none
+ */
+class CSPValidator final : public nsCSPSrcVisitor {
+ public:
+ CSPValidator(nsAString& aURL, CSPDirective aDirective,
+ bool aDirectiveRequired = true, uint32_t aPermittedPolicy = 0)
+ : mURL(aURL),
+ mDirective(CSP_CSPDirectiveToString(aDirective)),
+ mPermittedPolicy(aPermittedPolicy),
+ mDirectiveRequired(aDirectiveRequired),
+ mFoundSelf(false) {
+ // Start with the default error message for a missing directive, since no
+ // visitors will be called if the directive isn't present.
+ mError.SetIsVoid(true);
+ }
+
+ // Visitors
+
+ bool visitSchemeSrc(const nsCSPSchemeSrc& src) override {
+ nsAutoString scheme;
+ src.getScheme(scheme);
+
+ if (SchemeInList(scheme, allowedHostSchemes)) {
+ FormatError("csp-error-missing-host"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ if (!SchemeInList(scheme, allowedSchemes)) {
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ return true;
+ };
+
+ bool visitHostSrc(const nsCSPHostSrc& src) override {
+ nsAutoString scheme, host;
+
+ src.getScheme(scheme);
+ src.getHost(host);
+
+ if (scheme.LowerCaseEqualsLiteral("http")) {
+ // Allow localhost on http
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_LOCALHOST &&
+ HostIsLocal(host)) {
+ return true;
+ }
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ if (scheme.LowerCaseEqualsLiteral("https")) {
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_LOCALHOST &&
+ HostIsLocal(host)) {
+ return true;
+ }
+ if (!(mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_REMOTE)) {
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ if (!HostIsAllowed(host)) {
+ FormatError("csp-error-illegal-host-wildcard"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ } else if (scheme.LowerCaseEqualsLiteral("moz-extension")) {
+ // The CSP parser silently converts 'self' keywords to the origin
+ // URL, so we need to reconstruct the URL to see if it was present.
+ if (!mFoundSelf) {
+ nsAutoString url(u"moz-extension://");
+ url.Append(host);
+
+ mFoundSelf = url.Equals(mURL);
+ }
+
+ if (host.IsEmpty() || host.EqualsLiteral("*")) {
+ FormatError("csp-error-missing-host"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+ } else if (!SchemeInList(scheme, allowedSchemes)) {
+ FormatError("csp-error-illegal-protocol"_ns, "scheme"_ns, scheme);
+ return false;
+ }
+
+ return true;
+ };
+
+ bool visitKeywordSrc(const nsCSPKeywordSrc& src) override {
+ switch (src.getKeyword()) {
+ case CSP_NONE:
+ case CSP_SELF:
+ return true;
+ case CSP_WASM_UNSAFE_EVAL:
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_WASM) {
+ return true;
+ }
+ // fall through to also check CSP_ALLOW_EVAL
+ [[fallthrough]];
+ case CSP_UNSAFE_EVAL:
+ if (mPermittedPolicy & nsIAddonContentPolicy::CSP_ALLOW_EVAL) {
+ return true;
+ }
+ // fall through and produce an illegal-keyword error.
+ [[fallthrough]];
+ default:
+ FormatError(
+ "csp-error-illegal-keyword"_ns, "keyword"_ns,
+ nsDependentString(CSP_EnumToUTF16Keyword(src.getKeyword())));
+ return false;
+ }
+ };
+
+ bool visitNonceSrc(const nsCSPNonceSrc& src) override {
+ FormatError("csp-error-illegal-keyword"_ns, "keyword"_ns, u"'nonce-*'"_ns);
+ return false;
+ };
+
+ bool visitHashSrc(const nsCSPHashSrc& src) override { return true; };
+
+ // Accessors
+
+ inline nsAString& GetError() {
+ if (mError.IsVoid() && mDirectiveRequired) {
+ FormatError("csp-error-missing-directive"_ns);
+ }
+
+ return mError;
+ };
+
+ inline bool FoundSelf() { return mFoundSelf; };
+
+ // Formatters
+
+ inline void FormatError(const nsACString& l10nId,
+ const nsACString& aKey = ""_ns,
+ const nsAString& aValue = u""_ns) {
+ nsTArray<nsCString> resIds = {"toolkit/global/cspErrors.ftl"_ns};
+ RefPtr<intl::Localization> l10n = intl::Localization::Create(resIds, true);
+
+ auto l10nArgs = dom::Optional<intl::L10nArgs>();
+ l10nArgs.Construct();
+
+ auto dirArg = l10nArgs.Value().Entries().AppendElement();
+ dirArg->mKey = "directive";
+ dirArg->mValue.SetValue().SetAsUTF8String().Assign(
+ NS_ConvertUTF16toUTF8(mDirective));
+
+ if (!aKey.IsEmpty()) {
+ auto optArg = l10nArgs.Value().Entries().AppendElement();
+ optArg->mKey = aKey;
+ optArg->mValue.SetValue().SetAsUTF8String().Assign(
+ NS_ConvertUTF16toUTF8(aValue));
+ }
+
+ nsAutoCString translation;
+ IgnoredErrorResult rv;
+ l10n->FormatValueSync(l10nId, l10nArgs, translation, rv);
+ if (rv.Failed()) {
+ mError.AssignLiteral("An unexpected error occurred");
+ } else {
+ mError = NS_ConvertUTF8toUTF16(translation);
+ }
+ };
+
+ private:
+ // Validators
+ bool HostIsLocal(nsAString& host) {
+ return host.EqualsLiteral("localhost") || host.EqualsLiteral("127.0.0.1");
+ }
+
+ bool HostIsAllowed(nsAString& host) {
+ if (host.First() == '*') {
+ if (host.EqualsLiteral("*") || host[1] != '.') {
+ return false;
+ }
+
+ host.Cut(0, 2);
+
+ nsCOMPtr<nsIEffectiveTLDService> tldService =
+ do_GetService(NS_EFFECTIVETLDSERVICE_CONTRACTID);
+
+ if (!tldService) {
+ return false;
+ }
+
+ NS_ConvertUTF16toUTF8 cHost(host);
+ nsAutoCString publicSuffix;
+
+ nsresult rv = tldService->GetPublicSuffixFromHost(cHost, publicSuffix);
+
+ return NS_SUCCEEDED(rv) && !cHost.Equals(publicSuffix);
+ }
+
+ return true;
+ };
+
+ bool SchemeInList(nsAString& scheme, const char** schemes) {
+ for (; *schemes; schemes++) {
+ if (scheme.LowerCaseEqualsASCII(*schemes)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // Data members
+
+ nsAutoString mURL;
+ NS_ConvertASCIItoUTF16 mDirective;
+ nsString mError;
+
+ uint32_t mPermittedPolicy;
+ bool mDirectiveRequired;
+ bool mFoundSelf;
+};
+
+/**
+ * Validates a custom content security policy string for use by an add-on.
+ * In particular, ensures that:
+ *
+ * - That script-src (or default-src) directive is present, and meets
+ * the policies required by the CSPValidator class
+ *
+ * - The script-src directive includes the source 'self'
+ */
+NS_IMETHODIMP
+AddonContentPolicy::ValidateAddonCSP(const nsAString& aPolicyString,
+ uint32_t aPermittedPolicy,
+ nsAString& aResult) {
+ nsresult rv;
+
+ // Validate against a randomly-generated extension origin.
+ // There is no add-on-specific behavior in the CSP code, beyond the ability
+ // for add-ons to specify a custom policy, but the parser requires a valid
+ // origin in order to operate correctly.
+ nsAutoString url(u"moz-extension://");
+ {
+ nsCOMPtr<nsIUUIDGenerator> uuidgen = components::UUIDGenerator::Service();
+ NS_ENSURE_TRUE(uuidgen, NS_ERROR_FAILURE);
+
+ nsID id;
+ rv = uuidgen->GenerateUUIDInPlace(&id);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ char idString[NSID_LENGTH];
+ id.ToProvidedString(idString);
+
+ MOZ_RELEASE_ASSERT(idString[0] == '{' && idString[NSID_LENGTH - 2] == '}',
+ "UUID generator did not return a valid UUID");
+
+ url.AppendASCII(idString + 1, NSID_LENGTH - 3);
+ }
+
+ RefPtr<BasePrincipal> principal =
+ BasePrincipal::CreateContentPrincipal(NS_ConvertUTF16toUTF8(url));
+
+ nsCOMPtr<nsIURI> selfURI;
+ principal->GetURI(getter_AddRefs(selfURI));
+ RefPtr<nsCSPContext> csp = new nsCSPContext();
+ rv = csp->SetRequestContextWithPrincipal(principal, selfURI, u""_ns, 0);
+ NS_ENSURE_SUCCESS(rv, rv);
+ csp->AppendPolicy(aPolicyString, false, false);
+
+ const nsCSPPolicy* policy = csp->GetPolicy(0);
+ if (!policy) {
+ CSPValidator validator(url, nsIContentSecurityPolicy::SCRIPT_SRC_DIRECTIVE,
+ true, aPermittedPolicy);
+ aResult.Assign(validator.GetError());
+ return NS_OK;
+ }
+
+ bool haveValidDefaultSrc = false;
+ bool hasValidScriptSrc = false;
+ {
+ CSPDirective directive = nsIContentSecurityPolicy::DEFAULT_SRC_DIRECTIVE;
+ CSPValidator validator(url, directive);
+
+ haveValidDefaultSrc = policy->visitDirectiveSrcs(directive, &validator);
+ }
+
+ aResult.SetIsVoid(true);
+ {
+ CSPDirective directive = nsIContentSecurityPolicy::SCRIPT_SRC_DIRECTIVE;
+ CSPValidator validator(url, directive, !haveValidDefaultSrc,
+ aPermittedPolicy);
+
+ if (!policy->visitDirectiveSrcs(directive, &validator)) {
+ aResult.Assign(validator.GetError());
+ } else if (!validator.FoundSelf()) {
+ validator.FormatError("csp-error-missing-source"_ns, "source"_ns,
+ u"'self'"_ns);
+ aResult.Assign(validator.GetError());
+ }
+ hasValidScriptSrc = true;
+ }
+
+ if (aResult.IsVoid()) {
+ CSPDirective directive = nsIContentSecurityPolicy::WORKER_SRC_DIRECTIVE;
+ CSPValidator validator(url, directive,
+ !haveValidDefaultSrc && !hasValidScriptSrc,
+ aPermittedPolicy);
+
+ if (!policy->visitDirectiveSrcs(directive, &validator)) {
+ aResult.Assign(validator.GetError());
+ }
+ }
+
+ return NS_OK;
+}
diff --git a/toolkit/mozapps/extensions/AddonContentPolicy.h b/toolkit/mozapps/extensions/AddonContentPolicy.h
new file mode 100644
index 0000000000..d889490379
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonContentPolicy.h
@@ -0,0 +1,18 @@
+/* -*- 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 "nsIAddonPolicyService.h"
+
+class AddonContentPolicy : public nsIAddonContentPolicy {
+ protected:
+ virtual ~AddonContentPolicy();
+
+ public:
+ AddonContentPolicy();
+
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIADDONCONTENTPOLICY
+};
diff --git a/toolkit/mozapps/extensions/AddonManager.sys.mjs b/toolkit/mozapps/extensions/AddonManager.sys.mjs
new file mode 100644
index 0000000000..e4e51885cf
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManager.sys.mjs
@@ -0,0 +1,5538 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Cannot use Services.appinfo here, or else xpcshell-tests will blow up, as
+// most tests later register different nsIAppInfo implementations, which
+// wouldn't be reflected in Services.appinfo anymore, as the lazy getter
+// underlying it would have been initialized if we used it here.
+if ("@mozilla.org/xre/app-info;1" in Cc) {
+ // eslint-disable-next-line mozilla/use-services
+ let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
+ if (runtime.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
+ // Refuse to run in child processes.
+ throw new Error("You cannot use the AddonManager in child processes!");
+ }
+}
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const MOZ_COMPATIBILITY_NIGHTLY = ![
+ "aurora",
+ "beta",
+ "release",
+ "esr",
+].includes(AppConstants.MOZ_UPDATE_CHANNEL);
+
+const INTL_LOCALES_CHANGED = "intl:app-locales-changed";
+
+const PREF_AMO_ABUSEREPORT = "extensions.abuseReport.amWebAPI.enabled";
+const PREF_BLOCKLIST_PINGCOUNTVERSION = "extensions.blocklist.pingCountVersion";
+const PREF_EM_UPDATE_ENABLED = "extensions.update.enabled";
+const PREF_EM_LAST_APP_VERSION = "extensions.lastAppVersion";
+const PREF_EM_LAST_PLATFORM_VERSION = "extensions.lastPlatformVersion";
+const PREF_EM_AUTOUPDATE_DEFAULT = "extensions.update.autoUpdateDefault";
+const PREF_EM_STRICT_COMPATIBILITY = "extensions.strictCompatibility";
+const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+const PREF_SYS_ADDON_UPDATE_ENABLED = "extensions.systemAddon.update.enabled";
+const PREF_REMOTESETTINGS_DISABLED = "extensions.remoteSettings.disabled";
+const PREF_USE_REMOTE = "extensions.webextensions.remote";
+
+const PREF_MIN_WEBEXT_PLATFORM_VERSION =
+ "extensions.webExtensionsMinPlatformVersion";
+const PREF_WEBAPI_TESTING = "extensions.webapi.testing";
+const PREF_EM_POSTDOWNLOAD_THIRD_PARTY =
+ "extensions.postDownloadThirdPartyPrompt";
+
+const UPDATE_REQUEST_VERSION = 2;
+
+const BRANCH_REGEXP = /^([^\.]+\.[0-9]+[a-z]*).*/gi;
+const PREF_EM_CHECK_COMPATIBILITY_BASE = "extensions.checkCompatibility";
+var PREF_EM_CHECK_COMPATIBILITY = MOZ_COMPATIBILITY_NIGHTLY
+ ? PREF_EM_CHECK_COMPATIBILITY_BASE + ".nightly"
+ : undefined;
+
+const WEBAPI_INSTALL_HOSTS =
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ? ["addons.mozilla.org"]
+ : ["addons.thunderbird.net"];
+const WEBAPI_TEST_INSTALL_HOSTS =
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ? ["addons.allizom.org", "addons-dev.allizom.org", "example.com"]
+ : ["addons-stage.thunderbird.net", "example.com"];
+
+const AMO_ATTRIBUTION_ALLOWED_SOURCES = ["amo", "disco", "rtamo"];
+const AMO_ATTRIBUTION_DATA_KEYS = [
+ "utm_campaign",
+ "utm_content",
+ "utm_medium",
+ "utm_source",
+];
+const AMO_ATTRIBUTION_DATA_MAX_LENGTH = 40;
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+// This global is overridden by xpcshell tests, and therefore cannot be
+// a const.
+import { AsyncShutdown as realAsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
+
+var AsyncShutdown = realAsyncShutdown;
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TelemetryTimestamps: "resource://gre/modules/TelemetryTimestamps.sys.mjs",
+ isGatedPermissionType:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+ isKnownPublicSuffix:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+ isPrincipalInSitePermissionsBlocklist:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "WEBEXT_POSTDOWNLOAD_THIRD_PARTY",
+ PREF_EM_POSTDOWNLOAD_THIRD_PARTY,
+ false
+);
+
+// Initialize the WebExtension process script service as early as possible,
+// since it needs to be able to track things like new frameLoader globals that
+// are created before other framework code has been initialized.
+Services.ppmm.loadProcessScript(
+ "resource://gre/modules/extensionProcessScriptLoader.js",
+ true
+);
+
+const INTEGER = /^[1-9]\d*$/;
+
+const CATEGORY_PROVIDER_MODULE = "addon-provider-module";
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+// Configure a logger at the parent 'addons' level to format
+// messages for all the modules under addons.*
+const PARENT_LOGGER_ID = "addons";
+var parentLogger = Log.repository.getLogger(PARENT_LOGGER_ID);
+parentLogger.level = Log.Level.Warn;
+var formatter = new Log.BasicFormatter();
+// Set parent logger (and its children) to append to
+// the Javascript section of the Browser Console
+parentLogger.addAppender(new Log.ConsoleAppender(formatter));
+
+// Create a new logger (child of 'addons' logger)
+// for use by the Addons Manager
+const LOGGER_ID = "addons.manager";
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+// Provide the ability to enable/disable logging
+// messages at runtime.
+// If the "extensions.logging.enabled" preference is
+// missing or 'false', messages at the WARNING and higher
+// severity should be logged to the JS console and standard error.
+// If "extensions.logging.enabled" is set to 'true', messages
+// at DEBUG and higher should go to JS console and standard error.
+const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
+const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";
+
+const UNNAMED_PROVIDER = "<unnamed-provider>";
+function providerName(aProvider) {
+ return aProvider.name || UNNAMED_PROVIDER;
+}
+
+// A reference to XPIProvider. This should only be used to access properties or
+// methods that are independent of XPIProvider startup.
+var gXPIProvider;
+
+/**
+ * Preference listener which listens for a change in the
+ * "extensions.logging.enabled" preference and changes the logging level of the
+ * parent 'addons' level logger accordingly.
+ */
+var PrefObserver = {
+ init() {
+ Services.prefs.addObserver(PREF_LOGGING_ENABLED, this);
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ this.observe(null, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID, PREF_LOGGING_ENABLED);
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "xpcom-shutdown") {
+ Services.prefs.removeObserver(PREF_LOGGING_ENABLED, this);
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ } else if (aTopic == NS_PREFBRANCH_PREFCHANGE_TOPIC_ID) {
+ let debugLogEnabled = Services.prefs.getBoolPref(
+ PREF_LOGGING_ENABLED,
+ false
+ );
+ if (debugLogEnabled) {
+ parentLogger.level = Log.Level.Debug;
+ } else {
+ parentLogger.level = Log.Level.Warn;
+ }
+ }
+ },
+};
+
+PrefObserver.init();
+
+/**
+ * Calls a callback method consuming any thrown exception. Any parameters after
+ * the callback parameter will be passed to the callback.
+ *
+ * @param aCallback
+ * The callback method to call
+ */
+function safeCall(aCallback, ...aArgs) {
+ try {
+ aCallback.apply(null, aArgs);
+ } catch (e) {
+ logger.warn("Exception calling callback", e);
+ }
+}
+
+/**
+ * Report an exception thrown by a provider API method.
+ */
+function reportProviderError(aProvider, aMethod, aError) {
+ let method = `provider ${providerName(aProvider)}.${aMethod}`;
+ AddonManagerPrivate.recordException("AMI", method, aError);
+ logger.error("Exception calling " + method, aError);
+}
+
+/**
+ * Calls a method on a provider if it exists and consumes any thrown exception.
+ * Any parameters after the aDefault parameter are passed to the provider's method.
+ *
+ * @param aProvider
+ * The provider to call
+ * @param aMethod
+ * The method name to call
+ * @param aDefault
+ * A default return value if the provider does not implement the named
+ * method or throws an error.
+ * @return the return value from the provider, or aDefault if the provider does not
+ * implement method or throws an error
+ */
+function callProvider(aProvider, aMethod, aDefault, ...aArgs) {
+ if (!(aMethod in aProvider)) {
+ return aDefault;
+ }
+
+ try {
+ return aProvider[aMethod].apply(aProvider, aArgs);
+ } catch (e) {
+ reportProviderError(aProvider, aMethod, e);
+ return aDefault;
+ }
+}
+
+/**
+ * Calls a method on a provider if it exists and consumes any thrown exception.
+ * Parameters after aMethod are passed to aProvider.aMethod().
+ * If the provider does not implement the method, or the method throws, calls
+ * the callback with 'undefined'.
+ *
+ * @param aProvider
+ * The provider to call
+ * @param aMethod
+ * The method name to call
+ */
+async function promiseCallProvider(aProvider, aMethod, ...aArgs) {
+ if (!(aMethod in aProvider)) {
+ return undefined;
+ }
+ try {
+ return aProvider[aMethod].apply(aProvider, aArgs);
+ } catch (e) {
+ reportProviderError(aProvider, aMethod, e);
+ return undefined;
+ }
+}
+
+/**
+ * Gets the currently selected locale for display.
+ * @return the selected locale or "en-US" if none is selected
+ */
+function getLocale() {
+ return Services.locale.requestedLocale || "en-US";
+}
+
+const WEB_EXPOSED_ADDON_PROPERTIES = [
+ "id",
+ "version",
+ "type",
+ "name",
+ "description",
+ "isActive",
+];
+
+function webAPIForAddon(addon) {
+ if (!addon) {
+ return null;
+ }
+
+ // These web-exposed Addon properties (see AddonManager.webidl)
+ // just come directly from an Addon object.
+ let result = {};
+ for (let prop of WEB_EXPOSED_ADDON_PROPERTIES) {
+ result[prop] = addon[prop];
+ }
+
+ // These properties are computed.
+ result.isEnabled = !addon.userDisabled;
+ result.canUninstall = Boolean(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+
+ return result;
+}
+
+/**
+ * Listens for a browser changing origin and cancels the installs that were
+ * started by it.
+ */
+function BrowserListener(aBrowser, aInstallingPrincipal, aInstall) {
+ this.browser = aBrowser;
+ this.messageManager = this.browser.messageManager;
+ this.principal = aInstallingPrincipal;
+ this.install = aInstall;
+
+ aBrowser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
+ Services.obs.addObserver(this, "message-manager-close", true);
+
+ aInstall.addListener(this);
+
+ this.registered = true;
+}
+
+BrowserListener.prototype = {
+ browser: null,
+ install: null,
+ registered: false,
+
+ unregister() {
+ if (!this.registered) {
+ return;
+ }
+ this.registered = false;
+
+ Services.obs.removeObserver(this, "message-manager-close");
+ // The browser may have already been detached
+ if (this.browser.removeProgressListener) {
+ this.browser.removeProgressListener(this);
+ }
+
+ this.install.removeListener(this);
+ this.install = null;
+ },
+
+ cancelInstall() {
+ try {
+ this.install.cancel();
+ } catch (e) {
+ // install may have already failed or been cancelled, ignore these
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (subject != this.messageManager) {
+ return;
+ }
+
+ // The browser's message manager has closed and so the browser is
+ // going away, cancel the install
+ this.cancelInstall();
+ },
+
+ onLocationChange(webProgress, request, location) {
+ if (
+ this.browser.contentPrincipal &&
+ this.principal.subsumes(this.browser.contentPrincipal)
+ ) {
+ return;
+ }
+
+ // The browser has navigated to a new origin so cancel the install
+ this.cancelInstall();
+ },
+
+ onDownloadCancelled(install) {
+ this.unregister();
+ },
+
+ onDownloadFailed(install) {
+ this.unregister();
+ },
+
+ onInstallFailed(install) {
+ this.unregister();
+ },
+
+ onInstallEnded(install) {
+ this.unregister();
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsISupportsWeakReference",
+ "nsIWebProgressListener",
+ "nsIObserver",
+ ]),
+};
+
+/**
+ * This represents an author of an add-on (e.g. creator or developer)
+ *
+ * @param aName
+ * The name of the author
+ * @param aURL
+ * The URL of the author's profile page
+ */
+function AddonAuthor(aName, aURL) {
+ this.name = aName;
+ this.url = aURL;
+}
+
+AddonAuthor.prototype = {
+ name: null,
+ url: null,
+
+ // Returns the author's name, defaulting to the empty string
+ toString() {
+ return this.name || "";
+ },
+};
+
+/**
+ * This represents an screenshot for an add-on
+ *
+ * @param aURL
+ * The URL to the full version of the screenshot
+ * @param aWidth
+ * The width in pixels of the screenshot
+ * @param aHeight
+ * The height in pixels of the screenshot
+ * @param aThumbnailURL
+ * The URL to the thumbnail version of the screenshot
+ * @param aThumbnailWidth
+ * The width in pixels of the thumbnail version of the screenshot
+ * @param aThumbnailHeight
+ * The height in pixels of the thumbnail version of the screenshot
+ * @param aCaption
+ * The caption of the screenshot
+ */
+function AddonScreenshot(
+ aURL,
+ aWidth,
+ aHeight,
+ aThumbnailURL,
+ aThumbnailWidth,
+ aThumbnailHeight,
+ aCaption
+) {
+ this.url = aURL;
+ if (aWidth) {
+ this.width = aWidth;
+ }
+ if (aHeight) {
+ this.height = aHeight;
+ }
+ if (aThumbnailURL) {
+ this.thumbnailURL = aThumbnailURL;
+ }
+ if (aThumbnailWidth) {
+ this.thumbnailWidth = aThumbnailWidth;
+ }
+ if (aThumbnailHeight) {
+ this.thumbnailHeight = aThumbnailHeight;
+ }
+ if (aCaption) {
+ this.caption = aCaption;
+ }
+}
+
+AddonScreenshot.prototype = {
+ url: null,
+ width: null,
+ height: null,
+ thumbnailURL: null,
+ thumbnailWidth: null,
+ thumbnailHeight: null,
+ caption: null,
+
+ // Returns the screenshot URL, defaulting to the empty string
+ toString() {
+ return this.url || "";
+ },
+};
+
+var gStarted = false;
+var gStartedPromise = Promise.withResolvers();
+var gStartupComplete = false;
+var gCheckCompatibility = true;
+var gStrictCompatibility = true;
+var gCheckUpdateSecurityDefault = true;
+var gCheckUpdateSecurity = gCheckUpdateSecurityDefault;
+var gUpdateEnabled = true;
+var gAutoUpdateDefault = true;
+var gWebExtensionsMinPlatformVersion = "";
+var gFinalShutdownBarrier = null;
+var gBeforeShutdownBarrier = null;
+var gRepoShutdownState = "";
+var gShutdownInProgress = false;
+var gBrowserUpdated = null;
+
+export var AMTelemetry;
+export var AMRemoteSettings;
+export var AMBrowserExtensionsImport;
+
+/**
+ * This is the real manager, kept here rather than in AddonManager to keep its
+ * contents hidden from API users.
+ * @class
+ * @lends AddonManager
+ */
+var AddonManagerInternal = {
+ managerListeners: new Set(),
+ installListeners: new Set(),
+ addonListeners: new Set(),
+ pendingProviders: new Set(),
+ providers: new Set(),
+ providerShutdowns: new Map(),
+ typesByProvider: new Map(),
+ startupChanges: {},
+ // Store telemetry details per addon provider
+ telemetryDetails: {},
+ upgradeListeners: new Map(),
+ externalExtensionLoaders: new Map(),
+
+ recordTimestamp(name, value) {
+ lazy.TelemetryTimestamps.add(name, value);
+ },
+
+ /**
+ * Start up a provider, and register its shutdown hook if it has one
+ *
+ * @param {string} aProvider - An add-on provider.
+ * @param {boolean} aAppChanged - Whether or not the app version has changed since last session.
+ * @param {string} aOldAppVersion - Previous application version, if changed.
+ * @param {string} aOldPlatformVersion - Previous platform version, if changed.
+ *
+ * @private
+ */
+ _startProvider(aProvider, aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ logger.debug(`Starting provider: ${providerName(aProvider)}`);
+ callProvider(
+ aProvider,
+ "startup",
+ null,
+ aAppChanged,
+ aOldAppVersion,
+ aOldPlatformVersion
+ );
+ if ("shutdown" in aProvider) {
+ let name = providerName(aProvider);
+ let AMProviderShutdown = () => {
+ // If the provider has been unregistered, it will have been removed from
+ // this.providers. If it hasn't been unregistered, then this is a normal
+ // shutdown - and we move it to this.pendingProviders in case we're
+ // running in a test that will start AddonManager again.
+ if (this.providers.has(aProvider)) {
+ this.providers.delete(aProvider);
+ this.pendingProviders.add(aProvider);
+ }
+
+ return new Promise((resolve, reject) => {
+ logger.debug("Calling shutdown blocker for " + name);
+ resolve(aProvider.shutdown());
+ }).catch(err => {
+ logger.warn("Failure during shutdown of " + name, err);
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "Async shutdown of " + name,
+ err
+ );
+ });
+ };
+ logger.debug("Registering shutdown blocker for " + name);
+ this.providerShutdowns.set(aProvider, AMProviderShutdown);
+ AddonManagerPrivate.finalShutdown.addBlocker(name, AMProviderShutdown);
+ }
+
+ this.pendingProviders.delete(aProvider);
+ this.providers.add(aProvider);
+ logger.debug(`Provider finished startup: ${providerName(aProvider)}`);
+ },
+
+ _getProviderByName(aName) {
+ for (let provider of this.providers) {
+ if (providerName(provider) == aName) {
+ return provider;
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Initializes the AddonManager, loading any known providers and initializing
+ * them.
+ */
+ startup() {
+ try {
+ if (gStarted) {
+ return;
+ }
+
+ this.recordTimestamp("AMI_startup_begin");
+
+ // Enable the addonsManager telemetry event category.
+ AMTelemetry.init();
+
+ // Enable the AMRemoteSettings client.
+ AMRemoteSettings.init();
+
+ // clear this for xpcshell test restarts
+ for (let provider in this.telemetryDetails) {
+ delete this.telemetryDetails[provider];
+ }
+
+ let appChanged = undefined;
+
+ let oldAppVersion = null;
+ try {
+ oldAppVersion = Services.prefs.getCharPref(PREF_EM_LAST_APP_VERSION);
+ appChanged = Services.appinfo.version != oldAppVersion;
+ } catch (e) {}
+
+ gBrowserUpdated = appChanged;
+
+ let oldPlatformVersion = Services.prefs.getCharPref(
+ PREF_EM_LAST_PLATFORM_VERSION,
+ ""
+ );
+
+ if (appChanged !== false) {
+ logger.debug("Application has been upgraded");
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_APP_VERSION,
+ Services.appinfo.version
+ );
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_PLATFORM_VERSION,
+ Services.appinfo.platformVersion
+ );
+ Services.prefs.setIntPref(
+ PREF_BLOCKLIST_PINGCOUNTVERSION,
+ appChanged === undefined ? 0 : -1
+ );
+ }
+
+ if (!MOZ_COMPATIBILITY_NIGHTLY) {
+ PREF_EM_CHECK_COMPATIBILITY =
+ PREF_EM_CHECK_COMPATIBILITY_BASE +
+ "." +
+ Services.appinfo.version.replace(BRANCH_REGEXP, "$1");
+ }
+
+ gCheckCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_COMPATIBILITY,
+ gCheckCompatibility
+ );
+ Services.prefs.addObserver(PREF_EM_CHECK_COMPATIBILITY, this);
+
+ gStrictCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_STRICT_COMPATIBILITY,
+ gStrictCompatibility
+ );
+ Services.prefs.addObserver(PREF_EM_STRICT_COMPATIBILITY, this);
+
+ let defaultBranch = Services.prefs.getDefaultBranch("");
+ gCheckUpdateSecurityDefault = defaultBranch.getBoolPref(
+ PREF_EM_CHECK_UPDATE_SECURITY,
+ gCheckUpdateSecurityDefault
+ );
+
+ gCheckUpdateSecurity = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_UPDATE_SECURITY,
+ gCheckUpdateSecurity
+ );
+ Services.prefs.addObserver(PREF_EM_CHECK_UPDATE_SECURITY, this);
+
+ gUpdateEnabled = Services.prefs.getBoolPref(
+ PREF_EM_UPDATE_ENABLED,
+ gUpdateEnabled
+ );
+ Services.prefs.addObserver(PREF_EM_UPDATE_ENABLED, this);
+
+ gAutoUpdateDefault = Services.prefs.getBoolPref(
+ PREF_EM_AUTOUPDATE_DEFAULT,
+ gAutoUpdateDefault
+ );
+ Services.prefs.addObserver(PREF_EM_AUTOUPDATE_DEFAULT, this);
+
+ gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref(
+ PREF_MIN_WEBEXT_PLATFORM_VERSION,
+ gWebExtensionsMinPlatformVersion
+ );
+ Services.prefs.addObserver(PREF_MIN_WEBEXT_PLATFORM_VERSION, this);
+
+ // Watch for changes to PREF_REMOTESETTINGS_DISABLED.
+ Services.prefs.addObserver(PREF_REMOTESETTINGS_DISABLED, this);
+
+ // Watch for language changes, refresh the addon cache when it changes.
+ Services.obs.addObserver(this, INTL_LOCALES_CHANGED);
+
+ // Watch for changes in the `AMBrowserExtensionsImport` singleton.
+ Services.obs.addObserver(this, AMBrowserExtensionsImport.TOPIC_CANCELLED);
+ Services.obs.addObserver(this, AMBrowserExtensionsImport.TOPIC_COMPLETE);
+ Services.obs.addObserver(this, AMBrowserExtensionsImport.TOPIC_PENDING);
+
+ // Ensure all default providers have had a chance to register themselves.
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ gXPIProvider = XPIExports.XPIProvider;
+ gXPIProvider.registerProvider();
+
+ // Load any providers registered in the category manager
+ for (let { entry, value: url } of Services.catMan.enumerateCategory(
+ CATEGORY_PROVIDER_MODULE
+ )) {
+ try {
+ ChromeUtils.importESModule(url);
+ logger.debug(`Loaded provider scope for ${url}`);
+ } catch (e) {
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "provider " + url + " load failed",
+ e
+ );
+ logger.error(
+ "Exception loading provider " +
+ entry +
+ ' from category "' +
+ url +
+ '"',
+ e
+ );
+ }
+ }
+
+ // Register our shutdown handler with the AsyncShutdown manager
+ gBeforeShutdownBarrier = new AsyncShutdown.Barrier(
+ "AddonManager: Waiting to start provider shutdown."
+ );
+ gFinalShutdownBarrier = new AsyncShutdown.Barrier(
+ "AddonManager: Waiting for providers to shut down."
+ );
+ AsyncShutdown.profileBeforeChange.addBlocker(
+ "AddonManager: shutting down.",
+ this.shutdownManager.bind(this),
+ { fetchState: this.shutdownState.bind(this) }
+ );
+
+ // Once we start calling providers we must allow all normal methods to work.
+ gStarted = true;
+
+ for (let provider of this.pendingProviders) {
+ this._startProvider(
+ provider,
+ appChanged,
+ oldAppVersion,
+ oldPlatformVersion
+ );
+ }
+
+ // If this is a new profile just pretend that there were no changes
+ if (appChanged === undefined) {
+ for (let type in this.startupChanges) {
+ delete this.startupChanges[type];
+ }
+ }
+
+ gStartupComplete = true;
+ gStartedPromise.resolve();
+ this.recordTimestamp("AMI_startup_end");
+ } catch (e) {
+ logger.error("startup failed", e);
+ AddonManagerPrivate.recordException("AMI", "startup failed", e);
+ gStartedPromise.reject("startup failed");
+ }
+
+ // Disable the quarantined domains feature if the system add-on has been
+ // disabled in a previous version.
+ if (
+ Services.prefs.getBoolPref(
+ "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled",
+ false
+ )
+ ) {
+ Services.prefs.setBoolPref(
+ "extensions.quarantinedDomains.enabled",
+ false
+ );
+ logger.debug(
+ "Disabled quarantined domains because the system add-on was disabled"
+ );
+ }
+
+ Glean.extensions.useRemotePolicy.set(
+ WebExtensionPolicy.useRemoteWebExtensions
+ );
+ Glean.extensions.useRemotePref.set(
+ Services.prefs.getBoolPref(PREF_USE_REMOTE)
+ );
+ Services.prefs.addObserver(PREF_USE_REMOTE, this);
+
+ logger.debug("Completed startup sequence");
+ this.callManagerListeners("onStartup");
+ },
+
+ /**
+ * Registers a new AddonProvider.
+ *
+ * @param {string} aProvider -The provider to register
+ * @param {string[]} [aTypes] - An optional array of add-on types
+ */
+ registerProvider(aProvider, aTypes) {
+ if (!aProvider || typeof aProvider != "object") {
+ throw Components.Exception(
+ "aProvider must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.pendingProviders.add(aProvider);
+
+ if (aTypes) {
+ this.typesByProvider.set(aProvider, new Set(aTypes));
+ }
+
+ // If we're registering after startup call this provider's startup.
+ if (gStarted) {
+ this._startProvider(aProvider);
+ }
+ },
+
+ /**
+ * Unregisters an AddonProvider.
+ *
+ * @param aProvider
+ * The provider to unregister
+ * @return Whatever the provider's 'shutdown' method returns (if anything).
+ * For providers that have async shutdown methods returning Promises,
+ * the caller should wait for that Promise to resolve.
+ */
+ unregisterProvider(aProvider) {
+ if (!aProvider || typeof aProvider != "object") {
+ throw Components.Exception(
+ "aProvider must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.providers.delete(aProvider);
+ // The test harness will unregister XPIProvider *after* shutdown, which is
+ // after the provider will have been moved from providers to
+ // pendingProviders.
+ this.pendingProviders.delete(aProvider);
+
+ this.typesByProvider.delete(aProvider);
+
+ // If we're unregistering after startup but before shutting down,
+ // remove the blocker for this provider's shutdown and call it.
+ // If we're already shutting down, just let gFinalShutdownBarrier
+ // call it to avoid races.
+ if (gStarted && !gShutdownInProgress) {
+ logger.debug(
+ "Unregistering shutdown blocker for " + providerName(aProvider)
+ );
+ let shutter = this.providerShutdowns.get(aProvider);
+ if (shutter) {
+ this.providerShutdowns.delete(aProvider);
+ gFinalShutdownBarrier.client.removeBlocker(shutter);
+ return shutter();
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Mark a provider as safe to access via AddonManager APIs, before its
+ * startup has completed.
+ *
+ * Normally a provider isn't marked as safe until after its (synchronous)
+ * startup() method has returned. Until a provider has been marked safe,
+ * it won't be used by any of the AddonManager APIs. markProviderSafe()
+ * allows a provider to mark itself as safe during its startup; this can be
+ * useful if the provider wants to perform tasks that block startup, which
+ * happen after its required initialization tasks and therefore when the
+ * provider is in a safe state.
+ *
+ * @param aProvider Provider object to mark safe
+ */
+ markProviderSafe(aProvider) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aProvider || typeof aProvider != "object") {
+ throw Components.Exception(
+ "aProvider must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!this.pendingProviders.has(aProvider)) {
+ return;
+ }
+
+ this.pendingProviders.delete(aProvider);
+ this.providers.add(aProvider);
+ },
+
+ /**
+ * Calls a method on all registered providers if it exists and consumes any
+ * thrown exception. Return values are ignored. Any parameters after the
+ * method parameter are passed to the provider's method.
+ * WARNING: Do not use for asynchronous calls; callProviders() does not
+ * invoke callbacks if provider methods throw synchronous exceptions.
+ *
+ * @param aMethod
+ * The method name to call
+ */
+ callProviders(aMethod, ...aArgs) {
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let providers = [...this.providers];
+ for (let provider of providers) {
+ try {
+ if (aMethod in provider) {
+ provider[aMethod].apply(provider, aArgs);
+ }
+ } catch (e) {
+ reportProviderError(provider, aMethod, e);
+ }
+ }
+ },
+
+ /**
+ * Report the current state of asynchronous shutdown
+ */
+ shutdownState() {
+ let state = [];
+ for (let barrier of [gBeforeShutdownBarrier, gFinalShutdownBarrier]) {
+ if (barrier) {
+ state.push({ name: barrier.client.name, state: barrier.state });
+ }
+ }
+ state.push({
+ name: "AddonRepository: async shutdown",
+ state: gRepoShutdownState,
+ });
+ return state;
+ },
+
+ /**
+ * Shuts down the addon manager and all registered providers, this must clean
+ * up everything in order for automated tests to fake restarts.
+ * @return Promise{null} that resolves when all providers and dependent modules
+ * have finished shutting down
+ */
+ async shutdownManager() {
+ logger.debug("before shutdown");
+ try {
+ await gBeforeShutdownBarrier.wait();
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ logger.debug("shutdown");
+ this.callManagerListeners("onShutdown");
+
+ if (!gStartupComplete) {
+ gStartedPromise.reject("shutting down");
+ }
+
+ gRepoShutdownState = "pending";
+ gShutdownInProgress = true;
+
+ // Clean up listeners
+ Services.prefs.removeObserver(PREF_EM_CHECK_COMPATIBILITY, this);
+ Services.prefs.removeObserver(PREF_EM_STRICT_COMPATIBILITY, this);
+ Services.prefs.removeObserver(PREF_EM_CHECK_UPDATE_SECURITY, this);
+ Services.prefs.removeObserver(PREF_EM_UPDATE_ENABLED, this);
+ Services.prefs.removeObserver(PREF_EM_AUTOUPDATE_DEFAULT, this);
+ Services.prefs.removeObserver(PREF_REMOTESETTINGS_DISABLED, this);
+
+ Services.obs.removeObserver(this, INTL_LOCALES_CHANGED);
+
+ Services.obs.removeObserver(
+ this,
+ AMBrowserExtensionsImport.TOPIC_CANCELLED
+ );
+ Services.obs.removeObserver(this, AMBrowserExtensionsImport.TOPIC_COMPLETE);
+ Services.obs.removeObserver(this, AMBrowserExtensionsImport.TOPIC_PENDING);
+
+ AMRemoteSettings.shutdown();
+
+ let savedError = null;
+ // Only shut down providers if they've been started.
+ if (gStarted) {
+ try {
+ await gFinalShutdownBarrier.wait();
+ } catch (err) {
+ savedError = err;
+ logger.error("Failure during wait for shutdown barrier", err);
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "Async shutdown of AddonManager providers",
+ err
+ );
+ }
+ }
+ gXPIProvider = null;
+
+ // Shut down AddonRepository after providers (if any).
+ try {
+ gRepoShutdownState = "in progress";
+ await lazy.AddonRepository.shutdown();
+ gRepoShutdownState = "done";
+ } catch (err) {
+ savedError = err;
+ logger.error("Failure during AddonRepository shutdown", err);
+ AddonManagerPrivate.recordException(
+ "AMI",
+ "Async shutdown of AddonRepository",
+ err
+ );
+ }
+
+ logger.debug("Async provider shutdown done");
+ this.managerListeners.clear();
+ this.installListeners.clear();
+ this.addonListeners.clear();
+ this.providerShutdowns.clear();
+ for (let type in this.startupChanges) {
+ delete this.startupChanges[type];
+ }
+ gStarted = false;
+ gStartedPromise = Promise.withResolvers();
+ gStartupComplete = false;
+ gFinalShutdownBarrier = null;
+ gBeforeShutdownBarrier = null;
+ gShutdownInProgress = false;
+ if (savedError) {
+ throw savedError;
+ }
+ },
+
+ /**
+ * Notified when a preference we're interested in has changed.
+ */
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case INTL_LOCALES_CHANGED: {
+ // Asynchronously fetch and update the addons cache.
+ lazy.AddonRepository.backgroundUpdateCheck();
+ return;
+ }
+
+ case AMBrowserExtensionsImport.TOPIC_CANCELLED:
+ case AMBrowserExtensionsImport.TOPIC_COMPLETE:
+ case AMBrowserExtensionsImport.TOPIC_PENDING:
+ this.callManagerListeners("onBrowserExtensionsImportChanged");
+ return;
+ }
+
+ switch (aData) {
+ case PREF_EM_CHECK_COMPATIBILITY: {
+ let oldValue = gCheckCompatibility;
+ gCheckCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_COMPATIBILITY,
+ true
+ );
+
+ this.callManagerListeners("onCompatibilityModeChanged");
+
+ if (gCheckCompatibility != oldValue) {
+ this.updateAddonAppDisabledStates();
+ }
+
+ break;
+ }
+ case PREF_EM_STRICT_COMPATIBILITY: {
+ let oldValue = gStrictCompatibility;
+ gStrictCompatibility = Services.prefs.getBoolPref(
+ PREF_EM_STRICT_COMPATIBILITY,
+ true
+ );
+
+ this.callManagerListeners("onCompatibilityModeChanged");
+
+ if (gStrictCompatibility != oldValue) {
+ this.updateAddonAppDisabledStates();
+ }
+
+ break;
+ }
+ case PREF_EM_CHECK_UPDATE_SECURITY: {
+ let oldValue = gCheckUpdateSecurity;
+ gCheckUpdateSecurity = Services.prefs.getBoolPref(
+ PREF_EM_CHECK_UPDATE_SECURITY,
+ true
+ );
+
+ this.callManagerListeners("onCheckUpdateSecurityChanged");
+
+ if (gCheckUpdateSecurity != oldValue) {
+ this.updateAddonAppDisabledStates();
+ }
+
+ break;
+ }
+ case PREF_EM_UPDATE_ENABLED: {
+ gUpdateEnabled = Services.prefs.getBoolPref(
+ PREF_EM_UPDATE_ENABLED,
+ true
+ );
+
+ this.callManagerListeners("onUpdateModeChanged");
+ break;
+ }
+ case PREF_EM_AUTOUPDATE_DEFAULT: {
+ gAutoUpdateDefault = Services.prefs.getBoolPref(
+ PREF_EM_AUTOUPDATE_DEFAULT,
+ true
+ );
+
+ this.callManagerListeners("onUpdateModeChanged");
+ break;
+ }
+ case PREF_MIN_WEBEXT_PLATFORM_VERSION: {
+ gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref(
+ PREF_MIN_WEBEXT_PLATFORM_VERSION
+ );
+ break;
+ }
+ case PREF_REMOTESETTINGS_DISABLED: {
+ if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) {
+ AMRemoteSettings.shutdown();
+ } else {
+ AMRemoteSettings.init();
+ }
+ break;
+ }
+ case PREF_USE_REMOTE: {
+ Glean.extensions.useRemotePref.set(
+ Services.prefs.getBoolPref(PREF_USE_REMOTE)
+ );
+ break;
+ }
+ }
+ },
+
+ /**
+ * Replaces %...% strings in an addon url (update and updateInfo) with
+ * appropriate values.
+ *
+ * @param aAddon
+ * The Addon representing the add-on
+ * @param aUri
+ * The string representation of the URI to escape
+ * @param aAppVersion
+ * The optional application version to use for %APP_VERSION%
+ * @return The appropriately escaped URI.
+ */
+ escapeAddonURI(aAddon, aUri, aAppVersion) {
+ if (!aAddon || typeof aAddon != "object") {
+ throw Components.Exception(
+ "aAddon must be an Addon object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aUri || typeof aUri != "string") {
+ throw Components.Exception(
+ "aUri must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aAppVersion && typeof aAppVersion != "string") {
+ throw Components.Exception(
+ "aAppVersion must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ var addonStatus =
+ aAddon.userDisabled || aAddon.softDisabled
+ ? "userDisabled"
+ : "userEnabled";
+
+ if (!aAddon.isCompatible) {
+ addonStatus += ",incompatible";
+ }
+
+ let { blocklistState } = aAddon;
+ if (blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) {
+ addonStatus += ",blocklisted";
+ }
+ if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ addonStatus += ",softblocked";
+ }
+
+ let params = new Map(
+ Object.entries({
+ ITEM_ID: aAddon.id,
+ ITEM_VERSION: aAddon.version,
+ ITEM_STATUS: addonStatus,
+ APP_ID: Services.appinfo.ID,
+ APP_VERSION: aAppVersion ? aAppVersion : Services.appinfo.version,
+ REQ_VERSION: UPDATE_REQUEST_VERSION,
+ APP_OS: Services.appinfo.OS,
+ APP_ABI: Services.appinfo.XPCOMABI,
+ APP_LOCALE: getLocale(),
+ CURRENT_APP_VERSION: Services.appinfo.version,
+ })
+ );
+
+ let uri = aUri.replace(/%([A-Z_]+)%/g, (m0, m1) => params.get(m1) || m0);
+
+ // escape() does not properly encode + symbols in any embedded FVF strings.
+ return uri.replace(/\+/g, "%2B");
+ },
+
+ _updatePromptHandler(info) {
+ let oldPerms = info.existingAddon.userPermissions;
+ if (!oldPerms) {
+ // Updating from a legacy add-on, just let it proceed
+ return Promise.resolve();
+ }
+
+ let newPerms = info.addon.userPermissions;
+
+ let difference = lazy.Extension.comparePermissions(oldPerms, newPerms);
+
+ // If there are no new permissions, just go ahead with the update
+ if (!difference.origins.length && !difference.permissions.length) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ let subject = {
+ wrappedJSObject: {
+ addon: info.addon,
+ permissions: difference,
+ resolve,
+ reject,
+ // Reference to the related AddonInstall object (used in AMTelemetry to
+ // link the recorded event to the other events from the same install flow).
+ install: info.install,
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-update-permissions");
+ });
+ },
+
+ // Returns true if System Addons should be updated
+ systemUpdateEnabled() {
+ if (!Services.prefs.getBoolPref(PREF_SYS_ADDON_UPDATE_ENABLED)) {
+ return false;
+ }
+ if (Services.policies && !Services.policies.isAllowed("SysAddonUpdate")) {
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Performs a background update check by starting an update for all add-ons
+ * that can be updated.
+ * @return Promise{null} Resolves when the background update check is complete
+ * (the resulting addon installations may still be in progress).
+ */
+ backgroundUpdateCheck() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ let buPromise = (async () => {
+ logger.debug("Background update check beginning");
+
+ Services.obs.notifyObservers(null, "addons-background-update-start");
+
+ if (this.updateEnabled) {
+ // Keep track of all the async add-on updates happening in parallel
+ let updates = [];
+
+ let allAddons = await this.getAllAddons();
+
+ // Repopulate repository cache first, to ensure compatibility overrides
+ // are up to date before checking for addon updates.
+ await lazy.AddonRepository.backgroundUpdateCheck();
+
+ for (let addon of allAddons) {
+ // Check all add-ons for updates so that any compatibility updates will
+ // be applied
+
+ if (!(addon.permissions & AddonManager.PERM_CAN_UPGRADE)) {
+ continue;
+ }
+
+ updates.push(
+ new Promise((resolve, reject) => {
+ addon.findUpdates(
+ {
+ onUpdateAvailable(aAddon, aInstall) {
+ // Start installing updates when the add-on can be updated and
+ // background updates should be applied.
+ logger.debug("Found update for add-on ${id}", aAddon);
+ if (AddonManager.shouldAutoUpdate(aAddon)) {
+ // XXX we really should resolve when this install is done,
+ // not when update-available check completes, no?
+ logger.debug(`Starting upgrade install of ${aAddon.id}`);
+ aInstall.promptHandler = (...args) =>
+ AddonManagerInternal._updatePromptHandler(...args);
+ aInstall.install();
+ }
+ },
+
+ onUpdateFinished: aAddon => {
+ logger.debug("onUpdateFinished for ${id}", aAddon);
+ resolve();
+ },
+ },
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ })
+ );
+ }
+ Services.obs.notifyObservers(
+ null,
+ "addons-background-updates-found",
+ updates.length
+ );
+ await Promise.all(updates);
+ }
+
+ if (AddonManagerInternal.systemUpdateEnabled()) {
+ try {
+ await AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).updateSystemAddons();
+ } catch (e) {
+ logger.warn("Failed to update system addons", e);
+ }
+ }
+
+ logger.debug("Background update check complete");
+ Services.obs.notifyObservers(null, "addons-background-update-complete");
+ })();
+ // Fork the promise chain so we can log the error and let our caller see it too.
+ buPromise.catch(e => logger.warn("Error in background update", e));
+ return buPromise;
+ },
+
+ /**
+ * Adds a add-on to the list of detected changes for this startup. If
+ * addStartupChange is called multiple times for the same add-on in the same
+ * startup then only the most recent change will be remembered.
+ *
+ * @param aType
+ * The type of change as a string. Providers can define their own
+ * types of changes or use the existing defined STARTUP_CHANGE_*
+ * constants
+ * @param aID
+ * The ID of the add-on
+ */
+ addStartupChange(aType, aID) {
+ if (!aType || typeof aType != "string") {
+ throw Components.Exception(
+ "aType must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aID || typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (gStartupComplete) {
+ return;
+ }
+ logger.debug("Registering startup change '" + aType + "' for " + aID);
+
+ // Ensure that an ID is only listed in one type of change
+ for (let type in this.startupChanges) {
+ this.removeStartupChange(type, aID);
+ }
+
+ if (!(aType in this.startupChanges)) {
+ this.startupChanges[aType] = [];
+ }
+ this.startupChanges[aType].push(aID);
+ },
+
+ /**
+ * Removes a startup change for an add-on.
+ *
+ * @param aType
+ * The type of change
+ * @param aID
+ * The ID of the add-on
+ */
+ removeStartupChange(aType, aID) {
+ if (!aType || typeof aType != "string") {
+ throw Components.Exception(
+ "aType must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aID || typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (gStartupComplete) {
+ return;
+ }
+
+ if (!(aType in this.startupChanges)) {
+ return;
+ }
+
+ this.startupChanges[aType] = this.startupChanges[aType].filter(
+ aItem => aItem != aID
+ );
+ },
+
+ /**
+ * Calls all registered AddonManagerListeners with an event. Any parameters
+ * after the method parameter are passed to the listener.
+ *
+ * @param aMethod
+ * The method on the listeners to call
+ */
+ callManagerListeners(aMethod, ...aArgs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let managerListeners = new Set(this.managerListeners);
+ for (let listener of managerListeners) {
+ try {
+ if (aMethod in listener) {
+ listener[aMethod].apply(listener, aArgs);
+ }
+ } catch (e) {
+ logger.warn(
+ "AddonManagerListener threw exception when calling " + aMethod,
+ e
+ );
+ }
+ }
+ },
+
+ /**
+ * Calls all registered InstallListeners with an event. Any parameters after
+ * the extraListeners parameter are passed to the listener.
+ *
+ * @param aMethod
+ * The method on the listeners to call
+ * @param aExtraListeners
+ * An optional array of extra InstallListeners to also call
+ * @return false if any of the listeners returned false, true otherwise
+ */
+ callInstallListeners(aMethod, aExtraListeners, ...aArgs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aExtraListeners && !Array.isArray(aExtraListeners)) {
+ throw Components.Exception(
+ "aExtraListeners must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let result = true;
+ let listeners;
+ if (aExtraListeners) {
+ listeners = new Set(
+ aExtraListeners.concat(Array.from(this.installListeners))
+ );
+ } else {
+ listeners = new Set(this.installListeners);
+ }
+
+ for (let listener of listeners) {
+ try {
+ if (aMethod in listener) {
+ if (listener[aMethod].apply(listener, aArgs) === false) {
+ result = false;
+ }
+ }
+ } catch (e) {
+ logger.warn(
+ "InstallListener threw exception when calling " + aMethod,
+ e
+ );
+ }
+ }
+ return result;
+ },
+
+ /**
+ * Calls all registered AddonListeners with an event. Any parameters after
+ * the method parameter are passed to the listener.
+ *
+ * @param aMethod
+ * The method on the listeners to call
+ */
+ callAddonListeners(aMethod, ...aArgs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMethod || typeof aMethod != "string") {
+ throw Components.Exception(
+ "aMethod must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addonListeners = new Set(this.addonListeners);
+ for (let listener of addonListeners) {
+ try {
+ if (aMethod in listener) {
+ listener[aMethod].apply(listener, aArgs);
+ }
+ } catch (e) {
+ logger.warn("AddonListener threw exception when calling " + aMethod, e);
+ }
+ }
+ },
+
+ /**
+ * Notifies all providers that an add-on has been enabled when that type of
+ * add-on only supports a single add-on being enabled at a time. This allows
+ * the providers to disable theirs if necessary.
+ *
+ * @param aID
+ * The ID of the enabled add-on
+ * @param aType
+ * The type of the enabled add-on
+ * @param aPendingRestart
+ * A boolean indicating if the change will only take place the next
+ * time the application is restarted
+ */
+ async notifyAddonChanged(aID, aType, aPendingRestart) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aID && typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aType || typeof aType != "string") {
+ throw Components.Exception(
+ "aType must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Temporary hack until bug 520124 lands.
+ // We can get here during synchronous startup, at which point it's
+ // considered unsafe (and therefore disallowed by AddonManager.jsm) to
+ // access providers that haven't been initialized yet. Since this is when
+ // XPIProvider is starting up, XPIProvider can't access itself via APIs
+ // going through AddonManager.jsm. Thankfully, this is the only use
+ // of this API, and we know it's safe to use this API with both
+ // providers; so we have this hack to allow bypassing the normal
+ // safetey guard.
+ // The notifyAddonChanged/addonChanged API will be unneeded and therefore
+ // removed by bug 520124, so this is a temporary quick'n'dirty hack.
+ let providers = [...this.providers, ...this.pendingProviders];
+ for (let provider of providers) {
+ let result = callProvider(
+ provider,
+ "addonChanged",
+ null,
+ aID,
+ aType,
+ aPendingRestart
+ );
+ if (result) {
+ await result;
+ }
+ }
+ },
+
+ /**
+ * Notifies all providers they need to update the appDisabled property for
+ * their add-ons in response to an application change such as a blocklist
+ * update.
+ */
+ updateAddonAppDisabledStates() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ this.callProviders("updateAddonAppDisabledStates");
+ },
+
+ /**
+ * Notifies all providers that the repository has updated its data for
+ * installed add-ons.
+ */
+ updateAddonRepositoryData() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return (async () => {
+ for (let provider of this.providers) {
+ await promiseCallProvider(provider, "updateAddonRepositoryData");
+ }
+
+ // only tests should care about this
+ Services.obs.notifyObservers(null, "TEST:addon-repository-data-updated");
+ })();
+ },
+
+ /**
+ * Asynchronously gets an AddonInstall for a URL.
+ *
+ * @param aUrl
+ * The string represenation of the URL where the add-on is located
+ * @param {Object} [aOptions = {}]
+ * Additional options for this install
+ * @param {string} [aOptions.hash]
+ * An optional hash of the add-on
+ * @param {string} [aOptions.name]
+ * An optional placeholder name while the add-on is being downloaded
+ * @param {string|Object} [aOptions.icons]
+ * Optional placeholder icons while the add-on is being downloaded
+ * @param {string} [aOptions.version]
+ * An optional placeholder version while the add-on is being downloaded
+ * @param {XULElement} [aOptions.browser]
+ * An optional <browser> element for download permissions prompts.
+ * @param {nsIPrincipal} [aOptions.triggeringPrincipal]
+ * The principal which is attempting to install the add-on.
+ * @param {Object} [aOptions.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @throws if aUrl is not specified or if an optional argument of
+ * an improper type is passed.
+ */
+ async getInstallForURL(aUrl, aOptions = {}) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aUrl || typeof aUrl != "string") {
+ throw Components.Exception(
+ "aURL must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.hash && typeof aOptions.hash != "string") {
+ throw Components.Exception(
+ "hash must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.name && typeof aOptions.name != "string") {
+ throw Components.Exception(
+ "name must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.icons) {
+ if (typeof aOptions.icons == "string") {
+ aOptions.icons = { 32: aOptions.icons };
+ } else if (typeof aOptions.icons != "object") {
+ throw Components.Exception(
+ "icons must be a string, an object or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+ } else {
+ aOptions.icons = {};
+ }
+
+ if (aOptions.version && typeof aOptions.version != "string") {
+ throw Components.Exception(
+ "version must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aOptions.browser && !Element.isInstance(aOptions.browser)) {
+ throw Components.Exception(
+ "aOptions.browser must be an Element or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let provider of this.providers) {
+ let install = await promiseCallProvider(
+ provider,
+ "getInstallForURL",
+ aUrl,
+ aOptions
+ );
+ if (install) {
+ return install;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Asynchronously gets an AddonInstall for an nsIFile.
+ *
+ * @param aFile
+ * The nsIFile where the add-on is located
+ * @param aMimetype
+ * An optional mimetype hint for the add-on
+ * @param aTelemetryInfo
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param aUseSystemLocation
+ * If true the addon is installed into the system profile location.
+ * @throws if the aFile or aCallback arguments are not specified
+ */
+ getInstallForFile(aFile, aMimetype, aTelemetryInfo, aUseSystemLocation) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!(aFile instanceof Ci.nsIFile)) {
+ throw Components.Exception(
+ "aFile must be a nsIFile",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aMimetype && typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a string or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ for (let provider of this.providers) {
+ let install = await promiseCallProvider(
+ provider,
+ "getInstallForFile",
+ aFile,
+ aTelemetryInfo,
+ aUseSystemLocation
+ );
+
+ if (install) {
+ return install;
+ }
+ }
+
+ return null;
+ })();
+ },
+
+ /**
+ * Get a SitePermsAddonInstall instance.
+ *
+ * @param {Element} aBrowser: The optional browser element that started the install
+ * @param {nsIPrincipal} aInstallingPrincipal
+ * @param {String} aSitePerm
+ * @returns {Promise<SitePermsAddonInstall|null>} The promise will resolve with null if there
+ * are no provider with a getSitePermsAddonInstallForWebpage method. In practice,
+ * this should only be the case when SitePermsAddonProvider is not enabled,
+ * i.e. when dom.sitepermsaddon-provider.enabled is false.
+ * @throws {Components.Exception} Will throw an error if:
+ * - the AddonManager is not initialized
+ * - `aInstallingPrincipal` is not a nsIPrincipal
+ * - `aInstallingPrincipal` scheme is not https
+ * - `aInstallingPrincipal` is a public etld
+ * - `aInstallingPrincipal` is a plain ip address
+ * - `aInstallingPrincipal` is in the blocklist
+ * - `aSitePerm` is not a gated permission
+ * - `aBrowser` is not null and not an element
+ */
+ async getSitePermsAddonInstallForWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aSitePerm
+ ) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (
+ !aInstallingPrincipal ||
+ !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
+ ) {
+ throw Components.Exception(
+ "aInstallingPrincipal must be a nsIPrincipal",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aBrowser && !Element.isInstance(aBrowser)) {
+ throw Components.Exception(
+ "aBrowser must be an Element, or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (lazy.isPrincipalInSitePermissionsBlocklist(aInstallingPrincipal)) {
+ throw Components.Exception(
+ `SitePermsAddons can't be installed`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Block install from null principal.
+ // /!\ We need to do this check before checking if this is a remote origin iframe,
+ // otherwise isThirdPartyPrincipal might throw.
+ if (aInstallingPrincipal.isNullPrincipal) {
+ throw Components.Exception(
+ `SitePermsAddons can't be installed from sandboxed subframes`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Block install from remote origin iframe
+ if (
+ aBrowser &&
+ aBrowser.contentPrincipal.isThirdPartyPrincipal(aInstallingPrincipal)
+ ) {
+ throw Components.Exception(
+ `SitePermsAddons can't be installed from cross origin subframes`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aInstallingPrincipal.isIpAddress) {
+ throw Components.Exception(
+ `SitePermsAddons install disallowed when the host is an IP address`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Gated APIs should probably not be available on non-secure origins,
+ // but let's double check here.
+ if (aInstallingPrincipal.scheme !== "https") {
+ throw Components.Exception(
+ `SitePermsAddons can only be installed from secure origins`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Install origin cannot be on a known etld (e.g. github.io).
+ if (lazy.isKnownPublicSuffix(aInstallingPrincipal.siteOriginNoSuffix)) {
+ throw Components.Exception(
+ `SitePermsAddon can't be installed from public eTLDs ${aInstallingPrincipal.siteOriginNoSuffix}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!lazy.isGatedPermissionType(aSitePerm)) {
+ throw Components.Exception(
+ `"${aSitePerm}" is not a gated permission`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let provider of this.providers) {
+ let install = await promiseCallProvider(
+ provider,
+ "getSitePermsAddonInstallForWebpage",
+ aInstallingPrincipal,
+ aSitePerm
+ );
+ if (install) {
+ return install;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Uninstall an addon from the system profile location.
+ *
+ * @param {string} aID
+ * The ID of the addon to remove.
+ * @returns A promise that resolves when the addon is uninstalled.
+ */
+ uninstallSystemProfileAddon(aID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).uninstallSystemProfileAddon(aID);
+ },
+
+ /**
+ * Asynchronously gets all current AddonInstalls optionally limiting to a list
+ * of types.
+ *
+ * @param aTypes
+ * An optional array of types to retrieve. Each type is a string name
+ * @throws If the aCallback argument is not specified
+ */
+ getInstallsByTypes(aTypes) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ let installs = [];
+
+ for (let provider of this.providers) {
+ let providerInstalls = await promiseCallProvider(
+ provider,
+ "getInstallsByTypes",
+ aTypes
+ );
+
+ if (providerInstalls) {
+ installs.push(...providerInstalls);
+ }
+ }
+
+ return installs;
+ })();
+ },
+
+ /**
+ * Asynchronously gets all current AddonInstalls.
+ */
+ getAllInstalls() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return this.getInstallsByTypes(null);
+ },
+
+ /**
+ * Checks whether installation is enabled for a particular mimetype.
+ *
+ * @param aMimetype
+ * The mimetype to check
+ * @return true if installation is enabled for the mimetype
+ */
+ isInstallEnabled(aMimetype) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMimetype || typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let providers = [...this.providers];
+ for (let provider of providers) {
+ if (
+ callProvider(provider, "supportsMimetype", false, aMimetype) &&
+ callProvider(provider, "isInstallEnabled")
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Checks whether a particular source is allowed to install add-ons of a
+ * given mimetype.
+ *
+ * @param aMimetype
+ * The mimetype of the add-on
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @return true if the source is allowed to install this mimetype
+ */
+ isInstallAllowed(aMimetype, aInstallingPrincipal) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMimetype || typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (
+ !aInstallingPrincipal ||
+ !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
+ ) {
+ throw Components.Exception(
+ "aInstallingPrincipal must be a nsIPrincipal",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (
+ this.isInstallAllowedByPolicy(
+ aInstallingPrincipal,
+ null,
+ true /* explicit */
+ )
+ ) {
+ return true;
+ }
+
+ let providers = [...this.providers];
+ for (let provider of providers) {
+ if (
+ callProvider(provider, "supportsMimetype", false, aMimetype) &&
+ callProvider(provider, "isInstallAllowed", null, aInstallingPrincipal)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Checks whether a particular source is allowed to install add-ons based
+ * on policy.
+ *
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @param aInstall
+ * The AddonInstall to be installed
+ * @param explicit
+ * If this is set, we only return true if the source is explicitly
+ * blocked via policy.
+ *
+ * @return boolean
+ * By default, returns true if the source is blocked by policy
+ * or there is no policy.
+ * If explicit is set, only returns true of the source is
+ * blocked by policy, false otherwise. This is needed for
+ * handling inverse cases.
+ */
+ isInstallAllowedByPolicy(aInstallingPrincipal, aInstall, explicit) {
+ if (Services.policies) {
+ let extensionSettings = Services.policies.getExtensionSettings("*");
+ if (extensionSettings && extensionSettings.install_sources) {
+ if (
+ (!aInstall ||
+ Services.policies.allowedInstallSource(aInstall.sourceURI)) &&
+ (!aInstallingPrincipal ||
+ !aInstallingPrincipal.URI ||
+ Services.policies.allowedInstallSource(aInstallingPrincipal.URI))
+ ) {
+ return true;
+ }
+ return false;
+ }
+ }
+ return !explicit;
+ },
+
+ installNotifyObservers(
+ aTopic,
+ aBrowser,
+ aUri,
+ aInstall,
+ aInstallFn,
+ aCancelFn
+ ) {
+ let info = {
+ wrappedJSObject: {
+ browser: aBrowser,
+ originatingURI: aUri,
+ installs: [aInstall],
+ install: aInstallFn,
+ cancel: aCancelFn,
+ },
+ };
+ Services.obs.notifyObservers(info, aTopic);
+ },
+
+ startInstall(browser, url, install) {
+ this.installNotifyObservers("addon-install-started", browser, url, install);
+
+ // Local installs may already be in a failed state in which case
+ // we won't get any further events, detect those cases now.
+ if (
+ install.state == AddonManager.STATE_DOWNLOADED &&
+ install.addon.appDisabled
+ ) {
+ install.cancel();
+ this.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ return;
+ }
+
+ let self = this;
+ let listener = {
+ onDownloadCancelled() {
+ install.removeListener(listener);
+ },
+
+ onDownloadFailed() {
+ install.removeListener(listener);
+ self.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ },
+
+ onDownloadEnded() {
+ if (install.addon.appDisabled) {
+ // App disabled items are not compatible and so fail to install.
+ install.removeListener(listener);
+ install.cancel();
+ self.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ }
+ },
+
+ onInstallCancelled() {
+ install.removeListener(listener);
+ },
+
+ onInstallFailed() {
+ install.removeListener(listener);
+ self.installNotifyObservers(
+ "addon-install-failed",
+ browser,
+ url,
+ install
+ );
+ },
+
+ onInstallEnded() {
+ install.removeListener(listener);
+
+ // If installing a theme that is disabled and can be enabled
+ // then enable it
+ if (
+ install.addon.type == "theme" &&
+ !!install.addon.userDisabled &&
+ !install.addon.appDisabled
+ ) {
+ install.addon.enable();
+ }
+
+ let subject = {
+ wrappedJSObject: { target: browser, addon: install.addon },
+ };
+ Services.obs.notifyObservers(subject, "webextension-install-notify");
+ },
+ };
+
+ install.addListener(listener);
+
+ // Start downloading if it hasn't already begun
+ install.install();
+ },
+
+ /**
+ * Starts installation of a SitePermsAddonInstall notifying the registered
+ * web install listener of a blocked or started install.
+ *
+ * @param aBrowser
+ * The optional browser element that started the install
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @param aPermission
+ * The permission to install
+ * @returns {Promise} A promise that will resolve when the user installs the addon.
+ * The promise will reject if the user blocked the install, or if the addon
+ * can't be installed (e.g. the principal isn't supported).
+ * @throws {Components.Exception} Will throw an error if the AddonManager is not initialized
+ * or if `aInstallingPrincipal` is not a nsIPrincipal.
+ */
+ async installSitePermsAddonFromWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ ) {
+ const synthAddonInstall =
+ await AddonManagerInternal.getSitePermsAddonInstallForWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ );
+ const promiseInstall = new Promise((resolve, reject) => {
+ const installListener = {
+ onInstallFailed() {
+ synthAddonInstall.removeListener(installListener);
+ reject(new Error("Install Failed"));
+ },
+
+ onInstallCancelled() {
+ synthAddonInstall.removeListener(installListener);
+ reject(new Error("Install Cancelled"));
+ },
+
+ onInstallEnded() {
+ synthAddonInstall.removeListener(installListener);
+ resolve();
+ },
+ };
+ synthAddonInstall.addListener(installListener);
+ });
+
+ let startInstall = () => {
+ AddonManagerInternal.setupPromptHandler(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ synthAddonInstall,
+ true,
+ "SitePermissionAddonPrompt"
+ );
+
+ AddonManagerInternal.startInstall(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ synthAddonInstall
+ );
+ };
+
+ startInstall();
+
+ return promiseInstall;
+ },
+
+ /**
+ * Starts installation of an AddonInstall notifying the registered
+ * web install listener of a blocked or started install.
+ *
+ * @param aMimetype
+ * The mimetype of the add-on being installed
+ * @param aBrowser
+ * The optional browser element that started the install
+ * @param aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @param aInstall
+ * The AddonInstall to be installed
+ * @param [aDetails]
+ * Additional optional details
+ * @param [aDetails.hasCrossOriginAncestor]
+ * Boolean value set to true if any of cross-origin ancestors of the triggering frame
+ * (if set to true the installation will be denied).
+ */
+ installAddonFromWebpage(
+ aMimetype,
+ aBrowser,
+ aInstallingPrincipal,
+ aInstall,
+ aDetails
+ ) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aMimetype || typeof aMimetype != "string") {
+ throw Components.Exception(
+ "aMimetype must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (aBrowser && !Element.isInstance(aBrowser)) {
+ throw Components.Exception(
+ "aSource must be an Element, or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (
+ !aInstallingPrincipal ||
+ !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
+ ) {
+ throw Components.Exception(
+ "aInstallingPrincipal must be a nsIPrincipal",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // When a chrome in-content UI has loaded a <browser> inside to host a
+ // website we want to do our security checks on the inner-browser but
+ // notify front-end that install events came from the top browser (the
+ // main tab's browser).
+ // aBrowser is null in GeckoView.
+ let topBrowser = aBrowser?.browsingContext.top.embedderElement;
+ try {
+ // Use fullscreenElement to check for DOM fullscreen, while still allowing
+ // macOS fullscreen, which still has a browser chrome.
+ if (topBrowser && topBrowser.ownerDocument.fullscreenElement) {
+ // Addon installation and the resulting notifications should be
+ // blocked in DOM fullscreen for security and usability reasons.
+ // Installation prompts in fullscreen can trick the user into
+ // installing unwanted addons.
+ // In fullscreen the notification box does not have a clear
+ // visual association with its parent anymore.
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-fullscreen-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ } else if (!this.isInstallEnabled(aMimetype)) {
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-disabled",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ } else if (
+ !this.isInstallAllowedByPolicy(
+ aInstallingPrincipal,
+ aInstall,
+ false /* explicit */
+ )
+ ) {
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-policy-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ } else if (
+ // Block the install request if the triggering frame does have any cross-origin
+ // ancestor.
+ aDetails?.hasCrossOriginAncestor ||
+ // Block the install if triggered by a null principal.
+ aInstallingPrincipal.isNullPrincipal ||
+ (aBrowser &&
+ (!aBrowser.contentPrincipal ||
+ // When we attempt to handle an XPI load immediately after a
+ // process switch, the DocShell it's being loaded into will have
+ // a null principal, since it won't have been initialized yet.
+ // Allowing installs in this case is relatively safe, since
+ // there isn't much to gain by spoofing an install request from
+ // a null principal in any case. This exception can be removed
+ // once content handlers are triggered by DocumentChannel in the
+ // parent process.
+ !(
+ aBrowser.contentPrincipal.isNullPrincipal ||
+ aInstallingPrincipal.subsumes(aBrowser.contentPrincipal)
+ )))
+ ) {
+ aInstall.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-origin-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ return;
+ }
+
+ if (aBrowser) {
+ // The install may start now depending on the web install listener,
+ // listen for the browser navigating to a new origin and cancel the
+ // install in that case.
+ new BrowserListener(aBrowser, aInstallingPrincipal, aInstall);
+ }
+
+ let startInstall = source => {
+ AddonManagerInternal.setupPromptHandler(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ aInstall,
+ true,
+ source
+ );
+
+ AddonManagerInternal.startInstall(
+ aBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ };
+
+ let installAllowed = this.isInstallAllowed(
+ aMimetype,
+ aInstallingPrincipal
+ );
+ let installPerm = Services.perms.testPermissionFromPrincipal(
+ aInstallingPrincipal,
+ "install"
+ );
+
+ if (installAllowed) {
+ startInstall("AMO");
+ } else if (installPerm === Ci.nsIPermissionManager.DENY_ACTION) {
+ // Block without prompt
+ aInstall.cancel();
+ this.installNotifyObservers(
+ "addon-install-blocked-silent",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall
+ );
+ } else if (!lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY) {
+ // Block with prompt
+ this.installNotifyObservers(
+ "addon-install-blocked",
+ topBrowser,
+ aInstallingPrincipal.URI,
+ aInstall,
+ () => startInstall("other"),
+ () => aInstall.cancel()
+ );
+ } else {
+ // We download the addon and validate whether a 3rd party
+ // install prompt should be shown using e.g. recommended
+ // state and install_origins.
+ logger.info(`Addon download before validation.`);
+ startInstall("other");
+ }
+ } catch (e) {
+ // In the event that the weblistener throws during instantiation or when
+ // calling onWebInstallBlocked or onWebInstallRequested the
+ // install should get cancelled.
+ logger.warn("Failure calling web installer", e);
+ aInstall.cancel();
+ }
+ },
+
+ /**
+ * Starts installation of an AddonInstall created from add-ons manager
+ * front-end code (e.g., drag-and-drop of xpis or "Install Add-on from File"
+ *
+ * @param browser
+ * The browser element where the installation was initiated
+ * @param uri
+ * The URI of the page where the installation was initiated
+ * @param install
+ * The AddonInstall to be installed
+ */
+ installAddonFromAOM(browser, uri, install) {
+ if (!this.isInstallAllowedByPolicy(null, install)) {
+ install.cancel();
+
+ this.installNotifyObservers(
+ "addon-install-policy-blocked",
+ browser,
+ install.sourceURI,
+ install
+ );
+ return;
+ }
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ AddonManagerInternal.setupPromptHandler(
+ browser,
+ uri,
+ install,
+ true,
+ "local"
+ );
+ AddonManagerInternal.startInstall(browser, uri, install);
+ },
+
+ /**
+ * Adds a new InstallListener if the listener is not already registered.
+ *
+ * @param aListener
+ * The InstallListener to add
+ */
+ addInstallListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be a InstallListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.installListeners.add(aListener);
+ },
+
+ /**
+ * Removes an InstallListener if the listener is registered.
+ *
+ * @param aListener
+ * The InstallListener to remove
+ */
+ removeInstallListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be a InstallListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.installListeners.delete(aListener);
+ },
+ /**
+ * Adds new or overrides existing UpgradeListener.
+ *
+ * @param aInstanceID
+ * The instance ID of an addon to register a listener for.
+ * @param aCallback
+ * The callback to invoke when updates are available for this addon.
+ * @throws if there is no addon matching the instanceID
+ */
+ addUpgradeListener(aInstanceID, aCallback) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a symbol",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ if (!aCallback || typeof aCallback != "function") {
+ throw Components.Exception(
+ "aCallback must be a function",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addonId = this.syncGetAddonIDByInstanceID(aInstanceID);
+ if (!addonId) {
+ throw Error(`No addon matching instanceID: ${String(aInstanceID)}`);
+ }
+ logger.debug(`Registering upgrade listener for ${addonId}`);
+ this.upgradeListeners.set(addonId, aCallback);
+ },
+
+ /**
+ * Removes an UpgradeListener if the listener is registered.
+ *
+ * @param aInstanceID
+ * The instance ID of the addon to remove
+ */
+ removeUpgradeListener(aInstanceID) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a symbol",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addonId = this.syncGetAddonIDByInstanceID(aInstanceID);
+ if (!addonId) {
+ throw Error(`No addon for instanceID: ${aInstanceID}`);
+ }
+ if (this.upgradeListeners.has(addonId)) {
+ this.upgradeListeners.delete(addonId);
+ } else {
+ throw Error(`No upgrade listener registered for addon ID: ${addonId}`);
+ }
+ },
+
+ addExternalExtensionLoader(loader) {
+ this.externalExtensionLoaders.set(loader.name, loader);
+ },
+
+ /**
+ * Installs a temporary add-on from a local file or directory.
+ *
+ * @param aFile
+ * An nsIFile for the file or directory of the add-on to be
+ * temporarily installed.
+ * @returns a Promise that rejects if the add-on is not a valid restartless
+ * add-on or if the same ID is already temporarily installed.
+ */
+ installTemporaryAddon(aFile) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!(aFile instanceof Ci.nsIFile)) {
+ throw Components.Exception(
+ "aFile must be a nsIFile",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).installTemporaryAddon(aFile);
+ },
+
+ /**
+ * Installs an add-on from a built-in location
+ * (ie a resource: url referencing assets shipped with the application)
+ *
+ * @param aBase
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns a Promise that resolves when the addon is installed.
+ */
+ installBuiltinAddon(aBase) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).installBuiltinAddon(aBase);
+ },
+
+ /**
+ * Like `installBuiltinAddon`, but only installs the addon at `aBase`
+ * if an existing built-in addon with the ID `aID` and version doesn't
+ * already exist.
+ *
+ * @param {string} aID
+ * The ID of the add-on being registered.
+ * @param {string} aVersion
+ * The version of the add-on being registered.
+ * @param {string} aBase
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns a Promise that resolves when the addon is installed.
+ */
+ maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).maybeInstallBuiltinAddon(aID, aVersion, aBase);
+ },
+
+ syncGetAddonIDByInstanceID(aInstanceID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a Symbol()",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).getAddonIDByInstanceID(aInstanceID);
+ },
+
+ /**
+ * Gets an icon from the icon set provided by the add-on
+ * that is closest to the specified size.
+ *
+ * The optional window parameter will be used to determine
+ * the screen resolution and select a more appropriate icon.
+ * Calling this method with 48px on retina screens will try to
+ * match an icon of size 96px.
+ *
+ * @param aAddon
+ * An addon object, meaning:
+ * An object with either an icons property that is a key-value list
+ * of icon size and icon URL, or an object having an iconURL property.
+ * @param aSize
+ * Ideal icon size in pixels
+ * @param aWindow
+ * Optional window object for determining the correct scale.
+ * @return {String} The absolute URL of the icon or null if the addon doesn't have icons
+ */
+ getPreferredIconURL(aAddon, aSize, aWindow = undefined) {
+ if (aWindow && aWindow.devicePixelRatio) {
+ aSize *= aWindow.devicePixelRatio;
+ }
+
+ let icons = aAddon.icons;
+
+ // certain addon-types only have iconURLs
+ if (!icons) {
+ icons = {};
+ if (aAddon.iconURL) {
+ icons[32] = aAddon.iconURL;
+ icons[48] = aAddon.iconURL;
+ }
+ }
+
+ // quick return if the exact size was found
+ if (icons[aSize]) {
+ return icons[aSize];
+ }
+
+ let bestSize = null;
+
+ for (let size of Object.keys(icons)) {
+ if (!INTEGER.test(size)) {
+ throw Components.Exception(
+ "Invalid icon size, must be an integer",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+
+ size = parseInt(size, 10);
+
+ if (!bestSize) {
+ bestSize = size;
+ continue;
+ }
+
+ if (size > aSize && bestSize > aSize) {
+ // If both best size and current size are larger than the wanted size then choose
+ // the one closest to the wanted size
+ bestSize = Math.min(bestSize, size);
+ } else {
+ // Otherwise choose the largest of the two so we'll prefer sizes as close to below aSize
+ // or above aSize
+ bestSize = Math.max(bestSize, size);
+ }
+ }
+
+ return icons[bestSize] || null;
+ },
+
+ /**
+ * Asynchronously gets an add-on with a specific ID.
+ *
+ * @type {function}
+ * @param {string} aID
+ * The ID of the add-on to retrieve
+ * @returns {Promise} resolves with the found Addon or null if no such add-on exists. Never rejects.
+ * @throws if the aID argument is not specified
+ */
+ getAddonByID(aID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aID || typeof aID != "string") {
+ throw Components.Exception(
+ "aID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let promises = Array.from(this.providers, p =>
+ promiseCallProvider(p, "getAddonByID", aID)
+ );
+ return Promise.all(promises).then(aAddons => {
+ return aAddons.find(a => !!a) || null;
+ });
+ },
+
+ /**
+ * Asynchronously get an add-on with a specific Sync GUID.
+ *
+ * @param aGUID
+ * String GUID of add-on to retrieve
+ * @throws if the aGUID argument is not specified
+ */
+ getAddonBySyncGUID(aGUID) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!aGUID || typeof aGUID != "string") {
+ throw Components.Exception(
+ "aGUID must be a non-empty string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ for (let provider of this.providers) {
+ let addon = await promiseCallProvider(
+ provider,
+ "getAddonBySyncGUID",
+ aGUID
+ );
+
+ if (addon) {
+ return addon;
+ }
+ }
+
+ return null;
+ })();
+ },
+
+ /**
+ * Asynchronously gets an array of add-ons.
+ *
+ * @param aIDs
+ * The array of IDs to retrieve
+ * @return {Promise}
+ * @resolves The array of found add-ons.
+ * @rejects Never
+ * @throws if the aIDs argument is not specified
+ */
+ getAddonsByIDs(aIDs) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!Array.isArray(aIDs)) {
+ throw Components.Exception(
+ "aIDs must be an array",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let promises = aIDs.map(a => AddonManagerInternal.getAddonByID(a));
+ return Promise.all(promises);
+ },
+
+ /**
+ * Asynchronously gets add-ons of specific types.
+ *
+ * @param aTypes
+ * An optional array of types to retrieve. Each type is a string name
+ */
+ getAddonsByTypes(aTypes) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return (async () => {
+ let addons = [];
+
+ for (let provider of this.providers) {
+ let providerAddons = await promiseCallProvider(
+ provider,
+ "getAddonsByTypes",
+ aTypes
+ );
+
+ if (providerAddons) {
+ addons.push(...providerAddons);
+ }
+ }
+
+ return addons;
+ })();
+ },
+
+ /**
+ * Gets active add-ons of specific types.
+ *
+ * This is similar to getAddonsByTypes() but it may return a limited
+ * amount of information about only active addons. Consequently, it
+ * can be implemented by providers using only immediately available
+ * data as opposed to getAddonsByTypes which may require I/O).
+ *
+ * @param aTypes
+ * An optional array of types to retrieve. Each type is a string name
+ *
+ * @resolve {addons: Array, fullData: bool}
+ * fullData is true if addons contains all the data we have on those
+ * addons. It is false if addons only contains partial data.
+ */
+ async getActiveAddons(aTypes) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (aTypes && !Array.isArray(aTypes)) {
+ throw Components.Exception(
+ "aTypes must be an array or null",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let addons = [],
+ fullData = true;
+
+ for (let provider of this.providers) {
+ let providerAddons, providerFullData;
+ if ("getActiveAddons" in provider) {
+ ({ addons: providerAddons, fullData: providerFullData } =
+ await callProvider(provider, "getActiveAddons", null, aTypes));
+ } else {
+ providerAddons = await promiseCallProvider(
+ provider,
+ "getAddonsByTypes",
+ aTypes
+ );
+ providerAddons = providerAddons.filter(a => a.isActive);
+ providerFullData = true;
+ }
+
+ if (providerAddons) {
+ addons.push(...providerAddons);
+ fullData = fullData && providerFullData;
+ }
+ }
+
+ return { addons, fullData };
+ },
+
+ /**
+ * Asynchronously gets all installed add-ons.
+ */
+ getAllAddons() {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ return this.getAddonsByTypes(null);
+ },
+
+ /**
+ * Adds a new AddonManagerListener if the listener is not already registered.
+ *
+ * @param {AddonManagerListener} aListener
+ * The listener to add
+ */
+ addManagerListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonManagerListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.managerListeners.add(aListener);
+ },
+
+ /**
+ * Removes an AddonManagerListener if the listener is registered.
+ *
+ * @param {AddonManagerListener} aListener
+ * The listener to remove
+ */
+ removeManagerListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonManagerListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.managerListeners.delete(aListener);
+ },
+
+ /**
+ * Adds a new AddonListener if the listener is not already registered.
+ *
+ * @param {AddonManagerListener} aListener
+ * The AddonListener to add.
+ */
+ addAddonListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.addonListeners.add(aListener);
+ },
+
+ /**
+ * Removes an AddonListener if the listener is registered.
+ *
+ * @param {object} aListener
+ * The AddonListener to remove
+ */
+ removeAddonListener(aListener) {
+ if (!aListener || typeof aListener != "object") {
+ throw Components.Exception(
+ "aListener must be an AddonListener object",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ this.addonListeners.delete(aListener);
+ },
+
+ /**
+ * @param {string} addonType
+ * @returns {boolean}
+ * Whether there is a provider that provides the given addon type.
+ */
+ hasAddonType(addonType) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ for (let addonTypes of this.typesByProvider.values()) {
+ if (addonTypes.has(addonType)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ get autoUpdateDefault() {
+ return gAutoUpdateDefault;
+ },
+
+ set autoUpdateDefault(aValue) {
+ aValue = !!aValue;
+ if (aValue != gAutoUpdateDefault) {
+ Services.prefs.setBoolPref(PREF_EM_AUTOUPDATE_DEFAULT, aValue);
+ }
+ },
+
+ get checkCompatibility() {
+ return gCheckCompatibility;
+ },
+
+ set checkCompatibility(aValue) {
+ aValue = !!aValue;
+ if (aValue != gCheckCompatibility) {
+ if (!aValue) {
+ Services.prefs.setBoolPref(PREF_EM_CHECK_COMPATIBILITY, false);
+ } else {
+ Services.prefs.clearUserPref(PREF_EM_CHECK_COMPATIBILITY);
+ }
+ }
+ },
+
+ get strictCompatibility() {
+ return gStrictCompatibility;
+ },
+
+ set strictCompatibility(aValue) {
+ aValue = !!aValue;
+ if (aValue != gStrictCompatibility) {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, aValue);
+ }
+ },
+
+ get checkUpdateSecurityDefault() {
+ return gCheckUpdateSecurityDefault;
+ },
+
+ get checkUpdateSecurity() {
+ return gCheckUpdateSecurity;
+ },
+
+ set checkUpdateSecurity(aValue) {
+ aValue = !!aValue;
+ if (aValue != gCheckUpdateSecurity) {
+ if (aValue != gCheckUpdateSecurityDefault) {
+ Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, aValue);
+ } else {
+ Services.prefs.clearUserPref(PREF_EM_CHECK_UPDATE_SECURITY);
+ }
+ }
+ },
+
+ get updateEnabled() {
+ return gUpdateEnabled;
+ },
+
+ set updateEnabled(aValue) {
+ aValue = !!aValue;
+ if (aValue != gUpdateEnabled) {
+ Services.prefs.setBoolPref(PREF_EM_UPDATE_ENABLED, aValue);
+ }
+ },
+
+ /**
+ * Verify whether we need to show the 3rd party install prompt.
+ *
+ * Bypass the third party install prompt if this is an install:
+ * - is an install from a recognized source
+ * - is a an addon that can bypass the panel, such as a recommended addon
+ *
+ * @param {browser} browser browser user is installing from
+ * @param {nsIURI} url URI for the principal of the installing source
+ * @param {AddonInstallWrapper} install
+ * @param {Object} info information such as addon wrapper
+ * @param {AddonWrapper} info.addon
+ * @param {string} source simplified string describing source of install and is
+ * generated based on the installing principal and checking
+ * against site permissions and enterprise policy.
+ * It may be one of "AMO", "local" or "other".
+ * @returns {Promise} Rejected when the installation should not proceed.
+ */
+ _verifyThirdPartyInstall(browser, url, install, info, source) {
+ // If we are not post-download processing, this panel was already shown.
+ // Otherwise, if this is from AMO or local, bypass the prompt.
+ if (
+ !lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY ||
+ ["AMO", "local"].includes(source)
+ ) {
+ return Promise.resolve();
+ }
+
+ // verify both the installing source and the xpi url are allowed.
+ if (
+ !info.addon.validInstallOrigins({
+ installFrom: url,
+ source: install.sourceURI,
+ })
+ ) {
+ install.error = AddonManager.ERROR_INVALID_DOMAIN;
+ return Promise.reject();
+ }
+
+ // Some addons such as recommended addons do not result in this prompt.
+ if (info.addon.canBypassThirdParyInstallPrompt) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ this.installNotifyObservers(
+ "addon-install-blocked",
+ browser,
+ url,
+ install,
+ resolve,
+ reject
+ );
+ });
+ },
+
+ setupPromptHandler(browser, url, install, requireConfirm, source) {
+ install.promptHandler = info =>
+ new Promise((resolve, reject) => {
+ this._verifyThirdPartyInstall(browser, url, install, info, source)
+ .then(() => {
+ // All installs end up in this callback when the add-on is available
+ // for installation. There are numerous different things that can
+ // happen from here though. For webextensions, if the application
+ // implements webextension permission prompts, those always take
+ // precedence.
+ // If this add-on is not a webextension or if the application does not
+ // implement permission prompts, no confirmation is displayed for
+ // installs created from about:addons (in which case requireConfirm
+ // is false).
+ // In the remaining cases, a confirmation prompt is displayed but the
+ // application may override it either by implementing the
+ // "@mozilla.org/addons/web-install-prompt;1" contract or by setting
+ // the customConfirmationUI preference and responding to the
+ // "addon-install-confirmation" notification. If the application
+ // does not implement its own prompt, use the built-in xul dialog.
+ if (info.addon.userPermissions) {
+ let subject = {
+ wrappedJSObject: {
+ target: browser,
+ info: Object.assign({ resolve, reject, source }, info),
+ },
+ };
+ subject.wrappedJSObject.info.permissions =
+ info.addon.userPermissions;
+ Services.obs.notifyObservers(
+ subject,
+ "webextension-permission-prompt"
+ );
+ } else if (info.addon.sitePermissions) {
+ // Handle prompting for DOM permissions in SitePermission addons.
+ let { sitePermissions, siteOrigin } = info.addon;
+ let subject = {
+ wrappedJSObject: {
+ target: browser,
+ info: Object.assign(
+ { resolve, reject, source, sitePermissions, siteOrigin },
+ info
+ ),
+ },
+ };
+ Services.obs.notifyObservers(
+ subject,
+ "webextension-permission-prompt"
+ );
+ } else if (requireConfirm) {
+ // The methods below all want to call the install() or cancel()
+ // method on the provided AddonInstall object to either accept
+ // or reject the confirmation. Fit that into our promise-based
+ // control flow by wrapping the install object. However,
+ // xpInstallConfirm.xul matches the install object it is passed
+ // with the argument passed to an InstallListener, so give it
+ // access to the underlying object through the .wrapped property.
+ let proxy = new Proxy(install, {
+ get(target, property) {
+ if (property == "install") {
+ return resolve;
+ } else if (property == "cancel") {
+ return reject;
+ } else if (property == "wrapped") {
+ return target;
+ }
+ let result = target[property];
+ return typeof result == "function"
+ ? result.bind(target)
+ : result;
+ },
+ });
+
+ // Check for a custom installation prompt that may be provided by the
+ // applicaton
+ if ("@mozilla.org/addons/web-install-prompt;1" in Cc) {
+ try {
+ let prompt = Cc[
+ "@mozilla.org/addons/web-install-prompt;1"
+ ].getService(Ci.amIWebInstallPrompt);
+ prompt.confirm(browser, url, [proxy]);
+ return;
+ } catch (e) {}
+ }
+
+ this.installNotifyObservers(
+ "addon-install-confirmation",
+ browser,
+ url,
+ proxy
+ );
+ } else {
+ resolve();
+ }
+ })
+ .catch(e => {
+ // Error is undefined if the promise was rejected.
+ if (e) {
+ Cu.reportError(`Install prompt handler error: ${e}`);
+ }
+ reject();
+ });
+ });
+ },
+
+ webAPI: {
+ // installs maps integer ids to AddonInstall instances.
+ installs: new Map(),
+ nextInstall: 0,
+
+ sendEvent: null,
+ setEventHandler(fn) {
+ this.sendEvent = fn;
+ },
+
+ async getAddonByID(target, id) {
+ return webAPIForAddon(await AddonManager.getAddonByID(id));
+ },
+
+ // helper to copy (and convert) the properties we care about
+ copyProps(install, obj) {
+ obj.state = AddonManager.stateToString(install.state);
+ obj.error = AddonManager.errorToString(install.error);
+ obj.progress = install.progress;
+ obj.maxProgress = install.maxProgress;
+ },
+
+ forgetInstall(id) {
+ let info = this.installs.get(id);
+ if (!info) {
+ throw new Error(`forgetInstall cannot find ${id}`);
+ }
+ info.install.removeListener(info.listener);
+ this.installs.delete(id);
+ },
+
+ createInstall(target, options) {
+ // Throw an appropriate error if the given URL is not valid
+ // as an installation source. Return silently if it is okay.
+ function checkInstallUri(uri) {
+ if (Services.policies && !Services.policies.allowedInstallSource(uri)) {
+ // eslint-disable-next-line no-throw-literal
+ return {
+ success: false,
+ code: "addon-install-policy-blocked",
+ message: `Install from ${uri.spec} not permitted by policy`,
+ };
+ }
+
+ if (WEBAPI_INSTALL_HOSTS.includes(uri.host)) {
+ return { success: true };
+ }
+ if (
+ Services.prefs.getBoolPref(PREF_WEBAPI_TESTING, false) &&
+ WEBAPI_TEST_INSTALL_HOSTS.includes(uri.host)
+ ) {
+ return { success: true };
+ }
+
+ // eslint-disable-next-line no-throw-literal
+ return {
+ success: false,
+ code: "addon-install-webapi-blocked",
+ message: `Install from ${uri.host} not permitted`,
+ };
+ }
+
+ const makeListener = (id, mm) => {
+ const events = [
+ "onDownloadStarted",
+ "onDownloadProgress",
+ "onDownloadEnded",
+ "onDownloadCancelled",
+ "onDownloadFailed",
+ "onInstallStarted",
+ "onInstallEnded",
+ "onInstallCancelled",
+ "onInstallFailed",
+ ];
+
+ let listener = {};
+ let installPromise = new Promise((resolve, reject) => {
+ events.forEach(event => {
+ listener[event] = (install, addon) => {
+ let data = { event, id };
+ AddonManager.webAPI.copyProps(install, data);
+ this.sendEvent(mm, data);
+ if (event == "onInstallEnded") {
+ resolve(addon);
+ } else if (
+ event == "onDownloadFailed" ||
+ event == "onInstallFailed"
+ ) {
+ reject({ message: "install failed" });
+ } else if (
+ event == "onDownloadCancelled" ||
+ event == "onInstallCancelled"
+ ) {
+ reject({ message: "install cancelled" });
+ } else if (event == "onDownloadEnded") {
+ if (install.addon.appDisabled) {
+ // App disabled items are not compatible and so fail to install
+ install.cancel();
+ AddonManagerInternal.installNotifyObservers(
+ "addon-install-failed",
+ target,
+ Services.io.newURI(options.url),
+ install
+ );
+ }
+ }
+ };
+ });
+ });
+
+ // We create the promise here since this is where we're setting
+ // up the InstallListener, but if the install is never started,
+ // no handlers will be attached so make sure we terminate errors.
+ installPromise.catch(() => {});
+
+ return { listener, installPromise };
+ };
+
+ let uri;
+ try {
+ uri = Services.io.newURI(options.url);
+ const { success, code, message } = checkInstallUri(uri);
+ if (!success) {
+ let info = {
+ wrappedJSObject: {
+ browser: target,
+ originatingURI: uri,
+ installs: [],
+ },
+ };
+ Cu.reportError(`${code}: ${message}`);
+ Services.obs.notifyObservers(info, code);
+ return Promise.reject({ code, message });
+ }
+ } catch (err) {
+ // Reject Components.Exception errors (e.g. NS_ERROR_MALFORMED_URI) as is.
+ if (err instanceof Components.Exception) {
+ return Promise.reject({ message: err.message });
+ }
+ return Promise.reject({
+ message: "Install Failed on unexpected error",
+ });
+ }
+
+ return AddonManagerInternal.getInstallForURL(options.url, {
+ browser: target,
+ triggeringPrincipal: options.triggeringPrincipal,
+ hash: options.hash,
+ telemetryInfo: {
+ source: AddonManager.getInstallSourceFromHost(options.sourceHost),
+ sourceURL: options.sourceURL,
+ method: "amWebAPI",
+ },
+ }).then(install => {
+ let requireConfirm = true;
+ if (
+ target.contentDocument &&
+ target.contentDocument.nodePrincipal.isSystemPrincipal
+ ) {
+ requireConfirm = false;
+ }
+ AddonManagerInternal.setupPromptHandler(
+ target,
+ null,
+ install,
+ requireConfirm,
+ "AMO"
+ );
+
+ let id = this.nextInstall++;
+ let { listener, installPromise } = makeListener(
+ id,
+ target.messageManager
+ );
+ install.addListener(listener);
+
+ this.installs.set(id, {
+ install,
+ target,
+ listener,
+ installPromise,
+ messageManager: target.messageManager,
+ });
+
+ let result = { id };
+ this.copyProps(install, result);
+ return result;
+ });
+ },
+
+ async addonUninstall(target, id) {
+ let addon = await AddonManager.getAddonByID(id);
+ if (!addon) {
+ return false;
+ }
+
+ if (!(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
+ return Promise.reject({ message: "Addon cannot be uninstalled" });
+ }
+
+ try {
+ addon.uninstall();
+ return true;
+ } catch (err) {
+ Cu.reportError(err);
+ return false;
+ }
+ },
+
+ async addonSetEnabled(target, id, value) {
+ let addon = await AddonManager.getAddonByID(id);
+ if (!addon) {
+ throw new Error(`No such addon ${id}`);
+ }
+
+ if (value) {
+ await addon.enable();
+ } else {
+ await addon.disable();
+ }
+ },
+
+ async addonInstallDoInstall(target, id) {
+ let state = this.installs.get(id);
+ if (!state) {
+ throw new Error(`invalid id ${id}`);
+ }
+
+ let addon = await state.install.install();
+
+ if (addon.type == "theme" && !addon.appDisabled) {
+ await addon.enable();
+ }
+
+ await new Promise(resolve => {
+ let subject = {
+ wrappedJSObject: { target, addon, callback: resolve },
+ };
+ Services.obs.notifyObservers(subject, "webextension-install-notify");
+ });
+ },
+
+ addonInstallCancel(target, id) {
+ let state = this.installs.get(id);
+ if (!state) {
+ return Promise.reject(`invalid id ${id}`);
+ }
+ return Promise.resolve(state.install.cancel());
+ },
+
+ clearInstalls(ids) {
+ for (let id of ids) {
+ this.forgetInstall(id);
+ }
+ },
+
+ clearInstallsFrom(mm) {
+ for (let [id, info] of this.installs) {
+ if (info.messageManager == mm) {
+ this.forgetInstall(id);
+ }
+ }
+ },
+
+ async addonReportAbuse(target, id) {
+ if (!Services.prefs.getBoolPref(PREF_AMO_ABUSEREPORT, false)) {
+ return Promise.reject({
+ message: "amWebAPI reportAbuse not supported",
+ });
+ }
+
+ let existingDialog = lazy.AbuseReporter.getOpenDialog();
+ if (existingDialog) {
+ existingDialog.close();
+ }
+
+ const dialog = await lazy.AbuseReporter.openDialog(
+ id,
+ "amo",
+ target
+ ).catch(err => {
+ Cu.reportError(err);
+ return Promise.reject({
+ message: "Error creating abuse report",
+ });
+ });
+
+ return dialog.promiseReport.then(
+ async report => {
+ if (!report) {
+ return false;
+ }
+
+ await report.submit().catch(err => {
+ Cu.reportError(err);
+ return Promise.reject({
+ message: "Error submitting abuse report",
+ });
+ });
+
+ return true;
+ },
+ err => {
+ Cu.reportError(err);
+ dialog.close();
+ return Promise.reject({
+ message: "Error creating abuse report",
+ });
+ }
+ );
+ },
+ },
+};
+
+/**
+ * Should not be used outside of core Mozilla code. This is a private API for
+ * the startup and platform integration code to use. Refer to the methods on
+ * AddonManagerInternal for documentation however note that these methods are
+ * subject to change at any time.
+ */
+export var AddonManagerPrivate = {
+ startup() {
+ AddonManagerInternal.startup();
+ },
+
+ addonIsActive(addonId) {
+ return AddonManagerInternal._getProviderByName("XPIProvider").addonIsActive(
+ addonId
+ );
+ },
+
+ /**
+ * Gets an array of add-ons which were side-loaded prior to the last
+ * startup, and are currently disabled.
+ *
+ * @returns {Promise<Array<Addon>>}
+ */
+ getNewSideloads() {
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).getNewSideloads();
+ },
+
+ get browserUpdated() {
+ return gBrowserUpdated;
+ },
+
+ registerProvider(aProvider, aTypes) {
+ AddonManagerInternal.registerProvider(aProvider, aTypes);
+ },
+
+ unregisterProvider(aProvider) {
+ AddonManagerInternal.unregisterProvider(aProvider);
+ },
+
+ /**
+ * Get a list of addon types that was passed to registerProvider for the
+ * provider with the given name.
+ *
+ * @param {string} aProviderName
+ * @returns {Array<string>}
+ */
+ getAddonTypesByProvider(aProviderName) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ for (let [provider, addonTypes] of AddonManagerInternal.typesByProvider) {
+ if (providerName(provider) === aProviderName) {
+ // Return an array because methods such as getAddonsByTypes expect
+ // aTypes to be an array.
+ return Array.from(addonTypes);
+ }
+ }
+ throw Components.Exception(
+ `No addonTypes found for provider: ${aProviderName}`,
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ },
+
+ markProviderSafe(aProvider) {
+ AddonManagerInternal.markProviderSafe(aProvider);
+ },
+
+ backgroundUpdateCheck() {
+ return AddonManagerInternal.backgroundUpdateCheck();
+ },
+
+ backgroundUpdateTimerHandler() {
+ // Don't return the promise here, since the caller doesn't care.
+ AddonManagerInternal.backgroundUpdateCheck();
+ },
+
+ addStartupChange(aType, aID) {
+ AddonManagerInternal.addStartupChange(aType, aID);
+ },
+
+ removeStartupChange(aType, aID) {
+ AddonManagerInternal.removeStartupChange(aType, aID);
+ },
+
+ notifyAddonChanged(aID, aType, aPendingRestart) {
+ return AddonManagerInternal.notifyAddonChanged(aID, aType, aPendingRestart);
+ },
+
+ updateAddonAppDisabledStates() {
+ AddonManagerInternal.updateAddonAppDisabledStates();
+ },
+
+ updateAddonRepositoryData() {
+ return AddonManagerInternal.updateAddonRepositoryData();
+ },
+
+ callInstallListeners(...aArgs) {
+ return AddonManagerInternal.callInstallListeners.apply(
+ AddonManagerInternal,
+ aArgs
+ );
+ },
+
+ callAddonListeners(...aArgs) {
+ AddonManagerInternal.callAddonListeners.apply(AddonManagerInternal, aArgs);
+ },
+
+ AddonAuthor,
+
+ AddonScreenshot,
+
+ get BOOTSTRAP_REASONS() {
+ // BOOTSTRAP_REASONS is a set of constants, and may be accessed before the
+ // provider has fully been started.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1760146#c1
+ return gXPIProvider.BOOTSTRAP_REASONS;
+ },
+
+ setAddonStartupData(addonId, startupData) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ // TODO bug 1761079: Ensure that XPIProvider is available before calling it.
+ gXPIProvider.setStartupData(addonId, startupData);
+ },
+
+ unregisterDictionaries(aDicts) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ // TODO bug 1761093: Use _getProviderByName instead of gXPIProvider.
+ gXPIProvider.unregisterDictionaries(aDicts);
+ },
+
+ recordTimestamp(name, value) {
+ AddonManagerInternal.recordTimestamp(name, value);
+ },
+
+ _simpleMeasures: {},
+ recordSimpleMeasure(name, value) {
+ this._simpleMeasures[name] = value;
+ },
+
+ recordException(aModule, aContext, aException) {
+ let report = {
+ module: aModule,
+ context: aContext,
+ };
+
+ if (typeof aException == "number") {
+ report.message = Components.Exception("", aException).name;
+ } else {
+ report.message = aException.toString();
+ if (aException.fileName) {
+ report.file = aException.fileName;
+ report.line = aException.lineNumber;
+ }
+ }
+
+ this._simpleMeasures.exception = report;
+ },
+
+ getSimpleMeasures() {
+ return this._simpleMeasures;
+ },
+
+ getTelemetryDetails() {
+ return AddonManagerInternal.telemetryDetails;
+ },
+
+ setTelemetryDetails(aProvider, aDetails) {
+ AddonManagerInternal.telemetryDetails[aProvider] = aDetails;
+ },
+
+ // Start a timer, record a simple measure of the time interval when
+ // timer.done() is called
+ simpleTimer(aName) {
+ let startTime = Cu.now();
+ return {
+ done: () =>
+ this.recordSimpleMeasure(aName, Math.round(Cu.now() - startTime)),
+ };
+ },
+
+ async recordTiming(name, task) {
+ let timer = this.simpleTimer(name);
+ try {
+ return await task();
+ } finally {
+ timer.done();
+ }
+ },
+
+ /**
+ * Helper to call update listeners when no update is available.
+ *
+ * This can be used as an implementation for Addon.findUpdates() when
+ * no update mechanism is available.
+ */
+ callNoUpdateListeners(addon, listener, reason, appVersion, platformVersion) {
+ if ("onNoCompatibilityUpdateAvailable" in listener) {
+ safeCall(listener.onNoCompatibilityUpdateAvailable.bind(listener), addon);
+ }
+ if ("onNoUpdateAvailable" in listener) {
+ safeCall(listener.onNoUpdateAvailable.bind(listener), addon);
+ }
+ if ("onUpdateFinished" in listener) {
+ safeCall(listener.onUpdateFinished.bind(listener), addon);
+ }
+ },
+
+ get webExtensionsMinPlatformVersion() {
+ return gWebExtensionsMinPlatformVersion;
+ },
+
+ hasUpgradeListener(aId) {
+ return AddonManagerInternal.upgradeListeners.has(aId);
+ },
+
+ getUpgradeListener(aId) {
+ return AddonManagerInternal.upgradeListeners.get(aId);
+ },
+
+ get externalExtensionLoaders() {
+ return AddonManagerInternal.externalExtensionLoaders;
+ },
+
+ /**
+ * Predicate that returns true if we think the given extension ID
+ * might have been generated by XPIProvider.
+ */
+ isTemporaryInstallID(extensionId) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+
+ if (!extensionId || typeof extensionId != "string") {
+ throw Components.Exception(
+ "extensionId must be a string",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).isTemporaryInstallID(extensionId);
+ },
+
+ isDBLoaded() {
+ let provider = AddonManagerInternal._getProviderByName("XPIProvider");
+ return provider ? provider.isDBLoaded : false;
+ },
+
+ get databaseReady() {
+ let provider = AddonManagerInternal._getProviderByName("XPIProvider");
+ return provider ? provider.databaseReady : new Promise(() => {});
+ },
+
+ /**
+ * Async shutdown barrier which blocks the completion of add-on
+ * manager shutdown. This should generally only be used by add-on
+ * providers (i.e., XPIProvider) to complete their final shutdown
+ * tasks.
+ */
+ get finalShutdown() {
+ return gFinalShutdownBarrier.client;
+ },
+
+ // Used by tests to call repo shutdown.
+ overrideAddonRepository(mockRepo) {
+ lazy.AddonRepository = mockRepo;
+ },
+
+ // Used by tests to shut down AddonManager.
+ overrideAsyncShutdown(mockAsyncShutdown) {
+ AsyncShutdown = mockAsyncShutdown;
+ },
+};
+
+/**
+ * This is the public API that UI and developers should be calling. All methods
+ * just forward to AddonManagerInternal.
+ * @class
+ */
+export var AddonManager = {
+ // Map used to convert the known install source hostnames into the value to set into the
+ // telemetry events.
+ _installHostSource: new Map([
+ ["addons.mozilla.org", "amo"],
+ ["discovery.addons.mozilla.org", "disco"],
+ ]),
+
+ // Constants for the AddonInstall.state property
+ // These will show up as AddonManager.STATE_* (eg, STATE_AVAILABLE)
+ _states: new Map([
+ // The install is available for download.
+ ["STATE_AVAILABLE", 0],
+ // The install is being downloaded.
+ ["STATE_DOWNLOADING", 1],
+ // The install is checking the update for compatibility information.
+ ["STATE_CHECKING_UPDATE", 2],
+ // The install is downloaded and ready to install.
+ ["STATE_DOWNLOADED", 3],
+ // The download failed.
+ ["STATE_DOWNLOAD_FAILED", 4],
+ // The install may not proceed until the user accepts a prompt
+ ["STATE_AWAITING_PROMPT", 5],
+ // Any prompts are done
+ ["STATE_PROMPTS_DONE", 6],
+ // The install has been postponed.
+ ["STATE_POSTPONED", 7],
+ // The install is ready to be applied.
+ ["STATE_READY", 8],
+ // The add-on is being installed.
+ ["STATE_INSTALLING", 9],
+ // The add-on has been installed.
+ ["STATE_INSTALLED", 10],
+ // The install failed.
+ ["STATE_INSTALL_FAILED", 11],
+ // The install has been cancelled.
+ ["STATE_CANCELLED", 12],
+ ]),
+
+ // Constants representing different types of errors while downloading an
+ // add-on as a preparation for installation.
+ // These will show up as AddonManager.ERROR_* (eg, ERROR_NETWORK_FAILURE)
+ // The _errors codes are translated to text for a panel in browser-addons.js.
+ // The localized messages are located in extensionsUI.ftl.
+ // Errors with the "Updates only:" prefix are not translated
+ // because the error is dumped to the console instead of a panel.
+ _errors: new Map([
+ // The download failed due to network problems.
+ ["ERROR_NETWORK_FAILURE", -1],
+ // The downloaded file did not match the provided hash.
+ ["ERROR_INCORRECT_HASH", -2],
+ // The downloaded file seems to be corrupted in some way.
+ ["ERROR_CORRUPT_FILE", -3],
+ // An error occurred trying to write to the filesystem.
+ ["ERROR_FILE_ACCESS", -4],
+ // The add-on must be signed and isn't.
+ ["ERROR_SIGNEDSTATE_REQUIRED", -5],
+ // Updates only: The downloaded add-on had a different type than expected.
+ ["ERROR_UNEXPECTED_ADDON_TYPE", -6],
+ // Updates only: The addon did not have the expected ID.
+ ["ERROR_INCORRECT_ID", -7],
+ // The addon install_origins does not list the 3rd party domain.
+ ["ERROR_INVALID_DOMAIN", -8],
+ // Updates only: The downloaded add-on had a different version than expected.
+ ["ERROR_UNEXPECTED_ADDON_VERSION", -9],
+ // The add-on is blocklisted.
+ ["ERROR_BLOCKLISTED", -10],
+ // The add-on is incompatible (w.r.t. the compatibility range).
+ ["ERROR_INCOMPATIBLE", -11],
+ // The add-on type is not supported by the platform.
+ ["ERROR_UNSUPPORTED_ADDON_TYPE", -12],
+ ]),
+ // The update check timed out
+ ERROR_TIMEOUT: -1,
+ // There was an error while downloading the update information.
+ ERROR_DOWNLOAD_ERROR: -2,
+ // The update information was malformed in some way.
+ ERROR_PARSE_ERROR: -3,
+ // The update information was not in any known format.
+ ERROR_UNKNOWN_FORMAT: -4,
+ // The update information was not correctly signed or there was an SSL error.
+ ERROR_SECURITY_ERROR: -5,
+ // The update was cancelled
+ ERROR_CANCELLED: -6,
+ // These must be kept in sync with AddonUpdateChecker.
+ // No error was encountered.
+ UPDATE_STATUS_NO_ERROR: 0,
+ // The update check timed out
+ UPDATE_STATUS_TIMEOUT: -1,
+ // There was an error while downloading the update information.
+ UPDATE_STATUS_DOWNLOAD_ERROR: -2,
+ // The update information was malformed in some way.
+ UPDATE_STATUS_PARSE_ERROR: -3,
+ // The update information was not in any known format.
+ UPDATE_STATUS_UNKNOWN_FORMAT: -4,
+ // The update information was not correctly signed or there was an SSL error.
+ UPDATE_STATUS_SECURITY_ERROR: -5,
+ // The update was cancelled.
+ UPDATE_STATUS_CANCELLED: -6,
+ // Constants to indicate why an update check is being performed
+ // Update check has been requested by the user.
+ UPDATE_WHEN_USER_REQUESTED: 1,
+ // Update check is necessary to see if the Addon is compatibile with a new
+ // version of the application.
+ UPDATE_WHEN_NEW_APP_DETECTED: 2,
+ // Update check is necessary because a new application has been installed.
+ UPDATE_WHEN_NEW_APP_INSTALLED: 3,
+ // Update check is a regular background update check.
+ UPDATE_WHEN_PERIODIC_UPDATE: 16,
+ // Update check is needed to check an Addon that is being installed.
+ UPDATE_WHEN_ADDON_INSTALLED: 17,
+
+ // Constants for operations in Addon.pendingOperations
+ // Indicates that the Addon has no pending operations.
+ PENDING_NONE: 0,
+ // Indicates that the Addon will be enabled after the application restarts.
+ PENDING_ENABLE: 1,
+ // Indicates that the Addon will be disabled after the application restarts.
+ PENDING_DISABLE: 2,
+ // Indicates that the Addon will be uninstalled after the application restarts.
+ PENDING_UNINSTALL: 4,
+ // Indicates that the Addon will be installed after the application restarts.
+ PENDING_INSTALL: 8,
+ PENDING_UPGRADE: 16,
+
+ // Constants for operations in Addon.operationsRequiringRestart
+ // Indicates that restart isn't required for any operation.
+ OP_NEEDS_RESTART_NONE: 0,
+ // Indicates that restart is required for enabling the addon.
+ OP_NEEDS_RESTART_ENABLE: 1,
+ // Indicates that restart is required for disabling the addon.
+ OP_NEEDS_RESTART_DISABLE: 2,
+ // Indicates that restart is required for uninstalling the addon.
+ OP_NEEDS_RESTART_UNINSTALL: 4,
+ // Indicates that restart is required for installing the addon.
+ OP_NEEDS_RESTART_INSTALL: 8,
+
+ // Constants for permissions in Addon.permissions.
+ // Indicates that the Addon can be uninstalled.
+ PERM_CAN_UNINSTALL: 1,
+ // Indicates that the Addon can be enabled by the user.
+ PERM_CAN_ENABLE: 2,
+ // Indicates that the Addon can be disabled by the user.
+ PERM_CAN_DISABLE: 4,
+ // Indicates that the Addon can be upgraded.
+ PERM_CAN_UPGRADE: 8,
+ // Indicates that the Addon can be set to be allowed/disallowed
+ // in private browsing windows.
+ PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS: 32,
+ // Indicates that internal APIs can uninstall the add-on, even if the
+ // front-end cannot.
+ PERM_API_CAN_UNINSTALL: 64,
+
+ // General descriptions of where items are installed.
+ // Installed in this profile.
+ SCOPE_PROFILE: 1,
+ // Installed for all of this user's profiles.
+ SCOPE_USER: 2,
+ // Installed and owned by the application.
+ SCOPE_APPLICATION: 4,
+ // Installed for all users of the computer.
+ SCOPE_SYSTEM: 8,
+ // Installed temporarily
+ SCOPE_TEMPORARY: 16,
+ // The combination of all scopes.
+ SCOPE_ALL: 31,
+
+ // Constants for Addon.applyBackgroundUpdates.
+ // Indicates that the Addon should not update automatically.
+ AUTOUPDATE_DISABLE: 0,
+ // Indicates that the Addon should update automatically only if
+ // that's the global default.
+ AUTOUPDATE_DEFAULT: 1,
+ // Indicates that the Addon should update automatically.
+ AUTOUPDATE_ENABLE: 2,
+
+ // Constants for how Addon options should be shown.
+ // Options will be displayed in a new tab, if possible
+ OPTIONS_TYPE_TAB: 3,
+ // Similar to OPTIONS_TYPE_INLINE, but rather than generating inline
+ // options from a specially-formatted XUL file, the contents of the
+ // file are simply displayed in an inline <browser> element.
+ OPTIONS_TYPE_INLINE_BROWSER: 5,
+
+ // Constants for displayed or hidden options notifications
+ // Options notification will be displayed
+ OPTIONS_NOTIFICATION_DISPLAYED: "addon-options-displayed",
+ // Options notification will be hidden
+ OPTIONS_NOTIFICATION_HIDDEN: "addon-options-hidden",
+
+ // Constants for getStartupChanges, addStartupChange and removeStartupChange
+ // Add-ons that were detected as installed during startup. Doesn't include
+ // add-ons that were pending installation the last time the application ran.
+ STARTUP_CHANGE_INSTALLED: "installed",
+ // Add-ons that were detected as changed during startup. This includes an
+ // add-on moving to a different location, changing version or just having
+ // been detected as possibly changed.
+ STARTUP_CHANGE_CHANGED: "changed",
+ // Add-ons that were detected as uninstalled during startup. Doesn't include
+ // add-ons that were pending uninstallation the last time the application ran.
+ STARTUP_CHANGE_UNINSTALLED: "uninstalled",
+ // Add-ons that were detected as disabled during startup, normally because of
+ // an application change making an add-on incompatible. Doesn't include
+ // add-ons that were pending being disabled the last time the application ran.
+ STARTUP_CHANGE_DISABLED: "disabled",
+ // Add-ons that were detected as enabled during startup, normally because of
+ // an application change making an add-on compatible. Doesn't include
+ // add-ons that were pending being enabled the last time the application ran.
+ STARTUP_CHANGE_ENABLED: "enabled",
+
+ // Constants for Addon.signedState. Any states that should cause an add-on
+ // to be unusable in builds that require signing should have negative values.
+ // Add-on signing is not required, e.g. because the pref is disabled.
+ SIGNEDSTATE_NOT_REQUIRED: undefined,
+ // Add-on is signed but signature verification has failed.
+ SIGNEDSTATE_BROKEN: -2,
+ // Add-on may be signed but by an certificate that doesn't chain to our
+ // our trusted certificate.
+ SIGNEDSTATE_UNKNOWN: -1,
+ // Add-on is unsigned.
+ SIGNEDSTATE_MISSING: 0,
+ // Add-on is preliminarily reviewed.
+ SIGNEDSTATE_PRELIMINARY: 1,
+ // Add-on is fully reviewed.
+ SIGNEDSTATE_SIGNED: 2,
+ // Add-on is system add-on.
+ SIGNEDSTATE_SYSTEM: 3,
+ // Add-on is signed with a "Mozilla Extensions" certificate
+ SIGNEDSTATE_PRIVILEGED: 4,
+
+ get __AddonManagerInternal__() {
+ return AppConstants.DEBUG ? AddonManagerInternal : undefined;
+ },
+
+ /** Boolean indicating whether AddonManager startup has completed. */
+ get isReady() {
+ return gStartupComplete && !gShutdownInProgress;
+ },
+
+ /**
+ * A promise that is resolved when the AddonManager startup has completed.
+ * This may be rejected if startup of the AddonManager is not successful, or
+ * if shutdown is started before the AddonManager has finished starting.
+ */
+ get readyPromise() {
+ return gStartedPromise.promise;
+ },
+
+ /** @constructor */
+ init() {
+ this._stateToString = new Map();
+ for (let [name, value] of this._states) {
+ this[name] = value;
+ this._stateToString.set(value, name);
+ }
+ this._errorToString = new Map();
+ for (let [name, value] of this._errors) {
+ this[name] = value;
+ this._errorToString.set(value, name);
+ }
+ },
+
+ stateToString(state) {
+ return this._stateToString.get(state);
+ },
+
+ errorToString(err) {
+ return err ? this._errorToString.get(err) : null;
+ },
+
+ getInstallSourceFromHost(host) {
+ if (this._installHostSource.has(host)) {
+ return this._installHostSource.get(host);
+ }
+
+ if (WEBAPI_TEST_INSTALL_HOSTS.includes(host)) {
+ return "test-host";
+ }
+
+ return "unknown";
+ },
+
+ getInstallForURL(aUrl, aOptions) {
+ return AddonManagerInternal.getInstallForURL(aUrl, aOptions);
+ },
+
+ getInstallForFile(
+ aFile,
+ aMimetype,
+ aTelemetryInfo,
+ aUseSystemLocation = false
+ ) {
+ return AddonManagerInternal.getInstallForFile(
+ aFile,
+ aMimetype,
+ aTelemetryInfo,
+ aUseSystemLocation
+ );
+ },
+
+ uninstallSystemProfileAddon(aID) {
+ return AddonManagerInternal.uninstallSystemProfileAddon(aID);
+ },
+
+ stageLangpacksForAppUpdate(appVersion, platformVersion) {
+ return AddonManagerInternal._getProviderByName(
+ "XPIProvider"
+ ).stageLangpacksForAppUpdate(appVersion, platformVersion);
+ },
+
+ /**
+ * Gets an array of add-on IDs that changed during the most recent startup.
+ *
+ * @param aType
+ * The type of startup change to get
+ * @return An array of add-on IDs
+ */
+ getStartupChanges(aType) {
+ if (!(aType in AddonManagerInternal.startupChanges)) {
+ return [];
+ }
+ return AddonManagerInternal.startupChanges[aType].slice(0);
+ },
+
+ getAddonByID(aID) {
+ return AddonManagerInternal.getAddonByID(aID);
+ },
+
+ getAddonBySyncGUID(aGUID) {
+ return AddonManagerInternal.getAddonBySyncGUID(aGUID);
+ },
+
+ getAddonsByIDs(aIDs) {
+ return AddonManagerInternal.getAddonsByIDs(aIDs);
+ },
+
+ getAddonsByTypes(aTypes) {
+ return AddonManagerInternal.getAddonsByTypes(aTypes);
+ },
+
+ getActiveAddons(aTypes) {
+ return AddonManagerInternal.getActiveAddons(aTypes);
+ },
+
+ getAllAddons() {
+ return AddonManagerInternal.getAllAddons();
+ },
+
+ getInstallsByTypes(aTypes) {
+ return AddonManagerInternal.getInstallsByTypes(aTypes);
+ },
+
+ getAllInstalls() {
+ return AddonManagerInternal.getAllInstalls();
+ },
+
+ isInstallEnabled(aType) {
+ return AddonManagerInternal.isInstallEnabled(aType);
+ },
+
+ isInstallAllowed(aType, aInstallingPrincipal) {
+ return AddonManagerInternal.isInstallAllowed(aType, aInstallingPrincipal);
+ },
+
+ installSitePermsAddonFromWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ ) {
+ return AddonManagerInternal.installSitePermsAddonFromWebpage(
+ aBrowser,
+ aInstallingPrincipal,
+ aPermission
+ );
+ },
+
+ installAddonFromWebpage(
+ aType,
+ aBrowser,
+ aInstallingPrincipal,
+ aInstall,
+ details
+ ) {
+ AddonManagerInternal.installAddonFromWebpage(
+ aType,
+ aBrowser,
+ aInstallingPrincipal,
+ aInstall,
+ details
+ );
+ },
+
+ installAddonFromAOM(aBrowser, aUri, aInstall) {
+ AddonManagerInternal.installAddonFromAOM(aBrowser, aUri, aInstall);
+ },
+
+ installTemporaryAddon(aDirectory) {
+ return AddonManagerInternal.installTemporaryAddon(aDirectory);
+ },
+
+ installBuiltinAddon(aBase) {
+ return AddonManagerInternal.installBuiltinAddon(aBase);
+ },
+
+ maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ return AddonManagerInternal.maybeInstallBuiltinAddon(aID, aVersion, aBase);
+ },
+
+ addManagerListener(aListener) {
+ AddonManagerInternal.addManagerListener(aListener);
+ },
+
+ removeManagerListener(aListener) {
+ AddonManagerInternal.removeManagerListener(aListener);
+ },
+
+ addInstallListener(aListener) {
+ AddonManagerInternal.addInstallListener(aListener);
+ },
+
+ removeInstallListener(aListener) {
+ AddonManagerInternal.removeInstallListener(aListener);
+ },
+
+ getUpgradeListener(aId) {
+ return AddonManagerInternal.upgradeListeners.get(aId);
+ },
+
+ addUpgradeListener(aInstanceID, aCallback) {
+ AddonManagerInternal.addUpgradeListener(aInstanceID, aCallback);
+ },
+
+ removeUpgradeListener(aInstanceID) {
+ return AddonManagerInternal.removeUpgradeListener(aInstanceID);
+ },
+
+ addExternalExtensionLoader(loader) {
+ return AddonManagerInternal.addExternalExtensionLoader(loader);
+ },
+
+ addAddonListener(aListener) {
+ AddonManagerInternal.addAddonListener(aListener);
+ },
+
+ removeAddonListener(aListener) {
+ AddonManagerInternal.removeAddonListener(aListener);
+ },
+
+ hasAddonType(addonType) {
+ return AddonManagerInternal.hasAddonType(addonType);
+ },
+
+ hasProvider(name) {
+ if (!gStarted) {
+ throw Components.Exception(
+ "AddonManager is not initialized",
+ Cr.NS_ERROR_NOT_INITIALIZED
+ );
+ }
+ return !!AddonManagerInternal._getProviderByName(name);
+ },
+
+ /**
+ * Determines whether an Addon should auto-update or not.
+ *
+ * @param aAddon
+ * The Addon representing the add-on
+ * @return true if the addon should auto-update, false otherwise.
+ */
+ shouldAutoUpdate(aAddon) {
+ if (!aAddon || typeof aAddon != "object") {
+ throw Components.Exception(
+ "aAddon must be specified",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ // Special case colorway built-in themes being migrated to an AMO installed theme
+ // when an update was found and:
+ //
+ // - `extensions.update.enable` is set to true (and so add-on updates are still
+ // being checked automatically on the background)
+ // - `extensions.update.autoUpdateDefault` is set to false (likely because the
+ // user has disabled auto-applying add-ons updates in about:addons to review
+ // extensions changelogs before accepting an update, e.g. to avoid unexpected
+ // issues that a new version of an extension may be introducing in the update)
+ //
+ // TODO(Bug 1815898): remove this special case along with other AOM/XPIProvider
+ // special cases introduced for colorways themes or colorways migration.
+ if (aAddon.isBuiltinColorwayTheme) {
+ return true;
+ }
+
+ if (!("applyBackgroundUpdates" in aAddon)) {
+ return false;
+ }
+ if (!(aAddon.permissions & AddonManager.PERM_CAN_UPGRADE)) {
+ return false;
+ }
+ if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_ENABLE) {
+ return true;
+ }
+ if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_DISABLE) {
+ return false;
+ }
+ return this.autoUpdateDefault;
+ },
+
+ get checkCompatibility() {
+ return AddonManagerInternal.checkCompatibility;
+ },
+
+ set checkCompatibility(aValue) {
+ AddonManagerInternal.checkCompatibility = aValue;
+ },
+
+ get strictCompatibility() {
+ return AddonManagerInternal.strictCompatibility;
+ },
+
+ set strictCompatibility(aValue) {
+ AddonManagerInternal.strictCompatibility = aValue;
+ },
+
+ get checkUpdateSecurityDefault() {
+ return AddonManagerInternal.checkUpdateSecurityDefault;
+ },
+
+ get checkUpdateSecurity() {
+ return AddonManagerInternal.checkUpdateSecurity;
+ },
+
+ set checkUpdateSecurity(aValue) {
+ AddonManagerInternal.checkUpdateSecurity = aValue;
+ },
+
+ get updateEnabled() {
+ return AddonManagerInternal.updateEnabled;
+ },
+
+ set updateEnabled(aValue) {
+ AddonManagerInternal.updateEnabled = aValue;
+ },
+
+ get autoUpdateDefault() {
+ return AddonManagerInternal.autoUpdateDefault;
+ },
+
+ set autoUpdateDefault(aValue) {
+ AddonManagerInternal.autoUpdateDefault = aValue;
+ },
+
+ escapeAddonURI(aAddon, aUri, aAppVersion) {
+ return AddonManagerInternal.escapeAddonURI(aAddon, aUri, aAppVersion);
+ },
+
+ getPreferredIconURL(aAddon, aSize, aWindow = undefined) {
+ return AddonManagerInternal.getPreferredIconURL(aAddon, aSize, aWindow);
+ },
+
+ get webAPI() {
+ return AddonManagerInternal.webAPI;
+ },
+
+ /**
+ * Async shutdown barrier which blocks the start of AddonManager
+ * shutdown. Callers should add blockers to this barrier if they need
+ * to complete add-on manager operations before it shuts down.
+ */
+ get beforeShutdown() {
+ return gBeforeShutdownBarrier.client;
+ },
+};
+
+/**
+ * Manage AddonManager settings propagated over RemoteSettings synced data.
+ *
+ * See :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>`.
+ *
+ * .. warning::
+ * Before landing any change to ``AMRemoteSettings`` or the format expected for the
+ * remotely controlled settings (on the service or Firefos side), please read the
+ * documentation page linked above and make sure to keep the JSON Schema described
+ * and controlled settings groups included in that documentation page in sync with
+ * the one actually set on the RemoteSettings service side.
+ */
+AMRemoteSettings = {
+ RS_COLLECTION: "addons-manager-settings",
+
+ /**
+ * RemoteSettings settings group map.
+ *
+ * .. note::
+ * Please keep in sync the "Controlled Settings Groups" documentation from
+ * :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>` in sync with
+ * the settings groups defined here.
+ */
+ RS_ENTRIES_MAP: {
+ installTriggerDeprecation: [
+ "extensions.InstallTriggerImpl.enabled",
+ "extensions.InstallTrigger.enabled",
+ ],
+ quarantinedDomains: ["extensions.quarantinedDomains.list"],
+ },
+
+ client: null,
+ onSync: null,
+ promiseStartup: null,
+
+ init() {
+ try {
+ if (!this.promiseStartup) {
+ // Creating a promise to resolved when the browser startup was completed,
+ // used to process the existing entries (if any) after the startup is completed
+ // and to only to it ones.
+ this.promiseStartup = new Promise(resolve => {
+ function observer() {
+ resolve();
+ Services.obs.removeObserver(
+ observer,
+ "browser-delayed-startup-finished"
+ );
+ }
+ Services.obs.addObserver(
+ observer,
+ "browser-delayed-startup-finished"
+ );
+ });
+ }
+
+ if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) {
+ return;
+ }
+
+ if (!this.client) {
+ this.client = lazy.RemoteSettings(this.RS_COLLECTION);
+ this.onSync = this.processEntries.bind(this);
+ this.client.on("sync", this.onSync);
+ // Process existing entries if any, once the browser has been fully initialized.
+ this.promiseStartup.then(() => this.processEntries());
+ }
+ } catch (err) {
+ logger.error("Failure to initialize AddonManager RemoteSettings", err);
+ }
+ },
+
+ shutdown() {
+ try {
+ if (this.client) {
+ this.client.off("sync", this.onSync);
+ this.client = null;
+ this.onSync = null;
+ }
+ this.promiseStartup = null;
+ } catch (err) {
+ logger.error("Failure on shutdown AddonManager RemoteSettings", err);
+ }
+ },
+
+ /**
+ * Process all the settings groups that are included in the collection entry with ``"id"`` set to ``"AddonManagerSettings"``
+ * (if any).
+ *
+ * .. note::
+ * This method may need to be updated if the preference value type is not yet expected by this method
+ * (which means that it would be ignored until handled explicitly).
+ */
+ async processEntries() {
+ const entries = await this.client.get({ syncIfEmpty: false }).catch(err => {
+ logger.error("Failure to process AddonManager RemoteSettings", err);
+ return [];
+ });
+
+ const processEntryPref = (entryId, groupName, prefName, prefValue) => {
+ try {
+ logger.debug(
+ `Process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`
+ );
+
+ // Support for controlling boolean and string AddonManager settings.
+ switch (typeof prefValue) {
+ case "boolean":
+ Services.prefs.setBoolPref(prefName, prefValue);
+ break;
+ case "string":
+ Services.prefs.setStringPref(prefName, prefValue);
+ break;
+ default:
+ throw new Error(`Unexpected type ${typeof prefValue}`);
+ }
+
+ // Notify observers about the pref set from AMRemoteSettings.
+ Services.obs.notifyObservers(
+ { entryId, groupName, prefName, prefValue },
+ "am-remote-settings-setpref"
+ );
+ } catch (e) {
+ logger.error(
+ `Failed to process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`,
+ e
+ );
+ }
+ };
+
+ for (const entry of entries) {
+ logger.debug(`Processing AddonManager RemoteSettings "${entry.id}"`);
+
+ for (const [groupName, prefs] of Object.entries(this.RS_ENTRIES_MAP)) {
+ const data = entry[groupName];
+ if (!data) {
+ continue;
+ }
+
+ for (const pref of prefs) {
+ // Skip the pref if it is not included in the remote settings data.
+ if (!(pref in data)) {
+ continue;
+ }
+
+ processEntryPref(entry.id, groupName, pref, data[pref]);
+ }
+ }
+ }
+ },
+};
+
+/**
+ * Listens to the AddonManager install and addon events and send telemetry events.
+ */
+AMTelemetry = {
+ telemetrySetupDone: false,
+
+ init() {
+ // Enable the addonsManager telemetry event category before the AddonManager
+ // has completed its startup, otherwise telemetry events recorded during the
+ // AddonManager/XPIProvider startup will not be recorded.
+ Services.telemetry.setEventRecordingEnabled("addonsManager", true);
+ },
+
+ // This method is called by the AddonManager, once it has been started, so that we can
+ // init the telemetry event category and start listening for the events related to the
+ // addons installation and management.
+ onStartup() {
+ if (this.telemetrySetupDone) {
+ return;
+ }
+
+ this.telemetrySetupDone = true;
+
+ Services.obs.addObserver(this, "addon-install-origin-blocked");
+ Services.obs.addObserver(this, "addon-install-disabled");
+ Services.obs.addObserver(this, "addon-install-blocked");
+
+ AddonManager.addInstallListener(this);
+ AddonManager.addAddonListener(this);
+ },
+
+ // Observer Service notification callback.
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "addon-install-blocked": {
+ const { installs } = subject.wrappedJSObject;
+ this.recordInstallEvent(installs[0], { step: "site_warning" });
+ break;
+ }
+ case "addon-install-origin-blocked": {
+ const { installs } = subject.wrappedJSObject;
+ this.recordInstallEvent(installs[0], { step: "site_blocked" });
+ break;
+ }
+ case "addon-install-disabled": {
+ const { installs } = subject.wrappedJSObject;
+ this.recordInstallEvent(installs[0], {
+ step: "install_disabled_warning",
+ });
+ break;
+ }
+ }
+ },
+
+ // AddonManager install listener callbacks.
+
+ onNewInstall(install) {
+ this.recordInstallEvent(install, { step: "started" });
+ },
+
+ onInstallCancelled(install) {
+ this.recordInstallEvent(install, { step: "cancelled" });
+ },
+
+ onInstallPostponed(install) {
+ this.recordInstallEvent(install, { step: "postponed" });
+ },
+
+ onInstallFailed(install) {
+ this.recordInstallEvent(install, { step: "failed" });
+ },
+
+ onInstallEnded(install) {
+ this.recordInstallEvent(install, { step: "completed" });
+ // Skip install_stats events for install objects related to.
+ // add-on updates.
+ if (!install.existingAddon) {
+ this.recordInstallStatsEvent(install);
+ }
+ },
+
+ onDownloadStarted(install) {
+ this.recordInstallEvent(install, { step: "download_started" });
+ },
+
+ onDownloadCancelled(install) {
+ this.recordInstallEvent(install, { step: "cancelled" });
+ },
+
+ onDownloadEnded(install) {
+ let download_time = Math.round(Cu.now() - install.downloadStartedAt);
+ this.recordInstallEvent(install, {
+ step: "download_completed",
+ download_time,
+ });
+ },
+
+ onDownloadFailed(install) {
+ let download_time = Math.round(Cu.now() - install.downloadStartedAt);
+ this.recordInstallEvent(install, {
+ step: "download_failed",
+ download_time,
+ });
+ },
+
+ // Addon listeners callbacks.
+
+ onUninstalled(addon) {
+ this.recordManageEvent(addon, "uninstall");
+ },
+
+ onEnabled(addon) {
+ this.recordManageEvent(addon, "enable");
+ },
+
+ onDisabled(addon) {
+ this.recordManageEvent(addon, "disable");
+ },
+
+ // Internal helpers methods.
+
+ /**
+ * Get a trimmed version of the given string if it is longer than 80 chars.
+ *
+ * @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.
+ */
+ 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)}`;
+ },
+
+ /**
+ * Retrieve the addonId for the given AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to retrieve the addonId from.
+ *
+ * @returns {string | null}
+ * The addonId for the given AddonInstall instance (if any).
+ */
+ getAddonIdFromInstall(install) {
+ // Returns the id of the extension that is being installed, as soon as the
+ // addon is available in the AddonInstall instance (after being downloaded
+ // and validated successfully).
+ if (install.addon) {
+ return install.addon.id;
+ }
+
+ // While updating an addon, the existing addon can be
+ // used to retrieve the addon id since the first update event.
+ if (install.existingAddon) {
+ return install.existingAddon.id;
+ }
+
+ return null;
+ },
+
+ /**
+ * Retrieve the telemetry event's object property value for the given
+ * AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to retrieve the event object from.
+ *
+ * @returns {string}
+ * The object for the given AddonInstall instance.
+ */
+ getEventObjectFromInstall(install) {
+ let addonType;
+
+ if (install.type) {
+ // The AddonInstall wrapper already provides a type (if it was known when the
+ // install object has been created).
+ addonType = install.type;
+ } else if (install.addon) {
+ // The install flow has reached a step that has an addon instance which we can
+ // check to know the extension type (e.g. after download for the DownloadAddonInstall).
+ addonType = install.addon.type;
+ } else if (install.existingAddon) {
+ // The install flow is an update and we can look the existingAddon to check which was
+ // the add-on type that is being installed.
+ addonType = install.existingAddon.type;
+ }
+
+ return this.getEventObjectFromAddonType(addonType);
+ },
+
+ /**
+ * Retrieve the telemetry event source for the given AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to retrieve the source from.
+ *
+ * @returns {Object | null}
+ * The telemetry infor ({source, method}) from the given AddonInstall instance.
+ */
+ getInstallTelemetryInfo(install) {
+ if (install.installTelemetryInfo) {
+ return install.installTelemetryInfo;
+ } else if (
+ install.existingAddon &&
+ install.existingAddon.installTelemetryInfo
+ ) {
+ // Get the install source from the existing addon (e.g. for an extension update).
+ return install.existingAddon.installTelemetryInfo;
+ }
+
+ return null;
+ },
+
+ /**
+ * Get the telemetry event's object property for the given addon type
+ *
+ * @param {string} addonType
+ * The addon type to convert into the related telemetry event object.
+ *
+ * @returns {string}
+ * The object for the given addon type.
+ */
+ getEventObjectFromAddonType(addonType) {
+ switch (addonType) {
+ case undefined:
+ return "unknown";
+ case "extension":
+ case "theme":
+ case "locale":
+ case "dictionary":
+ case "sitepermission":
+ return addonType;
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ case "sitepermission-deprecated":
+ // Telemetry events' object maximum length is 20 chars (See https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/collection/events.html#limits)
+ // and the value needs to matching the "^[a-zA-Z][a-zA-Z0-9_.]*[a-zA-Z0-9]$" pattern.
+ return "siteperm_deprecated";
+ default:
+ // Currently this should only include gmp-plugins ("plugin").
+ return "other";
+ }
+ },
+
+ convertToString(value) {
+ if (value == null) {
+ // Convert null and undefined to empty strings.
+ return "";
+ }
+ switch (typeof value) {
+ case "string":
+ return value;
+ case "boolean":
+ return value ? "1" : "0";
+ }
+ return String(value);
+ },
+
+ /**
+ * Return the UTM parameters found in `sourceURL` for AMO attribution data.
+ *
+ * @param {string} sourceURL
+ * The source URL from where the add-on has been installed.
+ *
+ * @returns {object}
+ * An object containing the attribution data for AMO if any. Keys
+ * are defined in `AMO_ATTRIBUTION_DATA_KEYS`. Values are strings.
+ */
+ parseAttributionDataForAMO(sourceURL) {
+ let searchParams;
+
+ try {
+ searchParams = new URL(sourceURL).searchParams;
+ } catch {
+ return {};
+ }
+
+ const utmKeys = [...searchParams.keys()].filter(key =>
+ AMO_ATTRIBUTION_DATA_KEYS.includes(key)
+ );
+
+ return utmKeys.reduce((params, key) => {
+ let value = searchParams.get(key);
+ if (typeof value === "string") {
+ value = value.slice(0, AMO_ATTRIBUTION_DATA_MAX_LENGTH);
+ }
+
+ return { ...params, [key]: value };
+ }, {});
+ },
+
+ /**
+ * Record an "install stats" event when the source is included in
+ * `AMO_ATTRIBUTION_ALLOWED_SOURCES`.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to record an install_stats event for.
+ */
+ recordInstallStatsEvent(install) {
+ const telemetryInfo = this.getInstallTelemetryInfo(install);
+
+ if (!AMO_ATTRIBUTION_ALLOWED_SOURCES.includes(telemetryInfo?.source)) {
+ return;
+ }
+
+ const method = "install_stats";
+ const object = this.getEventObjectFromInstall(install);
+ const addonId = this.getAddonIdFromInstall(install);
+
+ if (!addonId) {
+ Cu.reportError(
+ "Missing addonId when trying to record an install_stats event"
+ );
+ return;
+ }
+
+ let extra = {
+ addon_id: this.getTrimmedString(addonId),
+ };
+
+ if (
+ telemetryInfo?.source === "amo" &&
+ typeof telemetryInfo?.sourceURL === "string"
+ ) {
+ extra = {
+ ...extra,
+ ...this.parseAttributionDataForAMO(telemetryInfo.sourceURL),
+ };
+ }
+
+ if (
+ telemetryInfo?.source === "disco" &&
+ typeof telemetryInfo?.taarRecommended === "boolean"
+ ) {
+ extra = {
+ ...extra,
+ taar_based: this.convertToString(telemetryInfo.taarRecommended),
+ };
+ }
+
+ this.recordEvent({ method, object, value: install.hashedAddonId, extra });
+ Glean.addonsManager.installStats.record(
+ this.formatExtraVars({
+ addon_id: extra.addon_id,
+ addon_type: object,
+ taar_based: extra.taar_based,
+ utm_campaign: extra.utm_campaign,
+ utm_content: extra.utm_content,
+ utm_medium: extra.utm_medium,
+ utm_source: extra.utm_source,
+ })
+ );
+ },
+
+ /**
+ * Convert all the telemetry event's extra_vars into strings, if needed.
+ *
+ * @param {object} extraVars
+ * @returns {object} The formatted extra vars.
+ */
+ formatExtraVars({ addon, ...extraVars }) {
+ if (addon) {
+ extraVars.addonId = addon.id;
+ extraVars.type = addon.type;
+ }
+
+ // All the extra_vars in a telemetry event have to be strings.
+ for (var [key, value] of Object.entries(extraVars)) {
+ if (value == undefined) {
+ delete extraVars[key];
+ } else {
+ extraVars[key] = this.convertToString(value);
+ }
+ }
+
+ if (extraVars.addonId) {
+ extraVars.addonId = this.getTrimmedString(extraVars.addonId);
+ }
+
+ return extraVars;
+ },
+
+ /**
+ * Record an install or update event for the given AddonInstall instance.
+ *
+ * @param {AddonInstall} install
+ * The AddonInstall instance to record an install or update event for.
+ * @param {object} extraVars
+ * The additional extra_vars to include in the recorded event.
+ * @param {string} extraVars.step
+ * The current step in the install or update flow.
+ * @param {string} extraVars.download_time
+ * The number of ms needed to download the extension.
+ * @param {string} extraVars.num_strings
+ * The number of permission description string for the extension
+ * permission doorhanger.
+ */
+ recordInstallEvent(install, extraVars) {
+ // Early exit if AMTelemetry's telemetry setup has not been done yet.
+ if (!this.telemetrySetupDone) {
+ return;
+ }
+
+ let extra = {};
+
+ let telemetryInfo = this.getInstallTelemetryInfo(install);
+ if (telemetryInfo && typeof telemetryInfo.source === "string") {
+ extra.source = telemetryInfo.source;
+ }
+
+ if (extra.source === "internal") {
+ // Do not record the telemetry event for installation sources
+ // that are marked as "internal".
+ return;
+ }
+
+ // Also include the install source's method when applicable (e.g. install events with
+ // source "about:addons" may have "install-from-file" or "url" as their source method).
+ if (telemetryInfo && typeof telemetryInfo.method === "string") {
+ extra.method = telemetryInfo.method;
+ }
+
+ let addonId = this.getAddonIdFromInstall(install);
+ let object = this.getEventObjectFromInstall(install);
+
+ let installId = String(install.installId);
+ let eventMethod = install.existingAddon ? "update" : "install";
+
+ if (addonId) {
+ extra.addon_id = this.getTrimmedString(addonId);
+ }
+
+ if (install.error) {
+ extra.error = AddonManager.errorToString(install.error);
+ }
+
+ if (
+ eventMethod === "install" &&
+ Services.prefs.getBoolPref("extensions.install_origins.enabled", true)
+ ) {
+ // This is converted to "1" / "0".
+ extra.install_origins = Array.isArray(install.addon?.installOrigins);
+ }
+
+ if (eventMethod === "update") {
+ // For "update" telemetry events, also include an extra var which determine
+ // if the update has been requested by the user.
+ extra.updated_from = install.isUserRequestedUpdate ? "user" : "app";
+ }
+
+ // All the extra vars in a telemetry event have to be strings.
+ extra = this.formatExtraVars({ ...extraVars, ...extra });
+
+ this.recordEvent({ method: eventMethod, object, value: installId, extra });
+ Glean.addonsManager[eventMethod]?.record(
+ this.formatExtraVars({
+ addon_id: extra.addon_id,
+ addon_type: object,
+ install_id: installId,
+ download_time: extra.download_time,
+ error: extra.error,
+ source: extra.source,
+ source_method: extra.method,
+ num_strings: extra.num_strings,
+ updated_from: extra.updated_from,
+ install_origins: extra.install_origins,
+ step: extra.step,
+ })
+ );
+ },
+
+ /**
+ * Record a manage event for the given addon.
+ *
+ * @param {AddonWrapper} addon
+ * The AddonWrapper instance.
+ * @param {object} extraVars
+ * The additional extra_vars to include in the recorded event.
+ * @param {string} extraVars.num_strings
+ * The number of permission description string for the extension
+ * permission doorhanger.
+ */
+ recordManageEvent(addon, method, extraVars) {
+ // Early exit if AMTelemetry's telemetry setup has not been done yet.
+ if (!this.telemetrySetupDone) {
+ return;
+ }
+
+ let extra = {};
+
+ if (addon.installTelemetryInfo) {
+ if ("source" in addon.installTelemetryInfo) {
+ extra.source = addon.installTelemetryInfo.source;
+ }
+
+ // Also include the install source's method when applicable (e.g. install events with
+ // source "about:addons" may have "install-from-file" or "url" as their source method).
+ if ("method" in addon.installTelemetryInfo) {
+ extra.method = addon.installTelemetryInfo.method;
+ }
+ }
+
+ if (extra.source === "internal") {
+ // Do not record the telemetry event for installation sources
+ // that are marked as "internal".
+ return;
+ }
+
+ let object = this.getEventObjectFromAddonType(addon.type);
+ let value = this.getTrimmedString(addon.id);
+
+ extra = { ...extraVars, ...extra };
+
+ let hasExtraVars = !!Object.keys(extra).length;
+ extra = this.formatExtraVars(extra);
+
+ this.recordEvent({
+ method,
+ object,
+ value,
+ extra: hasExtraVars ? extra : null,
+ });
+ Glean.addonsManager.manage.record(
+ this.formatExtraVars({
+ method,
+ addon_id: value,
+ addon_type: object,
+ source: extra.source,
+ source_method: extra.method,
+ num_strings: extra.num_strings,
+ })
+ );
+ },
+
+ /**
+ * Record an event on abuse report submissions.
+ *
+ * @params {object} opts
+ * @params {string} opts.addonId
+ * The id of the addon being reported.
+ * @params {string} [opts.addonType]
+ * The type of the addon being reported (only present for an existing
+ * addonId).
+ * @params {string} [opts.errorType]
+ * The AbuseReport errorType for a submission failure.
+ * @params {string} opts.reportEntryPoint
+ * The entry point of the abuse report.
+ */
+ recordReportEvent({ addonId, addonType, errorType, reportEntryPoint }) {
+ this.recordEvent({
+ method: "report",
+ object: reportEntryPoint,
+ value: addonId,
+ extra: this.formatExtraVars({
+ addon_type: addonType,
+ error_type: errorType,
+ }),
+ });
+ Glean.addonsManager.report.record(
+ this.formatExtraVars({
+ addon_id: addonId,
+ addon_type: addonType,
+ entry_point: reportEntryPoint,
+ error_type: errorType,
+ })
+ );
+ },
+
+ /**
+ * @params {object} opts
+ * @params {nsIURI} opts.displayURI
+ */
+ recordSuspiciousSiteEvent({ displayURI }) {
+ let site = displayURI?.displayHost ?? "(unknown)";
+ this.recordEvent({
+ method: "reportSuspiciousSite",
+ object: "suspiciousSite",
+ value: site,
+ extra: {},
+ });
+ Glean.addonsManager.reportSuspiciousSite.record(
+ this.formatExtraVars({ suspiciousSite: site })
+ );
+ },
+
+ recordEvent({ method, object, value, extra }) {
+ if (typeof value != "string") {
+ // The value must be a string or null, make sure it's valid so sending
+ // the event doesn't fail.
+ value = null;
+ }
+ try {
+ Services.telemetry.recordEvent(
+ "addonsManager",
+ method,
+ object,
+ value,
+ extra
+ );
+ } catch (err) {
+ // If the telemetry throws just log the error so it doesn't break any
+ // functionality.
+ Cu.reportError(err);
+ }
+ },
+};
+
+/**
+ * AMBrowserExtensionsImport is used by the migration wizard to import/install
+ * Firefox add-ons based on a set of non-Firefox browser extensions.
+ */
+AMBrowserExtensionsImport = {
+ TELEMETRY_SOURCE: "browser-import",
+ TOPIC_CANCELLED: "webextension-imported-addons-cancelled",
+ TOPIC_COMPLETE: "webextension-imported-addons-complete",
+ TOPIC_PENDING: "webextension-imported-addons-pending",
+
+ // AddonId => AddonInstall
+ _pendingInstallsMap: new Map(),
+ _importInProgress: false,
+ _canCompleteOrCancelInstalls: false,
+ // Prompt handler set on the AddonInstall instances part of the imports
+ // (which currently makes sure we are not prompting for permissions when the
+ // imported addons are being downloaded, staged and then installed).
+ _installPromptHandler: () => {},
+ // Optionally override the `AddonRepository`, mainly for testing purposes.
+ _addonRepository: null,
+
+ get hasPendingImportedAddons() {
+ return !!this._pendingInstallsMap.size;
+ },
+
+ get importedAddonIDs() {
+ return Array.from(this._pendingInstallsMap.keys());
+ },
+
+ get canCompleteOrCancelInstalls() {
+ return this._canCompleteOrCancelInstalls && this.hasPendingImportedAddons;
+ },
+
+ get addonRepository() {
+ return this._addonRepository || lazy.AddonRepository;
+ },
+
+ /**
+ * Stage an install for each add-on mapped to a browser extension ID in the
+ * list of IDs passed to this method.
+ *
+ * @param {string} browserId A browser identifier.
+ * @param {Array<string} extensionIDs A list of non-Firefox extension IDs.
+ * @returns {Promise<object>} The return value is an object with data for
+ * the caller.
+ * @throws {Error} When there are pending imported add-ons.
+ */
+ async stageInstalls(browserId, extensionIDs) {
+ // In case we have an import in progress, we throw so that the caller knows
+ // that there is already an import in progress, which it may want to either
+ // cancel or complete.
+ if (this._importInProgress) {
+ throw new Error(
+ "Cannot stage installs because there are pending imported add-ons"
+ );
+ }
+ this._importInProgress = true;
+ this._canCompleteOrCancelInstalls = false;
+
+ let importedAddons = [];
+ // We first retrieve a list of `AddonSearchResult`, which are the Firefox
+ // add-ons mapped to the list of extension IDs passed to this method. We
+ // might not have as many mapped add-ons as extension IDs because not all
+ // browser extensions will be mapped to Firefox add-ons.
+ try {
+ let matchedIDs = [];
+ let unmatchedIDs = [];
+
+ ({
+ addons: importedAddons,
+ matchedIDs,
+ unmatchedIDs,
+ } = await this.addonRepository.getMappedAddons(browserId, extensionIDs));
+
+ Glean.browserMigration.matchedExtensions.set(matchedIDs);
+ Glean.browserMigration.unmatchedExtensions.set(unmatchedIDs);
+ } catch (err) {
+ Cu.reportError(err);
+ }
+
+ const alreadyInstalledIDs = (await AddonManager.getAllAddons()).map(
+ addon => addon.id
+ );
+
+ const { _pendingInstallsMap } = this;
+
+ const results = await Promise.allSettled(
+ // For each add-on to import, we create an `AddonInstall` instance and we
+ // start the install process until we reach the "downloaded ended" step.
+ // At this point, we call `postpone()`, and we are done when the add-on
+ // install has been postponed.
+ importedAddons
+ // Do not import add-ons already installed.
+ .filter(({ id }) => !alreadyInstalledIDs.includes(id))
+ .map(async ({ id, sourceURI, name, version, icons }) => {
+ let addonInstall;
+
+ try {
+ addonInstall = await AddonManager.getInstallForURL(sourceURI.spec, {
+ name,
+ version,
+ icons,
+ telemetryInfo: { source: this.TELEMETRY_SOURCE },
+ promptHandler: this._installPromptHandler,
+ });
+ } catch (err) {
+ return Promise.reject(err);
+ }
+
+ return new Promise((resolve, reject) => {
+ const rejectWithMessage = err => () => reject(new Error(err));
+
+ addonInstall.addListener({
+ onDownloadEnded(install) {
+ install
+ .postpone(null, /* requiresRestart */ false)
+ .then(_pendingInstallsMap.set(id, install));
+ },
+
+ onInstallPostponed() {
+ resolve();
+ },
+
+ onDownloadCancelled: rejectWithMessage("Download cancelled"),
+ onDownloadFailed: rejectWithMessage("Download failed"),
+ onInstallCancelled: rejectWithMessage("Install cancelled"),
+ onInstallFailed: rejectWithMessage("Install failed"),
+ });
+
+ addonInstall.install();
+ });
+ })
+ );
+ this._reportErrors(results);
+
+ // All the imported add-ons should have been staged for install at this
+ // point, unless there was no add-on mapped OR some errors.
+ const { importedAddonIDs } = this;
+
+ this._canCompleteOrCancelInstalls = !!importedAddonIDs.length;
+ this._importInProgress = !!importedAddonIDs.length;
+
+ if (importedAddonIDs.length) {
+ Services.obs.notifyObservers(null, this.TOPIC_PENDING);
+ }
+
+ return { importedAddonIDs };
+ },
+
+ /**
+ * Finalize the installation of the add-ons for which we staged their install.
+ *
+ * @returns {Promise<void>}
+ * @throws {Error} When there is no import in progress.
+ */
+ async completeInstalls() {
+ if (!this._importInProgress) {
+ throw new Error("No import in progress");
+ }
+
+ const results = await Promise.allSettled(
+ Array.from(this._pendingInstallsMap.values()).map(install => {
+ return install.continuePostponedInstall();
+ })
+ );
+ this._reportErrors(results);
+ this._clearInternalState();
+
+ Services.obs.notifyObservers(null, this.TOPIC_COMPLETE);
+ },
+
+ /**
+ * Cancel the installation of the add-ons for which we staged their install.
+ *
+ * @returns {Promise<void>}
+ * @throws {Error} When there is no import in progress.
+ */
+ async cancelInstalls() {
+ if (!this._importInProgress) {
+ throw new Error("No import in progress");
+ }
+
+ const results = await Promise.allSettled(
+ Array.from(this._pendingInstallsMap.values()).map(install => {
+ return install.cancel();
+ })
+ );
+ this._reportErrors(results);
+ this._clearInternalState();
+
+ Services.obs.notifyObservers(null, this.TOPIC_CANCELLED);
+ },
+
+ _reportErrors(results) {
+ results
+ .filter(result => result.status === "rejected")
+ .forEach(result => Cu.reportError(result.reason));
+ },
+
+ _clearInternalState() {
+ this._pendingInstallsMap.clear();
+ this._importInProgress = false;
+ this._canCompleteOrCancelInstalls = false;
+ },
+};
+
+AddonManager.init();
+
+// Setup the AMTelemetry once the AddonManager has been started.
+AddonManager.addManagerListener(AMTelemetry);
+Object.freeze(AddonManagerInternal);
+Object.freeze(AddonManagerPrivate);
+Object.freeze(AddonManager);
diff --git a/toolkit/mozapps/extensions/AddonManagerStartup-inlines.h b/toolkit/mozapps/extensions/AddonManagerStartup-inlines.h
new file mode 100644
index 0000000000..64211d98e7
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerStartup-inlines.h
@@ -0,0 +1,229 @@
+/* -*- 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 AddonManagerStartup_inlines_h
+#define AddonManagerStartup_inlines_h
+
+#include <utility>
+
+#include "js/Array.h" // JS::GetArrayLength, JS::IsArrayObject
+#include "js/Exception.h"
+#include "js/PropertyAndElement.h" // JS_Enumerate, JS_GetElement, JS_GetProperty, JS_GetPropertyById
+#include "jsapi.h"
+#include "mozilla/Maybe.h"
+#include "nsJSUtils.h"
+
+namespace mozilla {
+
+class ArrayIterElem;
+class PropertyIterElem;
+
+/*****************************************************************************
+ * Object iterator base classes
+ *****************************************************************************/
+
+template <class T, class PropertyType>
+class MOZ_STACK_CLASS BaseIter {
+ public:
+ typedef T SelfType;
+
+ PropertyType begin() const { return PropertyType(Self()); }
+
+ PropertyType end() const {
+ PropertyType elem(Self());
+ return elem.End();
+ }
+
+ void* Context() const { return mContext; }
+
+ protected:
+ BaseIter(JSContext* cx, JS::Handle<JSObject*> object, void* context = nullptr)
+ : mCx(cx), mObject(object), mContext(context) {}
+
+ const SelfType& Self() const { return *static_cast<const SelfType*>(this); }
+ SelfType& Self() { return *static_cast<SelfType*>(this); }
+
+ JSContext* mCx;
+
+ JS::Handle<JSObject*> mObject;
+
+ void* mContext;
+};
+
+template <class T, class IterType>
+class MOZ_STACK_CLASS BaseIterElem {
+ public:
+ typedef T SelfType;
+
+ explicit BaseIterElem(const IterType& iter, uint32_t index = 0)
+ : mIter(iter), mIndex(index) {}
+
+ uint32_t Length() const { return mIter.Length(); }
+
+ JS::Value Value() {
+ JS::Rooted<JS::Value> value(mIter.mCx, JS::UndefinedValue());
+
+ auto& self = Self();
+ if (!self.GetValue(&value)) {
+ JS_ClearPendingException(mIter.mCx);
+ }
+
+ return value;
+ }
+
+ SelfType& operator*() { return Self(); }
+
+ SelfType& operator++() {
+ MOZ_ASSERT(mIndex < Length());
+ mIndex++;
+ return Self();
+ }
+
+ bool operator!=(const SelfType& other) const {
+ return &mIter != &other.mIter || mIndex != other.mIndex;
+ }
+
+ SelfType End() const {
+ SelfType end(mIter);
+ end.mIndex = Length();
+ return end;
+ }
+
+ void* Context() const { return mIter.Context(); }
+
+ protected:
+ const SelfType& Self() const { return *static_cast<const SelfType*>(this); }
+ SelfType& Self() { return *static_cast<SelfType*>(this); }
+
+ const IterType& mIter;
+
+ uint32_t mIndex;
+};
+
+/*****************************************************************************
+ * Property iteration
+ *****************************************************************************/
+
+class MOZ_STACK_CLASS PropertyIter
+ : public BaseIter<PropertyIter, PropertyIterElem> {
+ friend class PropertyIterElem;
+ friend class BaseIterElem<PropertyIterElem, PropertyIter>;
+
+ public:
+ PropertyIter(JSContext* cx, JS::Handle<JSObject*> object,
+ void* context = nullptr)
+ : BaseIter(cx, object, context), mIds(cx, JS::IdVector(cx)) {
+ if (!JS_Enumerate(cx, object, &mIds)) {
+ JS_ClearPendingException(cx);
+ }
+ }
+
+ PropertyIter(const PropertyIter& other)
+ : PropertyIter(other.mCx, other.mObject, other.mContext) {}
+
+ PropertyIter& operator=(const PropertyIter& other) {
+ MOZ_ASSERT(other.mObject == mObject);
+ mCx = other.mCx;
+ mContext = other.mContext;
+
+ mIds.clear();
+ if (!JS_Enumerate(mCx, mObject, &mIds)) {
+ JS_ClearPendingException(mCx);
+ }
+ return *this;
+ }
+
+ int32_t Length() const { return mIds.length(); }
+
+ protected:
+ JS::Rooted<JS::IdVector> mIds;
+};
+
+class MOZ_STACK_CLASS PropertyIterElem
+ : public BaseIterElem<PropertyIterElem, PropertyIter> {
+ friend class BaseIterElem<PropertyIterElem, PropertyIter>;
+
+ public:
+ using BaseIterElem::BaseIterElem;
+
+ PropertyIterElem(const PropertyIterElem& other)
+ : BaseIterElem(other.mIter, other.mIndex) {}
+
+ jsid Id() {
+ MOZ_ASSERT(mIndex < mIter.mIds.length());
+
+ return mIter.mIds[mIndex];
+ }
+
+ const nsAString& Name() {
+ if (mName.isNothing()) {
+ mName.emplace();
+ mName.ref().init(mIter.mCx, Id());
+ }
+ return mName.ref();
+ }
+
+ JSContext* Cx() { return mIter.mCx; }
+
+ protected:
+ bool GetValue(JS::MutableHandle<JS::Value> value) {
+ MOZ_ASSERT(mIndex < Length());
+ JS::Rooted<jsid> id(mIter.mCx, Id());
+
+ return JS_GetPropertyById(mIter.mCx, mIter.mObject, id, value);
+ }
+
+ private:
+ Maybe<nsAutoJSString> mName;
+};
+
+/*****************************************************************************
+ * Array iteration
+ *****************************************************************************/
+
+class MOZ_STACK_CLASS ArrayIter : public BaseIter<ArrayIter, ArrayIterElem> {
+ friend class ArrayIterElem;
+ friend class BaseIterElem<ArrayIterElem, ArrayIter>;
+
+ public:
+ ArrayIter(JSContext* cx, JS::Handle<JSObject*> object)
+ : BaseIter(cx, object), mLength(0) {
+ bool isArray;
+ if (!JS::IsArrayObject(cx, object, &isArray) || !isArray) {
+ JS_ClearPendingException(cx);
+ return;
+ }
+
+ if (!JS::GetArrayLength(cx, object, &mLength)) {
+ JS_ClearPendingException(cx);
+ }
+ }
+
+ uint32_t Length() const { return mLength; }
+
+ private:
+ uint32_t mLength;
+};
+
+class MOZ_STACK_CLASS ArrayIterElem
+ : public BaseIterElem<ArrayIterElem, ArrayIter> {
+ friend class BaseIterElem<ArrayIterElem, ArrayIter>;
+
+ public:
+ using BaseIterElem::BaseIterElem;
+
+ ArrayIterElem(const ArrayIterElem& other)
+ : BaseIterElem(other.mIter, other.mIndex) {}
+
+ protected:
+ bool GetValue(JS::MutableHandle<JS::Value> value) {
+ MOZ_ASSERT(mIndex < Length());
+ return JS_GetElement(mIter.mCx, mIter.mObject, mIndex, value);
+ }
+};
+
+} // namespace mozilla
+
+#endif // AddonManagerStartup_inlines_h
diff --git a/toolkit/mozapps/extensions/AddonManagerStartup.cpp b/toolkit/mozapps/extensions/AddonManagerStartup.cpp
new file mode 100644
index 0000000000..40d7c6ddaf
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerStartup.cpp
@@ -0,0 +1,884 @@
+/* -*- 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 "AddonManagerStartup.h"
+#include "AddonManagerStartup-inlines.h"
+
+#include "jsapi.h"
+#include "jsfriendapi.h"
+#include "js/Array.h" // JS::IsArrayObject
+#include "js/ArrayBuffer.h"
+#include "js/Exception.h"
+#include "js/JSON.h"
+#include "js/PropertyAndElement.h" // JS_GetProperty, JS_SetProperty
+#include "js/TracingAPI.h"
+#include "xpcpublic.h"
+
+#include "mozilla/AppShutdown.h"
+#include "mozilla/ClearOnShutdown.h"
+#include "mozilla/EndianUtils.h"
+#include "mozilla/Components.h"
+#include "mozilla/Compression.h"
+#include "mozilla/LinkedList.h"
+#include "mozilla/Preferences.h"
+#include "mozilla/ResultExtensions.h"
+#include "mozilla/URLPreloader.h"
+#include "mozilla/Unused.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/Services.h"
+#include "mozilla/Try.h"
+#include "mozilla/dom/ipc/StructuredCloneData.h"
+#include "mozilla/dom/TypedArray.h"
+
+#include "nsAppDirectoryServiceDefs.h"
+#include "nsAppRunner.h"
+#include "nsChromeRegistry.h"
+#include "nsIDOMWindowUtils.h" // for nsIJSRAIIHelper
+#include "nsIFileURL.h"
+#include "nsIIOService.h"
+#include "nsIJARURI.h"
+#include "nsIStringEnumerator.h"
+#include "nsIZipReader.h"
+#include "nsJARProtocolHandler.h"
+#include "nsJSUtils.h"
+#include "nsIObserverService.h"
+#include "nsReadableUtils.h"
+#include "nsXULAppAPI.h"
+
+#include <stdlib.h>
+
+namespace mozilla {
+
+using Compression::LZ4;
+using dom::ipc::StructuredCloneData;
+
+AddonManagerStartup& AddonManagerStartup::GetSingleton() {
+ static RefPtr<AddonManagerStartup> singleton;
+ if (!singleton) {
+ singleton = new AddonManagerStartup();
+ ClearOnShutdown(&singleton);
+ }
+ return *singleton;
+}
+
+AddonManagerStartup::AddonManagerStartup() = default;
+
+nsIFile* AddonManagerStartup::ProfileDir() {
+ if (!mProfileDir) {
+ nsresult rv;
+
+ rv = NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR,
+ getter_AddRefs(mProfileDir));
+ MOZ_RELEASE_ASSERT(NS_SUCCEEDED(rv));
+ }
+
+ return mProfileDir;
+}
+
+NS_IMPL_ISUPPORTS(AddonManagerStartup, amIAddonManagerStartup, nsIObserver)
+
+/*****************************************************************************
+ * URI utils
+ *****************************************************************************/
+
+static nsresult ParseJARURI(nsIJARURI* uri, nsIURI** jarFile,
+ nsCString& entry) {
+ MOZ_TRY(uri->GetJARFile(jarFile));
+ MOZ_TRY(uri->GetJAREntry(entry));
+
+ // The entry portion of a jar: URI is required to begin with a '/', but for
+ // nested JAR URIs, the leading / of the outer entry is currently stripped.
+ // This is a bug which should be fixed in the JAR URI code, but...
+ if (entry.IsEmpty() || entry[0] != '/') {
+ entry.Insert('/', 0);
+ }
+ return NS_OK;
+}
+
+static nsresult ParseJARURI(nsIURI* uri, nsIURI** jarFile, nsCString& entry) {
+ nsresult rv;
+ nsCOMPtr<nsIJARURI> jarURI = do_QueryInterface(uri, &rv);
+ MOZ_TRY(rv);
+
+ return ParseJARURI(jarURI, jarFile, entry);
+}
+
+static Result<nsCOMPtr<nsIFile>, nsresult> GetFile(nsIURI* uri) {
+ nsresult rv;
+ nsCOMPtr<nsIFileURL> fileURL = do_QueryInterface(uri, &rv);
+ MOZ_TRY(rv);
+
+ nsCOMPtr<nsIFile> file;
+ MOZ_TRY(fileURL->GetFile(getter_AddRefs(file)));
+ MOZ_ASSERT(file);
+
+ return std::move(file);
+}
+
+/*****************************************************************************
+ * File utils
+ *****************************************************************************/
+
+static already_AddRefed<nsIFile> CloneAndAppend(nsIFile* aFile,
+ const char* name) {
+ nsCOMPtr<nsIFile> file;
+ aFile->Clone(getter_AddRefs(file));
+ file->AppendNative(nsDependentCString(name));
+ return file.forget();
+}
+
+static bool IsNormalFile(nsIFile* file) {
+ bool result;
+ return NS_SUCCEEDED(file->IsFile(&result)) && result;
+}
+
+static const char STRUCTURED_CLONE_MAGIC[] = "mozJSSCLz40v001";
+
+template <typename T>
+static Result<nsCString, nsresult> DecodeLZ4(const nsACString& lz4,
+ const T& magicNumber) {
+ constexpr auto HEADER_SIZE = sizeof(magicNumber) + 4;
+
+ // Note: We want to include the null terminator here.
+ nsDependentCSubstring magic(magicNumber, sizeof(magicNumber));
+
+ if (lz4.Length() < HEADER_SIZE || StringHead(lz4, magic.Length()) != magic) {
+ return Err(NS_ERROR_UNEXPECTED);
+ }
+
+ auto data = lz4.BeginReading() + magic.Length();
+ auto size = LittleEndian::readUint32(data);
+ data += 4;
+
+ size_t dataLen = lz4.EndReading() - data;
+ size_t outputSize;
+
+ nsCString result;
+ if (!result.SetLength(size, fallible) ||
+ !LZ4::decompress(data, dataLen, result.BeginWriting(), size,
+ &outputSize)) {
+ return Err(NS_ERROR_UNEXPECTED);
+ }
+
+ MOZ_DIAGNOSTIC_ASSERT(size == outputSize);
+
+ return std::move(result);
+}
+
+// Our zlib headers redefine this to MOZ_Z_compress, which breaks LZ4::compress
+#undef compress
+
+template <typename T>
+static Result<nsCString, nsresult> EncodeLZ4(const nsACString& data,
+ const T& magicNumber) {
+ // Note: We want to include the null terminator here.
+ nsDependentCSubstring magic(magicNumber, sizeof(magicNumber));
+
+ nsAutoCString result;
+ result.Append(magic);
+
+ auto off = result.Length();
+ if (!result.SetLength(off + 4, fallible)) {
+ return Err(NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ LittleEndian::writeUint32(result.BeginWriting() + off, data.Length());
+ off += 4;
+
+ auto size = LZ4::maxCompressedSize(data.Length());
+ if (!result.SetLength(off + size, fallible)) {
+ return Err(NS_ERROR_OUT_OF_MEMORY);
+ }
+
+ size = LZ4::compress(data.BeginReading(), data.Length(),
+ result.BeginWriting() + off);
+
+ if (!result.SetLength(off + size, fallible)) {
+ return Err(NS_ERROR_OUT_OF_MEMORY);
+ }
+ return std::move(result);
+}
+
+static_assert(sizeof STRUCTURED_CLONE_MAGIC % 8 == 0,
+ "Magic number should be an array of uint64_t");
+
+/**
+ * Reads the contents of a LZ4-compressed file, as stored by the IOUtils
+ * module, and returns the decompressed contents on success.
+ */
+static Result<nsCString, nsresult> ReadFileLZ4(nsIFile* file) {
+ static const char MAGIC_NUMBER[] = "mozLz40";
+
+ nsCString lz4;
+ MOZ_TRY_VAR(lz4, URLPreloader::ReadFile(file));
+
+ if (lz4.IsEmpty()) {
+ return lz4;
+ }
+
+ return DecodeLZ4(lz4, MAGIC_NUMBER);
+}
+
+static bool ParseJSON(JSContext* cx, nsACString& jsonData,
+ JS::MutableHandle<JS::Value> result) {
+ NS_ConvertUTF8toUTF16 str(jsonData);
+ jsonData.Truncate();
+
+ return JS_ParseJSON(cx, str.Data(), str.Length(), result);
+}
+
+static Result<nsCOMPtr<nsIZipReaderCache>, nsresult> GetJarCache() {
+ nsCOMPtr<nsIIOService> ios = components::IO::Service();
+ NS_ENSURE_TRUE(ios, Err(NS_ERROR_FAILURE));
+
+ nsCOMPtr<nsIProtocolHandler> jarProto;
+ MOZ_TRY(ios->GetProtocolHandler("jar", getter_AddRefs(jarProto)));
+
+ auto jar = static_cast<nsJARProtocolHandler*>(jarProto.get());
+ MOZ_ASSERT(jar);
+
+ nsCOMPtr<nsIZipReaderCache> zipCache = jar->JarCache();
+ return std::move(zipCache);
+}
+
+static Result<FileLocation, nsresult> GetFileLocation(nsIURI* uri) {
+ FileLocation location;
+
+ nsCOMPtr<nsIFileURL> fileURL = do_QueryInterface(uri);
+ nsCOMPtr<nsIFile> file;
+ if (fileURL) {
+ MOZ_TRY(fileURL->GetFile(getter_AddRefs(file)));
+ location.Init(file);
+ } else {
+ nsCOMPtr<nsIURI> fileURI;
+ nsCString entry;
+ MOZ_TRY(ParseJARURI(uri, getter_AddRefs(fileURI), entry));
+
+ MOZ_TRY_VAR(file, GetFile(fileURI));
+
+ location.Init(file, entry.get());
+ }
+
+ return std::move(location);
+}
+
+/*****************************************************************************
+ * JSON data handling
+ *****************************************************************************/
+
+class MOZ_STACK_CLASS WrapperBase {
+ protected:
+ WrapperBase(JSContext* cx, JSObject* object) : mCx(cx), mObject(cx, object) {}
+
+ WrapperBase(JSContext* cx, const JS::Value& value) : mCx(cx), mObject(cx) {
+ if (value.isObject()) {
+ mObject = &value.toObject();
+ } else {
+ mObject = JS_NewPlainObject(cx);
+ }
+ }
+
+ protected:
+ JSContext* mCx;
+ JS::Rooted<JSObject*> mObject;
+
+ bool GetBool(const char* name, bool defVal = false);
+
+ double GetNumber(const char* name, double defVal = 0);
+
+ nsString GetString(const char* name, const char* defVal = "");
+
+ JSObject* GetObject(const char* name);
+};
+
+bool WrapperBase::GetBool(const char* name, bool defVal) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ if (val.isBoolean()) {
+ return val.toBoolean();
+ }
+ return defVal;
+}
+
+double WrapperBase::GetNumber(const char* name, double defVal) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ if (val.isNumber()) {
+ return val.toNumber();
+ }
+ return defVal;
+}
+
+nsString WrapperBase::GetString(const char* name, const char* defVal) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ nsString res;
+ if (val.isString()) {
+ AssignJSString(mCx, res, val.toString());
+ } else {
+ res.AppendASCII(defVal);
+ }
+ return res;
+}
+
+JSObject* WrapperBase::GetObject(const char* name) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::UndefinedValue());
+ if (!JS_GetProperty(mCx, obj, name, &val)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ if (val.isObject()) {
+ return &val.toObject();
+ }
+ return nullptr;
+}
+
+class MOZ_STACK_CLASS InstallLocation : public WrapperBase {
+ public:
+ InstallLocation(JSContext* cx, const JS::Value& value);
+
+ MOZ_IMPLICIT InstallLocation(PropertyIterElem& iter)
+ : InstallLocation(iter.Cx(), iter.Value()) {}
+
+ InstallLocation(const InstallLocation& other)
+ : InstallLocation(other.mCx, JS::ObjectValue(*other.mObject)) {}
+
+ void SetChanged(bool changed) {
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ JS::Rooted<JS::Value> val(mCx, JS::BooleanValue(changed));
+ if (!JS_SetProperty(mCx, obj, "changed", val)) {
+ JS_ClearPendingException(mCx);
+ }
+ }
+
+ PropertyIter& Addons() { return mAddonsIter.ref(); }
+
+ nsString Path() { return GetString("path"); }
+
+ bool ShouldCheckStartupModifications() {
+ return GetBool("checkStartupModifications");
+ }
+
+ private:
+ JS::Rooted<JSObject*> mAddonsObj;
+ Maybe<PropertyIter> mAddonsIter;
+};
+
+class MOZ_STACK_CLASS Addon : public WrapperBase {
+ public:
+ Addon(JSContext* cx, InstallLocation& location, const nsAString& id,
+ JSObject* object)
+ : WrapperBase(cx, object), mId(id), mLocation(location) {}
+
+ MOZ_IMPLICIT Addon(PropertyIterElem& iter)
+ : WrapperBase(iter.Cx(), iter.Value()),
+ mId(iter.Name()),
+ mLocation(*static_cast<InstallLocation*>(iter.Context())) {}
+
+ Addon(const Addon& other)
+ : WrapperBase(other.mCx, other.mObject),
+ mId(other.mId),
+ mLocation(other.mLocation) {}
+
+ const nsString& Id() { return mId; }
+
+ nsString Path() { return GetString("path"); }
+
+ nsString Type() { return GetString("type", "extension"); }
+
+ bool Enabled() { return GetBool("enabled"); }
+
+ double LastModifiedTime() { return GetNumber("lastModifiedTime"); }
+
+ bool ShouldCheckStartupModifications() {
+ return Type().EqualsLiteral("locale");
+ }
+
+ Result<nsCOMPtr<nsIFile>, nsresult> FullPath();
+
+ Result<bool, nsresult> UpdateLastModifiedTime();
+
+ private:
+ nsString mId;
+ InstallLocation& mLocation;
+};
+
+Result<nsCOMPtr<nsIFile>, nsresult> Addon::FullPath() {
+ nsString path = Path();
+
+ // First check for an absolute path, in case we have a proxy file.
+ nsCOMPtr<nsIFile> file;
+ if (NS_SUCCEEDED(NS_NewLocalFile(path, false, getter_AddRefs(file)))) {
+ return std::move(file);
+ }
+
+ // If not an absolute path, fall back to a relative path from the location.
+ MOZ_TRY(NS_NewLocalFile(mLocation.Path(), false, getter_AddRefs(file)));
+
+ MOZ_TRY(file->AppendRelativePath(path));
+ return std::move(file);
+}
+
+Result<bool, nsresult> Addon::UpdateLastModifiedTime() {
+ nsCOMPtr<nsIFile> file;
+ MOZ_TRY_VAR(file, FullPath());
+
+ JS::Rooted<JSObject*> obj(mCx, mObject);
+
+ bool result;
+ if (NS_FAILED(file->Exists(&result)) || !result) {
+ JS::Rooted<JS::Value> value(mCx, JS::NullValue());
+ if (!JS_SetProperty(mCx, obj, "currentModifiedTime", value)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ return true;
+ }
+
+ PRTime time;
+
+ nsCOMPtr<nsIFile> manifest = file;
+ if (!IsNormalFile(manifest)) {
+ manifest = CloneAndAppend(file, "manifest.json");
+ if (!IsNormalFile(manifest)) {
+ return true;
+ }
+ }
+
+ if (NS_FAILED(manifest->GetLastModifiedTime(&time))) {
+ return true;
+ }
+
+ double lastModified = time;
+ JS::Rooted<JS::Value> value(mCx, JS::NumberValue(lastModified));
+ if (!JS_SetProperty(mCx, obj, "currentModifiedTime", value)) {
+ JS_ClearPendingException(mCx);
+ }
+
+ return lastModified != LastModifiedTime();
+}
+
+InstallLocation::InstallLocation(JSContext* cx, const JS::Value& value)
+ : WrapperBase(cx, value), mAddonsObj(cx) {
+ mAddonsObj = GetObject("addons");
+ if (!mAddonsObj) {
+ mAddonsObj = JS_NewPlainObject(cx);
+ }
+ mAddonsIter.emplace(cx, mAddonsObj, this);
+}
+
+/*****************************************************************************
+ * XPC interfacing
+ *****************************************************************************/
+
+nsresult AddonManagerStartup::ReadStartupData(
+ JSContext* cx, JS::MutableHandle<JS::Value> locations) {
+ locations.set(JS::UndefinedValue());
+
+ nsCOMPtr<nsIFile> file =
+ CloneAndAppend(ProfileDir(), "addonStartup.json.lz4");
+
+ nsCString data;
+ auto res = ReadFileLZ4(file);
+ if (res.isOk()) {
+ data = res.unwrap();
+ } else if (res.inspectErr() != NS_ERROR_FILE_NOT_FOUND) {
+ return res.unwrapErr();
+ }
+
+ if (data.IsEmpty() || !ParseJSON(cx, data, locations)) {
+ return NS_OK;
+ }
+
+ if (!locations.isObject()) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ JS::Rooted<JSObject*> locs(cx, &locations.toObject());
+ for (auto e1 : PropertyIter(cx, locs)) {
+ InstallLocation loc(e1);
+
+ bool shouldCheck = loc.ShouldCheckStartupModifications();
+
+ for (auto e2 : loc.Addons()) {
+ Addon addon(e2);
+
+ if (addon.Enabled() &&
+ (shouldCheck || addon.ShouldCheckStartupModifications())) {
+ bool changed;
+ MOZ_TRY_VAR(changed, addon.UpdateLastModifiedTime());
+ if (changed) {
+ loc.SetChanged(true);
+ }
+ }
+ }
+ }
+
+ return NS_OK;
+}
+
+nsresult AddonManagerStartup::EncodeBlob(JS::Handle<JS::Value> value,
+ JSContext* cx,
+ JS::MutableHandle<JS::Value> result) {
+ StructuredCloneData holder;
+
+ ErrorResult rv;
+ holder.Write(cx, value, rv);
+ if (rv.Failed()) {
+ return rv.StealNSResult();
+ }
+
+ nsAutoCString scData;
+
+ bool ok =
+ holder.Data().ForEachDataChunk([&](const char* aData, size_t aSize) {
+ return scData.Append(nsDependentCSubstring(aData, aSize),
+ mozilla::fallible);
+ });
+ NS_ENSURE_TRUE(ok, NS_ERROR_OUT_OF_MEMORY);
+
+ nsCString lz4;
+ MOZ_TRY_VAR(lz4, EncodeLZ4(scData, STRUCTURED_CLONE_MAGIC));
+
+ JS::Rooted<JSObject*> obj(cx, dom::ArrayBuffer::Create(cx, lz4, rv));
+ ENSURE_SUCCESS(rv, rv.StealNSResult());
+
+ result.set(JS::ObjectValue(*obj));
+ return NS_OK;
+}
+
+nsresult AddonManagerStartup::DecodeBlob(JS::Handle<JS::Value> value,
+ JSContext* cx,
+ JS::MutableHandle<JS::Value> result) {
+ NS_ENSURE_TRUE(value.isObject() &&
+ JS::IsArrayBufferObject(&value.toObject()) &&
+ JS::ArrayBufferHasData(&value.toObject()),
+ NS_ERROR_INVALID_ARG);
+
+ StructuredCloneData holder;
+
+ nsCString data;
+ {
+ JS::AutoCheckCannotGC nogc;
+
+ auto obj = &value.toObject();
+ bool isShared;
+
+ size_t len = JS::GetArrayBufferByteLength(obj);
+ NS_ENSURE_TRUE(len <= INT32_MAX, NS_ERROR_INVALID_ARG);
+ nsDependentCSubstring lz4(
+ reinterpret_cast<char*>(JS::GetArrayBufferData(obj, &isShared, nogc)),
+ uint32_t(len));
+
+ MOZ_TRY_VAR(data, DecodeLZ4(lz4, STRUCTURED_CLONE_MAGIC));
+ }
+
+ bool ok = holder.CopyExternalData(data.get(), data.Length());
+ NS_ENSURE_TRUE(ok, NS_ERROR_OUT_OF_MEMORY);
+
+ ErrorResult rv;
+ holder.Read(cx, result, rv);
+ return rv.StealNSResult();
+}
+
+static nsresult EnumerateZip(nsIZipReader* zip, const nsACString& pattern,
+ nsTArray<nsString>& results) {
+ nsCOMPtr<nsIUTF8StringEnumerator> entries;
+ MOZ_TRY(zip->FindEntries(pattern, getter_AddRefs(entries)));
+
+ bool hasMore;
+ while (NS_SUCCEEDED(entries->HasMore(&hasMore)) && hasMore) {
+ nsAutoCString name;
+ MOZ_TRY(entries->GetNext(name));
+
+ results.AppendElement(NS_ConvertUTF8toUTF16(name));
+ }
+
+ return NS_OK;
+}
+
+nsresult AddonManagerStartup::EnumerateJAR(nsIURI* uri,
+ const nsACString& pattern,
+ nsTArray<nsString>& results) {
+ nsCOMPtr<nsIZipReaderCache> zipCache;
+ MOZ_TRY_VAR(zipCache, GetJarCache());
+
+ nsCOMPtr<nsIZipReader> zip;
+ nsCOMPtr<nsIFile> file;
+ if (nsCOMPtr<nsIJARURI> jarURI = do_QueryInterface(uri)) {
+ nsCOMPtr<nsIURI> fileURI;
+ nsCString entry;
+ MOZ_TRY(ParseJARURI(jarURI, getter_AddRefs(fileURI), entry));
+
+ MOZ_TRY_VAR(file, GetFile(fileURI));
+ MOZ_TRY(
+ zipCache->GetInnerZip(file, Substring(entry, 1), getter_AddRefs(zip)));
+ } else {
+ MOZ_TRY_VAR(file, GetFile(uri));
+ MOZ_TRY(zipCache->GetZip(file, getter_AddRefs(zip)));
+ }
+ MOZ_ASSERT(zip);
+
+ return EnumerateZip(zip, pattern, results);
+}
+
+nsresult AddonManagerStartup::EnumerateJARSubtree(nsIURI* uri,
+ nsTArray<nsString>& results) {
+ nsCOMPtr<nsIURI> fileURI;
+ nsCString entry;
+ MOZ_TRY(ParseJARURI(uri, getter_AddRefs(fileURI), entry));
+
+ // Mangle the path into a pattern to match all child entries by escaping any
+ // existing pattern matching metacharacters it contains and appending "/*".
+ constexpr auto metaChars = "[]()?*~|$\\"_ns;
+
+ nsCString pattern;
+ pattern.SetCapacity(entry.Length());
+
+ // The first character of the entry name is "/", which we want to skip.
+ for (auto chr : Span(Substring(entry, 1))) {
+ if (metaChars.FindChar(chr) >= 0) {
+ pattern.Append('\\');
+ }
+ pattern.Append(chr);
+ }
+ if (!pattern.IsEmpty() && !StringEndsWith(pattern, "/"_ns)) {
+ pattern.Append('/');
+ }
+ pattern.Append('*');
+
+ return EnumerateJAR(fileURI, pattern, results);
+}
+
+nsresult AddonManagerStartup::InitializeURLPreloader() {
+ MOZ_RELEASE_ASSERT(xpc::IsInAutomation());
+
+ URLPreloader::ReInitialize();
+
+ return NS_OK;
+}
+
+/******************************************************************************
+ * RegisterChrome
+ ******************************************************************************/
+
+namespace {
+static bool sObserverRegistered;
+
+struct ContentEntry final {
+ explicit ContentEntry(nsTArray<nsCString>&& aArgs, uint8_t aFlags = 0)
+ : mArgs(std::move(aArgs)), mFlags(aFlags) {}
+
+ AutoTArray<nsCString, 2> mArgs;
+ uint8_t mFlags;
+};
+
+}; // anonymous namespace
+}; // namespace mozilla
+
+MOZ_DECLARE_RELOCATE_USING_MOVE_CONSTRUCTOR(mozilla::ContentEntry);
+
+namespace mozilla {
+namespace {
+
+class RegistryEntries final : public nsIJSRAIIHelper,
+ public LinkedListElement<RegistryEntries> {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIJSRAIIHELPER
+
+ using Override = AutoTArray<nsCString, 2>;
+ using Locale = AutoTArray<nsCString, 3>;
+
+ RegistryEntries(FileLocation& location, nsTArray<Override>&& overrides,
+ nsTArray<ContentEntry>&& content, nsTArray<Locale>&& locales)
+ : mLocation(location),
+ mOverrides(std::move(overrides)),
+ mContent(std::move(content)),
+ mLocales(std::move(locales)) {}
+
+ void Register();
+
+ protected:
+ virtual ~RegistryEntries() { Unused << Destruct(); }
+
+ private:
+ FileLocation mLocation;
+ const nsTArray<Override> mOverrides;
+ const nsTArray<ContentEntry> mContent;
+ const nsTArray<Locale> mLocales;
+};
+
+NS_IMPL_ISUPPORTS(RegistryEntries, nsIJSRAIIHelper)
+
+void RegistryEntries::Register() {
+ RefPtr<nsChromeRegistry> cr = nsChromeRegistry::GetSingleton();
+
+ nsChromeRegistry::ManifestProcessingContext context(NS_EXTENSION_LOCATION,
+ mLocation);
+
+ for (auto& override : mOverrides) {
+ const char* args[] = {override[0].get(), override[1].get()};
+ cr->ManifestOverride(context, 0, const_cast<char**>(args), 0);
+ }
+
+ for (auto& content : mContent) {
+ const char* args[] = {content.mArgs[0].get(), content.mArgs[1].get()};
+ cr->ManifestContent(context, 0, const_cast<char**>(args), content.mFlags);
+ }
+
+ for (auto& locale : mLocales) {
+ const char* args[] = {locale[0].get(), locale[1].get(), locale[2].get()};
+ cr->ManifestLocale(context, 0, const_cast<char**>(args), 0);
+ }
+}
+
+NS_IMETHODIMP
+RegistryEntries::Destruct() {
+ if (isInList()) {
+ remove();
+
+ // No point in doing I/O to check for new chrome during shutdown, return
+ // early in that case.
+ if (AppShutdown::IsInOrBeyond(ShutdownPhase::AppShutdownConfirmed)) {
+ return NS_OK;
+ }
+
+ // When we remove dynamic entries from the registry, we need to rebuild it
+ // in order to ensure a consistent state. See comments in Observe().
+ RefPtr<nsChromeRegistry> cr = nsChromeRegistry::GetSingleton();
+ return cr->CheckForNewChrome();
+ }
+ return NS_OK;
+}
+
+static LinkedList<RegistryEntries>& GetRegistryEntries() {
+ static LinkedList<RegistryEntries> sEntries;
+ return sEntries;
+}
+}; // anonymous namespace
+
+NS_IMETHODIMP
+AddonManagerStartup::RegisterChrome(nsIURI* manifestURI,
+ JS::Handle<JS::Value> locations,
+ JSContext* cx, nsIJSRAIIHelper** result) {
+ auto IsArray = [cx](JS::Handle<JS::Value> val) -> bool {
+ bool isArray;
+ return JS::IsArrayObject(cx, val, &isArray) && isArray;
+ };
+
+ NS_ENSURE_ARG_POINTER(manifestURI);
+ NS_ENSURE_TRUE(IsArray(locations), NS_ERROR_INVALID_ARG);
+
+ FileLocation location;
+ MOZ_TRY_VAR(location, GetFileLocation(manifestURI));
+
+ nsTArray<RegistryEntries::Locale> locales;
+ nsTArray<ContentEntry> content;
+ nsTArray<RegistryEntries::Override> overrides;
+
+ JS::Rooted<JSObject*> locs(cx, &locations.toObject());
+ JS::Rooted<JS::Value> arrayVal(cx);
+ JS::Rooted<JSObject*> array(cx);
+
+ for (auto elem : ArrayIter(cx, locs)) {
+ arrayVal = elem.Value();
+ NS_ENSURE_TRUE(IsArray(arrayVal), NS_ERROR_INVALID_ARG);
+
+ array = &arrayVal.toObject();
+
+ AutoTArray<nsCString, 4> vals;
+ for (auto val : ArrayIter(cx, array)) {
+ nsAutoJSString str;
+ NS_ENSURE_TRUE(str.init(cx, val.Value()), NS_ERROR_OUT_OF_MEMORY);
+
+ vals.AppendElement(NS_ConvertUTF16toUTF8(str));
+ }
+ NS_ENSURE_TRUE(vals.Length() > 0, NS_ERROR_INVALID_ARG);
+
+ nsCString type = vals[0];
+ vals.RemoveElementAt(0);
+
+ if (type.EqualsLiteral("override")) {
+ NS_ENSURE_TRUE(vals.Length() == 2, NS_ERROR_INVALID_ARG);
+ overrides.AppendElement(std::move(vals));
+ } else if (type.EqualsLiteral("content")) {
+ if (vals.Length() == 3 &&
+ vals[2].EqualsLiteral("contentaccessible=yes")) {
+ NS_ENSURE_TRUE(xpc::IsInAutomation(), NS_ERROR_INVALID_ARG);
+ vals.RemoveElementAt(2);
+ content.AppendElement(ContentEntry(
+ std::move(vals), nsChromeRegistry::CONTENT_ACCESSIBLE));
+ } else {
+ NS_ENSURE_TRUE(vals.Length() == 2, NS_ERROR_INVALID_ARG);
+ content.AppendElement(ContentEntry(std::move(vals)));
+ }
+ } else if (type.EqualsLiteral("locale")) {
+ NS_ENSURE_TRUE(vals.Length() == 3, NS_ERROR_INVALID_ARG);
+ locales.AppendElement(std::move(vals));
+ } else {
+ return NS_ERROR_INVALID_ARG;
+ }
+ }
+
+ if (!sObserverRegistered) {
+ nsCOMPtr<nsIObserverService> obs = services::GetObserverService();
+ NS_ENSURE_TRUE(obs, NS_ERROR_UNEXPECTED);
+ obs->AddObserver(this, "chrome-manifests-loaded", false);
+
+ sObserverRegistered = true;
+ }
+
+ auto entry = MakeRefPtr<RegistryEntries>(
+ location, std::move(overrides), std::move(content), std::move(locales));
+
+ entry->Register();
+ GetRegistryEntries().insertBack(entry);
+
+ entry.forget(result);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+AddonManagerStartup::Observe(nsISupports* subject, const char* topic,
+ const char16_t* data) {
+ // The chrome registry is maintained as a set of global resource mappings
+ // generated mainly from manifest files, on-the-fly, as they're parsed.
+ // Entries added later override entries added earlier, and no record is kept
+ // of the former state.
+ //
+ // As a result, if we remove a dynamically-added manifest file, or a set of
+ // dynamic entries, the registry needs to be rebuilt from scratch, from the
+ // manifests and dynamic entries that remain. The chrome registry itself
+ // takes care of re-parsing manifes files. This observer notification lets
+ // us know when we need to re-register our dynamic entries.
+ if (!strcmp(topic, "chrome-manifests-loaded")) {
+ for (auto entry : GetRegistryEntries()) {
+ entry->Register();
+ }
+ }
+
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/toolkit/mozapps/extensions/AddonManagerStartup.h b/toolkit/mozapps/extensions/AddonManagerStartup.h
new file mode 100644
index 0000000000..a1cb5c9506
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerStartup.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 AddonManagerStartup_h
+#define AddonManagerStartup_h
+
+#include "amIAddonManagerStartup.h"
+#include "mozilla/Result.h"
+#include "nsCOMArray.h"
+#include "nsCOMPtr.h"
+#include "nsIFile.h"
+#include "nsIObserver.h"
+#include "nsISupports.h"
+
+namespace mozilla {
+
+class Addon;
+
+class AddonManagerStartup final : public amIAddonManagerStartup,
+ public nsIObserver {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_AMIADDONMANAGERSTARTUP
+ NS_DECL_NSIOBSERVER
+
+ AddonManagerStartup();
+
+ static AddonManagerStartup& GetSingleton();
+
+ static already_AddRefed<AddonManagerStartup> GetInstance() {
+ RefPtr<AddonManagerStartup> inst = &GetSingleton();
+ return inst.forget();
+ }
+
+ private:
+ nsIFile* ProfileDir();
+
+ nsCOMPtr<nsIFile> mProfileDir;
+
+ protected:
+ virtual ~AddonManagerStartup() = default;
+};
+
+} // namespace mozilla
+
+#define NS_ADDONMANAGERSTARTUP_CONTRACTID \
+ "@mozilla.org/addons/addon-manager-startup;1"
+
+// {17a59a6b-92b8-42e5-bce0-ab434c7a7135
+#define NS_ADDON_MANAGER_STARTUP_CID \
+ { \
+ 0x17a59a6b, 0x92b8, 0x42e5, { \
+ 0xbc, 0xe0, 0xab, 0x43, 0x4c, 0x7a, 0x71, 0x35 \
+ } \
+ }
+
+#endif // AddonManagerStartup_h
diff --git a/toolkit/mozapps/extensions/AddonManagerWebAPI.cpp b/toolkit/mozapps/extensions/AddonManagerWebAPI.cpp
new file mode 100644
index 0000000000..d35a67a288
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerWebAPI.cpp
@@ -0,0 +1,168 @@
+/* -*- 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 "AddonManagerWebAPI.h"
+
+#include "mozilla/BasePrincipal.h"
+#include "mozilla/dom/Document.h"
+#include "mozilla/dom/Navigator.h"
+#include "mozilla/dom/NavigatorBinding.h"
+
+#include "mozilla/Preferences.h"
+#include "mozilla/StaticPrefs_extensions.h"
+#include "nsGlobalWindowInner.h"
+#include "xpcpublic.h"
+
+#include "nsIDocShell.h"
+#include "nsIScriptObjectPrincipal.h"
+
+namespace mozilla {
+using namespace mozilla::dom;
+
+#ifndef MOZ_THUNDERBIRD
+# define MOZ_AMO_HOSTNAME "addons.mozilla.org"
+# define MOZ_AMO_STAGE_HOSTNAME "addons.allizom.org"
+# define MOZ_AMO_DEV_HOSTNAME "addons-dev.allizom.org"
+#else
+# define MOZ_AMO_HOSTNAME "addons.thunderbird.net"
+# define MOZ_AMO_STAGE_HOSTNAME "addons-stage.thunderbird.net"
+# undef MOZ_AMO_DEV_HOSTNAME
+#endif
+
+static bool IsValidHost(const nsACString& host) {
+ // This hidden pref allows users to disable mozAddonManager entirely if they
+ // want for fingerprinting resistance. Someone like Tor browser will use this
+ // pref.
+ if (StaticPrefs::privacy_resistFingerprinting_block_mozAddonManager()) {
+ return false;
+ }
+
+ if (host.EqualsLiteral(MOZ_AMO_HOSTNAME)) {
+ return true;
+ }
+
+ // When testing allow access to the developer sites.
+ if (StaticPrefs::extensions_webapi_testing()) {
+ if (host.LowerCaseEqualsLiteral(MOZ_AMO_STAGE_HOSTNAME) ||
+#ifdef MOZ_AMO_DEV_HOSTNAME
+ host.LowerCaseEqualsLiteral(MOZ_AMO_DEV_HOSTNAME) ||
+#endif
+ host.LowerCaseEqualsLiteral("example.com")) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+// Checks if the given uri is secure and matches one of the hosts allowed to
+// access the API.
+bool AddonManagerWebAPI::IsValidSite(nsIURI* uri) {
+ if (!uri) {
+ return false;
+ }
+
+ if (!uri->SchemeIs("https")) {
+ if (!(xpc::IsInAutomation() &&
+ StaticPrefs::extensions_webapi_testing_http())) {
+ return false;
+ }
+ }
+
+ nsAutoCString host;
+ nsresult rv = uri->GetHost(host);
+ if (NS_FAILED(rv)) {
+ return false;
+ }
+
+ return IsValidHost(host);
+}
+
+bool AddonManagerWebAPI::IsAPIEnabled(JSContext* aCx, JSObject* aGlobal) {
+ if (!StaticPrefs::extensions_webapi_enabled()) {
+ return false;
+ }
+
+ MOZ_DIAGNOSTIC_ASSERT(JS_IsGlobalObject(aGlobal));
+ nsCOMPtr<nsPIDOMWindowInner> win = xpc::WindowOrNull(aGlobal);
+ if (!win) {
+ return false;
+ }
+
+ // Check that the current window and all parent frames are allowed access to
+ // the API.
+ while (win) {
+ nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(win);
+ if (!sop) {
+ return false;
+ }
+
+ nsCOMPtr<nsIPrincipal> principal = sop->GetPrincipal();
+ if (!principal) {
+ return false;
+ }
+
+ // Reaching a window with a system principal means we have reached
+ // privileged UI of some kind so stop at this point and allow access.
+ if (principal->IsSystemPrincipal()) {
+ return true;
+ }
+
+ nsCOMPtr<nsIDocShell> docShell = win->GetDocShell();
+ if (!docShell) {
+ // This window has been torn down so don't allow access to the API.
+ return false;
+ }
+
+ if (!IsValidSite(win->GetDocumentURI())) {
+ return false;
+ }
+
+ // Checks whether there is a parent frame of the same type. This won't cross
+ // mozbrowser or chrome or fission/process boundaries.
+ nsCOMPtr<nsIDocShellTreeItem> parent;
+ nsresult rv = docShell->GetInProcessSameTypeParent(getter_AddRefs(parent));
+ if (NS_FAILED(rv)) {
+ return false;
+ }
+
+ // No parent means we've hit a mozbrowser or chrome or process boundary.
+ if (!parent) {
+ // With Fission, a cross-origin iframe has an out-of-process parent, but
+ // DocShell knows nothing about it. We need to ask BrowsingContext here,
+ // and only allow API access if AMO is actually at the top, not framed
+ // by evilleagueofevil.com.
+ return docShell->GetBrowsingContext()->IsTopContent();
+ }
+
+ Document* doc = win->GetDoc();
+ if (!doc) {
+ return false;
+ }
+
+ doc = doc->GetInProcessParentDocument();
+ if (!doc) {
+ // Getting here means something has been torn down so fail safe.
+ return false;
+ }
+
+ win = doc->GetInnerWindow();
+ }
+
+ // Found a document with no inner window, don't grant access to the API.
+ return false;
+}
+
+namespace dom {
+
+bool AddonManagerPermissions::IsHostPermitted(const GlobalObject& /*unused*/,
+ const nsAString& host) {
+ return IsValidHost(NS_ConvertUTF16toUTF8(host));
+}
+
+} // namespace dom
+
+} // namespace mozilla
diff --git a/toolkit/mozapps/extensions/AddonManagerWebAPI.h b/toolkit/mozapps/extensions/AddonManagerWebAPI.h
new file mode 100644
index 0000000000..4f34b366a4
--- /dev/null
+++ b/toolkit/mozapps/extensions/AddonManagerWebAPI.h
@@ -0,0 +1,32 @@
+/* -*- 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 addonmanagerwebapi_h_
+#define addonmanagerwebapi_h_
+
+#include "nsPIDOMWindow.h"
+
+namespace mozilla {
+
+class AddonManagerWebAPI {
+ public:
+ static bool IsAPIEnabled(JSContext* aCx, JSObject* aGlobal);
+
+ static bool IsValidSite(nsIURI* uri);
+};
+
+namespace dom {
+
+class AddonManagerPermissions {
+ public:
+ static bool IsHostPermitted(const GlobalObject&, const nsAString& host);
+};
+
+} // namespace dom
+
+} // namespace mozilla
+
+#endif // addonmanagerwebapi_h_
diff --git a/toolkit/mozapps/extensions/Blocklist.sys.mjs b/toolkit/mozapps/extensions/Blocklist.sys.mjs
new file mode 100644
index 0000000000..dc3d7ebeb3
--- /dev/null
+++ b/toolkit/mozapps/extensions/Blocklist.sys.mjs
@@ -0,0 +1,1490 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ jexlFilterFunc: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+const CascadeFilter = Components.Constructor(
+ "@mozilla.org/cascade-filter;1",
+ "nsICascadeFilter",
+ "setFilterData"
+);
+
+// The whole ID should be surrounded by literal ().
+// IDs may contain alphanumerics, _, -, {}, @ and a literal '.'
+// They may also contain backslashes (needed to escape the {} and dot)
+// We filter out backslash escape sequences (like `\w`) separately
+// (see kEscapeSequences).
+const kIdSubRegex =
+ "\\([" +
+ "\\\\" + // note: just a backslash, but between regex and string it needs escaping.
+ "\\w .{}@-]+\\)";
+
+// prettier-ignore
+// Find regular expressions of the form:
+// /^((id1)|(id2)|(id3)|...|(idN))$/
+// The outer set of parens enclosing the entire list of IDs is optional.
+const kIsMultipleIds = new RegExp(
+ // Start with literal sequence /^(
+ // (the `(` is optional)
+ "^/\\^\\(?" +
+ // Then at least one ID in parens ().
+ kIdSubRegex +
+ // Followed by any number of IDs in () separated by pipes.
+ // Note: using a non-capturing group because we don't care about the value.
+ "(?:\\|" + kIdSubRegex + ")*" +
+ // Finally, we need to end with literal sequence )$/
+ // (the leading `)` is optional like at the start)
+ "\\)?\\$/$"
+);
+
+// Check for a backslash followed by anything other than a literal . or curlies
+const kEscapeSequences = /\\[^.{}]/;
+
+// Used to remove the following 3 things:
+// leading literal /^(
+// plus an optional (
+// any backslash
+// trailing literal )$/
+// plus an optional ) before the )$/
+const kRegExpRemovalRegExp = /^\/\^\(\(?|\\|\)\)?\$\/$/g;
+
+// In order to block add-ons, their type should be part of this list. There
+// may be additional requirements such as requiring the add-on to be signed.
+// See the uses of kXPIAddonTypes before introducing new addon types or
+// providers that differ from the existing types.
+ChromeUtils.defineLazyGetter(lazy, "kXPIAddonTypes", () => {
+ // In practice, this result is equivalent to ALL_XPI_TYPES in
+ // XPIProvider.sys.mjs.
+ // "plugin" (from GMPProvider.sys.mjs) is intentionally omitted, as we decided to
+ // not support blocklisting of GMP plugins in bug 1086668.
+ return lazy.AddonManagerPrivate.getAddonTypesByProvider("XPIProvider");
+});
+
+// For a given input string matcher, produce either a string to compare with,
+// a regular expression, or a set of strings to compare with.
+function processMatcher(str) {
+ if (!str.startsWith("/")) {
+ return str;
+ }
+ // Process regexes matching multiple IDs into a set.
+ if (kIsMultipleIds.test(str) && !kEscapeSequences.test(str)) {
+ // Remove the regexp gunk at the start and end of the string, as well
+ // as all backslashes, and split by )|( to leave the list of IDs.
+ return new Set(str.replace(kRegExpRemovalRegExp, "").split(")|("));
+ }
+ let lastSlash = str.lastIndexOf("/");
+ let pattern = str.slice(1, lastSlash);
+ let flags = str.slice(lastSlash + 1);
+ return new RegExp(pattern, flags);
+}
+
+// Returns true if the addonProps object passes the constraints set by matches.
+// (For every non-null property in matches, the same key must exist in
+// addonProps and its value must match)
+function doesAddonEntryMatch(matches, addonProps) {
+ for (let [key, value] of Object.entries(matches)) {
+ if (value === null || value === undefined) {
+ continue;
+ }
+ if (addonProps[key]) {
+ // If this property matches (member of the set, matches regex, or
+ // an exact string match), continue to look at the other properties of
+ // the `matches` object.
+ // If not, return false immediately.
+ if (value.has && value.has(addonProps[key])) {
+ continue;
+ }
+ if (value.test && value.test(addonProps[key])) {
+ continue;
+ }
+ if (typeof value == "string" && value === addonProps[key]) {
+ continue;
+ }
+ }
+ // If we get here, the property doesn't match, so this entry doesn't match.
+ return false;
+ }
+ // If we get here, all the properties must have matched.
+ return true;
+}
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+const PREF_BLOCKLIST_ITEM_URL = "extensions.blocklist.itemURL";
+const PREF_BLOCKLIST_ADDONITEM_URL = "extensions.blocklist.addonItemURL";
+const PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
+const PREF_BLOCKLIST_LEVEL = "extensions.blocklist.level";
+const PREF_BLOCKLIST_USE_MLBF = "extensions.blocklist.useMLBF";
+const PREF_EM_LOGGING_ENABLED = "extensions.logging.enabled";
+const DEFAULT_SEVERITY = 3;
+const DEFAULT_LEVEL = 2;
+const MAX_BLOCK_LEVEL = 3;
+
+const BLOCKLIST_BUCKET = "blocklists";
+
+const BlocklistTelemetry = {
+ init() {
+ // Used by BlocklistTelemetry.recordAddonBlockChangeTelemetry.
+ Services.telemetry.setEventRecordingEnabled("blocklist", true);
+ },
+
+ /**
+ * Record the RemoteSettings Blocklist lastModified server time into the
+ * "blocklist.lastModified_rs keyed scalar (or "Missing Date" when unable
+ * to retrieve a valid timestamp).
+ *
+ * @param {string} blocklistType
+ * The blocklist type that has been updated ("addons" or "addons_mlbf");
+ * the "gfx" blocklist is not covered by this telemetry).
+ * @param {RemoteSettingsClient} remoteSettingsClient
+ * The RemoteSettings client to retrieve the lastModified timestamp from.
+ */
+ async recordRSBlocklistLastModified(blocklistType, remoteSettingsClient) {
+ // In some tests overrides ensureInitialized and remoteSettingsClient
+ // can be undefined, and in that case we don't want to record any
+ // telemetry scalar.
+ if (!remoteSettingsClient) {
+ return;
+ }
+
+ let lastModified = await remoteSettingsClient.getLastModified();
+ if (blocklistType === "addons_mlbf") {
+ BlocklistTelemetry.recordTimeScalar(
+ "lastModified_rs_" + blocklistType,
+ lastModified
+ );
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.lastModifiedRsAddonsMblf,
+ lastModified
+ );
+ }
+ },
+
+ /**
+ * Record a timestamp in telemetry as a UTC string or "Missing Date" if the
+ * input is not a valid timestamp.
+ *
+ * @param {string} telemetryKey
+ * The part of after "blocklist.", as defined in Scalars.yaml.
+ * @param {number} time
+ * A timestamp to record. If invalid, "Missing Date" will be recorded.
+ */
+ recordTimeScalar(telemetryKey, time) {
+ if (time > 0) {
+ // convert from timestamp in ms into UTC datetime string, so it is going
+ // to be record in the same format previously used by blocklist.lastModified_xml.
+ let dateString = new Date(time).toUTCString();
+ Services.telemetry.scalarSet("blocklist." + telemetryKey, dateString);
+ } else {
+ Services.telemetry.scalarSet("blocklist." + telemetryKey, "Missing Date");
+ }
+ },
+
+ /**
+ * Records a glean datetime if time is > than 0, otherwise 0 is submitted.
+ *
+ * @param {nsIGleanDatetime} gleanTelemetry
+ * A glean telemetry datetime object.
+ * @param {number} time
+ * A timestamp to record.
+ */
+ recordGleanDateTime(gleanTelemetry, time) {
+ if (time > 0) {
+ // Glean date times are provided in nanoseconds, `getTime()` yields
+ // milliseconds (after the Unix epoch).
+ gleanTelemetry.set(time * 1000);
+ } else {
+ gleanTelemetry.set(0);
+ }
+ },
+
+ /**
+ * Record whether an add-on is blocked and the parameters that guided the
+ * decision to block or unblock the add-on.
+ *
+ * @param {AddonWrapper|object} addon
+ * The blocked or unblocked add-on. Not necessarily installed.
+ * Could be an object with the id, version and blocklistState
+ * properties when the AddonWrapper is not available (e.g. during
+ * update checks).
+ * @param {string} reason
+ * The reason for recording the event,
+ * "addon_install", "addon_update", "addon_update_check",
+ * "addon_db_modified", "blocklist_update".
+ */
+ recordAddonBlockChangeTelemetry(addon, reason) {
+ // Reduce the timer resolution for anonymity.
+ let hoursSinceInstall = -1;
+ if (reason === "blocklist_update" || reason === "addon_db_modified") {
+ hoursSinceInstall = Math.round(
+ (Date.now() - addon.installDate.getTime()) / 3600000
+ );
+ }
+
+ const value = addon.id;
+ const extra = {
+ blocklistState: `${addon.blocklistState}`,
+ addon_version: addon.version,
+ signed_date: `${addon.signedDate?.getTime() || 0}`,
+ hours_since: `${hoursSinceInstall}`,
+
+ ...ExtensionBlocklistMLBF.getBlocklistMetadataForTelemetry(),
+ };
+ Glean.blocklist.addonBlockChange.record({
+ value,
+ object: reason,
+ blocklist_state: extra.blocklistState,
+ addon_version: extra.addon_version,
+ signed_date: extra.signed_date,
+ hours_since: extra.hours_since,
+ mlbf_last_time: extra.mlbf_last_time,
+ mlbf_generation: extra.mlbf_generation,
+ mlbf_source: extra.mlbf_source,
+ });
+
+ Services.telemetry.recordEvent(
+ "blocklist",
+ "addonBlockChange",
+ reason,
+ value,
+ extra
+ );
+ },
+};
+
+const Utils = {
+ /**
+ * Checks whether this entry is valid for the current OS and ABI.
+ * If the entry has an "os" property then the current OS must appear in
+ * its comma separated list for it to be valid. Similarly for the
+ * xpcomabi property.
+ *
+ * @param {Object} item
+ * The blocklist item.
+ * @returns {bool}
+ * Whether the entry matches the current OS.
+ */
+ matchesOSABI(item) {
+ if (item.os) {
+ let os = item.os.split(",");
+ if (!os.includes(lazy.gAppOS)) {
+ return false;
+ }
+ }
+
+ if (item.xpcomabi) {
+ let xpcomabi = item.xpcomabi.split(",");
+ if (!xpcomabi.includes(lazy.gApp.XPCOMABI)) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ /**
+ * Checks if a version is higher than or equal to the minVersion (if provided)
+ * and lower than or equal to the maxVersion (if provided).
+ * @param {string} version
+ * The version to test.
+ * @param {string?} minVersion
+ * The minimum version. If null it is assumed that version is always
+ * larger.
+ * @param {string?} maxVersion
+ * The maximum version. If null it is assumed that version is always
+ * smaller.
+ * @returns {boolean}
+ * Whether the item matches the range.
+ */
+ versionInRange(version, minVersion, maxVersion) {
+ if (minVersion && Services.vc.compare(version, minVersion) < 0) {
+ return false;
+ }
+ if (maxVersion && Services.vc.compare(version, maxVersion) > 0) {
+ return false;
+ }
+ return true;
+ },
+
+ /**
+ * Tests if this versionRange matches the item specified, and has a matching
+ * targetApplication id and version.
+ * @param {Object} versionRange
+ * The versionRange to check against
+ * @param {string} itemVersion
+ * The version of the actual addon/plugin to test for.
+ * @param {string} appVersion
+ * The version of the application to test for.
+ * @param {string} toolkitVersion
+ * The version of toolkit to check for.
+ * @returns {boolean}
+ * True if this version range covers the item and app/toolkit version given.
+ */
+ versionsMatch(versionRange, itemVersion, appVersion, toolkitVersion) {
+ // Some platforms have no version for plugins, these don't match if there
+ // was a min/maxVersion provided
+ if (!itemVersion && (versionRange.minVersion || versionRange.maxVersion)) {
+ return false;
+ }
+
+ // Check if the item version matches
+ if (
+ !this.versionInRange(
+ itemVersion,
+ versionRange.minVersion,
+ versionRange.maxVersion
+ )
+ ) {
+ return false;
+ }
+
+ // Check if the application or toolkit version matches
+ for (let tA of versionRange.targetApplication) {
+ if (
+ tA.guid == lazy.gAppID &&
+ this.versionInRange(appVersion, tA.minVersion, tA.maxVersion)
+ ) {
+ return true;
+ }
+ if (
+ tA.guid == TOOLKIT_ID &&
+ this.versionInRange(toolkitVersion, tA.minVersion, tA.maxVersion)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Given a blocklist JS object entry, ensure it has a versionRange property, where
+ * each versionRange property has a valid severity property
+ * and at least 1 valid targetApplication.
+ * If it didn't have a valid targetApplication array before and/or it was empty,
+ * fill it with an entry with null min/maxVersion properties, which will match
+ * every version.
+ *
+ * If there *are* targetApplications, if any of them don't have a guid property,
+ * assign them the current app's guid.
+ *
+ * @param {Object} entry
+ * blocklist entry object.
+ */
+ ensureVersionRangeIsSane(entry) {
+ if (!entry.versionRange.length) {
+ entry.versionRange.push({});
+ }
+ for (let vr of entry.versionRange) {
+ if (!vr.hasOwnProperty("severity")) {
+ vr.severity = DEFAULT_SEVERITY;
+ }
+ if (!Array.isArray(vr.targetApplication)) {
+ vr.targetApplication = [];
+ }
+ if (!vr.targetApplication.length) {
+ vr.targetApplication.push({ minVersion: null, maxVersion: null });
+ }
+ vr.targetApplication.forEach(tA => {
+ if (!tA.guid) {
+ tA.guid = lazy.gAppID;
+ }
+ });
+ }
+ },
+
+ /**
+ * Create a blocklist URL for the given blockID
+ * @param {String} id the blockID to use
+ * @returns {String} the blocklist URL.
+ */
+ _createBlocklistURL(id) {
+ let url = Services.urlFormatter.formatURLPref(PREF_BLOCKLIST_ITEM_URL);
+ return url.replace(/%blockID%/g, id);
+ },
+};
+
+/**
+ * This custom filter function is used to limit the entries returned
+ * by `RemoteSettings("...").get()` depending on the target app information
+ * defined on entries.
+ *
+ * Note that this is async because `jexlFilterFunc` is async.
+ *
+ * @param {Object} entry a Remote Settings record
+ * @param {Object} environment the JEXL environment object.
+ * @returns {Object} The entry if it matches, `null` otherwise.
+ */
+async function targetAppFilter(entry, environment) {
+ // If the entry has a JEXL filter expression, it should prevail.
+ // The legacy target app mechanism will be kept in place for old entries.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1463377
+ const { filter_expression } = entry;
+ if (filter_expression) {
+ return lazy.jexlFilterFunc(entry, environment);
+ }
+
+ // Keep entries without target information.
+ if (!("versionRange" in entry)) {
+ return entry;
+ }
+
+ const { versionRange } = entry;
+
+ // Everywhere in this method, we avoid checking the minVersion, because
+ // we want to retain items whose minVersion is higher than the current
+ // app version, so that we have the items around for app updates.
+
+ // Gfx blocklist has a specific versionRange object, which is not a list.
+ if (!Array.isArray(versionRange)) {
+ const { maxVersion = "*" } = versionRange;
+ const matchesRange =
+ Services.vc.compare(lazy.gApp.version, maxVersion) <= 0;
+ return matchesRange ? entry : null;
+ }
+
+ // Iterate the targeted applications, at least one of them must match.
+ // If no target application, keep the entry.
+ if (!versionRange.length) {
+ return entry;
+ }
+ for (const vr of versionRange) {
+ const { targetApplication = [] } = vr;
+ if (!targetApplication.length) {
+ return entry;
+ }
+ for (const ta of targetApplication) {
+ const { guid } = ta;
+ if (!guid) {
+ return entry;
+ }
+ const { maxVersion = "*" } = ta;
+ if (
+ guid == lazy.gAppID &&
+ Services.vc.compare(lazy.gApp.version, maxVersion) <= 0
+ ) {
+ return entry;
+ }
+ if (
+ guid == "toolkit@mozilla.org" &&
+ Services.vc.compare(Services.appinfo.platformVersion, maxVersion) <= 0
+ ) {
+ return entry;
+ }
+ }
+ }
+ // Skip this entry.
+ return null;
+}
+
+/**
+ * The Graphics blocklist implementation. The JSON objects for graphics blocks look
+ * something like:
+ *
+ * {
+ * "blockID": "g35",
+ * "os": "WINNT 6.1",
+ * "vendor": "0xabcd",
+ * "devices": [
+ * "0x2783",
+ * "0x1234",
+ * ],
+ * "feature": " DIRECT2D ",
+ * "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ * "driverVersion": " 8.52.322.2202 ",
+ * "driverVersionComparator": " LESS_THAN ",
+ * "versionRange": {"minVersion": "5.0", "maxVersion: "25.0"},
+ * }
+ *
+ * The RemoteSetttings client takes care of filtering out versions that don't apply.
+ * The code here stores entries in memory and sends them to the gfx component in
+ * serialized text form, using ',', '\t' and '\n' as separators.
+ */
+const GfxBlocklistRS = {
+ _ensureInitialized() {
+ if (this._initialized || !gBlocklistEnabled) {
+ return;
+ }
+ this._initialized = true;
+ this._client = lazy.RemoteSettings("gfx", {
+ bucketName: BLOCKLIST_BUCKET,
+ filterFunc: targetAppFilter,
+ });
+ this.checkForEntries = this.checkForEntries.bind(this);
+ this._client.on("sync", this.checkForEntries);
+ },
+
+ shutdown() {
+ if (this._client) {
+ this._client.off("sync", this.checkForEntries);
+ }
+ },
+
+ sync() {
+ this._ensureInitialized();
+ return this._client.sync();
+ },
+
+ async checkForEntries() {
+ this._ensureInitialized();
+ if (!gBlocklistEnabled) {
+ return []; // return value expected by tests.
+ }
+ let entries = await this._client.get().catch(ex => Cu.reportError(ex));
+ // Handle error silently. This can happen if our request to fetch data is aborted,
+ // e.g. by application shutdown.
+ if (!entries) {
+ return [];
+ }
+ // Trim helper (spaces, tabs, no-break spaces..)
+ const trim = s =>
+ (s || "").replace(/(^[\s\uFEFF\xA0]+)|([\s\uFEFF\xA0]+$)/g, "");
+
+ entries = entries.map(entry => {
+ let props = [
+ "blockID",
+ "driverVersion",
+ "driverVersionMax",
+ "driverVersionComparator",
+ "feature",
+ "featureStatus",
+ "os",
+ "vendor",
+ "devices",
+ ];
+ let rv = {};
+ for (let p of props) {
+ let val = entry[p];
+ // Ignore falsy values or empty arrays.
+ if (!val || (Array.isArray(val) && !val.length)) {
+ continue;
+ }
+ if (typeof val == "string") {
+ val = trim(val);
+ } else if (p == "devices") {
+ let invalidDevices = [];
+ let validDevices = [];
+ // We serialize the array of devices as a comma-separated string, so
+ // we need to ensure that none of the entries contain commas, also in
+ // the future.
+ val.forEach(v =>
+ v.includes(",") ? invalidDevices.push(v) : validDevices.push(v)
+ );
+ for (let dev of invalidDevices) {
+ const e = new Error(
+ `Block ${entry.blockID} contains unsupported device: ${dev}`
+ );
+ Cu.reportError(e);
+ }
+ if (!validDevices) {
+ continue;
+ }
+ val = validDevices;
+ }
+ rv[p] = val;
+ }
+ if (entry.versionRange) {
+ rv.versionRange = {
+ minVersion: trim(entry.versionRange.minVersion) || "0",
+ maxVersion: trim(entry.versionRange.maxVersion) || "*",
+ };
+ }
+ return rv;
+ });
+ if (entries.length) {
+ let sortedProps = [
+ "blockID",
+ "devices",
+ "driverVersion",
+ "driverVersionComparator",
+ "driverVersionMax",
+ "feature",
+ "featureStatus",
+ "hardware",
+ "manufacturer",
+ "model",
+ "os",
+ "osversion",
+ "product",
+ "vendor",
+ "versionRange",
+ ];
+ // Notify `GfxInfoBase`, by passing a string serialization.
+ let payload = [];
+ for (let gfxEntry of entries) {
+ let entryLines = [];
+ for (let key of sortedProps) {
+ if (gfxEntry[key]) {
+ let value = gfxEntry[key];
+ if (Array.isArray(value)) {
+ value = value.join(",");
+ } else if (value.maxVersion) {
+ // Both minVersion and maxVersion are always set on each entry.
+ value = value.minVersion + "," + value.maxVersion;
+ }
+ entryLines.push(key + ":" + value);
+ }
+ }
+ payload.push(entryLines.join("\t"));
+ }
+ Services.obs.notifyObservers(
+ null,
+ "blocklist-data-gfxItems",
+ payload.join("\n")
+ );
+ }
+ // The return value is only used by tests.
+ return entries;
+ },
+};
+
+/**
+ * The extensions blocklist implementation. The JSON objects for extension
+ * blocks look something like:
+ *
+ * {
+ * "guid": "someguid@addons.mozilla.org",
+ * "prefs": ["i.am.a.pref.that.needs.resetting"],
+ * "schema": 1480349193877,
+ * "blockID": "i12345",
+ * "details": {
+ * "bug": "https://bugzilla.mozilla.org/show_bug.cgi?id=1234567",
+ * "who": "All Firefox users who have this add-on installed. If you wish to continue using this add-on, you can enable it in the Add-ons Manager.",
+ * "why": "This add-on is in violation of the <a href=\"https://developer.mozilla.org/en-US/Add-ons/Add-on_guidelines\">Add-on Guidelines</a>, using multiple add-on IDs and potentially doing other unwanted activities.",
+ * "name": "Some pretty name",
+ * "created": "2019-05-06T19:52:20Z"
+ * },
+ * "enabled": true,
+ * "versionRange": [
+ * {
+ * "severity": 1,
+ * "maxVersion": "*",
+ * "minVersion": "0",
+ * "targetApplication": []
+ * }
+ * ],
+ * "id": "<unique guid>",
+ * "last_modified": 1480349215672,
+ * }
+ *
+ * This is a legacy format, and implements deprecated operations (bug 1620580).
+ * ExtensionBlocklistMLBF supersedes this implementation.
+ */
+const ExtensionBlocklistRS = {
+ async _ensureEntries() {
+ this.ensureInitialized();
+ if (!this._entries && gBlocklistEnabled) {
+ await this._updateEntries();
+ }
+ },
+
+ async _updateEntries() {
+ if (!gBlocklistEnabled) {
+ this._entries = [];
+ return;
+ }
+ this._entries = await this._client.get().catch(ex => Cu.reportError(ex));
+ // Handle error silently. This can happen if our request to fetch data is aborted,
+ // e.g. by application shutdown.
+ if (!this._entries) {
+ this._entries = [];
+ return;
+ }
+ this._entries.forEach(entry => {
+ entry.matches = {};
+ if (entry.guid) {
+ entry.matches.id = processMatcher(entry.guid);
+ }
+ for (let key of EXTENSION_BLOCK_FILTERS) {
+ if (key == "id" || !entry[key]) {
+ continue;
+ }
+ entry.matches[key] = processMatcher(entry[key]);
+ }
+ Utils.ensureVersionRangeIsSane(entry);
+ });
+
+ BlocklistTelemetry.recordRSBlocklistLastModified("addons", this._client);
+ },
+
+ async _filterItem(entry, environment) {
+ if (!(await targetAppFilter(entry, environment))) {
+ return null;
+ }
+ if (!Utils.matchesOSABI(entry)) {
+ return null;
+ }
+ // Need something to filter on - at least a guid or name (either could be a regex):
+ if (!entry.guid && !entry.name) {
+ let blockID = entry.blockID || entry.id;
+ Cu.reportError(new Error(`Nothing to filter add-on item ${blockID} on`));
+ return null;
+ }
+ return entry;
+ },
+
+ sync() {
+ this.ensureInitialized();
+ return this._client.sync();
+ },
+
+ ensureInitialized() {
+ if (!gBlocklistEnabled || this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ this._client = lazy.RemoteSettings("addons", {
+ bucketName: BLOCKLIST_BUCKET,
+ filterFunc: this._filterItem,
+ });
+ this._onUpdate = this._onUpdate.bind(this);
+ this._client.on("sync", this._onUpdate);
+ },
+
+ shutdown() {
+ if (this._client) {
+ this._client.off("sync", this._onUpdate);
+ this._didShutdown = true;
+ }
+ },
+
+ // Called when the blocklist implementation is changed via a pref.
+ undoShutdown() {
+ if (this._didShutdown) {
+ this._client.on("sync", this._onUpdate);
+ this._didShutdown = false;
+ }
+ },
+
+ async _onUpdate() {
+ let oldEntries = this._entries || [];
+ await this.ensureInitialized();
+ await this._updateEntries();
+
+ let addons = await lazy.AddonManager.getAddonsByTypes(lazy.kXPIAddonTypes);
+ for (let addon of addons) {
+ let oldState = addon.blocklistState;
+ if (addon.updateBlocklistState) {
+ await addon.updateBlocklistState(false);
+ } else if (oldEntries) {
+ let oldEntry = this._getEntry(addon, oldEntries);
+ oldState = oldEntry
+ ? oldEntry.state
+ : Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ } else {
+ oldState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+ let state = addon.blocklistState;
+
+ LOG(
+ "Blocklist state for " +
+ addon.id +
+ " changed from " +
+ oldState +
+ " to " +
+ state
+ );
+
+ // We don't want to re-warn about add-ons
+ if (state == oldState) {
+ continue;
+ }
+
+ // Ensure that softDisabled is false if the add-on is not soft blocked
+ if (state != Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ await addon.setSoftDisabled(false);
+ }
+
+ // If an add-on has dropped from hard to soft blocked just mark it as
+ // soft disabled and don't warn about it.
+ if (
+ state == Ci.nsIBlocklistService.STATE_SOFTBLOCKED &&
+ oldState == Ci.nsIBlocklistService.STATE_BLOCKED
+ ) {
+ await addon.setSoftDisabled(true);
+ }
+
+ if (
+ state == Ci.nsIBlocklistService.STATE_BLOCKED ||
+ state == Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ ) {
+ // Mark it as softblocked if necessary. Note that we avoid setting
+ // softDisabled at the same time as userDisabled to make it clear
+ // which was the original cause of the add-on becoming disabled in a
+ // way that the user can change.
+ if (
+ state == Ci.nsIBlocklistService.STATE_SOFTBLOCKED &&
+ !addon.userDisabled
+ ) {
+ await addon.setSoftDisabled(true);
+ }
+ // It's a block. We must reset certain preferences.
+ let entry = this._getEntry(addon, this._entries);
+ if (entry.prefs && entry.prefs.length) {
+ for (let pref of entry.prefs) {
+ Services.prefs.clearUserPref(pref);
+ }
+ }
+ }
+ }
+
+ lazy.AddonManagerPrivate.updateAddonAppDisabledStates();
+ },
+
+ async getState(addon, appVersion, toolkitVersion) {
+ let entry = await this.getEntry(addon, appVersion, toolkitVersion);
+ return entry ? entry.state : Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+
+ async getEntry(addon, appVersion, toolkitVersion) {
+ await this._ensureEntries();
+ return this._getEntry(addon, this._entries, appVersion, toolkitVersion);
+ },
+
+ _getEntry(addon, addonEntries, appVersion, toolkitVersion) {
+ if (!gBlocklistEnabled || !addon) {
+ return null;
+ }
+
+ // Not all applications implement nsIXULAppInfo (e.g. xpcshell doesn't).
+ if (!appVersion && !lazy.gApp.version) {
+ return null;
+ }
+
+ if (!appVersion) {
+ appVersion = lazy.gApp.version;
+ }
+ if (!toolkitVersion) {
+ toolkitVersion = lazy.gApp.platformVersion;
+ }
+
+ let addonProps = {};
+ for (let key of EXTENSION_BLOCK_FILTERS) {
+ addonProps[key] = addon[key];
+ }
+ if (addonProps.creator) {
+ addonProps.creator = addonProps.creator.name;
+ }
+
+ for (let entry of addonEntries) {
+ // First check if it matches our properties. If not, just skip to the next item.
+ if (!doesAddonEntryMatch(entry.matches, addonProps)) {
+ continue;
+ }
+ // If those match, check the app or toolkit version works:
+ for (let versionRange of entry.versionRange) {
+ if (
+ Utils.versionsMatch(
+ versionRange,
+ addon.version,
+ appVersion,
+ toolkitVersion
+ )
+ ) {
+ let blockID = entry.blockID || entry.id;
+ return {
+ state:
+ versionRange.severity >= gBlocklistLevel
+ ? Ci.nsIBlocklistService.STATE_BLOCKED
+ : Ci.nsIBlocklistService.STATE_SOFTBLOCKED,
+ url: Utils._createBlocklistURL(blockID),
+ prefs: entry.prefs || [],
+ };
+ }
+ }
+ }
+ return null;
+ },
+};
+
+/**
+ * The extensions blocklist implementation, the third version.
+ *
+ * The current blocklist is represented by a multi-level bloom filter (MLBF)
+ * (aka "Cascade Bloom Filter") that works like a set, i.e. supports a has()
+ * operation, except it is probabilistic. The MLBF is 100% accurate for known
+ * entries and unreliable for unknown entries. When the backend generates the
+ * MLBF, all known add-ons are recorded, including their block state. Unknown
+ * add-ons are identified by their signature date being newer than the MLBF's
+ * generation time, and they are considered to not be blocked.
+ *
+ * Legacy blocklists used to distinguish between "soft block" and "hard block",
+ * but the current blocklist only supports one type of block ("hard block").
+ * After checking the blocklist states, any previous "soft blocked" addons will
+ * either be (hard) blocked or unblocked based on the blocklist.
+ *
+ * The MLBF is attached to a RemoteSettings record, as follows:
+ *
+ * {
+ * "generation_time": 1585692000000,
+ * "attachment": { ... RemoteSettings attachment ... }
+ * "attachment_type": "bloomfilter-base",
+ * }
+ *
+ * The collection can also contain stashes:
+ *
+ * {
+ * "stash_time": 1585692000001,
+ * "stash": {
+ * "blocked": [ "addonid:1.0", ... ],
+ * "unblocked": [ "addonid:1.0", ... ]
+ * }
+ *
+ * Stashes can be used to update the blocklist without forcing the whole MLBF
+ * to be downloaded again. These stashes are applied on top of the base MLBF.
+ */
+const ExtensionBlocklistMLBF = {
+ RS_ATTACHMENT_ID: "addons-mlbf.bin",
+
+ async _fetchMLBF(record) {
+ // |record| may be unset. In that case, the MLBF dump is used instead
+ // (provided that the client has been built with it included).
+ let hash = record?.attachment.hash;
+ if (this._mlbfData && hash && this._mlbfData.cascadeHash === hash) {
+ // MLBF not changed, save the efforts of downloading the data again.
+
+ // Although the MLBF has not changed, the time in the record has. This
+ // means that the MLBF is known to provide accurate results for add-ons
+ // that were signed after the previously known date (but before the newly
+ // given date). To ensure that add-ons in this time range are also blocked
+ // as expected, update the cached generationTime.
+ if (record.generation_time > this._mlbfData.generationTime) {
+ this._mlbfData.generationTime = record.generation_time;
+ }
+ return this._mlbfData;
+ }
+ const {
+ buffer,
+ record: actualRecord,
+ _source: rsAttachmentSource,
+ } = await this._client.attachments.download(record, {
+ attachmentId: this.RS_ATTACHMENT_ID,
+ fallbackToCache: true,
+ fallbackToDump: true,
+ });
+ return {
+ cascadeHash: actualRecord.attachment.hash,
+ cascadeFilter: new CascadeFilter(new Uint8Array(buffer)),
+ // Note: generation_time is semantically distinct from last_modified.
+ // generation_time is compared with the signing date of the add-on, so it
+ // should be in sync with the signing service's clock.
+ // In contrast, last_modified does not have such strong requirements.
+ generationTime: actualRecord.generation_time,
+ // Used for telemetry.
+ rsAttachmentSource,
+ };
+ },
+
+ async _updateMLBF(forceUpdate = false) {
+ // The update process consists of fetching the collection, followed by
+ // potentially multiple network requests. As long as the collection has not
+ // been changed, repeated update requests can be coalesced. But when the
+ // collection has been updated, all pending update requests should await the
+ // new update request instead of the previous one.
+ if (!forceUpdate && this._updatePromise) {
+ return this._updatePromise;
+ }
+ const isUpdateReplaced = () => this._updatePromise != updatePromise;
+ const updatePromise = (async () => {
+ if (!gBlocklistEnabled) {
+ this._mlbfData = null;
+ this._stashes = null;
+ return;
+ }
+ let records = await this._client.get();
+ if (isUpdateReplaced()) {
+ return;
+ }
+
+ let mlbfRecords = records
+ .filter(r => r.attachment)
+ // Newest attachments first.
+ .sort((a, b) => b.generation_time - a.generation_time);
+ const mlbfRecord = mlbfRecords.find(
+ r => r.attachment_type == "bloomfilter-base"
+ );
+ this._stashes = records
+ .filter(({ stash }) => {
+ return (
+ // Exclude non-stashes, e.g. MLBF attachments.
+ stash &&
+ // Sanity check for type.
+ Array.isArray(stash.blocked) &&
+ Array.isArray(stash.unblocked)
+ );
+ })
+ // Sort by stash time - newest first.
+ .sort((a, b) => b.stash_time - a.stash_time)
+ .map(({ stash, stash_time }) => ({
+ blocked: new Set(stash.blocked),
+ unblocked: new Set(stash.unblocked),
+ stash_time,
+ }));
+
+ let mlbf = await this._fetchMLBF(mlbfRecord);
+ // When a MLBF dump is packaged with the browser, mlbf will always be
+ // non-null at this point.
+ if (isUpdateReplaced()) {
+ return;
+ }
+ this._mlbfData = mlbf;
+ })()
+ .catch(e => {
+ Cu.reportError(e);
+ })
+ .then(() => {
+ if (!isUpdateReplaced()) {
+ this._updatePromise = null;
+ this._recordPostUpdateTelemetry();
+ }
+ return this._updatePromise;
+ });
+ this._updatePromise = updatePromise;
+ return updatePromise;
+ },
+
+ // Update the telemetry of the blocklist. This is always called, even if
+ // the update request failed (e.g. due to network errors or data corruption).
+ _recordPostUpdateTelemetry() {
+ BlocklistTelemetry.recordRSBlocklistLastModified(
+ "addons_mlbf",
+ this._client
+ );
+ Glean.blocklist.mlbfSource.set(
+ this._mlbfData?.rsAttachmentSource || "unknown"
+ );
+ BlocklistTelemetry.recordTimeScalar(
+ "mlbf_generation_time",
+ this._mlbfData?.generationTime
+ );
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.mlbfGenerationTime,
+ this._mlbfData?.generationTime
+ );
+ // stashes has conveniently already been sorted by stash_time, newest first.
+ let stashes = this._stashes || [];
+ BlocklistTelemetry.recordTimeScalar(
+ "mlbf_stash_time_oldest",
+ stashes[stashes.length - 1]?.stash_time
+ );
+ BlocklistTelemetry.recordTimeScalar(
+ "mlbf_stash_time_newest",
+ stashes[0]?.stash_time
+ );
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.mlbfStashTimeOldest,
+ stashes[stashes.length - 1]?.stash_time
+ );
+
+ BlocklistTelemetry.recordGleanDateTime(
+ Glean.blocklist.mlbfStashTimeNewest,
+ stashes[0]?.stash_time
+ );
+ },
+
+ // Used by BlocklistTelemetry.recordAddonBlockChangeTelemetry.
+ getBlocklistMetadataForTelemetry() {
+ // Blocklist telemetry can only be reported when a blocklist decision
+ // has been made. That implies that the blocklist has been loaded, so
+ // ExtensionBlocklistMLBF should have been initialized.
+ // (except when the blocklist is disabled, or blocklist v2 is used)
+ const generationTime = this._mlbfData?.generationTime ?? 0;
+
+ // Keys to include in the blocklist.addonBlockChange telemetry event.
+ return {
+ mlbf_last_time:
+ // stashes are sorted, newest first. Stashes are newer than the MLBF.
+ `${this._stashes?.[0]?.stash_time ?? generationTime}`,
+ mlbf_generation: `${generationTime}`,
+ mlbf_source: this._mlbfData?.rsAttachmentSource ?? "unknown",
+ };
+ },
+
+ ensureInitialized() {
+ if (!gBlocklistEnabled || this._initialized) {
+ return;
+ }
+ this._initialized = true;
+ this._client = lazy.RemoteSettings("addons-bloomfilters", {
+ bucketName: BLOCKLIST_BUCKET,
+ // Prevent the attachment for being pruned, since its ID does
+ // not match any record.
+ keepAttachmentsIds: [this.RS_ATTACHMENT_ID],
+ });
+ this._onUpdate = this._onUpdate.bind(this);
+ this._client.on("sync", this._onUpdate);
+ },
+
+ shutdown() {
+ if (this._client) {
+ this._client.off("sync", this._onUpdate);
+ this._didShutdown = true;
+ }
+ },
+
+ // Called when the blocklist implementation is changed via a pref.
+ undoShutdown() {
+ if (this._didShutdown) {
+ this._client.on("sync", this._onUpdate);
+ this._didShutdown = false;
+ }
+ },
+
+ async _onUpdate() {
+ this.ensureInitialized();
+ await this._updateMLBF(true);
+
+ let addons = await lazy.AddonManager.getAddonsByTypes(lazy.kXPIAddonTypes);
+ for (let addon of addons) {
+ let oldState = addon.blocklistState;
+ await addon.updateBlocklistState(false);
+ let state = addon.blocklistState;
+
+ LOG(
+ "Blocklist state for " +
+ addon.id +
+ " changed from " +
+ oldState +
+ " to " +
+ state
+ );
+
+ // We don't want to re-warn about add-ons
+ if (state == oldState) {
+ continue;
+ }
+
+ // Ensure that softDisabled is false if the add-on is not soft blocked
+ // (by a previous implementation of the blocklist).
+ if (state != Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ await addon.setSoftDisabled(false);
+ }
+
+ BlocklistTelemetry.recordAddonBlockChangeTelemetry(
+ addon,
+ "blocklist_update"
+ );
+ }
+
+ lazy.AddonManagerPrivate.updateAddonAppDisabledStates();
+ },
+
+ async getState(addon) {
+ let state = await this.getEntry(addon);
+ return state ? state.state : Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+
+ async getEntry(addon) {
+ if (!this._stashes) {
+ this.ensureInitialized();
+ await this._updateMLBF(false);
+ } else if (this._updatePromise) {
+ // _stashes has been initialized, but the initialization of _mlbfData is
+ // still pending.
+ await this._updatePromise;
+ }
+
+ let blockKey = addon.id + ":" + addon.version;
+
+ // _stashes will be unset if !gBlocklistEnabled.
+ if (this._stashes) {
+ // Stashes are ordered by newest first.
+ for (let stash of this._stashes) {
+ // blocked and unblocked do not have overlapping entries.
+ if (stash.blocked.has(blockKey)) {
+ return this._createBlockEntry(addon);
+ }
+ if (stash.unblocked.has(blockKey)) {
+ return null;
+ }
+ }
+ }
+
+ // signedDate is a Date if the add-on is signed, null if not signed,
+ // undefined if it's an addon update descriptor instead of an addon wrapper.
+ let { signedDate } = addon;
+ if (!signedDate) {
+ // The MLBF does not apply to unsigned add-ons.
+ return null;
+ }
+
+ if (!this._mlbfData) {
+ // This could happen in theory in any of the following cases:
+ // - the blocklist is disabled.
+ // - The RemoteSettings backend served a malformed MLBF.
+ // - The RemoteSettings backend is unreachable, and this client was built
+ // without including a dump of the MLBF.
+ //
+ // ... in other words, this is unlikely to happen in practice.
+ return null;
+ }
+ let { cascadeFilter, generationTime } = this._mlbfData;
+ if (!cascadeFilter.has(blockKey)) {
+ // Add-on not blocked or unknown.
+ return null;
+ }
+ // Add-on blocked, or unknown add-on inadvertently labeled as blocked.
+
+ let { signedState } = addon;
+ if (
+ signedState !== lazy.AddonManager.SIGNEDSTATE_PRELIMINARY &&
+ signedState !== lazy.AddonManager.SIGNEDSTATE_SIGNED
+ ) {
+ // The block decision can only be relied upon for known add-ons, i.e.
+ // signed via AMO. Anything else is unknown and ignored:
+ //
+ // - SIGNEDSTATE_SYSTEM and SIGNEDSTATE_PRIVILEGED are signed
+ // independently of AMO.
+ //
+ // - SIGNEDSTATE_NOT_REQUIRED already has an early return above due to
+ // signedDate being unset for these kinds of add-ons.
+ //
+ // - SIGNEDSTATE_BROKEN, SIGNEDSTATE_UNKNOWN and SIGNEDSTATE_MISSING
+ // means that the signature cannot be relied upon. It is equivalent to
+ // removing the signature from the XPI file, which already causes them
+ // to be disabled on release builds (where MOZ_REQUIRE_SIGNING=true).
+ return null;
+ }
+
+ if (signedDate.getTime() > generationTime) {
+ // The bloom filter only reports 100% accurate results for known add-ons.
+ // Since the add-on was unknown when the bloom filter was generated, the
+ // block decision is incorrect and should be treated as unblocked.
+ return null;
+ }
+
+ if (AppConstants.NIGHTLY_BUILD && addon.type === "locale") {
+ // Only Mozilla can create langpacks with a valid signature.
+ // Langpacks for Release, Beta and ESR are submitted to AMO.
+ // DevEd does not support external langpacks (bug 1563923), only builtins.
+ // (and built-in addons are not subjected to the blocklist).
+ // Langpacks for Nightly are not known to AMO, so the MLBF cannot be used.
+ return null;
+ }
+
+ return this._createBlockEntry(addon);
+ },
+
+ _createBlockEntry(addon) {
+ return {
+ state: Ci.nsIBlocklistService.STATE_BLOCKED,
+ url: this.createBlocklistURL(addon.id, addon.version),
+ };
+ },
+
+ createBlocklistURL(id, version) {
+ let url = Services.urlFormatter.formatURLPref(PREF_BLOCKLIST_ADDONITEM_URL);
+ return url.replace(/%addonID%/g, id).replace(/%addonVersion%/g, version);
+ },
+};
+
+const EXTENSION_BLOCK_FILTERS = [
+ "id",
+ "name",
+ "creator",
+ "homepageURL",
+ "updateURL",
+];
+
+var gLoggingEnabled = null;
+var gBlocklistEnabled = true;
+var gBlocklistLevel = DEFAULT_LEVEL;
+
+/**
+ * @class nsIBlocklistPrompt
+ *
+ * nsIBlocklistPrompt is used, if available, by the default implementation of
+ * nsIBlocklistService to display a confirmation UI to the user before blocking
+ * extensions/plugins.
+ */
+/**
+ * @method prompt
+ *
+ * Prompt the user about newly blocked addons. The prompt is then resposible
+ * for soft-blocking any addons that need to be afterwards
+ *
+ * @param {object[]} aAddons
+ * An array of addons and plugins that are blocked. These are javascript
+ * objects with properties:
+ * name - the plugin or extension name,
+ * version - the version of the extension or plugin,
+ * icon - the plugin or extension icon,
+ * disable - can be used by the nsIBlocklistPrompt to allows users to decide
+ * whether a soft-blocked add-on should be disabled,
+ * blocked - true if the item is hard-blocked, false otherwise,
+ * item - the Addon object
+ */
+
+// It is not possible to use the one in Services since it will not successfully
+// QueryInterface nsIXULAppInfo in xpcshell tests due to other code calling
+// Services.appinfo before the nsIXULAppInfo is created by the tests.
+ChromeUtils.defineLazyGetter(lazy, "gApp", function () {
+ // eslint-disable-next-line mozilla/use-services
+ let appinfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
+ try {
+ appinfo.QueryInterface(Ci.nsIXULAppInfo);
+ } catch (ex) {
+ // Not all applications implement nsIXULAppInfo (e.g. xpcshell doesn't).
+ if (
+ !(ex instanceof Components.Exception) ||
+ ex.result != Cr.NS_NOINTERFACE
+ ) {
+ throw ex;
+ }
+ }
+ return appinfo;
+});
+
+ChromeUtils.defineLazyGetter(lazy, "gAppID", function () {
+ return lazy.gApp.ID;
+});
+ChromeUtils.defineLazyGetter(lazy, "gAppOS", function () {
+ return lazy.gApp.OS;
+});
+
+/**
+ * Logs a string to the error console.
+ * @param {string} string
+ * The string to write to the error console..
+ */
+function LOG(string) {
+ if (gLoggingEnabled) {
+ dump("*** " + string + "\n");
+ Services.console.logStringMessage(string);
+ }
+}
+
+export let Blocklist = {
+ _init() {
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ gLoggingEnabled = Services.prefs.getBoolPref(
+ PREF_EM_LOGGING_ENABLED,
+ false
+ );
+ gBlocklistEnabled = Services.prefs.getBoolPref(
+ PREF_BLOCKLIST_ENABLED,
+ true
+ );
+ gBlocklistLevel = Math.min(
+ Services.prefs.getIntPref(PREF_BLOCKLIST_LEVEL, DEFAULT_LEVEL),
+ MAX_BLOCK_LEVEL
+ );
+ this._chooseExtensionBlocklistImplementationFromPref();
+ Services.prefs.addObserver("extensions.blocklist.", this);
+ Services.prefs.addObserver(PREF_EM_LOGGING_ENABLED, this);
+ BlocklistTelemetry.init();
+ },
+ isLoaded: true,
+
+ shutdown() {
+ GfxBlocklistRS.shutdown();
+ this.ExtensionBlocklist.shutdown();
+
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ Services.prefs.removeObserver("extensions.blocklist.", this);
+ Services.prefs.removeObserver(PREF_EM_LOGGING_ENABLED, this);
+ },
+
+ observe(subject, topic, prefName) {
+ switch (topic) {
+ case "xpcom-shutdown":
+ this.shutdown();
+ break;
+ case "nsPref:changed":
+ switch (prefName) {
+ case PREF_EM_LOGGING_ENABLED:
+ gLoggingEnabled = Services.prefs.getBoolPref(
+ PREF_EM_LOGGING_ENABLED,
+ false
+ );
+ break;
+ case PREF_BLOCKLIST_ENABLED:
+ gBlocklistEnabled = Services.prefs.getBoolPref(
+ PREF_BLOCKLIST_ENABLED,
+ true
+ );
+ this._blocklistUpdated();
+ break;
+ case PREF_BLOCKLIST_LEVEL:
+ gBlocklistLevel = Math.min(
+ Services.prefs.getIntPref(PREF_BLOCKLIST_LEVEL, DEFAULT_LEVEL),
+ MAX_BLOCK_LEVEL
+ );
+ this._blocklistUpdated();
+ break;
+ case PREF_BLOCKLIST_USE_MLBF:
+ let oldImpl = this.ExtensionBlocklist;
+ this._chooseExtensionBlocklistImplementationFromPref();
+ // The implementation may be unchanged when the pref is ignored.
+ if (oldImpl != this.ExtensionBlocklist && oldImpl._initialized) {
+ oldImpl.shutdown();
+ this.ExtensionBlocklist.undoShutdown();
+ this.ExtensionBlocklist._onUpdate();
+ } // else neither has been initialized yet. Wait for it to happen.
+ break;
+ }
+ break;
+ }
+ },
+
+ loadBlocklistAsync() {
+ // Need to ensure we notify gfx of new stuff.
+ // Geckoview calls this for each new tab (bug 1730026), so ensure we only
+ // check for entries when first initialized.
+ if (!GfxBlocklistRS._initialized) {
+ GfxBlocklistRS.checkForEntries();
+ }
+ this.ExtensionBlocklist.ensureInitialized();
+ },
+
+ getAddonBlocklistState(addon, appVersion, toolkitVersion) {
+ // NOTE: appVersion/toolkitVersion are only used by ExtensionBlocklistRS.
+ return this.ExtensionBlocklist.getState(addon, appVersion, toolkitVersion);
+ },
+
+ getAddonBlocklistEntry(addon, appVersion, toolkitVersion) {
+ // NOTE: appVersion/toolkitVersion are only used by ExtensionBlocklistRS.
+ return this.ExtensionBlocklist.getEntry(addon, appVersion, toolkitVersion);
+ },
+
+ recordAddonBlockChangeTelemetry(addon, reason) {
+ BlocklistTelemetry.recordAddonBlockChangeTelemetry(addon, reason);
+ },
+ // TODO bug 1649906: Remove blocklist v2 (dead code).
+ allowDeprecatedBlocklistV2: false,
+
+ _chooseExtensionBlocklistImplementationFromPref() {
+ if (
+ this.allowDeprecatedBlocklistV2 &&
+ !Services.prefs.getBoolPref(PREF_BLOCKLIST_USE_MLBF, false)
+ ) {
+ this.ExtensionBlocklist = ExtensionBlocklistRS;
+ } else {
+ this.ExtensionBlocklist = ExtensionBlocklistMLBF;
+ }
+ },
+
+ _blocklistUpdated() {
+ this.ExtensionBlocklist._onUpdate();
+ },
+};
+
+Blocklist._init();
+
+// Allow tests to reach implementation objects.
+export const BlocklistPrivate = {
+ BlocklistTelemetry,
+ ExtensionBlocklistMLBF,
+ ExtensionBlocklistRS,
+ GfxBlocklistRS,
+};
diff --git a/toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs b/toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs
new file mode 100644
index 0000000000..8a9225e8a7
--- /dev/null
+++ b/toolkit/mozapps/extensions/LightweightThemeManager.sys.mjs
@@ -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/. */
+
+// Holds optional fallback theme data that will be returned when no data for an
+// active theme can be found. This the case for WebExtension Themes, for example.
+var _fallbackThemeData = null;
+
+export var LightweightThemeManager = {
+ set fallbackThemeData(data) {
+ if (data && Object.getOwnPropertyNames(data).length) {
+ _fallbackThemeData = Object.assign({}, data);
+ } else {
+ _fallbackThemeData = null;
+ }
+ },
+
+ /*
+ * Returns the currently active theme, taking the fallback theme into account
+ * if we'd be using the default theme otherwise.
+ *
+ * This will always return the original theme data and not make use of
+ * locally persisted resources.
+ */
+ get currentThemeWithFallback() {
+ return _fallbackThemeData && _fallbackThemeData.theme;
+ },
+
+ get themeData() {
+ return _fallbackThemeData || { theme: null };
+ },
+};
diff --git a/toolkit/mozapps/extensions/amContentHandler.sys.mjs b/toolkit/mozapps/extensions/amContentHandler.sys.mjs
new file mode 100644
index 0000000000..7da2355dcb
--- /dev/null
+++ b/toolkit/mozapps/extensions/amContentHandler.sys.mjs
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const XPI_CONTENT_TYPE = "application/x-xpinstall";
+const MSG_INSTALL_ADDON = "WebInstallerInstallAddonFromWebpage";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+export function amContentHandler() {}
+
+amContentHandler.prototype = {
+ /**
+ * Handles a new request for an application/x-xpinstall file.
+ *
+ * @param aMimetype
+ * The mimetype of the file
+ * @param aContext
+ * The context passed to nsIChannel.asyncOpen
+ * @param aRequest
+ * The nsIRequest dealing with the content
+ */
+ handleContent(aMimetype, aContext, aRequest) {
+ if (aMimetype != XPI_CONTENT_TYPE) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ if (!(aRequest instanceof Ci.nsIChannel)) {
+ throw Components.Exception("", Cr.NS_ERROR_WONT_HANDLE_CONTENT);
+ }
+
+ let uri = aRequest.URI;
+
+ // This check will allow a link to an xpi clicked by the user to trigger the
+ // addon install flow, but prevents window.open or window.location from triggering
+ // an addon install even when called from inside a event listener triggered by
+ // user input.
+ if (
+ !aRequest.loadInfo.hasValidUserGestureActivation &&
+ Services.prefs.getBoolPref("xpinstall.userActivation.required", true)
+ ) {
+ const error = Components.Exception(
+ `${uri.spec} install cancelled because of missing user gesture activation`,
+ Cr.NS_ERROR_WONT_HANDLE_CONTENT
+ );
+ // Report the error in the BrowserConsole, the error thrown from here doesn't
+ // seem to be visible anywhere.
+ Cu.reportError(error);
+ throw error;
+ }
+
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+
+ let { loadInfo } = aRequest;
+ const { triggeringPrincipal } = loadInfo;
+
+ let browsingContext = loadInfo.targetBrowsingContext;
+
+ let sourceHost;
+ let sourceURL;
+
+ try {
+ sourceURL =
+ triggeringPrincipal.spec != "" ? triggeringPrincipal.spec : undefined;
+ sourceHost = triggeringPrincipal.host;
+ } catch (error) {
+ // Ignore errors when retrieving the host for the principal (e.g. data URIs return
+ // an NS_ERROR_FAILURE when principal.host is accessed).
+ }
+
+ let install = {
+ uri: uri.spec,
+ hash: null,
+ name: null,
+ icon: null,
+ mimetype: XPI_CONTENT_TYPE,
+ triggeringPrincipal,
+ callbackID: -1,
+ method: "link",
+ sourceHost,
+ sourceURL,
+ browsingContext,
+ hasCrossOriginAncestor: lazy.ThirdPartyUtil.isThirdPartyChannel(aRequest),
+ };
+
+ Services.cpmm.sendAsyncMessage(MSG_INSTALL_ADDON, install);
+ },
+
+ classID: Components.ID("{7beb3ba8-6ec3-41b4-b67c-da89b8518922}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIContentHandler"]),
+
+ log(aMsg) {
+ let msg = "amContentHandler.js: " + (aMsg.join ? aMsg.join("") : aMsg);
+ Services.console.logStringMessage(msg);
+ dump(msg + "\n");
+ },
+};
diff --git a/toolkit/mozapps/extensions/amIAddonManagerStartup.idl b/toolkit/mozapps/extensions/amIAddonManagerStartup.idl
new file mode 100644
index 0000000000..10a235a849
--- /dev/null
+++ b/toolkit/mozapps/extensions/amIAddonManagerStartup.idl
@@ -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/. */
+
+#include "nsISupports.idl"
+
+interface nsIFile;
+interface nsIJSRAIIHelper;
+interface nsIURI;
+
+[scriptable, builtinclass, uuid(01dfa47b-87e4-4135-877b-586d033e1b5d)]
+interface amIAddonManagerStartup : nsISupports
+{
+ /**
+ * Reads and parses startup data from the addonState.json.lz4 file, checks
+ * for modifications, and returns the result.
+ *
+ * Returns null for an empty or nonexistent state file, but throws for an
+ * invalid one.
+ */
+ [implicit_jscontext]
+ jsval readStartupData();
+
+ /**
+ * Registers a set of dynamic chrome registry entries, and returns an object
+ * with a `destruct()` method which must be called in order to unregister
+ * the entries.
+ *
+ * @param manifestURI The base manifest URI for the entries. URL values are
+ * resolved relative to this URI.
+ * @param entries An array of arrays, each containing a registry entry as it
+ * would appar in a chrome.manifest file. Only the following entry
+ * types are currently accepted:
+ *
+ * - "locale" A locale package entry. Must be a 4-element array.
+ * - "override" A URL override entry. Must be a 3-element array.
+ */
+ [implicit_jscontext]
+ nsIJSRAIIHelper registerChrome(in nsIURI manifestURI, in jsval entries);
+
+ [implicit_jscontext]
+ jsval encodeBlob(in jsval value);
+
+ [implicit_jscontext]
+ jsval decodeBlob(in jsval value);
+
+ /**
+ * Enumerates over all entries in the JAR file at the given URI, and returns
+ * an array of entry paths which match the given pattern. The URI may be
+ * either a file: URL pointing directly to a zip file, or a jar: URI
+ * pointing to a zip file nested within another zip file. Only one level of
+ * nesting is supported.
+ *
+ * This should be used in preference to manually opening or retrieving a
+ * ZipReader from the zip cache, since the former causes main thread IO and
+ * the latter can lead to file locking issues due to unpredictable GC behavior
+ * keeping the cached ZipReader alive after the cache is flushed.
+ *
+ * @param uri The URI of the zip file to enumerate.
+ * @param pattern The pattern to match, as passed to nsIZipReader.findEntries.
+ */
+ Array<AString> enumerateJAR(in nsIURI uri, in AUTF8String pattern);
+
+ /**
+ * Similar to |enumerateJAR| above, but accepts the URI of a directory
+ * within a JAR file, and returns a list of all entries below it.
+ *
+ * The given URI must be a jar: URI, and its JAR file must point either to a
+ * file: URI, or to a singly-nested JAR within another JAR file (i.e.,
+ * "jar:file:///thing.jar!/" or "jar:jar:file:///thing.jar!/stuff.jar!/").
+ * Multiple levels of nesting are not supported.
+ */
+ Array<AString> enumerateJARSubtree(in nsIURI uri);
+
+ /**
+ * Initializes the URL Preloader.
+ *
+ * NOT FOR USE OUTSIDE OF UNIT TESTS.
+ */
+ void initializeURLPreloader();
+
+};
diff --git a/toolkit/mozapps/extensions/amIWebInstallPrompt.idl b/toolkit/mozapps/extensions/amIWebInstallPrompt.idl
new file mode 100644
index 0000000000..6724303cba
--- /dev/null
+++ b/toolkit/mozapps/extensions/amIWebInstallPrompt.idl
@@ -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/. */
+
+#include "nsISupports.idl"
+
+interface nsIURI;
+interface nsIVariant;
+
+webidl Element;
+
+/**
+ * amIWebInstallPrompt is used, if available, by the default implementation of
+ * amIWebInstallInfo to display a confirmation UI to the user before running
+ * installs.
+ */
+[scriptable, uuid(386906f1-4d18-45bf-bc81-5dcd68e42c3b)]
+interface amIWebInstallPrompt : nsISupports
+{
+ /**
+ * Get a confirmation that the user wants to start the installs.
+ *
+ * @param aBrowser
+ * The browser that triggered the installs
+ * @param aUri
+ * The URI of the site that triggered the installs
+ * @param aInstalls
+ * The AddonInstalls that were requested
+ */
+ void confirm(in Element aBrowser, in nsIURI aUri,
+ in Array<nsIVariant> aInstalls);
+};
diff --git a/toolkit/mozapps/extensions/amInstallTrigger.sys.mjs b/toolkit/mozapps/extensions/amInstallTrigger.sys.mjs
new file mode 100644
index 0000000000..068c0f07c4
--- /dev/null
+++ b/toolkit/mozapps/extensions/amInstallTrigger.sys.mjs
@@ -0,0 +1,272 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+const XPINSTALL_MIMETYPE = "application/x-xpinstall";
+
+const MSG_INSTALL_ENABLED = "WebInstallerIsInstallEnabled";
+const MSG_INSTALL_ADDON = "WebInstallerInstallAddonFromWebpage";
+const MSG_INSTALL_CALLBACK = "WebInstallerInstallCallback";
+
+const SUPPORTED_XPI_SCHEMES = ["http", "https"];
+
+var log = Log.repository.getLogger("AddonManager.InstallTrigger");
+log.level =
+ Log.Level[
+ Services.prefs.getBoolPref("extensions.logging.enabled", false)
+ ? "Warn"
+ : "Trace"
+ ];
+
+function CallbackObject(id, callback, mediator) {
+ this.id = id;
+ this.callback = callback;
+ this.callCallback = function (url, status) {
+ try {
+ this.callback(url, status);
+ } catch (e) {
+ log.warn("InstallTrigger callback threw an exception: " + e);
+ }
+
+ mediator._callbacks.delete(id);
+ };
+}
+
+function RemoteMediator(window) {
+ this._windowID = window.windowGlobalChild.innerWindowId;
+
+ this.mm = window.docShell.messageManager;
+ this.mm.addWeakMessageListener(MSG_INSTALL_CALLBACK, this);
+
+ this._lastCallbackID = 0;
+ this._callbacks = new Map();
+}
+
+RemoteMediator.prototype = {
+ receiveMessage(message) {
+ if (message.name == MSG_INSTALL_CALLBACK) {
+ let payload = message.data;
+ let callbackHandler = this._callbacks.get(payload.callbackID);
+ if (callbackHandler) {
+ callbackHandler.callCallback(payload.url, payload.status);
+ }
+ }
+ },
+
+ enabled(url) {
+ let params = {
+ mimetype: XPINSTALL_MIMETYPE,
+ };
+ return this.mm.sendSyncMessage(MSG_INSTALL_ENABLED, params)[0];
+ },
+
+ install(install, principal, callback, window) {
+ let callbackID = this._addCallback(callback);
+
+ install.mimetype = XPINSTALL_MIMETYPE;
+ install.triggeringPrincipal = principal;
+ install.callbackID = callbackID;
+ install.browsingContext = BrowsingContext.getFromWindow(window);
+
+ return Services.cpmm.sendSyncMessage(MSG_INSTALL_ADDON, install)[0];
+ },
+
+ _addCallback(callback) {
+ if (!callback || typeof callback != "function") {
+ return -1;
+ }
+
+ let callbackID = this._windowID + "-" + ++this._lastCallbackID;
+ let callbackObject = new CallbackObject(callbackID, callback, this);
+ this._callbacks.set(callbackID, callbackObject);
+ return callbackID;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),
+};
+
+export function InstallTrigger() {}
+
+InstallTrigger.prototype = {
+ // We've declared ourselves as providing the nsIDOMGlobalPropertyInitializer
+ // interface. This means that when the InstallTrigger property is gotten from
+ // the window that will createInstance this object and then call init(),
+ // passing the window were bound to. It will then automatically create the
+ // WebIDL wrapper (InstallTriggerImpl) for this object. This indirection is
+ // necessary because webidl does not (yet) support statics (bug 863952). See
+ // bug 926712 and then bug 1442360 for more details about this implementation.
+ init(window) {
+ this._window = window;
+ this._principal = window.document.nodePrincipal;
+ this._url = window.document.documentURIObject;
+
+ this._mediator = new RemoteMediator(window);
+ // If we can't set up IPC (e.g., because this is a top-level window or
+ // something), then don't expose InstallTrigger. The Window code handles
+ // that, if we throw an exception here.
+ },
+
+ enabled() {
+ return this._mediator.enabled(this._url.spec);
+ },
+
+ updateEnabled() {
+ return this.enabled();
+ },
+
+ install(installs, callback) {
+ if (Services.prefs.getBoolPref("xpinstall.userActivation.required", true)) {
+ if (!this._window.windowUtils.isHandlingUserInput) {
+ throw new this._window.Error(
+ "InstallTrigger.install can only be called from a user input handler"
+ );
+ }
+ }
+
+ let keys = Object.keys(installs);
+ if (keys.length > 1) {
+ throw new this._window.Error("Only one XPI may be installed at a time");
+ }
+
+ let item = installs[keys[0]];
+
+ if (typeof item === "string") {
+ item = { URL: item };
+ }
+ if (!item.URL) {
+ throw new this._window.Error(
+ "Missing URL property for '" + keys[0] + "'"
+ );
+ }
+
+ let url = this._resolveURL(item.URL);
+ if (!this._checkLoadURIFromScript(url)) {
+ throw new this._window.Error(
+ "Insufficient permissions to install: " + url.spec
+ );
+ }
+
+ if (!SUPPORTED_XPI_SCHEMES.includes(url.scheme)) {
+ Cu.reportError(
+ `InstallTrigger call disallowed on install url with unsupported scheme: ${JSON.stringify(
+ {
+ installPrincipal: this._principal.spec,
+ installURL: url.spec,
+ }
+ )}`
+ );
+ throw new this._window.Error(`Unsupported scheme`);
+ }
+
+ let iconUrl = null;
+ if (item.IconURL) {
+ iconUrl = this._resolveURL(item.IconURL);
+ if (!this._checkLoadURIFromScript(iconUrl)) {
+ iconUrl = null; // If page can't load the icon, just ignore it
+ }
+ }
+
+ const getTriggeringSource = () => {
+ let url;
+ let host;
+ try {
+ if (this._url?.schemeIs("http") || this._url?.schemeIs("https")) {
+ url = this._url.spec;
+ host = this._url.host;
+ } else if (this._url?.schemeIs("blob")) {
+ // For a blob url, we keep the blob url as the sourceURL and
+ // we pick the related sourceHost from either the principal
+ // or the precursorPrincipal (if the principal is a null principal
+ // and the precursor one is defined).
+ url = this._url.spec;
+ host =
+ this._principal.isNullPrincipal &&
+ this._principal.precursorPrincipal
+ ? this._principal.precursorPrincipal.host
+ : this._principal.host;
+ } else if (!this._principal.isNullPrincipal) {
+ url = this._principal.exposableSpec;
+ host = this._principal.host;
+ } else if (this._principal.precursorPrincipal) {
+ url = this._principal.precursorPrincipal.exposableSpec;
+ host = this._principal.precursorPrincipal.host;
+ } else {
+ // Fallback to this._url as last resort.
+ url = this._url.spec;
+ host = this._url.host;
+ }
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ // Fallback to an empty string if url and host are still undefined.
+ return {
+ sourceURL: url || "",
+ sourceHost: host || "",
+ };
+ };
+
+ const { sourceHost, sourceURL } = getTriggeringSource();
+
+ let installData = {
+ uri: url.spec,
+ hash: item.Hash || null,
+ name: item.name,
+ icon: iconUrl ? iconUrl.spec : null,
+ method: "installTrigger",
+ sourceHost,
+ sourceURL,
+ hasCrossOriginAncestor: lazy.ThirdPartyUtil.isThirdPartyWindow(
+ this._window
+ ),
+ };
+
+ return this._mediator.install(
+ installData,
+ this._principal,
+ callback,
+ this._window
+ );
+ },
+
+ startSoftwareUpdate(url, flags) {
+ let filename = Services.io.newURI(url).QueryInterface(Ci.nsIURL).filename;
+ let args = {};
+ args[filename] = { URL: url };
+ return this.install(args);
+ },
+
+ installChrome(type, url, skin) {
+ return this.startSoftwareUpdate(url);
+ },
+
+ _resolveURL(url) {
+ return Services.io.newURI(url, null, this._url);
+ },
+
+ _checkLoadURIFromScript(uri) {
+ let secman = Services.scriptSecurityManager;
+ try {
+ secman.checkLoadURIWithPrincipal(
+ this._principal,
+ uri,
+ secman.DISALLOW_INHERIT_PRINCIPAL
+ );
+ return true;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ classID: Components.ID("{9df8ef2b-94da-45c9-ab9f-132eb55fddf1}"),
+ contractID: "@mozilla.org/addons/installtrigger;1",
+ QueryInterface: ChromeUtils.generateQI(["nsIDOMGlobalPropertyInitializer"]),
+};
diff --git a/toolkit/mozapps/extensions/amManager.sys.mjs b/toolkit/mozapps/extensions/amManager.sys.mjs
new file mode 100644
index 0000000000..5ce396d601
--- /dev/null
+++ b/toolkit/mozapps/extensions/amManager.sys.mjs
@@ -0,0 +1,359 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 component serves as integration between the platform and AddonManager.
+ * It is responsible for initializing and shutting down the AddonManager as well
+ * as passing new installs from webpages to the AddonManager.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "separatePrivilegedMozillaWebContentProcess",
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "extensionsWebAPITesting",
+ "extensions.webapi.testing",
+ false
+);
+
+// The old XPInstall error codes
+const EXECUTION_ERROR = -203;
+const CANT_READ_ARCHIVE = -207;
+const USER_CANCELLED = -210;
+const DOWNLOAD_ERROR = -228;
+const UNSUPPORTED_TYPE = -244;
+const SUCCESS = 0;
+
+const MSG_INSTALL_ENABLED = "WebInstallerIsInstallEnabled";
+const MSG_INSTALL_ADDON = "WebInstallerInstallAddonFromWebpage";
+const MSG_INSTALL_CALLBACK = "WebInstallerInstallCallback";
+
+const MSG_PROMISE_REQUEST = "WebAPIPromiseRequest";
+const MSG_PROMISE_RESULT = "WebAPIPromiseResult";
+const MSG_INSTALL_EVENT = "WebAPIInstallEvent";
+const MSG_INSTALL_CLEANUP = "WebAPICleanup";
+const MSG_ADDON_EVENT_REQ = "WebAPIAddonEventRequest";
+const MSG_ADDON_EVENT = "WebAPIAddonEvent";
+
+var AddonManager, AddonManagerPrivate;
+
+export function amManager() {
+ ({ AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ ));
+
+ Services.mm.addMessageListener(MSG_INSTALL_ENABLED, this);
+ Services.mm.addMessageListener(MSG_PROMISE_REQUEST, this);
+ Services.mm.addMessageListener(MSG_INSTALL_CLEANUP, this);
+ Services.mm.addMessageListener(MSG_ADDON_EVENT_REQ, this);
+
+ Services.ppmm.addMessageListener(MSG_INSTALL_ADDON, this);
+
+ Services.obs.addObserver(this, "message-manager-close");
+ Services.obs.addObserver(this, "message-manager-disconnect");
+
+ AddonManager.webAPI.setEventHandler(this.sendEvent);
+
+ // Needed so receiveMessage can be called directly by JS callers
+ this.wrappedJSObject = this;
+}
+
+amManager.prototype = {
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "addons-startup":
+ AddonManagerPrivate.startup();
+ break;
+
+ case "message-manager-close":
+ case "message-manager-disconnect":
+ this.childClosed(aSubject);
+ break;
+ }
+ },
+
+ installAddonFromWebpage(aPayload, aBrowser, aCallback) {
+ let retval = true;
+
+ const { mimetype, triggeringPrincipal, hash, icon, name, uri } = aPayload;
+
+ // NOTE: consider removing this call to isInstallAllowed from here, later it is going to be called
+ // again from inside AddonManager.installAddonFromWebpage as part of the block/allow logic.
+ //
+ // The sole purpose of the call here seems to be "clearing the optional InstallTrigger callback",
+ // which seems to be actually wrong if we are still proceeding to call getInstallForURL and the same
+ // logic used to block the install flow using the exact same method call later on.
+ if (!AddonManager.isInstallAllowed(mimetype, triggeringPrincipal)) {
+ aCallback = null;
+ retval = false;
+ }
+
+ let telemetryInfo = {
+ source: AddonManager.getInstallSourceFromHost(aPayload.sourceHost),
+ sourceURL: aPayload.sourceURL,
+ };
+
+ if ("method" in aPayload) {
+ telemetryInfo.method = aPayload.method;
+ }
+
+ AddonManager.getInstallForURL(uri, {
+ hash,
+ name,
+ icon,
+ browser: aBrowser,
+ triggeringPrincipal,
+ telemetryInfo,
+ sendCookies: true,
+ }).then(aInstall => {
+ function callCallback(status) {
+ try {
+ aCallback?.onInstallEnded(uri, status);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+
+ if (!aInstall) {
+ callCallback(UNSUPPORTED_TYPE);
+ return;
+ }
+
+ if (aCallback) {
+ aInstall.addListener({
+ onDownloadCancelled(aInstall) {
+ callCallback(USER_CANCELLED);
+ },
+
+ onDownloadFailed(aInstall) {
+ if (aInstall.error == AddonManager.ERROR_CORRUPT_FILE) {
+ callCallback(CANT_READ_ARCHIVE);
+ } else {
+ callCallback(DOWNLOAD_ERROR);
+ }
+ },
+
+ onInstallFailed(aInstall) {
+ callCallback(EXECUTION_ERROR);
+ },
+
+ onInstallEnded(aInstall, aStatus) {
+ callCallback(SUCCESS);
+ },
+ });
+ }
+
+ AddonManager.installAddonFromWebpage(
+ mimetype,
+ aBrowser,
+ triggeringPrincipal,
+ aInstall,
+ {
+ hasCrossOriginAncestor: aPayload.hasCrossOriginAncestor,
+ }
+ );
+ });
+
+ return retval;
+ },
+
+ notify(aTimer) {
+ AddonManagerPrivate.backgroundUpdateTimerHandler();
+ },
+
+ // Maps message manager instances for content processes to the associated
+ // AddonListener instances.
+ addonListeners: new Map(),
+
+ _addAddonListener(target) {
+ if (!this.addonListeners.has(target)) {
+ let handler = (event, id) => {
+ target.sendAsyncMessage(MSG_ADDON_EVENT, { event, id });
+ };
+ let listener = {
+ onEnabling: addon => handler("onEnabling", addon.id),
+ onEnabled: addon => handler("onEnabled", addon.id),
+ onDisabling: addon => handler("onDisabling", addon.id),
+ onDisabled: addon => handler("onDisabled", addon.id),
+ onInstalling: addon => handler("onInstalling", addon.id),
+ onInstalled: addon => handler("onInstalled", addon.id),
+ onUninstalling: addon => handler("onUninstalling", addon.id),
+ onUninstalled: addon => handler("onUninstalled", addon.id),
+ onOperationCancelled: addon =>
+ handler("onOperationCancelled", addon.id),
+ };
+ this.addonListeners.set(target, listener);
+ AddonManager.addAddonListener(listener);
+ }
+ },
+
+ _removeAddonListener(target) {
+ if (this.addonListeners.has(target)) {
+ AddonManager.removeAddonListener(this.addonListeners.get(target));
+ this.addonListeners.delete(target);
+ }
+ },
+
+ /**
+ * messageManager callback function.
+ *
+ * Listens to requests from child processes for InstallTrigger
+ * activity, and sends back callbacks.
+ */
+ receiveMessage(aMessage) {
+ let payload = aMessage.data;
+
+ switch (aMessage.name) {
+ case MSG_INSTALL_ENABLED:
+ return AddonManager.isInstallEnabled(payload.mimetype);
+
+ case MSG_INSTALL_ADDON: {
+ let browser = payload.browsingContext.top.embedderElement;
+
+ let callback = null;
+ if (payload.callbackID != -1) {
+ let mm = browser.messageManager;
+ callback = {
+ onInstallEnded(url, status) {
+ mm.sendAsyncMessage(MSG_INSTALL_CALLBACK, {
+ callbackID: payload.callbackID,
+ url,
+ status,
+ });
+ },
+ };
+ }
+
+ return this.installAddonFromWebpage(payload, browser, callback);
+ }
+
+ case MSG_PROMISE_REQUEST: {
+ if (
+ !lazy.extensionsWebAPITesting &&
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ aMessage.target &&
+ aMessage.target.remoteType != null &&
+ aMessage.target.remoteType !== "privilegedmozilla"
+ ) {
+ return undefined;
+ }
+
+ let mm = aMessage.target.messageManager;
+ let resolve = value => {
+ mm.sendAsyncMessage(MSG_PROMISE_RESULT, {
+ callbackID: payload.callbackID,
+ resolve: value,
+ });
+ };
+ let reject = value => {
+ mm.sendAsyncMessage(MSG_PROMISE_RESULT, {
+ callbackID: payload.callbackID,
+ reject: value,
+ });
+ };
+
+ let API = AddonManager.webAPI;
+ if (payload.type in API) {
+ API[payload.type](aMessage.target, ...payload.args).then(
+ resolve,
+ reject
+ );
+ } else {
+ reject("Unknown Add-on API request.");
+ }
+ break;
+ }
+
+ case MSG_INSTALL_CLEANUP: {
+ if (
+ !lazy.extensionsWebAPITesting &&
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ aMessage.target &&
+ aMessage.target.remoteType != null &&
+ aMessage.target.remoteType !== "privilegedmozilla"
+ ) {
+ return undefined;
+ }
+
+ AddonManager.webAPI.clearInstalls(payload.ids);
+ break;
+ }
+
+ case MSG_ADDON_EVENT_REQ: {
+ if (
+ !lazy.extensionsWebAPITesting &&
+ lazy.separatePrivilegedMozillaWebContentProcess &&
+ aMessage.target &&
+ aMessage.target.remoteType != null &&
+ aMessage.target.remoteType !== "privilegedmozilla"
+ ) {
+ return undefined;
+ }
+
+ let target = aMessage.target.messageManager;
+ if (payload.enabled) {
+ this._addAddonListener(target);
+ } else {
+ this._removeAddonListener(target);
+ }
+ }
+ }
+ return undefined;
+ },
+
+ childClosed(target) {
+ AddonManager.webAPI.clearInstallsFrom(target);
+ this._removeAddonListener(target);
+ },
+
+ sendEvent(mm, data) {
+ mm.sendAsyncMessage(MSG_INSTALL_EVENT, data);
+ },
+
+ classID: Components.ID("{4399533d-08d1-458c-a87a-235f74451cfa}"),
+ QueryInterface: ChromeUtils.generateQI([
+ "amIAddonManager",
+ "nsITimerCallback",
+ "nsIObserver",
+ ]),
+};
+
+const BLOCKLIST_SYS_MJS = "resource://gre/modules/Blocklist.sys.mjs";
+ChromeUtils.defineESModuleGetters(lazy, { Blocklist: BLOCKLIST_SYS_MJS });
+
+export function BlocklistService() {
+ this.wrappedJSObject = this;
+}
+
+BlocklistService.prototype = {
+ STATE_NOT_BLOCKED: Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ STATE_SOFTBLOCKED: Ci.nsIBlocklistService.STATE_SOFTBLOCKED,
+ STATE_BLOCKED: Ci.nsIBlocklistService.STATE_BLOCKED,
+
+ get isLoaded() {
+ return Cu.isESModuleLoaded(BLOCKLIST_SYS_MJS) && lazy.Blocklist.isLoaded;
+ },
+
+ observe(...args) {
+ return lazy.Blocklist.observe(...args);
+ },
+
+ notify() {
+ lazy.Blocklist.notify();
+ },
+
+ classID: Components.ID("{66354bc9-7ed1-4692-ae1d-8da97d6b205e}"),
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsIBlocklistService",
+ "nsITimerCallback",
+ ]),
+};
diff --git a/toolkit/mozapps/extensions/amWebAPI.sys.mjs b/toolkit/mozapps/extensions/amWebAPI.sys.mjs
new file mode 100644
index 0000000000..abe838af89
--- /dev/null
+++ b/toolkit/mozapps/extensions/amWebAPI.sys.mjs
@@ -0,0 +1,289 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "AMO_ABUSEREPORT",
+ "extensions.abuseReport.amWebAPI.enabled",
+ false
+);
+
+const MSG_PROMISE_REQUEST = "WebAPIPromiseRequest";
+const MSG_PROMISE_RESULT = "WebAPIPromiseResult";
+const MSG_INSTALL_EVENT = "WebAPIInstallEvent";
+const MSG_INSTALL_CLEANUP = "WebAPICleanup";
+const MSG_ADDON_EVENT_REQ = "WebAPIAddonEventRequest";
+const MSG_ADDON_EVENT = "WebAPIAddonEvent";
+
+class APIBroker {
+ constructor(mm) {
+ this.mm = mm;
+
+ this._promises = new Map();
+
+ // _installMap maps integer ids to DOM AddonInstall instances
+ this._installMap = new Map();
+
+ this.mm.addMessageListener(MSG_PROMISE_RESULT, this);
+ this.mm.addMessageListener(MSG_INSTALL_EVENT, this);
+
+ this._eventListener = null;
+ }
+
+ receiveMessage(message) {
+ let payload = message.data;
+
+ switch (message.name) {
+ case MSG_PROMISE_RESULT: {
+ if (!this._promises.has(payload.callbackID)) {
+ return;
+ }
+
+ let resolve = this._promises.get(payload.callbackID);
+ this._promises.delete(payload.callbackID);
+ resolve(payload);
+ break;
+ }
+
+ case MSG_INSTALL_EVENT: {
+ let install = this._installMap.get(payload.id);
+ if (!install) {
+ let err = new Error(
+ `Got install event for unknown install ${payload.id}`
+ );
+ Cu.reportError(err);
+ return;
+ }
+ install._dispatch(payload);
+ break;
+ }
+
+ case MSG_ADDON_EVENT: {
+ if (this._eventListener) {
+ this._eventListener(payload);
+ }
+ }
+ }
+ }
+
+ sendRequest(type, ...args) {
+ return new Promise(resolve => {
+ let callbackID = APIBroker._nextID++;
+
+ this._promises.set(callbackID, resolve);
+ this.mm.sendAsyncMessage(MSG_PROMISE_REQUEST, { type, callbackID, args });
+ });
+ }
+
+ setAddonListener(callback) {
+ this._eventListener = callback;
+ if (callback) {
+ this.mm.addMessageListener(MSG_ADDON_EVENT, this);
+ this.mm.sendAsyncMessage(MSG_ADDON_EVENT_REQ, { enabled: true });
+ } else {
+ this.mm.removeMessageListener(MSG_ADDON_EVENT, this);
+ this.mm.sendAsyncMessage(MSG_ADDON_EVENT_REQ, { enabled: false });
+ }
+ }
+
+ sendCleanup(ids) {
+ this.setAddonListener(null);
+ this.mm.sendAsyncMessage(MSG_INSTALL_CLEANUP, { ids });
+ }
+}
+
+APIBroker._nextID = 0;
+
+// Base class for building classes to back content-exposed interfaces.
+class APIObject {
+ init(window, broker, properties) {
+ this.window = window;
+ this.broker = broker;
+
+ // Copy any provided properties onto this object, webidl bindings
+ // will only expose to content what should be exposed.
+ for (let key of Object.keys(properties)) {
+ this[key] = properties[key];
+ }
+ }
+
+ /**
+ * Helper to implement an asychronous method visible to content, where
+ * the method is implemented by sending a message to the parent process
+ * and then wrapping the returned object or error in an appropriate object.
+ * This helper method ensures that:
+ * - Returned Promise objects are from the content window
+ * - Rejected Promises have Error objects from the content window
+ * - Only non-internal errors are exposed to the caller
+ *
+ * @param {string} apiRequest The command to invoke in the parent process.
+ * @param {array<cloneable>} apiArgs The arguments to include with the
+ * request to the parent process.
+ * @param {function} resultConvert If provided, a function called with the
+ * result from the parent process as an
+ * argument. Used to convert the result
+ * into something appropriate for content.
+ * @returns {Promise<any>} A Promise suitable for passing directly to content.
+ */
+ _apiTask(apiRequest, apiArgs, resultConverter) {
+ let win = this.window;
+ let broker = this.broker;
+ return new win.Promise((resolve, reject) => {
+ (async function () {
+ let result = await broker.sendRequest(apiRequest, ...apiArgs);
+ if ("reject" in result) {
+ let err = new win.Error(result.reject.message);
+ // We don't currently put any other properties onto Errors
+ // generated by mozAddonManager. If/when we do, they will
+ // need to get copied here.
+ reject(err);
+ return;
+ }
+
+ let obj = result.resolve;
+ if (resultConverter) {
+ obj = resultConverter(obj);
+ }
+ resolve(obj);
+ })().catch(err => {
+ Cu.reportError(err);
+ reject(new win.Error("Unexpected internal error"));
+ });
+ });
+ }
+}
+
+class Addon extends APIObject {
+ constructor(...args) {
+ super();
+ this.init(...args);
+ }
+
+ uninstall() {
+ return this._apiTask("addonUninstall", [this.id]);
+ }
+
+ setEnabled(value) {
+ return this._apiTask("addonSetEnabled", [this.id, value]);
+ }
+}
+
+class AddonInstall extends APIObject {
+ constructor(window, broker, properties) {
+ super();
+ this.init(window, broker, properties);
+
+ broker._installMap.set(properties.id, this);
+ }
+
+ _dispatch(data) {
+ // The message for the event includes updated copies of all install
+ // properties. Use the usual "let webidl filter visible properties" trick.
+ for (let key of Object.keys(data)) {
+ this[key] = data[key];
+ }
+
+ let event = new this.window.Event(data.event);
+ this.__DOM_IMPL__.dispatchEvent(event);
+ }
+
+ install() {
+ return this._apiTask("addonInstallDoInstall", [this.id]);
+ }
+
+ cancel() {
+ return this._apiTask("addonInstallCancel", [this.id]);
+ }
+}
+
+export class WebAPI extends APIObject {
+ constructor() {
+ super();
+ this.allInstalls = [];
+ this.listenerCount = 0;
+ }
+
+ init(window) {
+ let mm = window.docShell.messageManager;
+ let broker = new APIBroker(mm);
+
+ super.init(window, broker, {});
+
+ window.addEventListener("unload", event => {
+ this.broker.sendCleanup(this.allInstalls);
+ });
+ }
+
+ getAddonByID(id) {
+ return this._apiTask("getAddonByID", [id], addonInfo => {
+ if (!addonInfo) {
+ return null;
+ }
+ let addon = new Addon(this.window, this.broker, addonInfo);
+ return this.window.Addon._create(this.window, addon);
+ });
+ }
+
+ createInstall(options) {
+ if (!Services.prefs.getBoolPref("xpinstall.enabled", true)) {
+ throw new this.window.Error("Software installation is disabled.");
+ }
+
+ const triggeringPrincipal = this.window.document.nodePrincipal;
+
+ let installOptions = {
+ ...options,
+ triggeringPrincipal,
+ // Provide the host from which the amWebAPI is being called
+ // (so that we can detect if the API is being used from the disco pane,
+ // AMO, testpilot or another unknown webpage).
+ sourceHost: this.window.location?.host,
+ sourceURL: this.window.location?.href,
+ };
+ return this._apiTask("createInstall", [installOptions], installInfo => {
+ if (!installInfo) {
+ return null;
+ }
+ let install = new AddonInstall(this.window, this.broker, installInfo);
+ this.allInstalls.push(installInfo.id);
+ return this.window.AddonInstall._create(this.window, install);
+ });
+ }
+
+ reportAbuse(id) {
+ return this._apiTask("addonReportAbuse", [id]);
+ }
+
+ get abuseReportPanelEnabled() {
+ return lazy.AMO_ABUSEREPORT;
+ }
+
+ eventListenerAdded(type) {
+ if (this.listenerCount == 0) {
+ this.broker.setAddonListener(data => {
+ let event = new this.window.AddonEvent(data.event, data);
+ this.__DOM_IMPL__.dispatchEvent(event);
+ });
+ }
+ this.listenerCount++;
+ }
+
+ eventListenerRemoved(type) {
+ this.listenerCount--;
+ if (this.listenerCount == 0) {
+ this.broker.setAddonListener(null);
+ }
+ }
+}
+
+WebAPI.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIDOMGlobalPropertyInitializer",
+]);
+WebAPI.prototype.classID = Components.ID(
+ "{8866d8e3-4ea5-48b7-a891-13ba0ac15235}"
+);
diff --git a/toolkit/mozapps/extensions/components.conf b/toolkit/mozapps/extensions/components.conf
new file mode 100644
index 0000000000..680aa57c07
--- /dev/null
+++ b/toolkit/mozapps/extensions/components.conf
@@ -0,0 +1,45 @@
+# -*- 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 = [
+ {
+ 'js_name': 'blocklist',
+ 'cid': '{66354bc9-7ed1-4692-ae1d-8da97d6b205e}',
+ 'contract_ids': ['@mozilla.org/extensions/blocklist;1'],
+ 'esModule': 'resource://gre/modules/amManager.sys.mjs',
+ 'constructor': 'BlocklistService',
+ 'interfaces': ['nsIBlocklistService'],
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ },
+ {
+ 'cid': '{4399533d-08d1-458c-a87a-235f74451cfa}',
+ 'contract_ids': ['@mozilla.org/addons/integration;1'],
+ 'esModule': 'resource://gre/modules/amManager.sys.mjs',
+ 'constructor': 'amManager',
+ },
+ {
+ 'cid': '{9df8ef2b-94da-45c9-ab9f-132eb55fddf1}',
+ 'contract_ids': ['@mozilla.org/addons/installtrigger;1'],
+ 'esModule': 'resource://gre/modules/amInstallTrigger.sys.mjs',
+ 'constructor': 'InstallTrigger',
+ },
+ {
+ 'cid': '{8866d8e3-4ea5-48b7-a891-13ba0ac15235}',
+ 'contract_ids': ['@mozilla.org/addon-web-api/manager;1'],
+ 'esModule': 'resource://gre/modules/amWebAPI.sys.mjs',
+ 'constructor': 'WebAPI',
+ },
+]
+
+if buildconfig.substs['MOZ_WIDGET_TOOLKIT'] != 'android':
+ Classes += [
+ {
+ 'cid': '{7beb3ba8-6ec3-41b4-b67c-da89b8518922}',
+ 'contract_ids': ['@mozilla.org/uriloader/content-handler;1?type=application/x-xpinstall'],
+ 'esModule': 'resource://gre/modules/amContentHandler.sys.mjs',
+ 'constructor': 'amContentHandler',
+ },
+ ]
diff --git a/toolkit/mozapps/extensions/content/OpenH264-license.txt b/toolkit/mozapps/extensions/content/OpenH264-license.txt
new file mode 100644
index 0000000000..ad37989b8c
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/OpenH264-license.txt
@@ -0,0 +1,59 @@
+-------------------------------------------------------
+About The Cisco-Provided Binary of OpenH264 Video Codec
+-------------------------------------------------------
+
+Cisco provides this program under the terms of the BSD license.
+
+Additionally, this binary is licensed under Cisco’s AVC/H.264 Patent Portfolio License from MPEG LA, at no cost to you, provided that the requirements and conditions shown below in the AVC/H.264 Patent Portfolio sections are met.
+
+As with all AVC/H.264 codecs, you may also obtain your own patent license from MPEG LA or from the individual patent owners, or proceed at your own risk. Your rights from Cisco under the BSD license are not affected by this choice.
+
+For more information on the OpenH264 binary licensing, please see the OpenH264 FAQ found at http://www.openh264.org/faq.html#binary
+
+A corresponding source code to this binary program is available under the same BSD terms, which can be found at http://www.openh264.org
+
+-----------
+BSD License
+-----------
+
+Copyright © 2014 Cisco Systems, Inc.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. 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.
+
+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 HOLDER 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.
+
+-----------------------------------------
+AVC/H.264 Patent Portfolio License Notice
+-----------------------------------------
+
+The binary form of this Software is distributed by Cisco under the AVC/H.264 Patent Portfolio License from MPEG LA, and is subject to the following requirements, which may or may not be applicable to your use of this software:
+
+THIS PRODUCT IS LICENSED UNDER THE AVC PATENT PORTFOLIO LICENSE FOR THE PERSONAL USE OF A CONSUMER OR OTHER USES IN WHICH IT DOES NOT RECEIVE REMUNERATION TO (i) ENCODE VIDEO IN COMPLIANCE WITH THE AVC STANDARD (“AVC VIDEO”) AND/OR (ii) DECODE AVC VIDEO THAT WAS ENCODED BY A CONSUMER ENGAGED IN A PERSONAL ACTIVITY AND/OR WAS OBTAINED FROM A VIDEO PROVIDER LICENSED TO PROVIDE AVC VIDEO. NO LICENSE IS GRANTED OR SHALL BE IMPLIED FOR ANY OTHER USE. ADDITIONAL INFORMATION MAY BE OBTAINED FROM MPEG LA, L.L.C. SEE HTTP://WWW.MPEGLA.COM
+
+Accordingly, please be advised that content providers and broadcasters using AVC/H.264 in their service may be required to obtain a separate use license from MPEG LA, referred to as "(b) sublicenses" in the SUMMARY OF AVC/H.264 LICENSE TERMS from MPEG LA found at http://www.openh264.org/mpegla
+
+---------------------------------------------
+AVC/H.264 Patent Portfolio License Conditions
+---------------------------------------------
+
+In addition, the Cisco-provided binary of this Software is licensed under Cisco's license from MPEG LA only if the following conditions are met:
+
+1. The Cisco-provided binary is separately downloaded to an end user’s device, and not integrated into or combined with third party software prior to being downloaded to the end user’s device;
+
+2. The end user must have the ability to control (e.g., to enable, disable, or re-enable) the use of the Cisco-provided binary;
+
+3. Third party software, in the location where end users can control the use of the Cisco-provided binary, must display the following text:
+
+ "OpenH264 Video Codec provided by Cisco Systems, Inc."
+
+4. Any third-party software that makes use of the Cisco-provided binary must reproduce all of the above text, as well as this last condition, in the EULA and/or in another location where licensing information is to be presented to the end user.
+
+
+
+ v1.0
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.css b/toolkit/mozapps/extensions/content/aboutaddons.css
new file mode 100644
index 0000000000..3f4e80797a
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -0,0 +1,759 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+ --addon-icon-size: 32px;
+ --main-margin-start: 28px;
+ --section-width: 664px;
+ --sidebar-width: var(--in-content-sidebar-width);
+ --z-index-sticky-container: 5;
+ --z-index-popup: 10;
+}
+
+@media (max-width: 830px) {
+ :root {
+ --main-margin-start: 16px;
+ /* Maintain a main margin so card shadows don't overlap the sidebar. */
+ --sidebar-width: calc(var(--in-content-sidebar-width) - var(--main-margin-start));
+ }
+}
+
+*|*[hidden] {
+ display: none !important;
+}
+
+body {
+ cursor: default;
+ /* The page starts to look really bad lower than this. */
+ min-width: 500px;
+}
+
+h1 {
+ font-size: var(--font-size-xlarge);
+}
+
+h2 {
+ font-size: var(--font-size-large);
+}
+
+#full {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) 1fr;
+}
+
+#sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ margin: 0;
+ overflow: hidden auto;
+}
+
+@media (prefers-reduced-motion) {
+ /* Setting border-inline-end on #sidebar makes it a focusable element */
+ #sidebar::after {
+ content: "";
+ width: 1px;
+ height: 100%;
+ background-color: var(--in-content-border-color);
+ top: 0;
+ inset-inline-end: 0;
+ position: absolute;
+ }
+}
+
+#categories {
+ display: flex;
+ flex-direction: column;
+ padding-inline-end: 4px; /* Leave space for the button focus styles. */
+}
+
+.category {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ margin-block: 0;
+ align-items: center;
+ font-weight: normal;
+}
+
+.category[badge-count]::after {
+ display: inline-block;
+ min-width: 20px;
+ background-color: var(--in-content-accent-color);
+ color: var(--in-content-primary-button-text-color);
+ font-weight: bold;
+ /* Use a large border-radius to get semi-circles on the sides. */
+ border-radius: 1000px;
+ padding: 2px 6px;
+ content: attr(badge-count);
+ text-align: center;
+ margin-inline-start: 8px;
+ grid-column: 2;
+}
+
+.category[name="discover"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-discover.svg");
+}
+.category[name="locale"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-languages.svg");
+}
+.category[name="extension"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-extensions.svg");
+}
+.category[name="theme"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-themes.svg");
+}
+.category[name="plugin"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-plugins.svg");
+}
+.category[name="dictionary"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-dictionaries.svg");
+}
+.category[name="available-updates"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-available.svg");
+}
+.category[name="recent-updates"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-recent.svg");
+}
+.category[name="sitepermission"] {
+ background-image: url("chrome://mozapps/skin/extensions/category-sitepermission.svg");
+}
+
+.sticky-container {
+ background: var(--in-content-page-background);
+ width: 100%;
+ position: sticky;
+ top: 0;
+ z-index: var(--z-index-sticky-container);
+}
+
+.main-search {
+ background: var(--in-content-page-background);
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ padding-inline-start: 28px;
+ padding-top: 20px;
+ padding-bottom: 30px;
+ max-width: var(--section-width);
+}
+
+search-addons > search-textbox {
+ margin: 0;
+ width: 20em;
+ min-height: 32px;
+}
+
+.search-label {
+ margin-inline-end: 8px;
+}
+
+.main-heading {
+ background: var(--in-content-page-background);
+ display: flex;
+ margin-inline-start: var(--main-margin-start);
+ padding-bottom: 16px;
+ max-width: var(--section-width);
+}
+
+.spacer {
+ flex-grow: 1;
+}
+
+#updates-message {
+ display: flex;
+ align-items: center;
+ margin-inline-end: 8px;
+}
+
+.back-button {
+ margin-inline-end: 16px;
+}
+
+/* Plugins aren't yet disabled by safemode (bug 342333),
+ so don't show that warning when viewing plugins. */
+#page-header[current-param="plugin"] moz-message-bar[warning-type="safe-mode"] {
+ display: none;
+}
+
+#main {
+ margin-inline-start: var(--main-margin-start);
+ margin-bottom: 28px;
+ max-width: var(--section-width);
+}
+
+global-warnings,
+#abuse-reports-messages {
+ margin-inline-start: var(--main-margin-start);
+ max-width: var(--section-width);
+}
+
+/* The margin between message bars. */
+message-bar-stack > * {
+ margin-bottom: 8px;
+}
+
+/* List sections */
+
+.list-section-heading {
+ margin-bottom: 16px;
+}
+
+.list-section-subheading {
+ font-size: 0.9em;
+ font-weight: 400;
+ margin-block-start: 0.5em;
+}
+
+.section {
+ margin-bottom: 32px;
+}
+
+/* Add-on cards */
+
+.addon.card {
+ margin-bottom: 16px;
+ transition: opacity 150ms, box-shadow 150ms;
+}
+
+addon-list:not([type="theme"]) addon-card:not([expanded]):not([panelopen]) > .addon.card[active="false"]:not(:focus-within):not(:hover) {
+ opacity: 0.6;
+}
+
+.addon.card:hover {
+ box-shadow: var(--card-shadow);
+}
+
+addon-card:not([expanded]) > .addon.card:hover {
+ box-shadow: var(--card-shadow-hover);
+ cursor: pointer;
+}
+
+addon-card[expanded] .addon.card {
+ padding-bottom: 0;
+}
+
+.addon-card-collapsed {
+ display: flex;
+}
+
+addon-list addon-card > .addon.card {
+ user-select: none;
+}
+
+.addon-card-message,
+.update-postponed-bar {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ margin: 8px calc(var(--card-padding) * -1) calc(var(--card-padding) * -1);
+}
+
+addon-card[expanded] .addon-card-message,
+addon-card[expanded] .update-postponed-bar {
+ border-radius: 0;
+ margin-bottom: 0;
+}
+
+addon-card[expanded] .update-postponed-bar + .addon-card-message {
+ /* Remove margin between the two message bars when they are both
+ * visible in the detail view */
+ margin-top: 0px;
+}
+
+.update-postponed-bar + .addon-card-message:not([hidden]) {
+ /* Prevent the small overlapping between the two message bars
+ * when they are both visible at the same time one after the
+ * other on the same addon card */
+ margin-top: 12px;
+}
+
+/* Theme preview image. */
+.card-heading-image {
+ /* If the width, height or aspect ratio changes, don't forget to update the
+ * getScreenshotUrlForAddon function in aboutaddons.js */
+ width: var(--section-width);
+ /* Adjust height so that the image preserves the aspect ratio from AMO.
+ * For details, see https://bugzilla.mozilla.org/show_bug.cgi?id=1546123 */
+ height: calc(var(--section-width) * 92 / 680);
+ object-fit: cover;
+}
+
+.card-heading-icon {
+ flex-shrink: 0;
+ width: var(--addon-icon-size);
+ height: var(--addon-icon-size);
+ margin-inline-end: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.card-contents {
+ word-break: break-word;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.addon-name-container {
+ /* Subtract the top line-height so the text and icon align at the top. */
+ margin-top: -3px;
+ display: flex;
+ align-items: center;
+}
+
+.addon-name {
+ font-size: 16px;
+ font-weight: var(--font-weight-bold);
+ line-height: 22px;
+ margin: 0;
+ margin-inline-end: 8px;
+}
+
+.addon-name-link,
+.addon-name-link:hover {
+ color: var(--in-content-text-color);
+ text-decoration: none;
+}
+
+.addon-name-link:-moz-focusring {
+ /* Since the parent is overflow:hidden to ellipsize the regular outline is hidden. */
+ outline-offset: -1px;
+ outline-width: 1px;
+}
+
+.addon-badge {
+ display: inline-block;
+ margin-inline-end: 8px;
+ width: 22px;
+ height: 22px;
+ background-repeat: no-repeat;
+ background-position: center;
+ flex-shrink: 0;
+ border-radius: 11px;
+ -moz-context-properties: fill;
+ fill: #fff;
+}
+
+.addon-badge-private-browsing-allowed {
+ background-image: url("chrome://global/skin/icons/indicator-private-browsing.svg");
+}
+
+.addon-badge-recommended {
+ background-color: var(--orange-50);
+ background-image: url("chrome://mozapps/skin/extensions/recommended.svg");
+}
+
+.addon-badge-line {
+ background-color: #fff;
+ background-image: url("chrome://mozapps/skin/extensions/line.svg");
+ background-size: 16px;
+ border-radius: 10px;
+ border: 1px solid #CFCFD8;
+ width: 20px;
+ height: 20px;
+}
+
+.addon-badge-verified {
+ background-color: var(--green-70);
+ background-image: url("chrome://global/skin/icons/check.svg");
+}
+
+.theme-enable-button {
+ min-width: auto;
+ font-size: var(--font-size-small);
+ min-height: auto;
+ height: 24px;
+ margin: 0;
+ padding: 0 8px;
+ font-weight: normal;
+}
+
+.addon-description {
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--text-color-deemphasized);
+ font-weight: 400;
+}
+
+/* Prevent the content from wrapping unless expanded. */
+addon-card:not([expanded]) .card-contents {
+ /* We're hiding the content when it's too long, so we need to define the
+ * width. As long as this is less than the width of its parent it works. */
+ width: 1px;
+ white-space: nowrap;
+}
+
+/* Ellipsize if the content is too long. */
+addon-card:not([expanded]) .addon-name,
+addon-card:not([expanded]) .addon-description {
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+}
+
+.page-options-menu {
+ align-self: center;
+}
+
+.page-options-menu > .more-options-button {
+ background-image: url("chrome://global/skin/icons/settings.svg");
+ width: 32px;
+ height: 32px;
+}
+
+/* Recommended add-ons on list views */
+.recommended-heading {
+ margin-bottom: 24px;
+ margin-top: 48px;
+}
+
+/* Discopane extensions to the add-on card */
+
+recommended-addon-card .addon-description:not(:empty) {
+ margin-top: 0.5em;
+}
+
+.disco-card-head {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.disco-addon-name {
+ font-size: inherit;
+ font-weight: normal;
+ line-height: normal;
+ margin: 0;
+}
+
+.disco-addon-author {
+ font-size: 12px;
+ font-weight: normal;
+}
+
+.disco-description-statistics {
+ margin-top: 1em;
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ grid-column-gap: 2em;
+ align-items: center;
+}
+
+.disco-cta-button {
+ font-size: 14px;
+ flex-shrink: 0;
+ flex-grow: 0;
+ align-self: baseline;
+ margin-inline-end: 0;
+}
+
+.discopane-notice {
+ margin: 24px 0;
+}
+
+.view-footer {
+ text-align: center;
+}
+
+.view-footer-item {
+ margin-top: 30px;
+}
+
+.privacy-policy-link {
+ font-size: small;
+}
+
+.theme-recommendation {
+ text-align: start;
+}
+
+addon-details {
+ color: var(--text-color-deemphasized);
+}
+
+.addon-detail-description-wrapper {
+ margin: 16px 0;
+}
+
+.addon-detail-description-collapse .addon-detail-description {
+ max-height: 20rem;
+ overflow: hidden;
+}
+
+/* Include button to beat out .button-link which is below this */
+button.addon-detail-description-toggle {
+ display: flex;
+ align-items: center;
+ margin-top: 8px;
+ font-weight: normal;
+ gap: 4px;
+}
+
+.addon-detail-description-toggle::after {
+ content: "";
+ display: block;
+ background-image: url("chrome://global/skin/icons/arrow-up-12.svg");
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 12px;
+ height: 12px;
+}
+
+.addon-detail-description-collapse .addon-detail-description-toggle::after {
+ transform: scaleY(-1);
+}
+
+.addon-detail-contribute {
+ display: flex;
+ padding: var(--card-padding);
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 4px;
+ margin-bottom: var(--card-padding);
+ flex-direction: column;
+}
+
+.addon-detail-contribute > label {
+ font-style: italic;
+}
+
+.addon-detail-contribute-button {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://global/skin/icons/heart.svg");
+ background-repeat: no-repeat;
+ background-position: 8px;
+ padding-inline-start: 28px;
+ margin-top: var(--card-padding);
+ margin-bottom: 0;
+ align-self: flex-end;
+}
+
+.addon-detail-contribute-button:dir(rtl) {
+ background-position-x: right 8px;
+}
+
+.addon-detail-sitepermissions,
+.addon-detail-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid var(--in-content-border-color);
+ margin: 0 calc(var(--card-padding) * -1);
+ padding: var(--card-padding);
+ color: var(--in-content-text-color);
+}
+
+.addon-detail-row.addon-detail-help-row {
+ display: block;
+ color: var(--text-color-deemphasized);
+ padding-top: 4px;
+ padding-bottom: var(--card-padding);
+ border: none;
+}
+
+.addon-detail-row-has-help {
+ padding-bottom: 0;
+}
+
+.addon-detail-row input[type="checkbox"] {
+ margin: 0;
+}
+
+.addon-detail-actions,
+.addon-detail-rating {
+ display: flex;
+}
+
+.addon-detail-actions {
+ gap: 20px;
+}
+
+.addon-detail-actions > label {
+ flex-wrap: wrap;
+}
+
+.addon-detail-rating > a {
+ margin-inline-start: 8px;
+}
+
+.more-options-button {
+ min-width: auto;
+ min-height: auto;
+ width: 24px;
+ height: 24px;
+ margin: 0;
+ margin-inline-start: 8px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://global/skin/icons/more.svg");
+ background-repeat: no-repeat;
+ background-position: center center;
+ /* Get the -badged ::after element in the right spot. */
+ padding: 1px;
+ display: flex;
+ justify-content: flex-end;
+}
+
+.more-options-button-badged::after {
+ content: "";
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background-color: var(--in-content-accent-color);;
+}
+
+panel-item[action="remove"]::part(button) {
+ background-image: url("chrome://global/skin/icons/delete.svg");
+}
+
+panel-item[action="install-update"]::part(button) {
+ background-image: url("chrome://global/skin/icons/update-icon.svg");
+}
+
+panel-item[action="report"]::part(button) {
+ background-image: url(chrome://global/skin/icons/warning.svg);
+}
+
+.hide-amo-link .amo-link-container {
+ display: none;
+}
+
+.button-link {
+ min-height: auto;
+ background: none !important;
+ padding: 0;
+ margin: 0;
+ color: var(--link-color) !important;
+ cursor: pointer;
+ border: none;
+}
+
+.button-link:hover {
+ color: var(--link-color-hover) !important;
+ text-decoration: underline;
+}
+
+.button-link:active {
+ color: var(--link-color-active) !important;
+ text-decoration: none;
+}
+
+.inline-options-stack {
+ /* If the options browser triggers an alert we need room to show it. */
+ min-height: 250px;
+ width: 100%;
+ background-color: white;
+ margin-block: 4px;
+}
+
+addon-permissions-list > .addon-detail-row {
+ border-top: none;
+}
+
+.addon-permissions-list {
+ list-style-type: none;
+ margin: 0;
+ padding-inline-start: 8px;
+}
+
+.addon-permissions-list > li {
+ border: none;
+ padding-block: 4px;
+ padding-inline-start: 2rem;
+ background-image: none;
+ background-position: 0 center;
+ background-size: 1.6rem 1.6rem;
+ background-repeat: no-repeat;
+}
+
+.addon-permissions-list > li:dir(rtl) {
+ background-position-x: right 0;
+}
+
+/* using a list-style-image prevents aligning the image */
+.addon-permissions-list > li.permission-checked {
+ background-image: url("chrome://global/skin/icons/check.svg");
+ -moz-context-properties: fill;
+ fill: var(--green-60);
+}
+
+.permission-header {
+ font-size: 1em;
+}
+
+.tab-group {
+ display: block;
+ margin-top: 8px;
+ /* Pull the buttons flush with the side of the card */
+ margin-inline: calc(var(--card-padding) * -1);
+ border-bottom: 1px solid var(--in-content-border-color);
+ border-top: 1px solid var(--in-content-border-color);
+ font-size: 0;
+ line-height: 0;
+}
+
+button.tab-button {
+ appearance: none;
+ border-inline: none;
+ border-block: 2px solid transparent;
+ border-radius: 0;
+ background: transparent;
+ font-size: 14px;
+ line-height: 20px;
+ margin: 0;
+ padding: 4px 16px;
+}
+
+button.tab-button:hover {
+ border-top-color: var(--in-content-box-border-color);
+}
+
+button.tab-button[selected],
+button.tab-button[selected]:hover {
+ border-top-color: currentColor;
+ color: var(--in-content-accent-color);
+}
+
+@media (prefers-contrast) {
+ button.tab-button[selected],
+ button.tab-button[selected]:hover {
+ color: var(--in-content-primary-button-text-color);
+ background-color: var(--in-content-primary-button-background);
+ }
+}
+
+button.tab-button:-moz-focusring {
+ outline-offset: -2px;
+}
+
+.tab-group[last-input-type="mouse"] > button.tab-button:-moz-focusring {
+ outline: none;
+ box-shadow: none;
+}
+
+section:not(:empty) ~ #empty-addons-message {
+ display: none;
+}
+
+@media (max-width: 830px) {
+ .category[badge-count]::after {
+ content: "";
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ min-width: auto;
+ padding: 0;
+ /* move the badged dot into the top-end (right in ltr, left in rtl) corner. */
+ margin-top: -20px;
+ }
+}
+
+.permission-header > .addon-sitepermissions-host {
+ font-weight: bolder;
+}
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.html b/toolkit/mozapps/extensions/content/aboutaddons.html
new file mode 100644
index 0000000000..55d6625c08
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -0,0 +1,825 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <title data-l10n-id="addons-page-title"></title>
+
+ <!-- Bug 1571346 Remove 'unsafe-inline' from style-src within about:addons -->
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; style-src chrome: 'unsafe-inline'; img-src chrome: file: jar: https: http:; connect-src chrome: data: https: http:; object-src 'none'"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="stylesheet" href="chrome://global/content/tabprompts.css" />
+ <link rel="stylesheet" href="chrome://global/skin/tabprompts.css" />
+
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/aboutaddons.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/shortcuts.css"
+ />
+
+ <link
+ rel="shortcut icon"
+ href="chrome://mozapps/skin/extensions/extension.svg"
+ />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/about/aboutAddons.ftl" />
+ <link rel="localization" href="toolkit/about/abuseReports.ftl" />
+
+ <!-- Defer scripts so all the templates are loaded by the time they run. -->
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/aboutaddonsCommon.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/abuse-reports.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/shortcuts.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/drag-drop-addon-installer.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/view-controller.js"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/aboutaddons.js"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-message-bar.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-toggle.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-support-link.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-five-star.mjs"
+ ></script>
+ </head>
+ <body>
+ <drag-drop-addon-installer></drag-drop-addon-installer>
+ <div id="full">
+ <div id="sidebar">
+ <categories-box id="categories" orientation="vertical">
+ <button
+ is="discover-button"
+ viewid="addons://discover/"
+ class="category"
+ role="tab"
+ name="discover"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/extension"
+ class="category"
+ role="tab"
+ name="extension"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/theme"
+ class="category"
+ role="tab"
+ name="theme"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/plugin"
+ class="category"
+ role="tab"
+ name="plugin"
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/dictionary"
+ class="category"
+ role="tab"
+ name="dictionary"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/locale"
+ class="category"
+ role="tab"
+ name="locale"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://list/sitepermission"
+ class="category"
+ role="tab"
+ name="sitepermission"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://updates/available"
+ class="category"
+ role="tab"
+ name="available-updates"
+ hidden
+ default-hidden
+ ></button>
+ <button
+ is="category-button"
+ viewid="addons://updates/recent"
+ class="category"
+ role="tab"
+ name="recent-updates"
+ hidden
+ default-hidden
+ ></button>
+ </categories-box>
+ <div class="spacer"></div>
+ <sidebar-footer></sidebar-footer>
+ </div>
+ <div id="content">
+ <addon-page-header
+ id="page-header"
+ page-options-id="page-options"
+ ></addon-page-header>
+ <addon-page-options id="page-options"></addon-page-options>
+
+ <message-bar-stack
+ id="abuse-reports-messages"
+ reverse
+ max-message-bar-count="3"
+ >
+ </message-bar-stack>
+
+ <div id="main"></div>
+ </div>
+ </div>
+
+ <proxy-context-menu id="contentAreaContextMenu"></proxy-context-menu>
+
+ <template name="addon-page-header">
+ <div class="sticky-container">
+ <div class="main-search">
+ <label
+ for="search-addons"
+ class="search-label"
+ data-l10n-id="default-heading-search-label"
+ ></label>
+ <search-addons></search-addons>
+ </div>
+ <div class="main-heading">
+ <button
+ class="back-button"
+ action="go-back"
+ data-l10n-id="header-back-button"
+ hidden
+ ></button>
+ <h1 class="header-name"></h1>
+ <div class="spacer"></div>
+ <addon-updates-message
+ id="updates-message"
+ hidden
+ ></addon-updates-message>
+ <div class="page-options-menu">
+ <button
+ class="more-options-button"
+ action="page-options"
+ aria-haspopup="menu"
+ aria-expanded="false"
+ data-l10n-id="addon-page-options-button"
+ ></button>
+ </div>
+ </div>
+ </div>
+ <global-warnings></global-warnings>
+ </template>
+
+ <template name="addon-page-options">
+ <panel-list>
+ <panel-item
+ action="check-for-updates"
+ data-l10n-id="addon-updates-check-for-updates"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ action="view-recent-updates"
+ data-l10n-id="addon-updates-view-updates"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ action="install-from-file"
+ data-l10n-id="addon-install-from-file"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ action="debug-addons"
+ data-l10n-id="addon-open-about-debugging"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ action="set-update-automatically"
+ data-l10n-id="addon-updates-update-addons-automatically"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <panel-item
+ action="reset-update-states"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ <hr />
+ <panel-item
+ action="manage-shortcuts"
+ data-l10n-id="addon-manage-extensions-shortcuts"
+ data-l10n-attrs="accesskey"
+ ></panel-item>
+ </panel-list>
+ </template>
+
+ <template name="addon-options">
+ <panel-list>
+ <panel-item
+ data-l10n-id="remove-addon-button"
+ action="remove"
+ ></panel-item>
+ <panel-item
+ data-l10n-id="install-update-button"
+ action="install-update"
+ badged
+ ></panel-item>
+ <panel-item
+ data-l10n-id="preferences-addon-button"
+ action="preferences"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="report-addon-button"
+ action="report"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="manage-addon-button"
+ action="expand"
+ ></panel-item>
+ </panel-list>
+ </template>
+
+ <template name="plugin-options">
+ <panel-list>
+ <panel-item
+ data-l10n-id="always-activate-button"
+ action="always-activate"
+ ></panel-item>
+ <panel-item
+ data-l10n-id="never-activate-button"
+ action="never-activate"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="preferences-addon-button"
+ action="preferences"
+ ></panel-item>
+ <hr />
+ <panel-item
+ data-l10n-id="manage-addon-button"
+ action="expand"
+ ></panel-item>
+ </panel-list>
+ </template>
+
+ <template name="addon-permissions-list">
+ <div class="addon-permissions-required" hidden>
+ <h2
+ class="permission-header"
+ data-l10n-id="addon-permissions-required"
+ ></h2>
+ <ul class="addon-permissions-list"></ul>
+ </div>
+ <div class="addon-permissions-optional" hidden>
+ <h2
+ class="permission-header"
+ data-l10n-id="addon-permissions-optional"
+ ></h2>
+ <ul class="addon-permissions-list"></ul>
+ </div>
+ <div
+ class="addon-detail-row addon-permissions-empty"
+ data-l10n-id="addon-permissions-empty"
+ hidden
+ ></div>
+ <div class="addon-detail-row">
+ <a
+ is="moz-support-link"
+ support-page="extension-permissions"
+ data-l10n-id="addon-permissions-learnmore"
+ ></a>
+ </div>
+ </template>
+
+ <template name="addon-sitepermissions-list">
+ <div class="addon-permissions-required" hidden>
+ <h2
+ class="permission-header"
+ data-l10n-id="addon-sitepermissions-required"
+ >
+ <span
+ data-l10n-name="hostname"
+ class="addon-sitepermissions-host"
+ ></span>
+ </h2>
+ <ul class="addon-permissions-list"></ul>
+ </div>
+ </template>
+
+ <template name="card">
+ <div class="card addon">
+ <img class="card-heading-image" role="presentation" />
+ <div class="addon-card-collapsed">
+ <img class="card-heading-icon addon-icon" alt="" />
+ <div class="card-contents">
+ <div class="addon-name-container">
+ <a
+ class="addon-badge addon-badge-recommended"
+ is="moz-support-link"
+ support-page="add-on-badges"
+ utm-content="promoted-addon-badge"
+ data-l10n-id="addon-badge-recommended2"
+ hidden
+ >
+ </a>
+ <a
+ class="addon-badge addon-badge-line"
+ is="moz-support-link"
+ support-page="add-on-badges"
+ utm-content="promoted-addon-badge"
+ data-l10n-id="addon-badge-line3"
+ hidden
+ >
+ </a>
+ <a
+ class="addon-badge addon-badge-verified"
+ is="moz-support-link"
+ support-page="add-on-badges"
+ utm-content="promoted-addon-badge"
+ data-l10n-id="addon-badge-verified2"
+ hidden
+ >
+ </a>
+ <a
+ class="addon-badge addon-badge-private-browsing-allowed"
+ is="moz-support-link"
+ support-page="extensions-pb"
+ data-l10n-id="addon-badge-private-browsing-allowed2"
+ hidden
+ >
+ </a>
+ <div class="spacer"></div>
+ <button
+ class="theme-enable-button"
+ action="toggle-disabled"
+ hidden
+ ></button>
+ <moz-toggle
+ class="extension-enable-button"
+ action="toggle-disabled"
+ data-l10n-id="extension-enable-addon-button-label"
+ hidden
+ ></moz-toggle>
+ <button
+ class="more-options-button"
+ action="more-options"
+ data-l10n-id="addon-options-button"
+ aria-haspopup="menu"
+ aria-expanded="false"
+ ></button>
+ </div>
+ <!-- This ends up in the tab order when the ellipsis happens, but it isn't necessary. -->
+ <span class="addon-description" tabindex="-1"></span>
+ </div>
+ </div>
+ <moz-message-bar
+ class="update-postponed-bar"
+ data-l10n-id="install-postponed-message2"
+ data-l10n-attrs="message"
+ align="center"
+ hidden
+ >
+ <button
+ slot="actions"
+ action="install-postponed"
+ data-l10n-id="install-postponed-button"
+ ></button>
+ </moz-message-bar>
+ <moz-message-bar class="addon-card-message" align="center" hidden>
+ <button action="link"></button>
+ </moz-message-bar>
+ </div>
+ </template>
+
+ <template name="addon-name-container-in-disco-card">
+ <div class="disco-card-head">
+ <h3 class="disco-addon-name"></h3>
+ <span class="disco-addon-author"
+ ><a data-l10n-name="author" target="_blank"></a
+ ></span>
+ </div>
+ <button class="disco-cta-button" action="install-addon"></button>
+ <button
+ class="disco-cta-button"
+ data-l10n-id="manage-addon-button"
+ action="manage-addon"
+ ></button>
+ </template>
+
+ <template name="addon-description-in-disco-card">
+ <div>
+ <span class="disco-description-main"></span>
+ </div>
+ <div class="disco-description-statistics">
+ <moz-five-star></moz-five-star>
+ <span class="disco-user-count"></span>
+ </div>
+ </template>
+
+ <template name="addon-details">
+ <button-group class="tab-group">
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="details"
+ data-l10n-id="details-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="preferences"
+ data-l10n-id="preferences-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="permissions"
+ data-l10n-id="permissions-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ <button
+ is="named-deck-button"
+ deck="details-deck"
+ name="release-notes"
+ data-l10n-id="release-notes-addon-button"
+ class="tab-button ghost-button"
+ ></button>
+ </button-group>
+ <named-deck id="details-deck" is-tabbed>
+ <section name="details">
+ <div class="addon-detail-description-wrapper">
+ <div class="addon-detail-description"></div>
+ <button
+ class="button-link addon-detail-description-toggle"
+ data-l10n-id="addon-detail-description-expand"
+ hidden
+ ></button>
+ </div>
+ <div class="addon-detail-contribute">
+ <label data-l10n-id="detail-contributions-description"></label>
+ <button
+ class="addon-detail-contribute-button"
+ action="contribute"
+ data-l10n-id="detail-contributions-button"
+ data-l10n-attrs="accesskey"
+ ></button>
+ </div>
+ <div class="addon-detail-sitepermissions">
+ <addon-sitepermissions-list></addon-sitepermissions-list>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-updates"
+ role="group"
+ data-l10n-id="addon-detail-group-label-updates"
+ >
+ <span data-l10n-id="addon-detail-updates-label"></span>
+ <div class="addon-detail-actions">
+ <button
+ class="button-link"
+ data-l10n-id="addon-detail-update-check-label"
+ action="update-check"
+ hidden
+ ></button>
+ <label class="radio-container-with-text">
+ <input type="radio" name="autoupdate" value="1" />
+ <span data-l10n-id="addon-detail-updates-radio-default"></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input type="radio" name="autoupdate" value="2" />
+ <span data-l10n-id="addon-detail-updates-radio-on"></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input type="radio" name="autoupdate" value="0" />
+ <span data-l10n-id="addon-detail-updates-radio-off"></span>
+ </label>
+ </div>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing"
+ role="group"
+ data-l10n-id="addon-detail-group-label-private-browsing"
+ hidden
+ >
+ <span data-l10n-id="detail-private-browsing-label"></span>
+ <div class="addon-detail-actions">
+ <label class="radio-container-with-text">
+ <input type="radio" name="private-browsing" value="1" />
+ <span data-l10n-id="addon-detail-private-browsing-allow"></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input type="radio" name="private-browsing" value="0" />
+ <span
+ data-l10n-id="addon-detail-private-browsing-disallow"
+ ></span>
+ </label>
+ </div>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-help-row"
+ data-l10n-id="addon-detail-private-browsing-help"
+ hidden
+ >
+ <a
+ is="moz-support-link"
+ support-page="extensions-pb"
+ data-l10n-name="learn-more"
+ ></a>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing-disallowed"
+ hidden
+ >
+ <label data-l10n-id="detail-private-disallowed-label"></label>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-help-row"
+ data-l10n-id="detail-private-disallowed-description2"
+ hidden
+ >
+ <a
+ is="moz-support-link"
+ data-l10n-name="learn-more"
+ support-page="extensions-pb"
+ ></a>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-private-browsing-required"
+ hidden
+ >
+ <label
+ class="learn-more-label-link"
+ data-l10n-id="detail-private-required-label"
+ ></label>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-help-row"
+ data-l10n-id="detail-private-required-description2"
+ hidden
+ >
+ <a
+ is="moz-support-link"
+ data-l10n-name="learn-more"
+ support-page="extensions-pb"
+ ></a>
+ </div>
+ <div
+ class="addon-detail-row addon-detail-row-has-help addon-detail-row-quarantined-domains"
+ role="group"
+ data-l10n-id="addon-detail-group-label-quarantined-domains"
+ hidden
+ >
+ <span data-l10n-id="addon-detail-quarantined-domains-label"></span>
+ <div class="addon-detail-actions">
+ <label class="radio-container-with-text">
+ <input
+ type="radio"
+ name="quarantined-domains-user-allowed"
+ value="1"
+ />
+ <span
+ data-l10n-id="addon-detail-quarantined-domains-allow"
+ ></span>
+ </label>
+ <label class="radio-container-with-text">
+ <input
+ type="radio"
+ name="quarantined-domains-user-allowed"
+ value="0"
+ />
+ <span
+ data-l10n-id="addon-detail-quarantined-domains-disallow"
+ ></span>
+ </label>
+ </div>
+ </div>
+ <div class="addon-detail-row addon-detail-help-row" hidden>
+ <span data-l10n-id="addon-detail-quarantined-domains-help"></span>
+ <a is="moz-support-link" support-page="quarantined-domains"></a>
+ </div>
+ <div class="addon-detail-row addon-detail-row-author">
+ <label data-l10n-id="addon-detail-author-label"></label>
+ <a target="_blank"></a>
+ </div>
+ <div class="addon-detail-row addon-detail-row-version">
+ <label data-l10n-id="addon-detail-version-label"></label>
+ </div>
+ <div class="addon-detail-row addon-detail-row-lastUpdated">
+ <label data-l10n-id="addon-detail-last-updated-label"></label>
+ </div>
+ <div class="addon-detail-row addon-detail-row-homepage">
+ <label data-l10n-id="addon-detail-homepage-label"></label>
+ <!-- URLs should always be displayed as LTR. -->
+ <a target="_blank" dir="ltr"></a>
+ </div>
+ <div class="addon-detail-row addon-detail-row-rating">
+ <label data-l10n-id="addon-detail-rating-label"></label>
+ <div class="addon-detail-rating">
+ <moz-five-star></moz-five-star>
+ <a target="_blank"></a>
+ </div>
+ </div>
+ </section>
+ <inline-options-browser name="preferences"></inline-options-browser>
+ <addon-permissions-list name="permissions"></addon-permissions-list>
+ <update-release-notes name="release-notes"></update-release-notes>
+ </named-deck>
+ </template>
+
+ <template name="taar-notice">
+ <moz-message-bar
+ class="discopane-notice"
+ data-l10n-id="discopane-notice-recommendations2"
+ data-l10n-attrs="message"
+ dismissable
+ >
+ <a
+ is="moz-support-link"
+ support-page="personalized-addons"
+ data-l10n-id="discopane-notice-learn-more"
+ action="notice-learn-more"
+ slot="support-link"
+ ></a>
+ </moz-message-bar>
+ </template>
+
+ <template name="recommended-footer">
+ <div class="amo-link-container view-footer-item">
+ <button
+ class="primary"
+ action="open-amo"
+ data-l10n-id="find-more-addons"
+ ></button>
+ </div>
+ <div class="view-footer-item">
+ <a
+ class="privacy-policy-link"
+ data-l10n-id="privacy-policy"
+ target="_blank"
+ ></a>
+ </div>
+ </template>
+
+ <template name="discopane">
+ <header>
+ <p>
+ <span data-l10n-id="discopane-intro">
+ <a
+ class="discopane-intro-learn-more-link"
+ is="moz-support-link"
+ support-page="recommended-extensions-program"
+ data-l10n-name="learn-more-trigger"
+ >
+ </a>
+ </span>
+ </p>
+ </header>
+ <taar-notice></taar-notice>
+ <recommended-addon-list></recommended-addon-list>
+ <footer is="recommended-footer" class="view-footer"></footer>
+ </template>
+
+ <template name="recommended-extensions-section">
+ <h2
+ data-l10n-id="recommended-extensions-heading"
+ class="header-name recommended-heading"
+ ></h2>
+ <taar-notice></taar-notice>
+ <recommended-addon-list
+ type="extension"
+ hide-installed
+ ></recommended-addon-list>
+ <footer is="recommended-footer" class="view-footer"></footer>
+ </template>
+
+ <template name="recommended-themes-footer">
+ <p data-l10n-id="recommended-theme-1" class="theme-recommendation">
+ <a data-l10n-name="link" target="_blank"></a>
+ </p>
+ <div class="amo-link-container view-footer-item">
+ <button
+ class="primary"
+ action="open-amo"
+ data-l10n-id="find-more-themes"
+ ></button>
+ </div>
+ </template>
+
+ <template name="recommended-themes-section">
+ <h2
+ data-l10n-id="recommended-themes-heading"
+ class="header-name recommended-heading"
+ ></h2>
+ <recommended-addon-list
+ type="theme"
+ hide-installed
+ ></recommended-addon-list>
+ <footer is="recommended-themes-footer" class="view-footer"></footer>
+ </template>
+
+ <template id="shortcut-view">
+ <div class="error-message">
+ <img
+ class="error-message-icon"
+ src="chrome://global/skin/arrow/panelarrow-vertical.svg"
+ />
+ <div class="error-message-label"></div>
+ </div>
+ <message-bar-stack
+ id="duplicate-warning-messages"
+ reverse
+ max-message-bar-count="5"
+ >
+ </message-bar-stack>
+ </template>
+
+ <template id="shortcut-card-template">
+ <div class="card shortcut">
+ <div class="card-heading">
+ <img class="card-heading-icon addon-icon" />
+ <h2 class="addon-name"></h2>
+ </div>
+ </div>
+ </template>
+
+ <template id="shortcut-row-template">
+ <div class="shortcut-row">
+ <label class="shortcut-label"></label>
+ <input
+ class="shortcut-input"
+ data-l10n-id="shortcuts-input"
+ type="text"
+ readonly
+ />
+ <button
+ class="shortcut-remove-button ghost-button"
+ data-l10n-id="shortcuts-remove-button"
+ data-l10n-attrs="aria-label"
+ ></button>
+ </div>
+ </template>
+
+ <template id="expand-row-template">
+ <div class="expand-row">
+ <button class="expand-button"></button>
+ </div>
+ </template>
+
+ <template id="shortcuts-no-addons">
+ <div data-l10n-id="shortcuts-no-addons"></div>
+ </template>
+
+ <template id="shortcuts-no-commands-template">
+ <div data-l10n-id="shortcuts-no-commands"></div>
+ <ul class="shortcuts-no-commands-list"></ul>
+ </template>
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/content/aboutaddons.js b/toolkit/mozapps/extensions/content/aboutaddons.js
new file mode 100644
index 0000000000..37687a8be7
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -0,0 +1,4231 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+/* import-globals-from aboutaddonsCommon.js */
+/* import-globals-from abuse-reports.js */
+/* import-globals-from view-controller.js */
+/* global windowRoot */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+ ClientID: "resource://gre/modules/ClientID.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "manifestV3enabled",
+ "extensions.manifestV3.enabled"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "SUPPORT_URL",
+ "app.support.baseURL",
+ "",
+ null,
+ val => Services.urlFormatter.formatURL(val)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "XPINSTALL_ENABLED",
+ "xpinstall.enabled",
+ true
+);
+
+const UPDATES_RECENT_TIMESPAN = 2 * 24 * 3600000; // 2 days (in milliseconds)
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "ABUSE_REPORT_ENABLED",
+ "extensions.abuseReport.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "LIST_RECOMMENDATIONS_ENABLED",
+ "extensions.htmlaboutaddons.recommendations.enabled",
+ false
+);
+
+const PLUGIN_ICON_URL = "chrome://global/skin/icons/plugin.svg";
+const EXTENSION_ICON_URL =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+const PERMISSION_MASKS = {
+ enable: AddonManager.PERM_CAN_ENABLE,
+ "always-activate": AddonManager.PERM_CAN_ENABLE,
+ disable: AddonManager.PERM_CAN_DISABLE,
+ "never-activate": AddonManager.PERM_CAN_DISABLE,
+ uninstall: AddonManager.PERM_CAN_UNINSTALL,
+ upgrade: AddonManager.PERM_CAN_UPGRADE,
+ "change-privatebrowsing": AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS,
+};
+
+const PREF_DISCOVERY_API_URL = "extensions.getAddons.discovery.api_url";
+const PREF_THEME_RECOMMENDATION_URL =
+ "extensions.recommendations.themeRecommendationUrl";
+const PREF_RECOMMENDATION_HIDE_NOTICE = "extensions.recommendations.hideNotice";
+const PREF_PRIVACY_POLICY_URL = "extensions.recommendations.privacyPolicyUrl";
+const PREF_RECOMMENDATION_ENABLED = "browser.discovery.enabled";
+const PREF_TELEMETRY_ENABLED = "datareporting.healthreport.uploadEnabled";
+const PRIVATE_BROWSING_PERM_NAME = "internal:privateBrowsingAllowed";
+const PRIVATE_BROWSING_PERMS = {
+ permissions: [PRIVATE_BROWSING_PERM_NAME],
+ origins: [],
+};
+
+const L10N_ID_MAPPING = {
+ "theme-disabled-heading": "theme-disabled-heading2",
+};
+
+function getL10nIdMapping(id) {
+ return L10N_ID_MAPPING[id] || id;
+}
+
+function shouldSkipAnimations() {
+ return (
+ document.body.hasAttribute("skip-animations") ||
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function callListeners(name, args, listeners) {
+ for (let listener of listeners) {
+ try {
+ if (name in listener) {
+ listener[name](...args);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ }
+}
+
+function getUpdateInstall(addon) {
+ return (
+ // Install object for a pending update.
+ addon.updateInstall ||
+ // Install object for a postponed upgrade (only for extensions,
+ // because is the only addon type that can postpone their own
+ // updates).
+ (addon.type === "extension" &&
+ addon.pendingUpgrade &&
+ addon.pendingUpgrade.install)
+ );
+}
+
+function isManualUpdate(install) {
+ let isManual =
+ install.existingAddon &&
+ !AddonManager.shouldAutoUpdate(install.existingAddon);
+ let isExtension =
+ install.existingAddon && install.existingAddon.type == "extension";
+ return (
+ (isManual && isInState(install, "available")) ||
+ (isExtension && isInState(install, "postponed"))
+ );
+}
+
+const AddonManagerListenerHandler = {
+ listeners: new Set(),
+
+ addListener(listener) {
+ this.listeners.add(listener);
+ },
+
+ removeListener(listener) {
+ this.listeners.delete(listener);
+ },
+
+ delegateEvent(name, args) {
+ callListeners(name, args, this.listeners);
+ },
+
+ startup() {
+ this._listener = new Proxy(
+ {},
+ {
+ has: () => true,
+ get:
+ (_, name) =>
+ (...args) =>
+ this.delegateEvent(name, args),
+ }
+ );
+ AddonManager.addAddonListener(this._listener);
+ AddonManager.addInstallListener(this._listener);
+ AddonManager.addManagerListener(this._listener);
+ this._permissionHandler = (type, data) => {
+ if (type == "change-permissions") {
+ this.delegateEvent("onChangePermissions", [data]);
+ }
+ };
+ ExtensionPermissions.addListener(this._permissionHandler);
+ },
+
+ shutdown() {
+ AddonManager.removeAddonListener(this._listener);
+ AddonManager.removeInstallListener(this._listener);
+ AddonManager.removeManagerListener(this._listener);
+ ExtensionPermissions.removeListener(this._permissionHandler);
+ },
+};
+
+/**
+ * This object wires the AddonManager event listeners into addon-card and
+ * addon-details elements rather than needing to add/remove listeners all the
+ * time as the view changes.
+ */
+const AddonCardListenerHandler = new Proxy(
+ {},
+ {
+ has: () => true,
+ get(_, name) {
+ return (...args) => {
+ let elements = [];
+ let addonId;
+
+ // We expect args[0] to be of type:
+ // - AddonInstall, on AddonManager install events
+ // - AddonWrapper, on AddonManager addon events
+ // - undefined, on AddonManager manage events
+ if (args[0]) {
+ addonId =
+ args[0].addon?.id ||
+ args[0].existingAddon?.id ||
+ args[0].extensionId ||
+ args[0].id;
+ }
+
+ if (addonId) {
+ let cardSelector = `addon-card[addon-id="${addonId}"]`;
+ elements = document.querySelectorAll(
+ `${cardSelector}, ${cardSelector} addon-details`
+ );
+ } else if (name == "onUpdateModeChanged") {
+ elements = document.querySelectorAll("addon-card");
+ }
+
+ callListeners(name, args, elements);
+ };
+ },
+ }
+);
+AddonManagerListenerHandler.addListener(AddonCardListenerHandler);
+
+function isAbuseReportSupported(addon) {
+ return (
+ ABUSE_REPORT_ENABLED &&
+ AbuseReporter.isSupportedAddonType(addon.type) &&
+ !(addon.isBuiltin || addon.isSystem)
+ );
+}
+
+async function isAllowedInPrivateBrowsing(addon) {
+ // Use the Promise directly so this function stays sync for the other case.
+ let perms = await ExtensionPermissions.get(addon.id);
+ return perms.permissions.includes(PRIVATE_BROWSING_PERM_NAME);
+}
+
+function hasPermission(addon, permission) {
+ return !!(addon.permissions & PERMISSION_MASKS[permission]);
+}
+
+function isInState(install, state) {
+ return install.state == AddonManager["STATE_" + state.toUpperCase()];
+}
+
+async function getAddonMessageInfo(addon) {
+ const { name } = addon;
+ const { STATE_BLOCKED, STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
+
+ if (addon.blocklistState === STATE_BLOCKED) {
+ return {
+ linkUrl: await addon.getBlocklistURL(),
+ linkId: "details-notification-blocked-link",
+ messageId: "details-notification-blocked2",
+ messageArgs: { name },
+ type: "error",
+ };
+ } else if (isDisabledUnsigned(addon)) {
+ return {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ linkId: "details-notification-unsigned-and-disabled-link",
+ messageId: "details-notification-unsigned-and-disabled2",
+ messageArgs: { name },
+ type: "error",
+ };
+ } else if (
+ !addon.isCompatible &&
+ (AddonManager.checkCompatibility ||
+ addon.blocklistState !== STATE_SOFTBLOCKED)
+ ) {
+ return {
+ messageId: "details-notification-incompatible2",
+ messageArgs: { name, version: Services.appinfo.version },
+ type: "error",
+ };
+ } else if (!isCorrectlySigned(addon)) {
+ return {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ linkId: "details-notification-unsigned-link",
+ messageId: "details-notification-unsigned2",
+ messageArgs: { name },
+ type: "warning",
+ };
+ } else if (addon.blocklistState === STATE_SOFTBLOCKED) {
+ return {
+ linkUrl: await addon.getBlocklistURL(),
+ linkId: "details-notification-softblocked-link",
+ messageId: "details-notification-softblocked2",
+ messageArgs: { name },
+ type: "warning",
+ };
+ } else if (addon.isGMPlugin && !addon.isInstalled && addon.isActive) {
+ return {
+ messageId: "details-notification-gmp-pending2",
+ messageArgs: { name },
+ type: "warning",
+ };
+ }
+ return {};
+}
+
+function checkForUpdate(addon) {
+ return new Promise(resolve => {
+ let listener = {
+ onUpdateAvailable(addon, install) {
+ if (AddonManager.shouldAutoUpdate(addon)) {
+ // Make sure that an update handler is attached to all the install
+ // objects when updated xpis are going to be installed automatically.
+ attachUpdateHandler(install);
+
+ let failed = () => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: false, pending: false, found: true });
+ };
+ let updateListener = {
+ onDownloadFailed: failed,
+ onInstallCancelled: failed,
+ onInstallFailed: failed,
+ onInstallEnded: (...args) => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: true, pending: false, found: true });
+ },
+ onInstallPostponed: (...args) => {
+ detachUpdateHandler(install);
+ install.removeListener(updateListener);
+ resolve({ installed: false, pending: true, found: true });
+ },
+ };
+ install.addListener(updateListener);
+ install.install();
+ } else {
+ resolve({ installed: false, pending: true, found: true });
+ }
+ },
+ onNoUpdateAvailable() {
+ resolve({ found: false });
+ },
+ };
+ addon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ });
+}
+
+async function checkForUpdates() {
+ let addons = await AddonManager.getAddonsByTypes(null);
+ addons = addons.filter(addon => hasPermission(addon, "upgrade"));
+ let updates = await Promise.all(addons.map(addon => checkForUpdate(addon)));
+ gViewController.notifyEMUpdateCheckFinished();
+ return updates.reduce(
+ (counts, update) => ({
+ installed: counts.installed + (update.installed ? 1 : 0),
+ pending: counts.pending + (update.pending ? 1 : 0),
+ found: counts.found + (update.found ? 1 : 0),
+ }),
+ { installed: 0, pending: 0, found: 0 }
+ );
+}
+
+// Don't change how we handle this while the page is open.
+const INLINE_OPTIONS_ENABLED = Services.prefs.getBoolPref(
+ "extensions.htmlaboutaddons.inline-options.enabled"
+);
+const OPTIONS_TYPE_MAP = {
+ [AddonManager.OPTIONS_TYPE_TAB]: "tab",
+ [AddonManager.OPTIONS_TYPE_INLINE_BROWSER]: INLINE_OPTIONS_ENABLED
+ ? "inline"
+ : "tab",
+};
+
+// Check if an add-on has the provided options type, accounting for the pref
+// to disable inline options.
+function getOptionsType(addon, type) {
+ return OPTIONS_TYPE_MAP[addon.optionsType];
+}
+
+// Check whether the options page can be loaded in the current browser window.
+async function isAddonOptionsUIAllowed(addon) {
+ if (addon.type !== "extension" || !getOptionsType(addon)) {
+ // Themes never have options pages.
+ // Some plugins have preference pages, and they can always be shown.
+ // Extensions do not need to be checked if they do not have options pages.
+ return true;
+ }
+ if (!PrivateBrowsingUtils.isContentWindowPrivate(window)) {
+ return true;
+ }
+ if (addon.incognito === "not_allowed") {
+ return false;
+ }
+ // The current page is in a private browsing window, and the add-on does not
+ // have the permission to access private browsing windows. Block access.
+ return (
+ // Note: This function is async because isAllowedInPrivateBrowsing is async.
+ isAllowedInPrivateBrowsing(addon)
+ );
+}
+
+let _templates = {};
+
+/**
+ * Import a template from the main document.
+ */
+function importTemplate(name) {
+ if (!_templates.hasOwnProperty(name)) {
+ _templates[name] = document.querySelector(`template[name="${name}"]`);
+ }
+ let template = _templates[name];
+ if (template) {
+ return document.importNode(template.content, true);
+ }
+ throw new Error(`Unknown template: ${name}`);
+}
+
+function nl2br(text) {
+ let frag = document.createDocumentFragment();
+ let hasAppended = false;
+ for (let part of text.split("\n")) {
+ if (hasAppended) {
+ frag.appendChild(document.createElement("br"));
+ }
+ frag.appendChild(new Text(part));
+ hasAppended = true;
+ }
+ return frag;
+}
+
+/**
+ * Select the screeenshot to display above an add-on card.
+ *
+ * @param {AddonWrapper|DiscoAddonWrapper} addon
+ * @returns {string|null}
+ * The URL of the best fitting screenshot, if any.
+ */
+function getScreenshotUrlForAddon(addon) {
+ if (addon.id == "default-theme@mozilla.org") {
+ return "chrome://mozapps/content/extensions/default-theme/preview.svg";
+ }
+ const builtInThemePreview = BuiltInThemes.previewForBuiltInThemeId(addon.id);
+ if (builtInThemePreview) {
+ return builtInThemePreview;
+ }
+
+ let { screenshots } = addon;
+ if (!screenshots || !screenshots.length) {
+ return null;
+ }
+
+ // The image size is defined at .card-heading-image in aboutaddons.css, and
+ // is based on the aspect ratio for a 680x92 image. Use the image if possible,
+ // and otherwise fall back to the first image and hope for the best.
+ let screenshot = screenshots.find(s => s.width === 680 && s.height === 92);
+ if (!screenshot) {
+ console.warn(`Did not find screenshot with desired size for ${addon.id}.`);
+ screenshot = screenshots[0];
+ }
+ return screenshot.url;
+}
+
+/**
+ * Adds UTM parameters to a given URL, if it is an AMO URL.
+ *
+ * @param {string} contentAttribute
+ * Identifies the part of the UI with which the link is associated.
+ * @param {string} url
+ * @returns {string}
+ * The url with UTM parameters if it is an AMO URL.
+ * Otherwise the url in unmodified form.
+ */
+function formatUTMParams(contentAttribute, url) {
+ let parsedUrl = new URL(url);
+ let domain = `.${parsedUrl.hostname}`;
+ if (
+ !domain.endsWith(".mozilla.org") &&
+ // For testing: addons-dev.allizom.org and addons.allizom.org
+ !domain.endsWith(".allizom.org")
+ ) {
+ return url;
+ }
+
+ parsedUrl.searchParams.set("utm_source", "firefox-browser");
+ parsedUrl.searchParams.set("utm_medium", "firefox-browser");
+ parsedUrl.searchParams.set("utm_content", contentAttribute);
+ return parsedUrl.href;
+}
+
+// A wrapper around an item from the "results" array from AMO's discovery API.
+// See https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+class DiscoAddonWrapper {
+ /**
+ * @param {object} details
+ * An item in the "results" array from AMO's discovery API.
+ */
+ constructor(details) {
+ // Reuse AddonRepository._parseAddon to have the AMO response parsing logic
+ // in one place.
+ let repositoryAddon = AddonRepository._parseAddon(details.addon);
+
+ // Note: Any property used by RecommendedAddonCard should appear here.
+ // The property names and values should have the same semantics as
+ // AddonWrapper, to ease the reuse of helper functions in this file.
+ this.id = repositoryAddon.id;
+ this.type = repositoryAddon.type;
+ this.name = repositoryAddon.name;
+ this.screenshots = repositoryAddon.screenshots;
+ this.sourceURI = repositoryAddon.sourceURI;
+ this.creator = repositoryAddon.creator;
+ this.averageRating = repositoryAddon.averageRating;
+
+ this.dailyUsers = details.addon.average_daily_users;
+
+ this.editorialDescription = details.description_text;
+ this.iconURL = details.addon.icon_url;
+ this.amoListingUrl = details.addon.url;
+
+ this.taarRecommended = details.is_recommendation;
+ }
+}
+
+/**
+ * A helper to retrieve the list of recommended add-ons via AMO's discovery API.
+ */
+var DiscoveryAPI = {
+ // Map<boolean, Promise> Promises from fetching the API results with or
+ // without a client ID. The `false` (no client ID) case could actually
+ // have been fetched with a client ID. See getResults() for more info.
+ _resultPromises: new Map(),
+
+ /**
+ * Fetch the list of recommended add-ons. The results are cached.
+ *
+ * Pending requests are coalesced, so there is only one request at any given
+ * time. If a request fails, the pending promises are rejected, but a new
+ * call will result in a new request. A succesful response is cached for the
+ * lifetime of the document.
+ *
+ * @param {boolean} preferClientId
+ * A boolean indicating a preference for using a client ID.
+ * This will not overwrite the user preference but will
+ * avoid sending a client ID if no request has been made yet.
+ * @returns {Promise<DiscoAddonWrapper[]>}
+ */
+ async getResults(preferClientId = true) {
+ // Allow a caller to set preferClientId to false, but not true if discovery
+ // is disabled.
+ preferClientId = preferClientId && this.clientIdDiscoveryEnabled;
+
+ // Reuse a request for this preference first.
+ let resultPromise =
+ this._resultPromises.get(preferClientId) ||
+ // If the client ID isn't preferred, we can still reuse a request with the
+ // client ID.
+ (!preferClientId && this._resultPromises.get(true));
+
+ if (resultPromise) {
+ return resultPromise;
+ }
+
+ // Nothing is prepared for this preference, make a new request.
+ resultPromise = this._fetchRecommendedAddons(preferClientId).catch(e => {
+ // Delete the pending promise, so _fetchRecommendedAddons can be
+ // called again at the next property access.
+ this._resultPromises.delete(preferClientId);
+ Cu.reportError(e);
+ throw e;
+ });
+
+ // Store the new result for the preference.
+ this._resultPromises.set(preferClientId, resultPromise);
+
+ return resultPromise;
+ },
+
+ get clientIdDiscoveryEnabled() {
+ // These prefs match Discovery.sys.mjs for enabling clientId cookies.
+ return (
+ Services.prefs.getBoolPref(PREF_RECOMMENDATION_ENABLED, false) &&
+ Services.prefs.getBoolPref(PREF_TELEMETRY_ENABLED, false) &&
+ !PrivateBrowsingUtils.isContentWindowPrivate(window)
+ );
+ },
+
+ async _fetchRecommendedAddons(useClientId) {
+ let discoveryApiUrl = new URL(
+ Services.urlFormatter.formatURLPref(PREF_DISCOVERY_API_URL)
+ );
+
+ if (useClientId) {
+ let clientId = await ClientID.getClientIdHash();
+ discoveryApiUrl.searchParams.set("telemetry-client-id", clientId);
+ }
+ let res = await fetch(discoveryApiUrl.href, {
+ credentials: "omit",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to fetch recommended add-ons, ${res.status}`);
+ }
+ let { results } = await res.json();
+ return results.map(details => new DiscoAddonWrapper(details));
+ },
+};
+
+class SearchAddons extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.input = document.createXULElement("search-textbox");
+ this.input.setAttribute("searchbutton", true);
+ this.input.setAttribute("maxlength", 100);
+ this.input.setAttribute("data-l10n-attrs", "placeholder");
+ document.l10n.setAttributes(this.input, "addons-heading-search-input");
+ this.append(this.input);
+ }
+ this.input.addEventListener("command", this);
+ }
+
+ disconnectedCallback() {
+ this.input.removeEventListener("command", this);
+ }
+
+ handleEvent(e) {
+ if (e.type === "command") {
+ this.searchAddons(this.value);
+ }
+ }
+
+ get value() {
+ return this.input.value;
+ }
+
+ searchAddons(query) {
+ if (query.length === 0) {
+ return;
+ }
+
+ let url = formatUTMParams(
+ "addons-manager-search",
+ AddonRepository.getSearchURL(query)
+ );
+
+ let browser = getBrowserElement();
+ let chromewin = browser.ownerGlobal;
+ chromewin.openWebLinkIn(url, "tab");
+ }
+}
+customElements.define("search-addons", SearchAddons);
+
+class MessageBarStackElement extends HTMLElement {
+ constructor() {
+ super();
+ this._observer = null;
+ const shadowRoot = this.attachShadow({ mode: "open" });
+ shadowRoot.append(this.constructor.template.content.cloneNode(true));
+ }
+
+ connectedCallback() {
+ // Close any message bar that should be allowed based on the
+ // maximum number of message bars.
+ this.closeMessageBars();
+
+ // Observe mutations to close older bars when new ones have been
+ // added.
+ this._observer = new MutationObserver(() => {
+ this._observer.disconnect();
+ this.closeMessageBars();
+ this._observer.observe(this, { childList: true });
+ });
+ this._observer.observe(this, { childList: true });
+ }
+
+ disconnectedCallback() {
+ this._observer.disconnect();
+ this._observer = null;
+ }
+
+ closeMessageBars() {
+ const { maxMessageBarCount } = this;
+ if (maxMessageBarCount > 1) {
+ // Remove the older message bars if the stack reached the
+ // maximum number of message bars allowed.
+ while (this.childElementCount > maxMessageBarCount) {
+ this.firstElementChild.remove();
+ }
+ }
+ }
+
+ get maxMessageBarCount() {
+ return parseInt(this.getAttribute("max-message-bar-count"), 10);
+ }
+
+ static get template() {
+ const template = document.createElement("template");
+
+ const style = document.createElement("style");
+ // Render the stack in the reverse order if the stack has the
+ // reverse attribute set.
+ style.textContent = `
+ :host {
+ display: block;
+ }
+ :host([reverse]) > slot {
+ display: flex;
+ flex-direction: column-reverse;
+ }
+ `;
+ template.content.append(style);
+ template.content.append(document.createElement("slot"));
+
+ Object.defineProperty(this, "template", {
+ value: template,
+ });
+
+ return template;
+ }
+}
+
+customElements.define("message-bar-stack", MessageBarStackElement);
+
+class GlobalWarnings extends MessageBarStackElement {
+ constructor() {
+ super();
+ // This won't change at runtime, but we'll want to fake it in tests.
+ this.inSafeMode = Services.appinfo.inSafeMode;
+ this.globalWarning = null;
+ }
+
+ connectedCallback() {
+ this.refresh();
+ this.addEventListener("click", this);
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ refresh() {
+ if (this.inSafeMode) {
+ this.setWarning("safe-mode");
+ } else if (
+ AddonManager.checkUpdateSecurityDefault &&
+ !AddonManager.checkUpdateSecurity
+ ) {
+ this.setWarning("update-security", { action: true });
+ } else if (!AddonManager.checkCompatibility) {
+ this.setWarning("check-compatibility", { action: true });
+ } else if (AMBrowserExtensionsImport.canCompleteOrCancelInstalls) {
+ this.setWarning("imported-addons", { action: true });
+ } else {
+ this.removeWarning();
+ }
+ }
+
+ setWarning(type, opts) {
+ if (
+ this.globalWarning &&
+ this.globalWarning.getAttribute("warning-type") !== type
+ ) {
+ this.removeWarning();
+ }
+ if (!this.globalWarning) {
+ this.globalWarning = document.createElement("moz-message-bar");
+ this.globalWarning.setAttribute("warning-type", type);
+ let { messageId, buttonId } = this.getGlobalWarningL10nIds(type);
+ document.l10n.setAttributes(this.globalWarning, messageId);
+ this.globalWarning.setAttribute("data-l10n-attrs", "message");
+ if (opts && opts.action) {
+ let button = document.createElement("button");
+ document.l10n.setAttributes(button, buttonId);
+ button.setAttribute("action", type);
+ button.setAttribute("slot", "actions");
+ this.globalWarning.appendChild(button);
+ }
+ this.appendChild(this.globalWarning);
+ }
+ }
+
+ getGlobalWarningL10nIds(type) {
+ const WARNING_TYPE_TO_L10NID_MAPPING = {
+ "safe-mode": {
+ messageId: "extensions-warning-safe-mode2",
+ },
+ "update-security": {
+ messageId: "extensions-warning-update-security2",
+ buttonId: "extensions-warning-update-security-button",
+ },
+ "check-compatibility": {
+ messageId: "extensions-warning-check-compatibility2",
+ buttonId: "extensions-warning-check-compatibility-button",
+ },
+ "imported-addons": {
+ messageId: "extensions-warning-imported-addons2",
+ buttonId: "extensions-warning-imported-addons-button",
+ },
+ };
+
+ return WARNING_TYPE_TO_L10NID_MAPPING[type];
+ }
+
+ removeWarning() {
+ if (this.globalWarning) {
+ this.globalWarning.remove();
+ this.globalWarning = null;
+ }
+ }
+
+ handleEvent(e) {
+ if (e.type === "click") {
+ switch (e.target.getAttribute("action")) {
+ case "update-security":
+ AddonManager.checkUpdateSecurity = true;
+ break;
+ case "check-compatibility":
+ AddonManager.checkCompatibility = true;
+ break;
+ case "imported-addons":
+ AMBrowserExtensionsImport.completeInstalls();
+ break;
+ }
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onCompatibilityModeChanged() {
+ this.refresh();
+ }
+
+ onCheckUpdateSecurityChanged() {
+ this.refresh();
+ }
+
+ onBrowserExtensionsImportChanged() {
+ this.refresh();
+ }
+}
+customElements.define("global-warnings", GlobalWarnings);
+
+class AddonPageHeader extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.appendChild(importTemplate("addon-page-header"));
+ this.heading = this.querySelector(".header-name");
+ this.backButton = this.querySelector(".back-button");
+ this.pageOptionsMenuButton = this.querySelector(
+ '[action="page-options"]'
+ );
+ // The addon-page-options element is outside of this element since this is
+ // position: sticky and that would break the positioning of the menu.
+ this.pageOptionsMenu = document.getElementById(
+ this.getAttribute("page-options-id")
+ );
+ }
+ document.addEventListener("view-selected", this);
+ this.addEventListener("click", this);
+ this.addEventListener("mousedown", this);
+ // Use capture since the event is actually triggered on the internal
+ // panel-list and it doesn't bubble.
+ this.pageOptionsMenu.addEventListener("shown", this, true);
+ this.pageOptionsMenu.addEventListener("hidden", this, true);
+ }
+
+ disconnectedCallback() {
+ document.removeEventListener("view-selected", this);
+ this.removeEventListener("click", this);
+ this.removeEventListener("mousedown", this);
+ this.pageOptionsMenu.removeEventListener("shown", this, true);
+ this.pageOptionsMenu.removeEventListener("hidden", this, true);
+ }
+
+ setViewInfo({ type, param }) {
+ this.setAttribute("current-view", type);
+ this.setAttribute("current-param", param);
+ let viewType = type === "list" ? param : type;
+ this.setAttribute("type", viewType);
+
+ this.heading.hidden = viewType === "detail";
+ this.backButton.hidden = viewType !== "detail" && viewType !== "shortcuts";
+
+ this.backButton.disabled = !history.state?.previousView;
+
+ if (viewType !== "detail") {
+ document.l10n.setAttributes(this.heading, `${viewType}-heading`);
+ }
+ }
+
+ handleEvent(e) {
+ let { backButton, pageOptionsMenu, pageOptionsMenuButton } = this;
+ if (e.type === "click") {
+ switch (e.target) {
+ case backButton:
+ window.history.back();
+ break;
+ case pageOptionsMenuButton:
+ if (e.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ this.pageOptionsMenu.toggle(e);
+ }
+ break;
+ }
+ } else if (
+ e.type == "mousedown" &&
+ e.target == pageOptionsMenuButton &&
+ e.button == 0
+ ) {
+ this.pageOptionsMenu.toggle(e);
+ } else if (
+ e.target == pageOptionsMenu.panel &&
+ (e.type == "shown" || e.type == "hidden")
+ ) {
+ this.pageOptionsMenuButton.setAttribute(
+ "aria-expanded",
+ this.pageOptionsMenu.open
+ );
+ } else if (e.target == document && e.type == "view-selected") {
+ const { type, param } = e.detail;
+ this.setViewInfo({ type, param });
+ }
+ }
+}
+customElements.define("addon-page-header", AddonPageHeader);
+
+class AddonUpdatesMessage extends HTMLElement {
+ static get observedAttributes() {
+ return ["state"];
+ }
+
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open" });
+ let style = document.createElement("style");
+ style.textContent = `
+ @import "chrome://global/skin/in-content/common.css";
+ button {
+ margin: 0;
+ }
+ `;
+ this.message = document.createElement("span");
+ this.message.hidden = true;
+ this.button = document.createElement("button");
+ this.button.addEventListener("click", e => {
+ if (e.button === 0) {
+ gViewController.loadView("updates/available");
+ }
+ });
+ this.button.hidden = true;
+ this.shadowRoot.append(style, this.message, this.button);
+ }
+
+ connectedCallback() {
+ document.l10n.connectRoot(this.shadowRoot);
+ document.l10n.translateFragment(this.shadowRoot);
+ }
+
+ disconnectedCallback() {
+ document.l10n.disconnectRoot(this.shadowRoot);
+ }
+
+ attributeChangedCallback(name, oldVal, newVal) {
+ if (name === "state" && oldVal !== newVal) {
+ let l10nId = `addon-updates-${newVal}`;
+ switch (newVal) {
+ case "updating":
+ case "installed":
+ case "none-found":
+ this.button.hidden = true;
+ this.message.hidden = false;
+ document.l10n.setAttributes(this.message, l10nId);
+ break;
+ case "manual-updates-found":
+ this.message.hidden = true;
+ this.button.hidden = false;
+ document.l10n.setAttributes(this.button, l10nId);
+ break;
+ }
+ }
+ }
+
+ set state(val) {
+ this.setAttribute("state", val);
+ }
+}
+customElements.define("addon-updates-message", AddonUpdatesMessage);
+
+class AddonPageOptions extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount === 0) {
+ this.render();
+ }
+ this.addEventListener("click", this);
+ this.panel.addEventListener("showing", this);
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ this.panel.removeEventListener("showing", this);
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ toggle(...args) {
+ return this.panel.toggle(...args);
+ }
+
+ get open() {
+ return this.panel.open;
+ }
+
+ render() {
+ this.appendChild(importTemplate("addon-page-options"));
+ this.panel = this.querySelector("panel-list");
+ this.installFromFile = this.querySelector('[action="install-from-file"]');
+ this.toggleUpdatesEl = this.querySelector(
+ '[action="set-update-automatically"]'
+ );
+ this.resetUpdatesEl = this.querySelector('[action="reset-update-states"]');
+ this.onUpdateModeChanged();
+ }
+
+ async handleEvent(e) {
+ if (e.type === "click") {
+ e.target.disabled = true;
+ try {
+ await this.onClick(e);
+ } finally {
+ e.target.disabled = false;
+ }
+ } else if (e.type === "showing") {
+ this.installFromFile.hidden = !XPINSTALL_ENABLED;
+ }
+ }
+
+ async onClick(e) {
+ switch (e.target.getAttribute("action")) {
+ case "check-for-updates":
+ await this.checkForUpdates();
+ break;
+ case "view-recent-updates":
+ gViewController.loadView("updates/recent");
+ break;
+ case "install-from-file":
+ if (XPINSTALL_ENABLED) {
+ installAddonsFromFilePicker();
+ }
+ break;
+ case "debug-addons":
+ this.openAboutDebugging();
+ break;
+ case "set-update-automatically":
+ await this.toggleAutomaticUpdates();
+ break;
+ case "reset-update-states":
+ await this.resetAutomaticUpdates();
+ break;
+ case "manage-shortcuts":
+ gViewController.loadView("shortcuts/shortcuts");
+ break;
+ }
+ }
+
+ async checkForUpdates(e) {
+ let message = document.getElementById("updates-message");
+ message.state = "updating";
+ message.hidden = false;
+ let { installed, pending } = await checkForUpdates();
+ if (pending > 0) {
+ message.state = "manual-updates-found";
+ } else if (installed > 0) {
+ message.state = "installed";
+ } else {
+ message.state = "none-found";
+ }
+ }
+
+ openAboutDebugging() {
+ let mainWindow = window.windowRoot.ownerGlobal;
+ if ("switchToTabHavingURI" in mainWindow) {
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ mainWindow.switchToTabHavingURI(
+ `about:debugging#/runtime/this-firefox`,
+ true,
+ {
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal: principal,
+ }
+ );
+ }
+ }
+
+ automaticUpdatesEnabled() {
+ return AddonManager.updateEnabled && AddonManager.autoUpdateDefault;
+ }
+
+ toggleAutomaticUpdates() {
+ if (!this.automaticUpdatesEnabled()) {
+ // One or both of the prefs is false, i.e. the checkbox is not
+ // checked. Now toggle both to true. If the user wants us to
+ // auto-update add-ons, we also need to auto-check for updates.
+ AddonManager.updateEnabled = true;
+ AddonManager.autoUpdateDefault = true;
+ } else {
+ // Both prefs are true, i.e. the checkbox is checked.
+ // Toggle the auto pref to false, but don't touch the enabled check.
+ AddonManager.autoUpdateDefault = false;
+ }
+ }
+
+ async resetAutomaticUpdates() {
+ let addons = await AddonManager.getAllAddons();
+ for (let addon of addons) {
+ if ("applyBackgroundUpdates" in addon) {
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+ }
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onUpdateModeChanged() {
+ let updatesEnabled = this.automaticUpdatesEnabled();
+ this.toggleUpdatesEl.checked = updatesEnabled;
+ let resetType = updatesEnabled ? "automatic" : "manual";
+ let resetStringId = `addon-updates-reset-updates-to-${resetType}`;
+ document.l10n.setAttributes(this.resetUpdatesEl, resetStringId);
+ }
+}
+customElements.define("addon-page-options", AddonPageOptions);
+
+class CategoryButton extends HTMLButtonElement {
+ connectedCallback() {
+ if (this.childElementCount != 0) {
+ return;
+ }
+
+ // Make sure the aria-selected attribute is set correctly.
+ this.selected = this.hasAttribute("selected");
+
+ document.l10n.setAttributes(this, `addon-category-${this.name}-title`);
+
+ let text = document.createElement("span");
+ text.classList.add("category-name");
+ document.l10n.setAttributes(text, `addon-category-${this.name}`);
+
+ this.append(text);
+ }
+
+ load() {
+ gViewController.loadView(this.viewId);
+ }
+
+ get isVisible() {
+ // Make a category button visible only if the related addon type is
+ // supported by the AddonManager Providers actually registered to
+ // the AddonManager.
+ return AddonManager.hasAddonType(this.name);
+ }
+
+ get badgeCount() {
+ return parseInt(this.getAttribute("badge-count"), 10) || 0;
+ }
+
+ set badgeCount(val) {
+ let count = parseInt(val, 10);
+ if (count) {
+ this.setAttribute("badge-count", count);
+ } else {
+ this.removeAttribute("badge-count");
+ }
+ }
+
+ get selected() {
+ return this.hasAttribute("selected");
+ }
+
+ set selected(val) {
+ this.toggleAttribute("selected", !!val);
+ this.setAttribute("aria-selected", !!val);
+ }
+
+ get name() {
+ return this.getAttribute("name");
+ }
+
+ get viewId() {
+ return this.getAttribute("viewid");
+ }
+
+ // Just setting the hidden attribute isn't enough in case the category gets
+ // hidden while about:addons is closed since it could be the last active view
+ // which will unhide the button when it gets selected.
+ get defaultHidden() {
+ return this.hasAttribute("default-hidden");
+ }
+}
+customElements.define("category-button", CategoryButton, { extends: "button" });
+
+class DiscoverButton extends CategoryButton {
+ get isVisible() {
+ return isDiscoverEnabled();
+ }
+}
+customElements.define("discover-button", DiscoverButton, { extends: "button" });
+
+// Create the button-group element so it gets loaded.
+document.createElement("button-group");
+class CategoriesBox extends customElements.get("button-group") {
+ constructor() {
+ super();
+ // This will resolve when the initial category states have been set from
+ // our cached prefs. This is intended for use in testing to verify that we
+ // are caching the previous state.
+ this.promiseRendered = new Promise(resolve => {
+ this._resolveRendered = resolve;
+ });
+ }
+
+ handleEvent(e) {
+ if (e.target == document && e.type == "view-selected") {
+ const { type, param } = e.detail;
+ this.select(`addons://${type}/${param}`);
+ return;
+ }
+
+ if (e.target == this && e.type == "button-group:key-selected") {
+ this.activeChild.load();
+ return;
+ }
+
+ if (e.type == "click") {
+ const button = e.target.closest("[viewid]");
+ if (button) {
+ button.load();
+ return;
+ }
+ }
+
+ // Forward the unhandled events to the button-group custom element.
+ super.handleEvent(e);
+ }
+
+ disconnectedCallback() {
+ document.removeEventListener("view-selected", this);
+ this.removeEventListener("button-group:key-selected", this);
+ this.removeEventListener("click", this);
+ AddonManagerListenerHandler.removeListener(this);
+ super.disconnectedCallback();
+ }
+
+ async initialize() {
+ let hiddenTypes = new Set([]);
+
+ for (let button of this.children) {
+ let { defaultHidden, name } = button;
+ button.hidden =
+ !button.isVisible || (defaultHidden && this.shouldHideCategory(name));
+
+ if (defaultHidden && AddonManager.hasAddonType(name)) {
+ hiddenTypes.add(name);
+ }
+ }
+
+ let hiddenUpdated;
+ if (hiddenTypes.size) {
+ hiddenUpdated = this.updateHiddenCategories(Array.from(hiddenTypes));
+ }
+
+ this.updateAvailableCount();
+
+ document.addEventListener("view-selected", this);
+ this.addEventListener("button-group:key-selected", this);
+ this.addEventListener("click", this);
+ AddonManagerListenerHandler.addListener(this);
+
+ this._resolveRendered();
+ await hiddenUpdated;
+ }
+
+ shouldHideCategory(name) {
+ return Services.prefs.getBoolPref(`extensions.ui.${name}.hidden`, true);
+ }
+
+ setShouldHideCategory(name, hide) {
+ Services.prefs.setBoolPref(`extensions.ui.${name}.hidden`, hide);
+ }
+
+ getButtonByName(name) {
+ return this.querySelector(`[name="${name}"]`);
+ }
+
+ get selectedChild() {
+ return this._selectedChild;
+ }
+
+ set selectedChild(node) {
+ if (node && this.contains(node)) {
+ if (this._selectedChild) {
+ this._selectedChild.selected = false;
+ }
+ this._selectedChild = node;
+ this._selectedChild.selected = true;
+ }
+ }
+
+ select(viewId) {
+ let button = this.querySelector(`[viewid="${viewId}"]`);
+ if (button) {
+ this.activeChild = button;
+ this.selectedChild = button;
+ button.hidden = false;
+ Services.prefs.setStringPref(PREF_UI_LASTCATEGORY, viewId);
+ }
+ }
+
+ selectType(type) {
+ this.select(`addons://list/${type}`);
+ }
+
+ onInstalled(addon) {
+ let button = this.getButtonByName(addon.type);
+ if (button) {
+ button.hidden = false;
+ this.setShouldHideCategory(addon.type, false);
+ }
+ this.updateAvailableCount();
+ }
+
+ onInstallStarted(install) {
+ this.onInstalled(install);
+ }
+
+ onNewInstall() {
+ this.updateAvailableCount();
+ }
+
+ onInstallPostponed() {
+ this.updateAvailableCount();
+ }
+
+ onInstallCancelled() {
+ this.updateAvailableCount();
+ }
+
+ async updateAvailableCount() {
+ let installs = await AddonManager.getAllInstalls();
+ var count = installs.filter(install => {
+ return isManualUpdate(install) && !install.installed;
+ }).length;
+ let availableButton = this.getButtonByName("available-updates");
+ availableButton.hidden = !availableButton.selected && count == 0;
+ availableButton.badgeCount = count;
+ }
+
+ async updateHiddenCategories(types) {
+ let hiddenTypes = new Set(types);
+ let getAddons = AddonManager.getAddonsByTypes(types);
+ let getInstalls = AddonManager.getInstallsByTypes(types);
+
+ for (let addon of await getAddons) {
+ if (addon.hidden) {
+ continue;
+ }
+
+ this.onInstalled(addon);
+ hiddenTypes.delete(addon.type);
+
+ if (!hiddenTypes.size) {
+ return;
+ }
+ }
+
+ for (let install of await getInstalls) {
+ if (
+ install.existingAddon ||
+ install.state == AddonManager.STATE_AVAILABLE
+ ) {
+ continue;
+ }
+
+ this.onInstalled(install);
+ hiddenTypes.delete(install.type);
+
+ if (!hiddenTypes.size) {
+ return;
+ }
+ }
+
+ for (let type of hiddenTypes) {
+ let button = this.getButtonByName(type);
+ if (button.selected) {
+ // Cancel the load if this view should be hidden.
+ gViewController.resetState();
+ }
+ this.setShouldHideCategory(type, true);
+ button.hidden = true;
+ }
+ }
+}
+customElements.define("categories-box", CategoriesBox);
+
+class SidebarFooter extends HTMLElement {
+ connectedCallback() {
+ let list = document.createElement("ul");
+ list.classList.add("sidebar-footer-list");
+
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ let prefsItem = this.createItem({
+ icon: "chrome://global/skin/icons/settings.svg",
+ createLinkElement: () => {
+ let link = document.createElement("a");
+ link.href = "about:preferences";
+ link.id = "preferencesButton";
+ return link;
+ },
+ titleL10nId: "sidebar-settings-button-title",
+ labelL10nId: "addons-settings-button",
+ onClick: e => {
+ e.preventDefault();
+ windowRoot.ownerGlobal.switchToTabHavingURI("about:preferences", true, {
+ ignoreFragment: "whenComparing",
+ triggeringPrincipal: systemPrincipal,
+ });
+ },
+ });
+
+ let supportItem = this.createItem({
+ icon: "chrome://global/skin/icons/help.svg",
+ createLinkElement: () => {
+ let link = document.createElement("a", { is: "moz-support-link" });
+ link.setAttribute("support-page", "addons-help");
+ link.id = "help-button";
+ return link;
+ },
+ titleL10nId: "sidebar-help-button-title",
+ labelL10nId: "help-button",
+ });
+
+ list.append(prefsItem, supportItem);
+ this.append(list);
+ }
+
+ createItem({ onClick, titleL10nId, labelL10nId, icon, createLinkElement }) {
+ let listItem = document.createElement("li");
+
+ let link = createLinkElement();
+ link.classList.add("sidebar-footer-link");
+ link.addEventListener("click", onClick);
+ document.l10n.setAttributes(link, titleL10nId);
+
+ let img = document.createElement("img");
+ img.src = icon;
+ img.className = "sidebar-footer-icon";
+
+ let label = document.createElement("span");
+ label.className = "sidebar-footer-label";
+ document.l10n.setAttributes(label, labelL10nId);
+
+ link.append(img, label);
+ listItem.append(link);
+ return listItem;
+ }
+}
+customElements.define("sidebar-footer", SidebarFooter, { extends: "footer" });
+
+class AddonOptions extends HTMLElement {
+ connectedCallback() {
+ if (!this.children.length) {
+ this.render();
+ }
+ }
+
+ get panel() {
+ return this.querySelector("panel-list");
+ }
+
+ updateSeparatorsVisibility() {
+ let lastSeparator;
+ let elWasVisible = false;
+
+ // Collect the panel-list children that are not already hidden.
+ const children = Array.from(this.panel.children).filter(el => !el.hidden);
+
+ for (let child of children) {
+ if (child.localName == "hr") {
+ child.hidden = !elWasVisible;
+ if (!child.hidden) {
+ lastSeparator = child;
+ }
+ elWasVisible = false;
+ } else {
+ elWasVisible = true;
+ }
+ }
+ if (!elWasVisible && lastSeparator) {
+ lastSeparator.hidden = true;
+ }
+ }
+
+ get template() {
+ return "addon-options";
+ }
+
+ render() {
+ this.appendChild(importTemplate(this.template));
+ }
+
+ setElementState(el, card, addon, updateInstall) {
+ switch (el.getAttribute("action")) {
+ case "remove":
+ if (hasPermission(addon, "uninstall")) {
+ // Regular add-on that can be uninstalled.
+ el.disabled = false;
+ el.hidden = false;
+ document.l10n.setAttributes(el, "remove-addon-button");
+ } else if (addon.isBuiltin) {
+ // Likely the built-in themes, can't be removed, that's fine.
+ el.hidden = true;
+ } else {
+ // Likely sideloaded, mention that it can't be removed with a link.
+ el.hidden = false;
+ el.disabled = true;
+ if (!el.querySelector('[slot="support-link"]')) {
+ let link = document.createElement("a", { is: "moz-support-link" });
+ link.setAttribute("data-l10n-name", "link");
+ link.setAttribute("support-page", "cant-remove-addon");
+ link.setAttribute("slot", "support-link");
+ el.appendChild(link);
+ document.l10n.setAttributes(el, "remove-addon-disabled-button");
+ }
+ }
+ break;
+ case "report":
+ el.hidden = !isAbuseReportSupported(addon);
+ break;
+ case "install-update":
+ el.hidden = !updateInstall;
+ break;
+ case "expand":
+ el.hidden = card.expanded;
+ break;
+ case "preferences":
+ el.hidden =
+ getOptionsType(addon) !== "tab" &&
+ (getOptionsType(addon) !== "inline" || card.expanded);
+ if (!el.hidden) {
+ isAddonOptionsUIAllowed(addon).then(allowed => {
+ el.hidden = !allowed;
+ });
+ }
+ break;
+ }
+ }
+
+ update(card, addon, updateInstall) {
+ for (let el of this.items) {
+ this.setElementState(el, card, addon, updateInstall);
+ }
+
+ // Update the separators visibility based on the updated visibility
+ // of the actions in the panel-list.
+ this.updateSeparatorsVisibility();
+ }
+
+ get items() {
+ return this.querySelectorAll("panel-item");
+ }
+
+ get visibleItems() {
+ return Array.from(this.items).filter(item => !item.hidden);
+ }
+}
+customElements.define("addon-options", AddonOptions);
+
+class PluginOptions extends AddonOptions {
+ get template() {
+ return "plugin-options";
+ }
+
+ setElementState(el, card, addon) {
+ const userDisabledStates = {
+ "always-activate": false,
+ "never-activate": true,
+ };
+ const action = el.getAttribute("action");
+ if (action in userDisabledStates) {
+ let userDisabled = userDisabledStates[action];
+ el.checked = addon.userDisabled === userDisabled;
+ el.disabled = !(el.checked || hasPermission(addon, action));
+ } else {
+ super.setElementState(el, card, addon);
+ }
+ }
+}
+customElements.define("plugin-options", PluginOptions);
+
+class ProxyContextMenu extends HTMLElement {
+ openPopupAtScreen(...args) {
+ // prettier-ignore
+ const parentContextMenuPopup =
+ windowRoot.ownerGlobal.document.getElementById("contentAreaContextMenu");
+ return parentContextMenuPopup.openPopupAtScreen(...args);
+ }
+}
+customElements.define("proxy-context-menu", ProxyContextMenu);
+
+class InlineOptionsBrowser extends HTMLElement {
+ constructor() {
+ super();
+ // Force the options_ui remote browser to recompute window.mozInnerScreenX
+ // and window.mozInnerScreenY when the "addon details" page has been
+ // scrolled (See Bug 1390445 for rationale).
+ // Also force a repaint to fix an issue where the click location was
+ // getting out of sync (see bug 1548687).
+ this.updatePositionTask = new DeferredTask(() => {
+ if (this.browser && this.browser.isRemoteBrowser) {
+ // Select boxes can appear in the wrong spot after scrolling, this will
+ // clear that up. Bug 1390445.
+ this.browser.frameLoader.requestUpdatePosition();
+ }
+ }, 100);
+
+ this._embedderElement = null;
+ this._promiseDisconnected = new Promise(
+ resolve => (this._resolveDisconnected = resolve)
+ );
+ }
+
+ connectedCallback() {
+ window.addEventListener("scroll", this, true);
+ const { embedderElement } = top.browsingContext;
+ this._embedderElement = embedderElement;
+ embedderElement.addEventListener("FullZoomChange", this);
+ embedderElement.addEventListener("TextZoomChange", this);
+ }
+
+ disconnectedCallback() {
+ this._resolveDisconnected();
+ window.removeEventListener("scroll", this, true);
+ this._embedderElement?.removeEventListener("FullZoomChange", this);
+ this._embedderElement?.removeEventListener("TextZoomChange", this);
+ this._embedderElement = null;
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "scroll":
+ return this.updatePositionTask.arm();
+ case "FullZoomChange":
+ case "TextZoomChange":
+ return this.maybeUpdateZoom();
+ }
+ return undefined;
+ }
+
+ maybeUpdateZoom() {
+ let bc = this.browser?.browsingContext;
+ let topBc = top.browsingContext;
+ if (!bc || !topBc) {
+ return;
+ }
+ // Use the same full-zoom as our top window.
+ bc.fullZoom = topBc.fullZoom;
+ bc.textZoom = topBc.textZoom;
+ }
+
+ setAddon(addon) {
+ this.addon = addon;
+ }
+
+ destroyBrowser() {
+ this.textContent = "";
+ }
+
+ ensureBrowserCreated() {
+ if (this.childElementCount === 0) {
+ this.render();
+ }
+ }
+
+ async render() {
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon required to create inline options");
+ }
+
+ let browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("id", "addon-inline-options");
+ browser.setAttribute("transparent", "true");
+ browser.setAttribute("forcemessagemanager", "true");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+
+ let { optionsURL, optionsBrowserStyle } = addon;
+ if (addon.isWebExtension) {
+ let policy = ExtensionParent.WebExtensionPolicy.getByID(addon.id);
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ policy.browsingContextGroupId
+ );
+ }
+
+ let readyPromise;
+ let remoteSubframes = window.docShell.QueryInterface(
+ Ci.nsILoadContext
+ ).useRemoteSubframes;
+ // For now originAttributes have no effect, which will change if the
+ // optionsURL becomes anything but moz-extension* or we start considering
+ // OA for extensions.
+ var oa = E10SUtils.predictOriginAttributes({ browser });
+ let loadRemote = E10SUtils.canLoadURIInRemoteType(
+ optionsURL,
+ remoteSubframes,
+ E10SUtils.EXTENSION_REMOTE_TYPE,
+ oa
+ );
+ if (loadRemote) {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", E10SUtils.EXTENSION_REMOTE_TYPE);
+
+ readyPromise = promiseEvent("XULFrameLoaderCreated", browser);
+ } else {
+ readyPromise = promiseEvent("load", browser, true);
+ }
+
+ let stack = document.createXULElement("stack");
+ stack.classList.add("inline-options-stack");
+ stack.appendChild(browser);
+ this.appendChild(stack);
+ this.browser = browser;
+
+ // Force bindings to apply synchronously.
+ browser.clientTop;
+
+ await readyPromise;
+
+ this.maybeUpdateZoom();
+
+ if (!browser.messageManager) {
+ // If the browser.messageManager is undefined, the browser element has
+ // been removed from the document in the meantime (e.g. due to a rapid
+ // sequence of addon reload), return null.
+ return;
+ }
+
+ ExtensionParent.apiManager.emit("extension-browser-inserted", browser);
+
+ await new Promise(resolve => {
+ let messageListener = {
+ receiveMessage({ name, data }) {
+ if (name === "Extension:BrowserResized") {
+ browser.style.height = `${data.height}px`;
+ } else if (name === "Extension:BrowserContentLoaded") {
+ resolve();
+ }
+ },
+ };
+
+ let mm = browser.messageManager;
+
+ if (!mm) {
+ // If the browser.messageManager is undefined, the browser element has
+ // been removed from the document in the meantime (e.g. due to a rapid
+ // sequence of addon reload), return null.
+ resolve();
+ return;
+ }
+
+ mm.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+ mm.addMessageListener("Extension:BrowserContentLoaded", messageListener);
+ mm.addMessageListener("Extension:BrowserResized", messageListener);
+
+ let browserOptions = {
+ fixedWidth: true,
+ isInline: true,
+ };
+
+ if (optionsBrowserStyle) {
+ // aboutaddons.js is not used on Android. extension.css is included in
+ // Firefox desktop and Thunderbird.
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ browserOptions.stylesheets = ["chrome://browser/content/extension.css"];
+ }
+
+ mm.sendAsyncMessage("Extension:InitBrowser", browserOptions);
+
+ if (browser.isConnectedAndReady) {
+ this.fixupAndLoadURIString(optionsURL);
+ } else {
+ // browser custom element does opt-in the delayConnectedCallback
+ // behavior (see connectedCallback in the custom element definition
+ // from browser-custom-element.js) and so calling browser.loadURI
+ // would fail if the about:addons document is not yet fully loaded.
+ Promise.race([
+ promiseEvent("DOMContentLoaded", document),
+ this._promiseDisconnected,
+ ]).then(() => {
+ this.fixupAndLoadURIString(optionsURL);
+ });
+ }
+ });
+ }
+
+ fixupAndLoadURIString(uriString) {
+ if (!this.browser || !this.browser.isConnectedAndReady) {
+ throw new Error("Fail to loadURI");
+ }
+
+ this.browser.fixupAndLoadURIString(uriString, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ }
+}
+customElements.define("inline-options-browser", InlineOptionsBrowser);
+
+class UpdateReleaseNotes extends HTMLElement {
+ connectedCallback() {
+ this.addEventListener("click", this);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener("click", this);
+ }
+
+ handleEvent(e) {
+ // We used to strip links, but ParserUtils.parseFragment() leaves them in,
+ // so just make sure we open them using the null principal in a new tab.
+ if (e.type == "click" && e.target.localName == "a" && e.target.href) {
+ e.preventDefault();
+ e.stopPropagation();
+ windowRoot.ownerGlobal.openWebLinkIn(e.target.href, "tab");
+ }
+ }
+
+ async loadForUri(uri) {
+ // Can't load the release notes without a URL to load.
+ if (!uri || !uri.spec) {
+ this.setErrorMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-error"));
+ return;
+ }
+
+ // Don't try to load for the same update a second time.
+ if (this.url == uri.spec) {
+ this.dispatchEvent(new CustomEvent("release-notes-cached"));
+ return;
+ }
+
+ // Store the URL to skip the network if loaded again.
+ this.url = uri.spec;
+
+ // Set the loading message before hitting the network.
+ this.setLoadingMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-loading"));
+
+ try {
+ // loadReleaseNotes will fetch and sanitize the release notes.
+ let fragment = await loadReleaseNotes(uri);
+ this.textContent = "";
+ this.appendChild(fragment);
+ this.dispatchEvent(new CustomEvent("release-notes-loaded"));
+ } catch (e) {
+ this.setErrorMessage();
+ this.dispatchEvent(new CustomEvent("release-notes-error"));
+ }
+ }
+
+ setMessage(id) {
+ this.textContent = "";
+ let message = document.createElement("p");
+ document.l10n.setAttributes(message, id);
+ this.appendChild(message);
+ }
+
+ setLoadingMessage() {
+ this.setMessage("release-notes-loading");
+ }
+
+ setErrorMessage() {
+ this.setMessage("release-notes-error");
+ }
+}
+customElements.define("update-release-notes", UpdateReleaseNotes);
+
+class AddonPermissionsList extends HTMLElement {
+ setAddon(addon) {
+ this.addon = addon;
+ this.render();
+ }
+
+ async render() {
+ let empty = { origins: [], permissions: [] };
+ let requiredPerms = { ...(this.addon.userPermissions ?? empty) };
+ let optionalPerms = { ...(this.addon.optionalPermissions ?? empty) };
+ let grantedPerms = await ExtensionPermissions.get(this.addon.id);
+
+ if (manifestV3enabled) {
+ // If optional permissions include <all_urls>, extension can request and
+ // be granted permission for individual sites not listed in the manifest.
+ // Include them as well in the optional origins list.
+ optionalPerms.origins = [
+ ...optionalPerms.origins,
+ ...grantedPerms.origins.filter(o => !requiredPerms.origins.includes(o)),
+ ];
+ }
+
+ let permissions = Extension.formatPermissionStrings(
+ {
+ permissions: requiredPerms,
+ optionalPermissions: optionalPerms,
+ },
+ { buildOptionalOrigins: manifestV3enabled }
+ );
+ let optionalEntries = [
+ ...Object.entries(permissions.optionalPermissions),
+ ...Object.entries(permissions.optionalOrigins),
+ ];
+
+ this.textContent = "";
+ let frag = importTemplate("addon-permissions-list");
+
+ if (permissions.msgs.length) {
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ for (let msg of permissions.msgs) {
+ let item = document.createElement("li");
+ item.classList.add("permission-info", "permission-checked");
+ item.appendChild(document.createTextNode(msg));
+ list.appendChild(item);
+ }
+ }
+
+ if (optionalEntries.length) {
+ let section = frag.querySelector(".addon-permissions-optional");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+
+ for (let id = 0; id < optionalEntries.length; id++) {
+ let [perm, msg] = optionalEntries[id];
+
+ let type = "permission";
+ if (permissions.optionalOrigins[perm]) {
+ type = "origin";
+ }
+ let item = document.createElement("li");
+ item.classList.add("permission-info");
+
+ let toggle = document.createElement("moz-toggle");
+ toggle.setAttribute("label", msg);
+ toggle.id = `permission-${id}`;
+ toggle.setAttribute("permission-type", type);
+
+ let checked =
+ grantedPerms.permissions.includes(perm) ||
+ grantedPerms.origins.includes(perm);
+
+ // If this is one of the "all sites" permissions
+ if (Extension.isAllSitesPermission(perm)) {
+ // mark it as checked if ANY of the "all sites" permission is granted.
+ checked = await AddonCard.optionalAllSitesGranted(this.addon.id);
+ toggle.toggleAttribute("permission-all-sites", true);
+ }
+
+ toggle.pressed = checked;
+ item.classList.toggle("permission-checked", checked);
+
+ toggle.setAttribute("permission-key", perm);
+ toggle.setAttribute("action", "toggle-permission");
+ item.appendChild(toggle);
+ list.appendChild(item);
+ }
+ }
+ if (!permissions.msgs.length && !optionalEntries.length) {
+ let row = frag.querySelector(".addon-permissions-empty");
+ row.hidden = false;
+ }
+
+ this.appendChild(frag);
+ }
+}
+customElements.define("addon-permissions-list", AddonPermissionsList);
+
+class AddonSitePermissionsList extends HTMLElement {
+ setAddon(addon) {
+ this.addon = addon;
+ this.render();
+ }
+
+ async render() {
+ let permissions = Extension.formatPermissionStrings({
+ sitePermissions: this.addon.sitePermissions,
+ siteOrigin: this.addon.siteOrigin,
+ });
+
+ this.textContent = "";
+ let frag = importTemplate("addon-sitepermissions-list");
+
+ if (permissions.msgs.length) {
+ let section = frag.querySelector(".addon-permissions-required");
+ section.hidden = false;
+ let list = section.querySelector(".addon-permissions-list");
+ let header = section.querySelector(".permission-header");
+ document.l10n.setAttributes(header, "addon-sitepermissions-required", {
+ hostname: new URL(this.addon.siteOrigin).hostname,
+ });
+
+ for (let msg of permissions.msgs) {
+ let item = document.createElement("li");
+ item.classList.add("permission-info", "permission-checked");
+ item.appendChild(document.createTextNode(msg));
+ list.appendChild(item);
+ }
+ }
+
+ this.appendChild(frag);
+ }
+}
+customElements.define("addon-sitepermissions-list", AddonSitePermissionsList);
+
+class AddonDetails extends HTMLElement {
+ connectedCallback() {
+ if (!this.children.length) {
+ this.render();
+ }
+ this.deck.addEventListener("view-changed", this);
+ this.descriptionShowMoreButton.addEventListener("click", this);
+ }
+
+ disconnectedCallback() {
+ this.inlineOptions.destroyBrowser();
+ this.deck.removeEventListener("view-changed", this);
+ this.descriptionShowMoreButton.removeEventListener("click", this);
+ }
+
+ handleEvent(e) {
+ if (e.type == "view-changed" && e.target == this.deck) {
+ switch (this.deck.selectedViewName) {
+ case "release-notes":
+ let releaseNotes = this.querySelector("update-release-notes");
+ let uri = this.releaseNotesUri;
+ if (uri) {
+ releaseNotes.loadForUri(uri);
+ }
+ break;
+ case "preferences":
+ if (getOptionsType(this.addon) == "inline") {
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ break;
+ }
+
+ // When a details view is rendered again, the default details view is
+ // unconditionally shown. So if any other tab is selected, do not save
+ // the current scroll offset, but start at the top of the page instead.
+ ScrollOffsets.canRestore = this.deck.selectedViewName === "details";
+ } else if (
+ e.type == "click" &&
+ e.target == this.descriptionShowMoreButton
+ ) {
+ this.toggleDescription();
+ }
+ }
+
+ onInstalled() {
+ let policy = WebExtensionPolicy.getByID(this.addon.id);
+ let extension = policy && policy.extension;
+ if (extension && extension.startupReason === "ADDON_UPGRADE") {
+ // Ensure the options browser is recreated when a new version starts.
+ this.extensionShutdown();
+ this.extensionStartup();
+ }
+ }
+
+ onDisabled(addon) {
+ this.extensionShutdown();
+ }
+
+ onEnabled(addon) {
+ this.extensionStartup();
+ }
+
+ extensionShutdown() {
+ this.inlineOptions.destroyBrowser();
+ }
+
+ extensionStartup() {
+ if (this.deck.selectedViewName === "preferences") {
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ }
+
+ toggleDescription() {
+ this.descriptionCollapsed = !this.descriptionCollapsed;
+
+ this.descriptionWrapper.classList.toggle(
+ "addon-detail-description-collapse",
+ this.descriptionCollapsed
+ );
+
+ this.descriptionShowMoreButton.hidden = false;
+ document.l10n.setAttributes(
+ this.descriptionShowMoreButton,
+ this.descriptionCollapsed
+ ? "addon-detail-description-expand"
+ : "addon-detail-description-collapse"
+ );
+ }
+
+ get releaseNotesUri() {
+ let { releaseNotesURI } = getUpdateInstall(this.addon) || this.addon;
+ return releaseNotesURI;
+ }
+
+ setAddon(addon) {
+ this.addon = addon;
+ }
+
+ update() {
+ let { addon } = this;
+
+ // Hide tab buttons that won't have any content.
+ let getButtonByName = name =>
+ this.tabGroup.querySelector(`[name="${name}"]`);
+ let permsBtn = getButtonByName("permissions");
+ permsBtn.hidden = addon.type != "extension";
+ let notesBtn = getButtonByName("release-notes");
+ notesBtn.hidden = !this.releaseNotesUri;
+ let prefsBtn = getButtonByName("preferences");
+ prefsBtn.hidden = getOptionsType(addon) !== "inline";
+ if (prefsBtn.hidden) {
+ if (this.deck.selectedViewName === "preferences") {
+ this.deck.selectedViewName = "details";
+ }
+ } else {
+ isAddonOptionsUIAllowed(addon).then(allowed => {
+ prefsBtn.hidden = !allowed;
+ });
+ }
+
+ // Hide the tab group if "details" is the only visible button.
+ let tabGroupButtons = this.tabGroup.querySelectorAll(".tab-button");
+ this.tabGroup.hidden = Array.from(tabGroupButtons).every(button => {
+ return button.name == "details" || button.hidden;
+ });
+
+ // Show the update check button if necessary. The button might not exist if
+ // the add-on doesn't support updates.
+ let updateButton = this.querySelector('[action="update-check"]');
+ if (updateButton) {
+ updateButton.hidden =
+ this.addon.updateInstall || AddonManager.shouldAutoUpdate(this.addon);
+ }
+
+ // Set the value for auto updates.
+ let inputs = this.querySelectorAll(".addon-detail-row-updates input");
+ for (let input of inputs) {
+ input.checked = input.value == addon.applyBackgroundUpdates;
+ }
+ }
+
+ renderDescription(addon) {
+ this.descriptionWrapper = this.querySelector(
+ ".addon-detail-description-wrapper"
+ );
+ this.descriptionContents = this.querySelector(".addon-detail-description");
+ this.descriptionShowMoreButton = this.querySelector(
+ ".addon-detail-description-toggle"
+ );
+
+ if (addon.getFullDescription) {
+ this.descriptionContents.appendChild(addon.getFullDescription(document));
+ } else if (addon.fullDescription) {
+ this.descriptionContents.appendChild(nl2br(addon.fullDescription));
+ }
+
+ this.descriptionCollapsed = false;
+
+ requestAnimationFrame(() => {
+ const remSize = parseFloat(
+ getComputedStyle(document.documentElement).fontSize
+ );
+ const { height } = this.descriptionContents.getBoundingClientRect();
+
+ // collapse description if there are too many lines,i.e. height > (20 rem)
+ if (height > 20 * remSize) {
+ this.toggleDescription();
+ }
+ });
+ }
+
+ updateQuarantinedDomainsUserAllowed() {
+ const { addon } = this;
+ let quarantinedDomainsUserAllowedRow = this.querySelector(
+ ".addon-detail-row-quarantined-domains"
+ );
+ if (addon.canChangeQuarantineIgnored) {
+ quarantinedDomainsUserAllowedRow.hidden = false;
+ quarantinedDomainsUserAllowedRow.nextElementSibling.hidden = false;
+ quarantinedDomainsUserAllowedRow.querySelector(
+ `[value="${addon.quarantineIgnoredByUser ? 1 : 0}"]`
+ ).checked = true;
+ } else {
+ quarantinedDomainsUserAllowedRow.hidden = true;
+ quarantinedDomainsUserAllowedRow.nextElementSibling.hidden = true;
+ }
+ }
+
+ async render() {
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon-details must be initialized by setAddon");
+ }
+
+ this.textContent = "";
+ this.appendChild(importTemplate("addon-details"));
+
+ this.deck = this.querySelector("named-deck");
+ this.tabGroup = this.querySelector(".tab-group");
+
+ // Set the add-on for the permissions section.
+ this.permissionsList = this.querySelector("addon-permissions-list");
+ this.permissionsList.setAddon(addon);
+
+ // Set the add-on for the sitepermissions section.
+ this.sitePermissionsList = this.querySelector("addon-sitepermissions-list");
+ if (addon.type == "sitepermission") {
+ this.sitePermissionsList.setAddon(addon);
+ }
+ this.querySelector(".addon-detail-sitepermissions").hidden =
+ addon.type !== "sitepermission";
+
+ // Set the add-on for the preferences section.
+ this.inlineOptions = this.querySelector("inline-options-browser");
+ this.inlineOptions.setAddon(addon);
+
+ // Full description.
+ this.renderDescription(addon);
+ this.querySelector(".addon-detail-contribute").hidden =
+ !addon.contributionURL;
+ this.querySelector(".addon-detail-row-updates").hidden = !hasPermission(
+ addon,
+ "upgrade"
+ );
+
+ if (addon.type != "extension") {
+ // Don't show any private browsing related section for non-extension
+ // addon types, because not relevant or they are either always allowed
+ // (e.g. static themes).
+ //
+ // TODO(Bug 1799090): introduce ad-hoc UI for "sitepermission" addon type.
+ } else if (addon.incognito == "not_allowed") {
+ let pbRowNotAllowed = this.querySelector(
+ ".addon-detail-row-private-browsing-disallowed"
+ );
+ pbRowNotAllowed.hidden = false;
+ pbRowNotAllowed.nextElementSibling.hidden = false;
+ } else if (!hasPermission(addon, "change-privatebrowsing")) {
+ let pbRowRequired = this.querySelector(
+ ".addon-detail-row-private-browsing-required"
+ );
+ pbRowRequired.hidden = false;
+ pbRowRequired.nextElementSibling.hidden = false;
+ } else {
+ let pbRow = this.querySelector(".addon-detail-row-private-browsing");
+ pbRow.hidden = false;
+ pbRow.nextElementSibling.hidden = false;
+ let isAllowed = await isAllowedInPrivateBrowsing(addon);
+ pbRow.querySelector(`[value="${isAllowed ? 1 : 0}"]`).checked = true;
+ }
+
+ this.updateQuarantinedDomainsUserAllowed();
+
+ // Author.
+ let creatorRow = this.querySelector(".addon-detail-row-author");
+ if (addon.creator) {
+ let link = creatorRow.querySelector("a");
+ link.hidden = !addon.creator.url;
+ if (link.hidden) {
+ creatorRow.appendChild(new Text(addon.creator.name));
+ } else {
+ link.href = formatUTMParams(
+ "addons-manager-user-profile-link",
+ addon.creator.url
+ );
+ link.target = "_blank";
+ link.textContent = addon.creator.name;
+ }
+ } else {
+ creatorRow.hidden = true;
+ }
+
+ // Version. Don't show a version for LWTs.
+ let version = this.querySelector(".addon-detail-row-version");
+ if (addon.version && !/@personas\.mozilla\.org/.test(addon.id)) {
+ version.appendChild(new Text(addon.version));
+ } else {
+ version.hidden = true;
+ }
+
+ // Last updated.
+ let updateDate = this.querySelector(".addon-detail-row-lastUpdated");
+ if (addon.updateDate) {
+ let lastUpdated = addon.updateDate.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ updateDate.appendChild(new Text(lastUpdated));
+ } else {
+ updateDate.hidden = true;
+ }
+
+ // Homepage.
+ let homepageRow = this.querySelector(".addon-detail-row-homepage");
+ if (addon.homepageURL) {
+ let homepageURL = homepageRow.querySelector("a");
+ homepageURL.href = addon.homepageURL;
+ homepageURL.textContent = addon.homepageURL;
+ } else {
+ homepageRow.hidden = true;
+ }
+
+ // Rating.
+ let ratingRow = this.querySelector(".addon-detail-row-rating");
+ if (addon.reviewURL) {
+ ratingRow.querySelector("moz-five-star").rating = addon.averageRating;
+ let reviews = ratingRow.querySelector("a");
+ reviews.href = formatUTMParams(
+ "addons-manager-reviews-link",
+ addon.reviewURL
+ );
+ document.l10n.setAttributes(reviews, "addon-detail-reviews-link", {
+ numberOfReviews: addon.reviewCount,
+ });
+ } else {
+ ratingRow.hidden = true;
+ }
+
+ this.update();
+ }
+
+ showPrefs() {
+ if (getOptionsType(this.addon) == "inline") {
+ this.deck.selectedViewName = "preferences";
+ this.inlineOptions.ensureBrowserCreated();
+ }
+ }
+}
+customElements.define("addon-details", AddonDetails);
+
+/**
+ * A card component for managing an add-on. It should be initialized by setting
+ * the add-on with `setAddon()` before being connected to the document.
+ *
+ * let card = document.createElement("addon-card");
+ * card.setAddon(addon);
+ * document.body.appendChild(card);
+ */
+class AddonCard extends HTMLElement {
+ connectedCallback() {
+ // If we've already rendered we can just update, otherwise render.
+ if (this.children.length) {
+ this.update();
+ } else {
+ this.render();
+ }
+ this.registerListeners();
+ }
+
+ disconnectedCallback() {
+ this.removeListeners();
+ }
+
+ get expanded() {
+ return this.hasAttribute("expanded");
+ }
+
+ set expanded(val) {
+ if (val) {
+ this.setAttribute("expanded", "true");
+ } else {
+ this.removeAttribute("expanded");
+ }
+ }
+
+ get updateInstall() {
+ return this._updateInstall;
+ }
+
+ set updateInstall(install) {
+ this._updateInstall = install;
+ if (this.children.length) {
+ this.update();
+ }
+ }
+
+ get reloading() {
+ return this.hasAttribute("reloading");
+ }
+
+ set reloading(val) {
+ this.toggleAttribute("reloading", val);
+ }
+
+ /**
+ * Set the add-on for this card. The card will be populated based on the
+ * add-on when it is connected to the DOM.
+ *
+ * @param {AddonWrapper} addon The add-on to use.
+ */
+ setAddon(addon) {
+ this.addon = addon;
+ let install = getUpdateInstall(addon);
+ if (
+ install &&
+ (isInState(install, "available") || isInState(install, "postponed"))
+ ) {
+ this.updateInstall = install;
+ } else {
+ this.updateInstall = null;
+ }
+ if (this.children.length) {
+ this.render();
+ }
+ }
+
+ async setAddonPermission(permission, type, action) {
+ let { addon } = this;
+ let origins = [],
+ permissions = [];
+ if (!["add", "remove"].includes(action)) {
+ throw new Error("invalid action for permission change");
+ }
+ if (type == "permission") {
+ if (
+ action == "add" &&
+ !addon.optionalPermissions.permissions.includes(permission)
+ ) {
+ throw new Error("permission missing from manifest");
+ }
+ permissions = [permission];
+ } else if (type == "origin") {
+ if (action === "add") {
+ let { origins } = addon.optionalPermissions;
+ let patternSet = new MatchPatternSet(origins, { ignorePath: true });
+ if (!patternSet.subsumes(new MatchPattern(permission))) {
+ throw new Error("origin missing from manifest");
+ }
+ }
+ origins = [permission];
+
+ // If this is one of the "all sites" permissions
+ if (Extension.isAllSitesPermission(permission)) {
+ // Grant/revoke ALL "all sites" optional permissions from the manifest.
+ origins = addon.optionalPermissions.origins.filter(perm =>
+ Extension.isAllSitesPermission(perm)
+ );
+ }
+ } else {
+ throw new Error("unknown permission type changed");
+ }
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ExtensionPermissions[action](
+ addon.id,
+ { origins, permissions },
+ policy?.extension
+ );
+ }
+
+ async handleEvent(e) {
+ let { addon } = this;
+ let action = e.target.getAttribute("action");
+
+ if (e.type == "click") {
+ switch (action) {
+ case "toggle-disabled":
+ // Keep the checked state the same until the add-on's state changes.
+ e.target.checked = !addon.userDisabled;
+ if (addon.userDisabled) {
+ if (shouldShowPermissionsPrompt(addon)) {
+ await showPermissionsPrompt(addon);
+ } else {
+ await addon.enable();
+ }
+ } else {
+ await addon.disable();
+ }
+ break;
+ case "always-activate":
+ addon.userDisabled = false;
+ break;
+ case "never-activate":
+ addon.userDisabled = true;
+ break;
+ case "update-check": {
+ let { found } = await checkForUpdate(addon);
+ if (!found) {
+ this.sendEvent("no-update");
+ }
+ break;
+ }
+ case "install-postponed": {
+ const { updateInstall } = this;
+ if (updateInstall && isInState(updateInstall, "postponed")) {
+ updateInstall.continuePostponedInstall();
+ }
+ break;
+ }
+ case "install-update":
+ // Make sure that an update handler is attached to the install object
+ // before starting the update installation (otherwise the user would
+ // not be prompted for the new permissions requested if necessary),
+ // and also make sure that a prompt handler attached from a closed
+ // about:addons tab is replaced by the one attached by the currently
+ // active about:addons tab.
+ attachUpdateHandler(this.updateInstall);
+ this.updateInstall.install().then(
+ () => {
+ detachUpdateHandler(this.updateInstall);
+ // The card will update with the new add-on when it gets
+ // installed.
+ this.sendEvent("update-installed");
+ },
+ () => {
+ detachUpdateHandler(this.updateInstall);
+ // Update our state if the install is cancelled.
+ this.update();
+ this.sendEvent("update-cancelled");
+ }
+ );
+ // Clear the install since it will be removed from the global list of
+ // available updates (whether it succeeds or fails).
+ this.updateInstall = null;
+ break;
+ case "contribute":
+ windowRoot.ownerGlobal.openWebLinkIn(addon.contributionURL, "tab");
+ break;
+ case "preferences":
+ if (getOptionsType(addon) == "tab") {
+ openOptionsInTab(addon.optionsURL);
+ } else if (getOptionsType(addon) == "inline") {
+ gViewController.loadView(`detail/${this.addon.id}/preferences`);
+ }
+ break;
+ case "remove":
+ {
+ this.panel.hide();
+ if (!hasPermission(addon, "uninstall")) {
+ this.sendEvent("remove-disabled");
+ return;
+ }
+ let { BrowserAddonUI } = windowRoot.ownerGlobal;
+ let { remove, report } = await BrowserAddonUI.promptRemoveExtension(
+ addon
+ );
+ if (remove) {
+ await addon.uninstall(true);
+ this.sendEvent("remove");
+ if (report) {
+ openAbuseReport({
+ addonId: addon.id,
+ reportEntryPoint: "uninstall",
+ });
+ }
+ } else {
+ this.sendEvent("remove-cancelled");
+ }
+ }
+ break;
+ case "expand":
+ gViewController.loadView(`detail/${this.addon.id}`);
+ break;
+ case "more-options":
+ // Open panel on click from the keyboard.
+ if (e.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ this.panel.toggle(e);
+ }
+ break;
+ case "report":
+ this.panel.hide();
+ openAbuseReport({ addonId: addon.id, reportEntryPoint: "menu" });
+ break;
+ case "link":
+ if (e.target.getAttribute("url")) {
+ windowRoot.ownerGlobal.openWebLinkIn(
+ e.target.getAttribute("url"),
+ "tab"
+ );
+ }
+ break;
+ default:
+ // Handle a click on the card itself.
+ if (
+ !this.expanded &&
+ (e.target === this.addonNameEl || !e.target.closest("a"))
+ ) {
+ e.preventDefault();
+ gViewController.loadView(`detail/${this.addon.id}`);
+ }
+ break;
+ }
+ } else if (e.type == "toggle" && action == "toggle-permission") {
+ let permission = e.target.getAttribute("permission-key");
+ let type = e.target.getAttribute("permission-type");
+ let fname = e.target.pressed ? "add" : "remove";
+ this.setAddonPermission(permission, type, fname);
+ } else if (e.type == "change") {
+ let { name } = e.target;
+ switch (name) {
+ case "autoupdate": {
+ addon.applyBackgroundUpdates = e.target.value;
+ break;
+ }
+ case "private-browsing": {
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ let extension = policy && policy.extension;
+
+ if (e.target.value == "1") {
+ await ExtensionPermissions.add(
+ addon.id,
+ PRIVATE_BROWSING_PERMS,
+ extension
+ );
+ } else {
+ await ExtensionPermissions.remove(
+ addon.id,
+ PRIVATE_BROWSING_PERMS,
+ extension
+ );
+ }
+ // Reload the extension if it is already enabled. This ensures any
+ // change on the private browsing permission is properly handled.
+ if (addon.isActive) {
+ this.reloading = true;
+ // Reloading will trigger an enable and update the card.
+ addon.reload();
+ } else {
+ // Update the card if the add-on isn't active.
+ this.update();
+ }
+ break;
+ }
+ case "quarantined-domains-user-allowed": {
+ addon.quarantineIgnoredByUser = e.target.value == "1";
+ break;
+ }
+ }
+ } else if (e.type == "mousedown") {
+ // Open panel on mousedown when the mouse is used.
+ if (action == "more-options" && e.button == 0) {
+ this.panel.toggle(e);
+ }
+ } else if (e.type === "shown" || e.type === "hidden") {
+ let panelOpen = e.type === "shown";
+ // The card will be dimmed if it's disabled, but when the panel is open
+ // that should be reverted so the menu items can be easily read.
+ this.toggleAttribute("panelopen", panelOpen);
+ this.optionsButton.setAttribute("aria-expanded", panelOpen);
+ }
+ }
+
+ get panel() {
+ return this.card.querySelector("panel-list");
+ }
+
+ get postponedMessageBar() {
+ return this.card.querySelector(".update-postponed-bar");
+ }
+
+ registerListeners() {
+ this.addEventListener("change", this);
+ this.addEventListener("click", this);
+ this.addEventListener("mousedown", this);
+ this.addEventListener("toggle", this);
+ this.panel.addEventListener("shown", this);
+ this.panel.addEventListener("hidden", this);
+ }
+
+ removeListeners() {
+ this.removeEventListener("change", this);
+ this.removeEventListener("click", this);
+ this.removeEventListener("mousedown", this);
+ this.removeEventListener("toggle", this);
+ this.panel.removeEventListener("shown", this);
+ this.panel.removeEventListener("hidden", this);
+ }
+
+ /**
+ * Update the card's contents based on the previously set add-on. This should
+ * be called if there has been a change to the add-on.
+ */
+ update() {
+ let { addon, card } = this;
+
+ card.setAttribute("active", addon.isActive);
+
+ // Set the icon or theme preview.
+ let iconEl = card.querySelector(".addon-icon");
+ let preview = card.querySelector(".card-heading-image");
+ if (addon.type == "theme") {
+ iconEl.hidden = true;
+ let screenshotUrl = getScreenshotUrlForAddon(addon);
+ if (screenshotUrl) {
+ preview.src = screenshotUrl;
+ }
+ preview.hidden = !screenshotUrl;
+ } else {
+ preview.hidden = true;
+ iconEl.hidden = false;
+ if (addon.type == "plugin") {
+ iconEl.src = PLUGIN_ICON_URL;
+ } else {
+ iconEl.src =
+ AddonManager.getPreferredIconURL(addon, 32, window) ||
+ EXTENSION_ICON_URL;
+ }
+ }
+
+ // Update the name.
+ let name = this.addonNameEl;
+ let setDisabledStyle = !(addon.isActive || addon.type === "theme");
+ if (!setDisabledStyle) {
+ name.textContent = addon.name;
+ name.removeAttribute("data-l10n-id");
+ } else {
+ document.l10n.setAttributes(name, "addon-name-disabled", {
+ name: addon.name,
+ });
+ }
+ name.title = `${addon.name} ${addon.version}`;
+
+ let toggleDisabledButton = card.querySelector('[action="toggle-disabled"]');
+ if (toggleDisabledButton) {
+ let toggleDisabledAction = addon.userDisabled ? "enable" : "disable";
+ toggleDisabledButton.hidden = !hasPermission(addon, toggleDisabledAction);
+ if (addon.type === "theme") {
+ document.l10n.setAttributes(
+ toggleDisabledButton,
+ `${toggleDisabledAction}-addon-button`
+ );
+ } else if (
+ addon.type === "extension" ||
+ addon.type === "sitepermission"
+ ) {
+ toggleDisabledButton.pressed = !addon.userDisabled;
+ }
+ }
+
+ // Set the items in the more options menu.
+ this.options.update(this, addon, this.updateInstall);
+
+ // Badge the more options button if there's an update.
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ moreOptionsButton.classList.toggle(
+ "more-options-button-badged",
+ !!(this.updateInstall && isInState(this.updateInstall, "available"))
+ );
+
+ // Postponed update addon card message bar.
+ const hasPostponedInstall =
+ this.updateInstall && isInState(this.updateInstall, "postponed");
+ this.postponedMessageBar.hidden = !hasPostponedInstall;
+
+ // Hide the more options button if it's empty.
+ moreOptionsButton.hidden = this.options.visibleItems.length === 0;
+
+ // Ensure all badges are initially hidden.
+ for (let node of card.querySelectorAll(".addon-badge")) {
+ node.hidden = true;
+ }
+
+ // Set the private browsing badge visibility.
+ // TODO: We don't show the badge for SitePermsAddon for now, but this should
+ // be handled in Bug 1799090.
+ if (addon.incognito != "not_allowed" && addon.type == "extension") {
+ // Keep update synchronous, the badge can appear later.
+ isAllowedInPrivateBrowsing(addon).then(isAllowed => {
+ card.querySelector(".addon-badge-private-browsing-allowed").hidden =
+ !isAllowed;
+ });
+ }
+
+ // Show the recommended badges if needed.
+ // Plugins don't have recommendationStates, so ensure a default.
+ let states = addon.recommendationStates || [];
+ for (let badgeName of states) {
+ let badge = card.querySelector(`.addon-badge-${badgeName}`);
+ if (badge) {
+ badge.hidden = false;
+ }
+ }
+
+ // Update description.
+ card.querySelector(".addon-description").textContent = addon.description;
+
+ this.updateMessage();
+
+ // Update the details if they're shown.
+ if (this.details) {
+ this.details.update();
+ }
+
+ this.sendEvent("update");
+ }
+
+ async updateMessage() {
+ const messageBar = this.card.querySelector(".addon-card-message");
+
+ const {
+ linkUrl,
+ linkId,
+ messageId,
+ messageArgs,
+ type = "",
+ } = await getAddonMessageInfo(this.addon);
+
+ if (messageId) {
+ document.l10n.pauseObserving();
+ document.l10n.setAttributes(messageBar, messageId, messageArgs);
+ messageBar.setAttribute("data-l10n-attrs", "message");
+
+ const link = messageBar.querySelector("button");
+ if (linkUrl) {
+ document.l10n.setAttributes(link, linkId);
+ link.setAttribute("url", linkUrl);
+ link.setAttribute("slot", "actions");
+ link.hidden = false;
+ } else {
+ link.removeAttribute("slot");
+ link.hidden = true;
+ }
+
+ document.l10n.resumeObserving();
+ await document.l10n.translateFragment(messageBar);
+ messageBar.setAttribute("type", type);
+ messageBar.hidden = false;
+ } else {
+ messageBar.hidden = true;
+ }
+ }
+
+ showPrefs() {
+ this.details.showPrefs();
+ }
+
+ expand() {
+ if (!this.children.length) {
+ this.expanded = true;
+ } else {
+ throw new Error("expand() is only supported before render()");
+ }
+ }
+
+ render() {
+ this.textContent = "";
+
+ let { addon } = this;
+ if (!addon) {
+ throw new Error("addon-card must be initialized with setAddon()");
+ }
+
+ this.setAttribute("addon-id", addon.id);
+
+ this.card = importTemplate("card").firstElementChild;
+ let headingId = ExtensionCommon.makeWidgetId(`${addon.id}-heading`);
+ this.card.setAttribute("aria-labelledby", headingId);
+
+ // Remove the toggle-disabled button(s) based on type.
+ if (addon.type != "theme") {
+ this.card.querySelector(".theme-enable-button").remove();
+ }
+ if (addon.type != "extension" && addon.type != "sitepermission") {
+ this.card.querySelector(".extension-enable-button").remove();
+ }
+
+ let nameContainer = this.card.querySelector(".addon-name-container");
+ let headingLevel = this.expanded ? "h1" : "h3";
+ let nameHeading = document.createElement(headingLevel);
+ nameHeading.classList.add("addon-name");
+ nameHeading.id = headingId;
+ if (!this.expanded) {
+ let name = document.createElement("a");
+ name.classList.add("addon-name-link");
+ name.href = `addons://detail/${addon.id}`;
+ nameHeading.appendChild(name);
+ this.addonNameEl = name;
+ } else {
+ this.addonNameEl = nameHeading;
+ }
+ nameContainer.prepend(nameHeading);
+
+ let panelType = addon.type == "plugin" ? "plugin-options" : "addon-options";
+ this.options = document.createElement(panelType);
+ this.options.render();
+ this.card.appendChild(this.options);
+ this.optionsButton = this.card.querySelector(".more-options-button");
+
+ // Set the contents.
+ this.update();
+
+ let doneRenderPromise = Promise.resolve();
+ if (this.expanded) {
+ if (!this.details) {
+ this.details = document.createElement("addon-details");
+ }
+ this.details.setAddon(this.addon);
+ doneRenderPromise = this.details.render();
+
+ // If we're re-rendering we still need to append the details since the
+ // entire card was emptied at the beginning of the render.
+ this.card.appendChild(this.details);
+ }
+
+ this.appendChild(this.card);
+
+ if (this.expanded) {
+ requestAnimationFrame(() => this.optionsButton.focus());
+ }
+
+ // Return the promise of details rendering to wait on in DetailView.
+ return doneRenderPromise;
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onNewInstall(install) {
+ this.updateInstall = install;
+ this.sendEvent("update-found");
+ }
+
+ onInstallEnded(install) {
+ this.setAddon(install.addon);
+ }
+
+ onInstallPostponed(install) {
+ this.updateInstall = install;
+ this.sendEvent("update-postponed");
+ }
+
+ onDisabled(addon) {
+ if (!this.reloading) {
+ this.update();
+ }
+ }
+
+ onEnabled(addon) {
+ this.reloading = false;
+ this.update();
+ }
+
+ onInstalled(addon) {
+ // When a temporary addon is reloaded, onInstalled is triggered instead of
+ // onEnabled.
+ this.reloading = false;
+ this.update();
+ }
+
+ onUninstalling() {
+ // Dispatch a remove event, the DetailView is listening for this to get us
+ // back to the list view when the current add-on is removed.
+ this.sendEvent("remove");
+ }
+
+ onUpdateModeChanged() {
+ this.update();
+ }
+
+ onPropertyChanged(addon, changed) {
+ if (this.details && changed.includes("applyBackgroundUpdates")) {
+ this.details.update();
+ } else if (addon.type == "plugin" && changed.includes("userDisabled")) {
+ this.update();
+ }
+
+ if (this.details && changed.includes("quarantineIgnoredByUser")) {
+ this.details.updateQuarantinedDomainsUserAllowed();
+ }
+ }
+
+ /* Extension Permission change listener */
+ async onChangePermissions(data) {
+ let perms = data.added || data.removed;
+ let hasAllSites = false;
+ for (let permission of perms.permissions.concat(perms.origins)) {
+ if (Extension.isAllSitesPermission(permission)) {
+ hasAllSites = true;
+ continue;
+ }
+ let target = document.querySelector(`[permission-key="${permission}"]`);
+ let checked = !data.removed;
+ if (target) {
+ target.closest("li").classList.toggle("permission-checked", checked);
+ target.pressed = checked;
+ }
+ }
+ if (hasAllSites) {
+ // special-case for finding the all-sites target by attribute.
+ let target = document.querySelector("[permission-all-sites]");
+ let checked = await AddonCard.optionalAllSitesGranted(this.addon.id);
+ target.closest("li").classList.toggle("permission-checked", checked);
+ target.pressed = checked;
+ }
+ }
+
+ // Only covers optional_permissions in MV2 and all host permissions in MV3.
+ static async optionalAllSitesGranted(addonId) {
+ let granted = await ExtensionPermissions.get(addonId);
+ return granted.origins.some(perm => Extension.isAllSitesPermission(perm));
+ }
+}
+customElements.define("addon-card", AddonCard);
+
+/**
+ * A child element of `<recommended-addon-list>`. It should be initialized
+ * by calling `setDiscoAddon()` first. Call `setAddon(addon)` if it has been
+ * installed, and call `setAddon(null)` upon uninstall.
+ *
+ * let discoAddon = new DiscoAddonWrapper({ ... });
+ * let card = document.createElement("recommended-addon-card");
+ * card.setDiscoAddon(discoAddon);
+ * document.body.appendChild(card);
+ *
+ * AddonManager.getAddonsByID(discoAddon.id)
+ * .then(addon => card.setAddon(addon));
+ */
+class RecommendedAddonCard extends HTMLElement {
+ /**
+ * @param {DiscoAddonWrapper} addon
+ * The details of the add-on that should be rendered in the card.
+ */
+ setDiscoAddon(addon) {
+ this.addonId = addon.id;
+
+ // Save the information so we can install.
+ this.discoAddon = addon;
+
+ let card = importTemplate("card").firstElementChild;
+ let heading = card.querySelector(".addon-name-container");
+ heading.textContent = "";
+ heading.append(importTemplate("addon-name-container-in-disco-card"));
+
+ this.setCardContent(card, addon);
+ if (addon.type != "theme") {
+ card
+ .querySelector(".addon-description")
+ .append(importTemplate("addon-description-in-disco-card"));
+ this.setCardDescription(card, addon);
+ }
+ this.registerButtons(card, addon);
+
+ this.textContent = "";
+ this.append(card);
+
+ // We initially assume that the add-on is not installed.
+ this.setAddon(null);
+ }
+
+ /**
+ * Fills in all static parts of the card.
+ *
+ * @param {HTMLElement} card
+ * The primary content of this card.
+ * @param {DiscoAddonWrapper} addon
+ */
+ setCardContent(card, addon) {
+ // Set the icon.
+ if (addon.type == "theme") {
+ card.querySelector(".addon-icon").hidden = true;
+ } else {
+ card.querySelector(".addon-icon").src = AddonManager.getPreferredIconURL(
+ addon,
+ 32,
+ window
+ );
+ }
+
+ // Set the theme preview.
+ let preview = card.querySelector(".card-heading-image");
+ if (addon.type == "theme") {
+ let screenshotUrl = getScreenshotUrlForAddon(addon);
+ if (screenshotUrl) {
+ preview.src = screenshotUrl;
+ preview.hidden = false;
+ }
+ } else {
+ preview.hidden = true;
+ }
+
+ // Set the name.
+ card.querySelector(".disco-addon-name").textContent = addon.name;
+
+ // Set the author name and link to AMO.
+ if (addon.creator) {
+ let authorInfo = card.querySelector(".disco-addon-author");
+ document.l10n.setAttributes(authorInfo, "created-by-author", {
+ author: addon.creator.name,
+ });
+ // This is intentionally a link to the add-on listing instead of the
+ // author page, because the add-on listing provides more relevant info.
+ authorInfo.querySelector("a").href = formatUTMParams(
+ "discopane-entry-link",
+ addon.amoListingUrl
+ );
+ authorInfo.hidden = false;
+ }
+ }
+
+ setCardDescription(card, addon) {
+ // Set the description. Note that this is the editorial description, not
+ // the add-on's original description that would normally appear on a card.
+ card.querySelector(".disco-description-main").textContent =
+ addon.editorialDescription;
+
+ let hasStats = false;
+ if (addon.averageRating) {
+ hasStats = true;
+ card.querySelector("moz-five-star").rating = addon.averageRating;
+ } else {
+ card.querySelector("moz-five-star").hidden = true;
+ }
+
+ if (addon.dailyUsers) {
+ hasStats = true;
+ let userCountElem = card.querySelector(".disco-user-count");
+ document.l10n.setAttributes(userCountElem, "user-count", {
+ dailyUsers: addon.dailyUsers,
+ });
+ }
+
+ card.querySelector(".disco-description-statistics").hidden = !hasStats;
+ }
+
+ registerButtons(card, addon) {
+ let installButton = card.querySelector("[action='install-addon']");
+ if (addon.type == "theme") {
+ document.l10n.setAttributes(installButton, "install-theme-button");
+ } else {
+ document.l10n.setAttributes(installButton, "install-extension-button");
+ }
+
+ this.addEventListener("click", this);
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "install-addon":
+ this.installDiscoAddon();
+ break;
+ case "manage-addon":
+ gViewController.loadView(`detail/${this.addonId}`);
+ break;
+ }
+ }
+
+ async installDiscoAddon() {
+ let addon = this.discoAddon;
+ let url = addon.sourceURI.spec;
+ let install = await AddonManager.getInstallForURL(url, {
+ name: addon.name,
+ telemetryInfo: {
+ source: "disco",
+ taarRecommended: addon.taarRecommended,
+ },
+ });
+ // We are hosted in a <browser> in about:addons, but we can just use the
+ // main tab's browser since all of it is using the system principal.
+ let browser = window.docShell.chromeEventHandler;
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ browser,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ install
+ );
+ }
+
+ /**
+ * @param {AddonWrapper|null} addon
+ * The add-on that has been installed; null if it has been removed.
+ */
+ setAddon(addon) {
+ let card = this.firstElementChild;
+ card.querySelector("[action='install-addon']").hidden = !!addon;
+ card.querySelector("[action='manage-addon']").hidden = !addon;
+
+ this.dispatchEvent(new CustomEvent("disco-card-updated")); // For testing.
+ }
+}
+customElements.define("recommended-addon-card", RecommendedAddonCard);
+
+/**
+ * A list view for add-ons of a certain type. It should be initialized with the
+ * type of add-on to render and have section data set before being connected to
+ * the document.
+ *
+ * let list = document.createElement("addon-list");
+ * list.type = "plugin";
+ * list.setSections([{
+ * headingId: "plugin-section-heading",
+ * filterFn: addon => !addon.isSystem,
+ * }]);
+ * document.body.appendChild(list);
+ */
+class AddonList extends HTMLElement {
+ constructor() {
+ super();
+ this.sections = [];
+ this.pendingUninstallAddons = new Set();
+ this._addonsToUpdate = new Set();
+ this._userFocusListenersAdded = false;
+ }
+
+ async connectedCallback() {
+ // Register the listener and get the add-ons, these operations should
+ // happpen as close to each other as possible.
+ this.registerListener();
+ // Don't render again if we were rendered prior to being inserted.
+ if (!this.children.length) {
+ // Render the initial view.
+ this.render();
+ }
+ }
+
+ disconnectedCallback() {
+ // Remove content and stop listening until this is connected again.
+ this.textContent = "";
+ this.removeListener();
+
+ // Process any pending uninstall related to this list.
+ for (const addon of this.pendingUninstallAddons) {
+ if (isPending(addon, "uninstall")) {
+ addon.uninstall();
+ }
+ }
+ this.pendingUninstallAddons.clear();
+ }
+
+ /**
+ * Configure the sections in the list.
+ *
+ * @param {object[]} sections
+ * The options for the section. Each entry in the array should have:
+ * headingId: The fluent id for the section's heading.
+ * filterFn: A function that determines if an add-on belongs in
+ * the section.
+ */
+ setSections(sections) {
+ this.sections = sections.map(section => Object.assign({}, section));
+ }
+
+ /**
+ * Set the add-on type for this list. This will be used to filter the add-ons
+ * that are displayed.
+ *
+ * @param {string} val The type to filter on.
+ */
+ set type(val) {
+ this.setAttribute("type", val);
+ }
+
+ get type() {
+ return this.getAttribute("type");
+ }
+
+ getSection(index) {
+ return this.sections[index].node;
+ }
+
+ getCards(section) {
+ return section.querySelectorAll("addon-card");
+ }
+
+ getCard(addon) {
+ return this.querySelector(`addon-card[addon-id="${addon.id}"]`);
+ }
+
+ getPendingUninstallBar(addon) {
+ return this.querySelector(`moz-message-bar[addon-id="${addon.id}"]`);
+ }
+
+ sortByFn(aAddon, bAddon) {
+ return aAddon.name.localeCompare(bAddon.name);
+ }
+
+ async getAddons() {
+ if (!this.type) {
+ throw new Error(`type must be set to find add-ons`);
+ }
+
+ // Find everything matching our type, null will find all types.
+ let type = this.type == "all" ? null : [this.type];
+ let addons = await AddonManager.getAddonsByTypes(type);
+
+ if (type == "theme") {
+ await BuiltInThemes.ensureBuiltInThemes();
+ }
+
+ // Put the add-ons into the sections, an add-on goes in the first section
+ // that it matches the filterFn for. It might not go in any section.
+ let sectionedAddons = this.sections.map(() => []);
+ for (let addon of addons) {
+ let index = this.sections.findIndex(({ filterFn }) => filterFn(addon));
+ if (index != -1) {
+ sectionedAddons[index].push(addon);
+ } else if (isPending(addon, "uninstall")) {
+ // A second tab may be opened on "about:addons" (or Firefox may
+ // have crashed) while there are still "pending uninstall" add-ons.
+ // Ensure to list them in the pendingUninstall message-bar-stack
+ // when the AddonList is initially rendered.
+ this.pendingUninstallAddons.add(addon);
+ }
+ }
+
+ // Sort the add-ons in each section.
+ for (let [index, section] of sectionedAddons.entries()) {
+ let sortByFn = this.sections[index].sortByFn || this.sortByFn;
+ section.sort(sortByFn);
+ }
+
+ return sectionedAddons;
+ }
+
+ createPendingUninstallStack() {
+ const stack = document.createElement("message-bar-stack");
+ stack.setAttribute("class", "pending-uninstall");
+ stack.setAttribute("reverse", "");
+ return stack;
+ }
+
+ addPendingUninstallBar(addon) {
+ const stack = this.pendingUninstallStack;
+ const mb = document.createElement("moz-message-bar");
+ mb.setAttribute("addon-id", addon.id);
+ mb.setAttribute("type", "info");
+
+ const undo = document.createElement("button");
+ undo.setAttribute("action", "undo");
+ undo.addEventListener("click", () => {
+ addon.cancelUninstall();
+ });
+ undo.setAttribute("slot", "actions");
+
+ document.l10n.setAttributes(mb, "pending-uninstall-description2", {
+ addon: addon.name,
+ });
+ mb.setAttribute("data-l10n-attrs", "message");
+ document.l10n.setAttributes(undo, "pending-uninstall-undo-button");
+
+ mb.appendChild(undo);
+ stack.append(mb);
+ }
+
+ removePendingUninstallBar(addon) {
+ const messagebar = this.getPendingUninstallBar(addon);
+ if (messagebar) {
+ messagebar.remove();
+ }
+ }
+
+ createSectionHeading(headingIndex) {
+ let { headingId, subheadingId } = this.sections[headingIndex];
+ let frag = document.createDocumentFragment();
+ let heading = document.createElement("h2");
+ heading.classList.add("list-section-heading");
+ document.l10n.setAttributes(heading, headingId);
+ frag.append(heading);
+
+ if (subheadingId) {
+ heading.className = "header-name";
+ let subheading = document.createElement("h3");
+ subheading.classList.add("list-section-subheading");
+ document.l10n.setAttributes(subheading, subheadingId);
+ frag.append(subheading);
+ }
+
+ return frag;
+ }
+
+ createEmptyListMessage() {
+ let emptyMessage = "list-empty-get-extensions-message";
+ let linkPref = "extensions.getAddons.link.url";
+
+ if (this.sections && this.sections.length) {
+ if (this.sections[0].headingId == "locale-enabled-heading") {
+ emptyMessage = "list-empty-get-language-packs-message";
+ linkPref = "browser.dictionaries.download.url";
+ } else if (this.sections[0].headingId == "dictionary-enabled-heading") {
+ emptyMessage = "list-empty-get-dictionaries-message";
+ linkPref = "browser.dictionaries.download.url";
+ }
+ }
+
+ let messageContainer = document.createElement("p");
+ messageContainer.id = "empty-addons-message";
+ let a = document.createElement("a");
+ a.href = Services.urlFormatter.formatURLPref(linkPref);
+ a.setAttribute("target", "_blank");
+ a.setAttribute("data-l10n-name", "get-extensions");
+ document.l10n.setAttributes(messageContainer, emptyMessage, {
+ domain: a.hostname,
+ });
+ messageContainer.appendChild(a);
+ return messageContainer;
+ }
+
+ updateSectionIfEmpty(section) {
+ // The header is added before any add-on cards, so if there's only one
+ // child then it's the header. In that case we should empty out the section.
+ if (section.children.length == 1) {
+ section.textContent = "";
+ }
+ }
+
+ insertCardInto(card, sectionIndex) {
+ let section = this.getSection(sectionIndex);
+ let sectionCards = this.getCards(section);
+
+ // If this is the first card in the section, create the heading.
+ if (!sectionCards.length) {
+ section.appendChild(this.createSectionHeading(sectionIndex));
+ }
+
+ // Find where to insert the card.
+ let insertBefore = Array.from(sectionCards).find(
+ otherCard => this.sortByFn(card.addon, otherCard.addon) < 0
+ );
+ // This will append if insertBefore is null.
+ section.insertBefore(card, insertBefore || null);
+ }
+
+ addAddon(addon) {
+ // Only insert add-ons of the right type.
+ if (addon.type != this.type && this.type != "all") {
+ this.sendEvent("skip-add", "type-mismatch");
+ return;
+ }
+
+ let insertSection = this._addonSectionIndex(addon);
+
+ // Don't add the add-on if it doesn't go in a section.
+ if (insertSection == -1) {
+ return;
+ }
+
+ // Create and insert the card.
+ let card = document.createElement("addon-card");
+ card.setAddon(addon);
+ this.insertCardInto(card, insertSection);
+ this.sendEvent("add", { id: addon.id });
+ }
+
+ sendEvent(name, detail) {
+ this.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ removeAddon(addon) {
+ let card = this.getCard(addon);
+ if (card) {
+ let section = card.parentNode;
+ card.remove();
+ this.updateSectionIfEmpty(section);
+ this.sendEvent("remove", { id: addon.id });
+ }
+ }
+
+ updateAddon(addon) {
+ if (!this.getCard(addon)) {
+ // Try to add the add-on right away.
+ this.addAddon(addon);
+ } else if (this._addonSectionIndex(addon) == -1) {
+ // Try to remove the add-on right away.
+ this._updateAddon(addon);
+ } else if (this.isUserFocused) {
+ // Queue up a change for when the focus is cleared.
+ this.updateLater(addon);
+ } else {
+ // Not currently focused, make the change now.
+ this.withCardAnimation(() => this._updateAddon(addon));
+ }
+ }
+
+ updateLater(addon) {
+ this._addonsToUpdate.add(addon);
+ this._addUserFocusListeners();
+ }
+
+ _addUserFocusListeners() {
+ if (this._userFocusListenersAdded) {
+ return;
+ }
+
+ this._userFocusListenersAdded = true;
+ this.addEventListener("mouseleave", this);
+ this.addEventListener("hidden", this, true);
+ this.addEventListener("focusout", this);
+ }
+
+ _removeUserFocusListeners() {
+ if (!this._userFocusListenersAdded) {
+ return;
+ }
+
+ this.removeEventListener("mouseleave", this);
+ this.removeEventListener("hidden", this, true);
+ this.removeEventListener("focusout", this);
+ this._userFocusListenersAdded = false;
+ }
+
+ get hasMenuOpen() {
+ return !!this.querySelector("panel-list[open]");
+ }
+
+ get isUserFocused() {
+ return this.matches(":hover, :focus-within") || this.hasMenuOpen;
+ }
+
+ update() {
+ if (this._addonsToUpdate.size) {
+ this.withCardAnimation(() => {
+ for (let addon of this._addonsToUpdate) {
+ this._updateAddon(addon);
+ }
+ this._addonsToUpdate = new Set();
+ });
+ }
+ }
+
+ _getChildCoords() {
+ let results = new Map();
+ for (let child of this.querySelectorAll("addon-card")) {
+ results.set(child, child.getBoundingClientRect());
+ }
+ return results;
+ }
+
+ withCardAnimation(changeFn) {
+ if (shouldSkipAnimations()) {
+ changeFn();
+ return;
+ }
+
+ let origChildCoords = this._getChildCoords();
+
+ changeFn();
+
+ let newChildCoords = this._getChildCoords();
+ let cards = this.querySelectorAll("addon-card");
+ let transitionCards = [];
+ for (let card of cards) {
+ let orig = origChildCoords.get(card);
+ let moved = newChildCoords.get(card);
+ let changeY = moved.y - (orig || moved).y;
+ let cardEl = card.firstElementChild;
+
+ if (changeY != 0) {
+ cardEl.style.transform = `translateY(${changeY * -1}px)`;
+ transitionCards.push(card);
+ }
+ }
+ requestAnimationFrame(() => {
+ for (let card of transitionCards) {
+ card.firstElementChild.style.transition = "transform 125ms";
+ }
+
+ requestAnimationFrame(() => {
+ for (let card of transitionCards) {
+ let cardEl = card.firstElementChild;
+ cardEl.style.transform = "";
+ cardEl.addEventListener("transitionend", function handler(e) {
+ if (e.target == cardEl && e.propertyName == "transform") {
+ cardEl.style.transition = "";
+ cardEl.removeEventListener("transitionend", handler);
+ }
+ });
+ }
+ });
+ });
+ }
+
+ _addonSectionIndex(addon) {
+ return this.sections.findIndex(s => s.filterFn(addon));
+ }
+
+ _updateAddon(addon) {
+ let card = this.getCard(addon);
+ if (card) {
+ let sectionIndex = this._addonSectionIndex(addon);
+ if (sectionIndex != -1) {
+ // Move the card, if needed. This will allow an animation between
+ // page sections and provides clearer events for testing.
+ if (card.parentNode.getAttribute("section") != sectionIndex) {
+ let { activeElement } = document;
+ let refocus = card.contains(activeElement);
+ let oldSection = card.parentNode;
+ this.insertCardInto(card, sectionIndex);
+ this.updateSectionIfEmpty(oldSection);
+ if (refocus) {
+ activeElement.focus();
+ }
+ this.sendEvent("move", { id: addon.id });
+ }
+ } else {
+ this.removeAddon(addon);
+ }
+ }
+ }
+
+ renderSection(addons, index) {
+ const { sectionClass } = this.sections[index];
+
+ let section = document.createElement("section");
+ section.setAttribute("section", index);
+ if (sectionClass) {
+ section.setAttribute("class", sectionClass);
+ }
+
+ // Render the heading and add-ons if there are any.
+ if (addons.length) {
+ section.appendChild(this.createSectionHeading(index));
+ }
+
+ for (let addon of addons) {
+ let card = document.createElement("addon-card");
+ card.setAddon(addon);
+ card.render();
+ section.appendChild(card);
+ }
+
+ return section;
+ }
+
+ async render() {
+ this.textContent = "";
+
+ let sectionedAddons = await this.getAddons();
+
+ let frag = document.createDocumentFragment();
+
+ // Render the pending uninstall message-bar-stack.
+ this.pendingUninstallStack = this.createPendingUninstallStack();
+ for (let addon of this.pendingUninstallAddons) {
+ this.addPendingUninstallBar(addon);
+ }
+ frag.appendChild(this.pendingUninstallStack);
+
+ // Render the sections.
+ for (let i = 0; i < sectionedAddons.length; i++) {
+ this.sections[i].node = this.renderSection(sectionedAddons[i], i);
+ frag.appendChild(this.sections[i].node);
+ }
+
+ // Render the placeholder that is shown when all sections are empty.
+ // This call is after rendering the sections, because its visibility
+ // is controlled through the general sibling combinator relative to
+ // the sections (section ~).
+ let message = this.createEmptyListMessage();
+ frag.appendChild(message);
+
+ // Make sure fluent has set all the strings before we render. This will
+ // avoid the height changing as strings go from 0 height to having text.
+ await document.l10n.translateFragment(frag);
+ this.appendChild(frag);
+ }
+
+ registerListener() {
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ removeListener() {
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ handleEvent(e) {
+ if (!this.isUserFocused || (e.type == "mouseleave" && !this.hasMenuOpen)) {
+ this._removeUserFocusListeners();
+ this.update();
+ }
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onOperationCancelled(addon) {
+ if (
+ this.pendingUninstallAddons.has(addon) &&
+ !isPending(addon, "uninstall")
+ ) {
+ this.pendingUninstallAddons.delete(addon);
+ this.removePendingUninstallBar(addon);
+ }
+ this.updateAddon(addon);
+ }
+
+ onEnabled(addon) {
+ this.updateAddon(addon);
+ }
+
+ onDisabled(addon) {
+ this.updateAddon(addon);
+ }
+
+ onUninstalling(addon) {
+ if (
+ isPending(addon, "uninstall") &&
+ (this.type === "all" || addon.type === this.type)
+ ) {
+ this.pendingUninstallAddons.add(addon);
+ this.addPendingUninstallBar(addon);
+ this.updateAddon(addon);
+ }
+ }
+
+ onInstalled(addon) {
+ if (this.querySelector(`addon-card[addon-id="${addon.id}"]`)) {
+ return;
+ }
+ this.addAddon(addon);
+ }
+
+ onUninstalled(addon) {
+ this.pendingUninstallAddons.delete(addon);
+ this.removePendingUninstallBar(addon);
+ this.removeAddon(addon);
+ }
+}
+customElements.define("addon-list", AddonList);
+
+class RecommendedAddonList extends HTMLElement {
+ connectedCallback() {
+ if (this.isConnected) {
+ this.loadCardsIfNeeded();
+ this.updateCardsWithAddonManager();
+ }
+ AddonManagerListenerHandler.addListener(this);
+ }
+
+ disconnectedCallback() {
+ AddonManagerListenerHandler.removeListener(this);
+ }
+
+ get type() {
+ return this.getAttribute("type");
+ }
+
+ /**
+ * Set the add-on type for this list. This will be used to filter the add-ons
+ * that are displayed.
+ *
+ * Must be set prior to the first render.
+ *
+ * @param {string} val The type to filter on.
+ */
+ set type(val) {
+ this.setAttribute("type", val);
+ }
+
+ get hideInstalled() {
+ return this.hasAttribute("hide-installed");
+ }
+
+ /**
+ * Set whether installed add-ons should be hidden from the list. If false,
+ * installed add-ons will be shown with a "Manage" button, otherwise they
+ * will be hidden.
+ *
+ * Must be set prior to the first render.
+ *
+ * @param {boolean} val Whether to show installed add-ons.
+ */
+ set hideInstalled(val) {
+ this.toggleAttribute("hide-installed", val);
+ }
+
+ getCardById(addonId) {
+ for (let card of this.children) {
+ if (card.addonId === addonId) {
+ return card;
+ }
+ }
+ return null;
+ }
+
+ setAddonForCard(card, addon) {
+ card.setAddon(addon);
+
+ let wasHidden = card.hidden;
+ card.hidden = this.hideInstalled && addon;
+
+ if (wasHidden != card.hidden) {
+ let eventName = card.hidden ? "card-hidden" : "card-shown";
+ this.dispatchEvent(new CustomEvent(eventName, { detail: { card } }));
+ }
+ }
+
+ /**
+ * Whether the client ID should be preferred. This is disabled for themes
+ * since they don't use the telemetry data and don't show the TAAR notice.
+ */
+ get preferClientId() {
+ return !this.type || this.type == "extension";
+ }
+
+ async updateCardsWithAddonManager() {
+ let cards = Array.from(this.children);
+ let addonIds = cards.map(card => card.addonId);
+ let addons = await AddonManager.getAddonsByIDs(addonIds);
+ for (let [i, card] of cards.entries()) {
+ let addon = addons[i];
+ this.setAddonForCard(card, addon);
+ if (addon) {
+ // Already installed, move card to end.
+ this.append(card);
+ }
+ }
+ }
+
+ async loadCardsIfNeeded() {
+ // Use promise as guard. Also used by tests to detect when load completes.
+ if (!this.cardsReady) {
+ this.cardsReady = this._loadCards();
+ }
+ return this.cardsReady;
+ }
+
+ async _loadCards() {
+ let recommendedAddons;
+ try {
+ recommendedAddons = await DiscoveryAPI.getResults(this.preferClientId);
+ } catch (e) {
+ return;
+ }
+
+ let frag = document.createDocumentFragment();
+ for (let addon of recommendedAddons) {
+ if (this.type && addon.type != this.type) {
+ continue;
+ }
+ let card = document.createElement("recommended-addon-card");
+ card.setDiscoAddon(addon);
+ frag.append(card);
+ }
+ this.append(frag);
+ await this.updateCardsWithAddonManager();
+ }
+
+ /**
+ * AddonManager listener events.
+ */
+
+ onInstalled(addon) {
+ let card = this.getCardById(addon.id);
+ if (card) {
+ this.setAddonForCard(card, addon);
+ }
+ }
+
+ onUninstalled(addon) {
+ let card = this.getCardById(addon.id);
+ if (card) {
+ this.setAddonForCard(card, null);
+ }
+ }
+}
+customElements.define("recommended-addon-list", RecommendedAddonList);
+
+class TaarMessageBar extends HTMLElement {
+ connectedCallback() {
+ this.hidden =
+ Services.prefs.getBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, false) ||
+ !DiscoveryAPI.clientIdDiscoveryEnabled;
+ if (this.childElementCount == 0 && !this.hidden) {
+ this.appendChild(importTemplate("taar-notice"));
+ this.addEventListener("click", this);
+ this.messageBar = this.querySelector("moz-message-bar");
+ this.messageBar.addEventListener("message-bar:user-dismissed", this);
+ }
+ }
+
+ handleEvent(e) {
+ if (e.type == "message-bar:user-dismissed") {
+ Services.prefs.setBoolPref(PREF_RECOMMENDATION_HIDE_NOTICE, true);
+ }
+ }
+}
+customElements.define("taar-notice", TaarMessageBar);
+
+class RecommendedFooter extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.appendChild(importTemplate("recommended-footer"));
+ this.querySelector(".privacy-policy-link").href =
+ Services.prefs.getStringPref(PREF_PRIVACY_POLICY_URL);
+ this.addEventListener("click", this);
+ }
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "open-amo":
+ openAmoInTab(this);
+ break;
+ }
+ }
+}
+customElements.define("recommended-footer", RecommendedFooter, {
+ extends: "footer",
+});
+
+class RecommendedThemesFooter extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.appendChild(importTemplate("recommended-themes-footer"));
+ let themeRecommendationRow = this.querySelector(".theme-recommendation");
+ let themeRecommendationUrl = Services.prefs.getStringPref(
+ PREF_THEME_RECOMMENDATION_URL
+ );
+ if (themeRecommendationUrl) {
+ themeRecommendationRow.querySelector("a").href = themeRecommendationUrl;
+ }
+ themeRecommendationRow.hidden = !themeRecommendationUrl;
+ this.addEventListener("click", this);
+ }
+ }
+
+ handleEvent(event) {
+ let action = event.target.getAttribute("action");
+ switch (action) {
+ case "open-amo":
+ openAmoInTab(this, "themes");
+ break;
+ }
+ }
+}
+customElements.define("recommended-themes-footer", RecommendedThemesFooter, {
+ extends: "footer",
+});
+
+/**
+ * This element will handle showing recommendations with a
+ * <recommended-addon-list> and a <footer>. The footer will be hidden until
+ * the <recommended-addon-list> is done making its request so the footer
+ * doesn't move around.
+ *
+ * Subclass this element to use it and define a `template` property to pull
+ * the template from. Expected template:
+ *
+ * <h1>My extra content can go here.</h1>
+ * <p>It can be anything but a footer or recommended-addon-list.</p>
+ * <recommended-addon-list></recommended-addon-list>
+ * <footer>My custom footer</footer>
+ */
+class RecommendedSection extends HTMLElement {
+ connectedCallback() {
+ if (this.childElementCount == 0) {
+ this.render();
+ }
+ }
+
+ get list() {
+ return this.querySelector("recommended-addon-list");
+ }
+
+ get footer() {
+ return this.querySelector("footer");
+ }
+
+ render() {
+ this.appendChild(importTemplate(this.template));
+
+ // Hide footer until the cards are loaded, to prevent the content from
+ // suddenly shifting when the user attempts to interact with it.
+ let { footer } = this;
+ footer.hidden = true;
+ this.list.loadCardsIfNeeded().finally(() => {
+ footer.hidden = false;
+ });
+ }
+}
+
+class RecommendedExtensionsSection extends RecommendedSection {
+ get template() {
+ return "recommended-extensions-section";
+ }
+}
+customElements.define(
+ "recommended-extensions-section",
+ RecommendedExtensionsSection
+);
+
+class RecommendedThemesSection extends RecommendedSection {
+ get template() {
+ return "recommended-themes-section";
+ }
+}
+customElements.define("recommended-themes-section", RecommendedThemesSection);
+
+class DiscoveryPane extends RecommendedSection {
+ get template() {
+ return "discopane";
+ }
+}
+customElements.define("discovery-pane", DiscoveryPane);
+
+// Define views
+gViewController.defineView("list", async type => {
+ if (!AddonManager.hasAddonType(type)) {
+ return null;
+ }
+
+ let frag = document.createDocumentFragment();
+ let list = document.createElement("addon-list");
+ list.type = type;
+
+ let sections = [
+ {
+ headingId: type + "-enabled-heading",
+ sectionClass: `${type}-enabled-section`,
+ filterFn: addon =>
+ !addon.hidden && addon.isActive && !isPending(addon, "uninstall"),
+ },
+ ];
+
+ const disabledAddonsFilterFn = addon =>
+ !addon.hidden && !addon.isActive && !isPending(addon, "uninstall");
+
+ sections.push({
+ headingId: getL10nIdMapping(`${type}-disabled-heading`),
+ sectionClass: `${type}-disabled-section`,
+ filterFn: disabledAddonsFilterFn,
+ });
+
+ list.setSections(sections);
+ frag.appendChild(list);
+
+ // Show recommendations for themes and extensions.
+ if (
+ LIST_RECOMMENDATIONS_ENABLED &&
+ (type == "extension" || type == "theme")
+ ) {
+ let elementName =
+ type == "extension"
+ ? "recommended-extensions-section"
+ : "recommended-themes-section";
+ let recommendations = document.createElement(elementName);
+ // Start loading the recommendations. This can finish after the view load
+ // event is sent.
+ recommendations.render();
+ frag.appendChild(recommendations);
+ }
+
+ await list.render();
+
+ return frag;
+});
+
+gViewController.defineView("detail", async param => {
+ let [id, selectedTab] = param.split("/");
+ let addon = await AddonManager.getAddonByID(id);
+
+ if (!addon) {
+ return null;
+ }
+
+ let card = document.createElement("addon-card");
+
+ // Ensure the category for this add-on type is selected.
+ document.querySelector("categories-box").selectType(addon.type);
+
+ // Go back to the list view when the add-on is removed.
+ card.addEventListener("remove", () =>
+ gViewController.loadView(`list/${addon.type}`)
+ );
+
+ card.setAddon(addon);
+ card.expand();
+ await card.render();
+ if (selectedTab === "preferences" && (await isAddonOptionsUIAllowed(addon))) {
+ card.showPrefs();
+ }
+
+ return card;
+});
+
+gViewController.defineView("updates", async param => {
+ let list = document.createElement("addon-list");
+ list.type = "all";
+ if (param == "available") {
+ list.setSections([
+ {
+ headingId: "available-updates-heading",
+ filterFn: addon => {
+ // Filter the addons visible in the updates view using the same
+ // criteria that is being used to compute the counter on the
+ // available updates category button badge.
+ const install = getUpdateInstall(addon);
+ return install && isManualUpdate(install) && !install.installed;
+ },
+ },
+ ]);
+ } else if (param == "recent") {
+ list.sortByFn = (a, b) => {
+ if (a.updateDate > b.updateDate) {
+ return -1;
+ }
+ if (a.updateDate < b.updateDate) {
+ return 1;
+ }
+ return 0;
+ };
+ let updateLimit = new Date() - UPDATES_RECENT_TIMESPAN;
+ list.setSections([
+ {
+ headingId: "recent-updates-heading",
+ filterFn: addon =>
+ !addon.hidden && addon.updateDate && addon.updateDate > updateLimit,
+ },
+ ]);
+ } else {
+ throw new Error(`Unknown updates view ${param}`);
+ }
+
+ await list.render();
+ return list;
+});
+
+gViewController.defineView("discover", async () => {
+ let discopane = document.createElement("discovery-pane");
+ discopane.render();
+ await document.l10n.translateFragment(discopane);
+ return discopane;
+});
+
+gViewController.defineView("shortcuts", async () => {
+ // Force the extension category to be selected, in the case of a reload,
+ // restart, or if the view was opened from another category's page.
+ document.querySelector("categories-box").selectType("extension");
+
+ let view = document.createElement("addon-shortcuts");
+ await view.render();
+ await document.l10n.translateFragment(view);
+ return view;
+});
+
+/**
+ * @param {Element} el The button element.
+ */
+function openAmoInTab(el, path) {
+ let amoUrl = Services.urlFormatter.formatURLPref(
+ "extensions.getAddons.link.url"
+ );
+
+ if (path) {
+ amoUrl += path;
+ }
+
+ amoUrl = formatUTMParams("find-more-link-bottom", amoUrl);
+ windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab");
+}
+
+/**
+ * Called when about:addons is loaded.
+ */
+async function initialize() {
+ window.addEventListener(
+ "unload",
+ () => {
+ // Clear out the document so the disconnectedCallback will trigger
+ // properly and all of the custom elements can cleanup.
+ document.body.textContent = "";
+ AddonManagerListenerHandler.shutdown();
+ },
+ { once: true }
+ );
+
+ // Init UI and view management
+ gViewController.initialize(document.getElementById("main"));
+
+ document.querySelector("categories-box").initialize();
+ AddonManagerListenerHandler.startup();
+
+ // browser.js may call loadView here if it expects an EM-loaded notification
+ gViewController.notifyEMLoaded();
+
+ // Select an initial view if no listener has set one so far
+ if (!gViewController.currentViewId) {
+ if (history.state) {
+ // If there is a history state to restore then use that
+ await gViewController.renderState(history.state);
+ } else {
+ // Fallback to the last category or first valid category view otherwise.
+ await gViewController.loadView(
+ Services.prefs.getStringPref(
+ PREF_UI_LASTCATEGORY,
+ gViewController.defaultViewId
+ )
+ );
+ }
+ }
+}
+
+window.promiseInitialized = new Promise(resolve => {
+ window.addEventListener(
+ "load",
+ () => {
+ initialize().then(resolve);
+ },
+ { once: true }
+ );
+});
diff --git a/toolkit/mozapps/extensions/content/aboutaddonsCommon.js b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
new file mode 100644
index 0000000000..739e7629d7
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/aboutaddonsCommon.js
@@ -0,0 +1,275 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+/* exported attachUpdateHandler, detachUpdateHandler, gBrowser,
+ * getBrowserElement, installAddonsFromFilePicker,
+ * isCorrectlySigned, isDisabledUnsigned, isDiscoverEnabled,
+ * isPending, loadReleaseNotes, openOptionsInTab, promiseEvent,
+ * shouldShowPermissionsPrompt, showPermissionsPrompt,
+ * PREF_UI_LASTCATEGORY */
+
+const { AddonSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonSettings.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "XPINSTALL_ENABLED",
+ "xpinstall.enabled",
+ true
+);
+
+const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
+const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
+
+function isDiscoverEnabled() {
+ try {
+ if (!Services.prefs.getBoolPref(PREF_DISCOVER_ENABLED)) {
+ return false;
+ }
+ } catch (e) {}
+
+ if (!XPINSTALL_ENABLED) {
+ return false;
+ }
+
+ return true;
+}
+
+function getBrowserElement() {
+ return window.docShell.chromeEventHandler;
+}
+
+function promiseEvent(event, target, capture = false) {
+ return new Promise(resolve => {
+ target.addEventListener(event, resolve, { capture, once: true });
+ });
+}
+
+function installPromptHandler(info) {
+ const install = this;
+
+ let oldPerms = info.existingAddon.userPermissions;
+ if (!oldPerms) {
+ // Updating from a legacy add-on, let it proceed
+ return Promise.resolve();
+ }
+
+ let newPerms = info.addon.userPermissions;
+
+ let difference = Extension.comparePermissions(oldPerms, newPerms);
+
+ // If there are no new permissions, just proceed
+ if (!difference.origins.length && !difference.permissions.length) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ let subject = {
+ wrappedJSObject: {
+ target: getBrowserElement(),
+ info: {
+ type: "update",
+ addon: info.addon,
+ icon: info.addon.iconURL,
+ // Reference to the related AddonInstall object (used in
+ // AMTelemetry to link the recorded event to the other events from
+ // the same install flow).
+ install,
+ permissions: difference,
+ resolve,
+ reject,
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-permission-prompt");
+ });
+}
+
+function attachUpdateHandler(install) {
+ install.promptHandler = installPromptHandler;
+}
+
+function detachUpdateHandler(install) {
+ if (install?.promptHandler === installPromptHandler) {
+ install.promptHandler = null;
+ }
+}
+
+async function loadReleaseNotes(uri) {
+ const res = await fetch(uri.spec, { credentials: "omit" });
+
+ if (!res.ok) {
+ throw new Error("Error loading release notes");
+ }
+
+ // Load the content.
+ const text = await res.text();
+
+ // Setup the content sanitizer.
+ const ParserUtils = Cc["@mozilla.org/parserutils;1"].getService(
+ Ci.nsIParserUtils
+ );
+ const flags =
+ ParserUtils.SanitizerDropMedia |
+ ParserUtils.SanitizerDropNonCSSPresentation |
+ ParserUtils.SanitizerDropForms;
+
+ // Sanitize and parse the content to a fragment.
+ const context = document.createElementNS(HTML_NS, "div");
+ return ParserUtils.parseFragment(text, flags, false, uri, context);
+}
+
+function openOptionsInTab(optionsURL) {
+ let mainWindow = window.windowRoot.ownerGlobal;
+ if ("switchToTabHavingURI" in mainWindow) {
+ mainWindow.switchToTabHavingURI(optionsURL, true, {
+ relatedToCurrent: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ return true;
+ }
+ return false;
+}
+
+function shouldShowPermissionsPrompt(addon) {
+ if (!addon.isWebExtension || addon.seen) {
+ return false;
+ }
+
+ let perms = addon.userPermissions;
+ return perms?.origins.length || perms?.permissions.length;
+}
+
+function showPermissionsPrompt(addon) {
+ return new Promise(resolve => {
+ const permissions = addon.userPermissions;
+ const target = getBrowserElement();
+
+ const onAddonEnabled = () => {
+ // The user has just enabled a sideloaded extension, if the permission
+ // can be changed for the extension, show the post-install panel to
+ // give the user that opportunity.
+ if (
+ addon.permissions & AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ ) {
+ Services.obs.notifyObservers(
+ { addon, target },
+ "webextension-install-notify"
+ );
+ }
+ resolve();
+ };
+
+ const subject = {
+ wrappedJSObject: {
+ target,
+ info: {
+ type: "sideload",
+ addon,
+ icon: addon.iconURL,
+ permissions,
+ resolve() {
+ addon.markAsSeen();
+ addon.enable().then(onAddonEnabled);
+ },
+ reject() {
+ // Ignore a cancelled permission prompt.
+ },
+ },
+ },
+ };
+ Services.obs.notifyObservers(subject, "webextension-permission-prompt");
+ });
+}
+
+// Stub tabbrowser implementation for use by the tab-modal alert code
+// when an alert/prompt/confirm method is called in a WebExtensions options_ui
+// page (See Bug 1385548 for rationale).
+var gBrowser = {
+ getTabModalPromptBox(browser) {
+ const parentWindow = window.docShell.chromeEventHandler.ownerGlobal;
+
+ if (parentWindow.gBrowser) {
+ return parentWindow.gBrowser.getTabModalPromptBox(browser);
+ }
+
+ return null;
+ },
+};
+
+function isCorrectlySigned(addon) {
+ // Add-ons without an "isCorrectlySigned" property are correctly signed as
+ // they aren't the correct type for signing.
+ return addon.isCorrectlySigned !== false;
+}
+
+function isDisabledUnsigned(addon) {
+ let signingRequired =
+ addon.type == "locale"
+ ? AddonSettings.LANGPACKS_REQUIRE_SIGNING
+ : AddonSettings.REQUIRE_SIGNING;
+ return signingRequired && !isCorrectlySigned(addon);
+}
+
+function isPending(addon, action) {
+ const amAction = AddonManager["PENDING_" + action.toUpperCase()];
+ return !!(addon.pendingOperations & amAction);
+}
+
+async function installAddonsFromFilePicker() {
+ let [dialogTitle, filterName] = await document.l10n.formatMessages([
+ { id: "addon-install-from-file-dialog-title" },
+ { id: "addon-install-from-file-filter-name" },
+ ]);
+ const nsIFilePicker = Ci.nsIFilePicker;
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ fp.init(window, dialogTitle.value, nsIFilePicker.modeOpenMultiple);
+ try {
+ fp.appendFilter(filterName.value, "*.xpi;*.jar;*.zip");
+ fp.appendFilters(nsIFilePicker.filterAll);
+ } catch (e) {}
+
+ return new Promise(resolve => {
+ fp.open(async result => {
+ if (result != nsIFilePicker.returnOK) {
+ return;
+ }
+
+ let installTelemetryInfo = {
+ source: "about:addons",
+ method: "install-from-file",
+ };
+
+ let browser = getBrowserElement();
+ let installs = [];
+ for (let file of fp.files) {
+ let install = await AddonManager.getInstallForFile(
+ file,
+ null,
+ installTelemetryInfo
+ );
+ AddonManager.installAddonFromAOM(
+ browser,
+ document.documentURIObject,
+ install
+ );
+ installs.push(install);
+ }
+ resolve(installs);
+ });
+ });
+}
diff --git a/toolkit/mozapps/extensions/content/abuse-report-frame.html b/toolkit/mozapps/extensions/content/abuse-report-frame.html
new file mode 100644
index 0000000000..64148ecece
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-report-frame.html
@@ -0,0 +1,213 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <title></title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/aboutaddons.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://mozapps/content/extensions/abuse-report-panel.css"
+ />
+
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/about/aboutAddons.ftl" />
+ <link rel="localization" href="toolkit/about/abuseReports.ftl" />
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-button-group.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-support-link.mjs"
+ ></script>
+ <script
+ defer
+ src="chrome://mozapps/content/extensions/abuse-report-panel.js"
+ ></script>
+ </head>
+
+ <body>
+ <addon-abuse-report></addon-abuse-report>
+
+ <!-- WebComponents Templates -->
+ <template id="tmpl-modal">
+ <div class="modal-overlay-outer"></div>
+ <div class="modal-panel-container"></div>
+ </template>
+
+ <template id="tmpl-abuse-report">
+ <form class="addon-abuse-report" onsubmit="return false;">
+ <div class="abuse-report-header">
+ <img class="card-heading-icon addon-icon" />
+ <div class="card-contents">
+ <span class="addon-name"></span>
+ <span
+ class="addon-author-box"
+ data-l10n-args='{"author-name": "author placeholder"}'
+ data-l10n-id="abuse-report-addon-authored-by"
+ >
+ <a
+ data-l10n-name="author-name"
+ class="author"
+ href="#"
+ target="_blank"
+ ></a>
+ </span>
+ </div>
+ </div>
+ <button class="abuse-report-close-icon" type="button"></button>
+ <div class="abuse-report-contents">
+ <abuse-report-reasons-panel></abuse-report-reasons-panel>
+ <abuse-report-submit-panel hidden></abuse-report-submit-panel>
+ </div>
+ <div class="abuse-report-buttons">
+ <moz-button-group class="abuse-report-reasons-buttons">
+ <button
+ class="abuse-report-cancel"
+ type="button"
+ data-l10n-id="abuse-report-cancel-button"
+ ></button>
+ <button
+ class="primary abuse-report-next"
+ type="button"
+ data-l10n-id="abuse-report-next-button"
+ ></button>
+ </moz-button-group>
+ <moz-button-group class="abuse-report-submit-buttons" hidden>
+ <button
+ class="abuse-report-goback"
+ type="button"
+ data-l10n-id="abuse-report-goback-button"
+ ></button>
+ <button
+ class="primary abuse-report-submit"
+ type="button"
+ data-l10n-id="abuse-report-submit-button"
+ ></button>
+ </moz-button-group>
+ </div>
+ </form>
+ </template>
+
+ <template id="tmpl-reasons-panel">
+ <h2 class="abuse-report-title"></h2>
+ <hr />
+ <p class="abuse-report-subtitle" data-l10n-id="abuse-report-subtitle"></p>
+ <ul class="abuse-report-reasons">
+ <li is="abuse-report-reason-listitem" report-reason="other"></li>
+ </ul>
+ <p>
+ <span data-l10n-id="abuse-report-learnmore-intro"></span>
+ <a
+ is="moz-support-link"
+ target="_blank"
+ support-page="reporting-extensions-and-themes-abuse"
+ data-l10n-id="abuse-report-learnmore-link"
+ >
+ </a>
+ </p>
+ </template>
+
+ <template id="tmpl-submit-panel">
+ <h2 class="abuse-reason-title"></h2>
+ <abuse-report-reason-suggestions></abuse-report-reason-suggestions>
+ <hr />
+ <p
+ class="abuse-report-subtitle"
+ data-l10n-id="abuse-report-submit-description"
+ ></p>
+ <textarea name="message" data-l10n-id="abuse-report-textarea"></textarea>
+ <p class="abuse-report-note" data-l10n-id="abuse-report-submit-note"></p>
+ </template>
+
+ <template id="tmpl-reason-listitem">
+ <label>
+ <input type="radio" name="reason" class="radio" />
+ <span class="reason-description"></span>
+ <span hidden class="abuse-report-note reason-example"></span>
+ </label>
+ </template>
+
+ <template id="tmpl-suggestions-settings">
+ <p data-l10n-id="abuse-report-settings-suggestions"></p>
+ <p></p>
+ <ul>
+ <li>
+ <a
+ is="moz-support-link"
+ target="_blank"
+ data-l10n-id="abuse-report-settings-suggestions-search"
+ support-page="prefs-search"
+ >
+ </a>
+ </li>
+ <li>
+ <a
+ is="moz-support-link"
+ target="_blank"
+ data-l10n-id="abuse-report-settings-suggestions-homepage"
+ support-page="prefs-homepage"
+ >
+ </a>
+ </li>
+ </ul>
+ </template>
+
+ <template id="tmpl-suggestions-policy">
+ <p data-l10n-id="abuse-report-policy-suggestions">
+ <a
+ class="abuse-policy-learnmore"
+ target="_blank"
+ data-l10n-name="report-infringement-link"
+ >
+ </a>
+ </p>
+ </template>
+
+ <template id="tmpl-suggestions-broken-extension">
+ <p data-l10n-id="abuse-report-broken-suggestions-extension">
+ <a
+ class="extension-support-link"
+ target="_blank"
+ data-l10n-name="support-link"
+ >
+ </a>
+ </p>
+
+ <p></p
+ ></template>
+
+ <template id="tmpl-suggestions-broken-theme">
+ <p data-l10n-id="abuse-report-broken-suggestions-theme">
+ <a
+ class="extension-support-link"
+ target="_blank"
+ data-l10n-name="support-link"
+ >
+ </a>
+ </p>
+
+ <p></p
+ ></template>
+
+ <template id="tmpl-suggestions-broken-sitepermission">
+ <p data-l10n-id="abuse-report-broken-suggestions-sitepermission">
+ <a
+ class="extension-support-link"
+ target="_blank"
+ data-l10n-name="support-link"
+ >
+ </a>
+ </p>
+
+ <p></p
+ ></template>
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/content/abuse-report-panel.css b/toolkit/mozapps/extensions/content/abuse-report-panel.css
new file mode 100644
index 0000000000..aa2243c162
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-report-panel.css
@@ -0,0 +1,181 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Abuse Reports card */
+
+:root {
+ --close-icon-url: url("chrome://global/skin/icons/close.svg");
+ --close-icon-size: 20px;
+
+ --modal-panel-min-width: 60%;
+ --modal-panel-margin-top: 36px;
+ --modal-panel-margin-bottom: 36px;
+ --modal-panel-margin: 20%;
+ --modal-panel-padding: 40px;
+
+ --line-height: 20px;
+ --textarea-height: 220px;
+ --listitem-padding-bottom: 14px;
+ --list-radio-column-size: 28px;
+ --note-font-size: 14px;
+ --note-font-weight: 400;
+ --subtitle-font-size: 16px;
+ --subtitle-font-weight: 600;
+}
+
+/* Ensure that the document (embedded in the XUL about:addons using a
+ XUL browser) has a transparent background */
+html {
+ background-color: transparent;
+}
+
+.modal-overlay-outer {
+ background: var(--grey-90-a60);
+ width: 100%;
+ height: 100%;
+ position: fixed;
+ z-index: -1;
+}
+
+.modal-panel-container {
+ padding-top: var(--modal-panel-margin-top);
+ padding-bottom: var(--modal-panel-margin-bottom);
+ padding-left: var(--modal-panel-margin);
+ padding-right: var(--modal-panel-margin);
+}
+
+.addon-abuse-report {
+ min-width: var(--modal-panel-min-width);
+ padding: var(--modal-panel-padding);
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.addon-abuse-report:hover {
+ /* Avoid the card box highlighting on hover. */
+ box-shadow: none;
+}
+
+
+.abuse-report-close-icon {
+ /* position the close button in the panel upper-right corner */
+ position: absolute;
+ top: 12px;
+ inset-inline-end: 16px;
+}
+
+button.abuse-report-close-icon {
+ background: var(--close-icon-url) no-repeat center center;
+ -moz-context-properties: fill;
+ color: inherit !important;
+ fill: currentColor;
+ min-width: auto;
+ min-height: auto;
+ width: var(--close-icon-size);
+ height: var(--close-icon-size);
+ margin: 0;
+ padding: 0;
+}
+
+button.abuse-report-close-icon:hover {
+ fill-opacity: 0.1;
+}
+
+button.abuse-report-close-icon:hover:active {
+ fill-opacity: 0.2;
+}
+
+.abuse-report-header {
+ display: flex;
+ flex-direction: row;
+}
+
+.abuse-report-contents,
+.abuse-report-contents > hr {
+ width: 100%;
+}
+
+.abuse-report-note {
+ color: var(--text-color-deemphasized);
+ font-size: var(--note-font-size);
+ font-weight: var(--note-font-weight);
+ line-height: var(--line-height);
+}
+
+.abuse-report-subtitle {
+ font-size: var(--subtitle-font-size);
+ font-weight: var(--subtitle-font-weight);
+ line-height: var(--line-height);
+}
+
+ul.abuse-report-reasons {
+ list-style-type: none;
+ padding-inline-start: 0;
+}
+
+ul.abuse-report-reasons > li {
+ display: flex;
+ padding-bottom: var(--listitem-padding-bottom);
+}
+
+ul.abuse-report-reasons > li > label {
+ display: grid;
+ grid-template-columns: var(--list-radio-column-size) auto;
+ grid-template-rows: 50% auto;
+ width: 100%;
+ line-height: var(--line-height);
+ font-size: var(--subtitle-font-size);
+ font-weight: var(--note-font-weight);
+ margin-inline-start: 4px;
+}
+
+ul.abuse-report-reasons > li > label > [type="radio"] {
+ grid-column: 1;
+}
+
+ul.abuse-report-reasons > li > label > span {
+ grid-column: 2;
+}
+
+abuse-report-submit-panel textarea {
+ width: 100%;
+ height: var(--textarea-height);
+ resize: none;
+ box-sizing: border-box;
+}
+
+/* Adapt styles for the panel opened in its own dialog window */
+
+html.dialog-window {
+ background-color: var(--in-content-box-background);
+ height: 100%;
+ min-width: 740px;
+}
+
+html.dialog-window body {
+ overflow: hidden;
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+html.dialog-window .abuse-report-close-icon {
+ display: none;
+}
+
+html.dialog-window addon-abuse-report {
+ flex-grow: 1;
+ display: flex;
+ /* Ensure that the dialog window starts from a reasonable initial size */
+ --modal-panel-min-width: 700px;
+}
+
+html.dialog-window addon-abuse-report form {
+ display: flex;
+}
+
+html.dialog-window addon-abuse-report form .abuse-report-contents {
+ flex-grow: 1;
+}
diff --git a/toolkit/mozapps/extensions/content/abuse-report-panel.js b/toolkit/mozapps/extensions/content/abuse-report-panel.js
new file mode 100644
index 0000000000..53e0d76db0
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-report-panel.js
@@ -0,0 +1,873 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
+});
+
+const IS_DIALOG_WINDOW = window.arguments && window.arguments.length;
+
+let openWebLink = IS_DIALOG_WINDOW
+ ? window.arguments[0].wrappedJSObject.openWebLink
+ : url => {
+ window.windowRoot.ownerGlobal.openWebLinkIn(url, "tab", {
+ relatedToCurrent: true,
+ });
+ };
+
+const showOnAnyType = () => false;
+const hideOnAnyType = () => true;
+const hideOnAddonTypes = hideForTypes => {
+ return addonType => hideForTypes.includes(addonType);
+};
+
+// The reasons string used as a key in this Map is expected to stay in sync
+// with the reasons string used in the "abuseReports.ftl" locale file and
+// the suggestions templates included in abuse-report-frame.html.
+const ABUSE_REASONS = (window.ABUSE_REPORT_REASONS = {
+ damage: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["theme"]),
+ },
+ spam: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["sitepermission"]),
+ },
+ settings: {
+ hasSuggestions: true,
+ isExampleHidden: hideOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["theme", "sitepermission"]),
+ },
+ deceptive: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["sitepermission"]),
+ },
+ broken: {
+ hasAddonTypeL10nId: true,
+ hasAddonTypeSuggestionTemplate: true,
+ hasSuggestions: true,
+ isExampleHidden: hideOnAddonTypes(["theme"]),
+ isReasonHidden: showOnAnyType,
+ requiresSupportURL: true,
+ },
+ policy: {
+ hasSuggestions: true,
+ isExampleHidden: hideOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["sitepermission"]),
+ },
+ unwanted: {
+ isExampleHidden: showOnAnyType,
+ isReasonHidden: hideOnAddonTypes(["theme"]),
+ },
+ other: {
+ isExampleHidden: hideOnAnyType,
+ isReasonHidden: showOnAnyType,
+ },
+});
+
+// Maps the reason id to the last version of the related fluent id.
+// NOTE: when changing the localized string, increase the `-vN` suffix
+// in the abuseReports.ftl fluent file and update this mapping table.
+const REASON_L10N_STRING_MAPPING = {
+ "abuse-report-damage-reason": "abuse-report-damage-reason-v2",
+ "abuse-report-spam-reason": "abuse-report-spam-reason-v2",
+ "abuse-report-settings-reason": "abuse-report-settings-reason-v2",
+ "abuse-report-deceptive-reason": "abuse-report-deceptive-reason-v2",
+ "abuse-report-broken-reason-extension":
+ "abuse-report-broken-reason-extension-v2",
+ "abuse-report-broken-reason-sitepermission":
+ "abuse-report-broken-reason-sitepermission-v2",
+ "abuse-report-broken-reason-theme": "abuse-report-broken-reason-theme-v2",
+ "abuse-report-policy-reason": "abuse-report-policy-reason-v2",
+ "abuse-report-unwanted-reason": "abuse-report-unwanted-reason-v2",
+};
+
+function getReasonL10nId(reason, addonType) {
+ let reasonId = `abuse-report-${reason}-reason`;
+ // Special case reasons that have a addonType-specific
+ // l10n id.
+ if (ABUSE_REASONS[reason].hasAddonTypeL10nId) {
+ reasonId += `-${addonType}`;
+ }
+ // Map the reason to the corresponding versionized fluent string, using the
+ // mapping table above, if available.
+ return REASON_L10N_STRING_MAPPING[reasonId] || reasonId;
+}
+
+function getSuggestionsTemplate({ addonType, reason, supportURL }) {
+ const reasonInfo = ABUSE_REASONS[reason];
+
+ if (
+ !addonType ||
+ !reasonInfo.hasSuggestions ||
+ (reasonInfo.requiresSupportURL && !supportURL)
+ ) {
+ return null;
+ }
+
+ let templateId = `tmpl-suggestions-${reason}`;
+ // Special case reasons that have a addonType-specific
+ // suggestion template.
+ if (reasonInfo.hasAddonTypeSuggestionTemplate) {
+ templateId += `-${addonType}`;
+ }
+
+ return document.getElementById(templateId);
+}
+
+// Map of the learnmore links metadata, keyed by link element class.
+const LEARNMORE_LINKS = {
+ ".abuse-policy-learnmore": {
+ baseURL: "https://www.mozilla.org/%LOCALE%/",
+ path: "about/legal/report-infringement/",
+ },
+};
+
+// Format links that match the selector in the LEARNMORE_LINKS map
+// found in a given container element.
+function formatLearnMoreURLs(containerEl) {
+ for (const [linkClass, linkInfo] of Object.entries(LEARNMORE_LINKS)) {
+ for (const element of containerEl.querySelectorAll(linkClass)) {
+ const baseURL = Services.urlFormatter.formatURL(linkInfo.baseURL);
+
+ element.href = baseURL + linkInfo.path;
+ }
+ }
+}
+
+// Define a set of getters from a Map<propertyName, selector>.
+function defineElementSelectorsGetters(object, propsMap) {
+ const props = Object.entries(propsMap).reduce((acc, entry) => {
+ const [name, selector] = entry;
+ acc[name] = { get: () => object.querySelector(selector) };
+ return acc;
+ }, {});
+ Object.defineProperties(object, props);
+}
+
+// Define a set of properties getters and setters for a
+// Map<propertyName, attributeName>.
+function defineElementAttributesProperties(object, propsMap) {
+ const props = Object.entries(propsMap).reduce((acc, entry) => {
+ const [name, attr] = entry;
+ acc[name] = {
+ get: () => object.getAttribute(attr),
+ set: value => {
+ object.setAttribute(attr, value);
+ },
+ };
+ return acc;
+ }, {});
+ Object.defineProperties(object, props);
+}
+
+// Return an object with properties associated to elements
+// found using the related selector in the propsMap.
+function getElements(containerEl, propsMap) {
+ return Object.entries(propsMap).reduce((acc, entry) => {
+ const [name, selector] = entry;
+ let elements = containerEl.querySelectorAll(selector);
+ acc[name] = elements.length > 1 ? elements : elements[0];
+ return acc;
+ }, {});
+}
+
+function dispatchCustomEvent(el, eventName, detail) {
+ el.dispatchEvent(new CustomEvent(eventName, { detail }));
+}
+
+// This WebComponent extends the li item to represent an abuse report reason
+// and it is responsible for:
+// - embedding a photon styled radio buttons
+// - localizing the reason list item
+// - optionally embedding a localized example, positioned
+// below the reason label, and adjusts the item height
+// accordingly
+class AbuseReasonListItem extends HTMLLIElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ addonType: "addon-type",
+ reason: "report-reason",
+ checked: "checked",
+ });
+ }
+
+ connectedCallback() {
+ this.update();
+ }
+
+ async update() {
+ if (this.reason !== "other" && !this.addonType) {
+ return;
+ }
+
+ const { reason, checked, addonType } = this;
+
+ this.textContent = "";
+ const content = document.importNode(this.template.content, true);
+
+ if (reason) {
+ const reasonId = `abuse-reason-${reason}`;
+ const reasonInfo = ABUSE_REASONS[reason] || {};
+
+ const { labelEl, descriptionEl, radioEl } = getElements(content, {
+ labelEl: "label",
+ descriptionEl: ".reason-description",
+ radioEl: "input[type=radio]",
+ });
+
+ labelEl.setAttribute("for", reasonId);
+ radioEl.id = reasonId;
+ radioEl.value = reason;
+ radioEl.checked = !!checked;
+
+ // This reason has a different localized description based on the
+ // addon type.
+ document.l10n.setAttributes(
+ descriptionEl,
+ getReasonL10nId(reason, addonType)
+ );
+
+ // Show the reason example if supported for the addon type.
+ if (!reasonInfo.isExampleHidden(addonType)) {
+ const exampleEl = content.querySelector(".reason-example");
+ document.l10n.setAttributes(
+ exampleEl,
+ `abuse-report-${reason}-example`
+ );
+ exampleEl.hidden = false;
+ }
+ }
+
+ formatLearnMoreURLs(content);
+
+ this.appendChild(content);
+ }
+
+ get template() {
+ return document.getElementById("tmpl-reason-listitem");
+ }
+}
+
+// This WebComponents implements the first step of the abuse
+// report submission and embeds a randomized reasons list.
+class AbuseReasonsPanel extends HTMLElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ addonType: "addon-type",
+ });
+ }
+
+ connectedCallback() {
+ this.update();
+ }
+
+ update() {
+ if (!this.isConnected || !this.addonType) {
+ return;
+ }
+
+ const { addonType } = this;
+
+ this.textContent = "";
+ const content = document.importNode(this.template.content, true);
+
+ const { titleEl, listEl } = getElements(content, {
+ titleEl: ".abuse-report-title",
+ listEl: "ul.abuse-report-reasons",
+ });
+
+ // Change the title l10n-id if the addon type is theme.
+ document.l10n.setAttributes(titleEl, `abuse-report-title-${addonType}`);
+
+ // Create the randomized list of reasons.
+ const reasons = Object.keys(ABUSE_REASONS)
+ .filter(reason => reason !== "other")
+ .sort(() => Math.random() - 0.5);
+
+ for (const reason of reasons) {
+ const reasonInfo = ABUSE_REASONS[reason];
+ if (!reasonInfo || reasonInfo.isReasonHidden(addonType)) {
+ // Skip an extension only reason while reporting a theme.
+ continue;
+ }
+ const item = document.createElement("li", {
+ is: "abuse-report-reason-listitem",
+ });
+ item.reason = reason;
+ item.addonType = addonType;
+
+ listEl.prepend(item);
+ }
+
+ listEl.firstElementChild.checked = true;
+ formatLearnMoreURLs(content);
+
+ this.appendChild(content);
+ }
+
+ get template() {
+ return document.getElementById("tmpl-reasons-panel");
+ }
+}
+
+// This WebComponent is responsible for the suggestions, which are:
+// - generated based on a template keyed by abuse report reason
+// - localized by assigning fluent ids generated from the abuse report reason
+// - learn more and extension support url are then generated when the
+// specific reason expects it
+class AbuseReasonSuggestions extends HTMLElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ extensionSupportURL: "extension-support-url",
+ reason: "report-reason",
+ });
+ }
+
+ update() {
+ const { addonType, extensionSupportURL, reason } = this;
+
+ this.textContent = "";
+
+ let template = getSuggestionsTemplate({
+ addonType,
+ reason,
+ supportURL: extensionSupportURL,
+ });
+
+ if (template) {
+ let content = document.importNode(template.content, true);
+
+ formatLearnMoreURLs(content);
+
+ let extSupportLink = content.querySelector("a.extension-support-link");
+ if (extSupportLink) {
+ extSupportLink.href = extensionSupportURL;
+ }
+
+ this.appendChild(content);
+ this.hidden = false;
+ } else {
+ this.hidden = true;
+ }
+ }
+}
+
+// This WebComponents implements the last step of the abuse report submission.
+class AbuseSubmitPanel extends HTMLElement {
+ constructor() {
+ super();
+ defineElementAttributesProperties(this, {
+ addonType: "addon-type",
+ reason: "report-reason",
+ extensionSupportURL: "extensionSupportURL",
+ });
+ defineElementSelectorsGetters(this, {
+ _textarea: "textarea",
+ _title: ".abuse-reason-title",
+ _suggestions: "abuse-report-reason-suggestions",
+ });
+ }
+
+ connectedCallback() {
+ this.render();
+ }
+
+ render() {
+ this.textContent = "";
+ this.appendChild(document.importNode(this.template.content, true));
+ }
+
+ update() {
+ if (!this.isConnected || !this.addonType) {
+ return;
+ }
+ const { addonType, reason, _suggestions, _title } = this;
+ document.l10n.setAttributes(_title, getReasonL10nId(reason, addonType));
+ _suggestions.reason = reason;
+ _suggestions.addonType = addonType;
+ _suggestions.extensionSupportURL = this.extensionSupportURL;
+ _suggestions.update();
+ }
+
+ clear() {
+ this._textarea.value = "";
+ }
+
+ get template() {
+ return document.getElementById("tmpl-submit-panel");
+ }
+}
+
+// This WebComponent provides the abuse report
+class AbuseReport extends HTMLElement {
+ constructor() {
+ super();
+ this._report = null;
+ defineElementSelectorsGetters(this, {
+ _form: "form",
+ _textarea: "textarea",
+ _radioCheckedReason: "[type=radio]:checked",
+ _reasonsPanel: "abuse-report-reasons-panel",
+ _submitPanel: "abuse-report-submit-panel",
+ _reasonsPanelButtons: ".abuse-report-reasons-buttons",
+ _submitPanelButtons: ".abuse-report-submit-buttons",
+ _iconClose: ".abuse-report-close-icon",
+ _btnNext: "button.abuse-report-next",
+ _btnCancel: "button.abuse-report-cancel",
+ _btnGoBack: "button.abuse-report-goback",
+ _btnSubmit: "button.abuse-report-submit",
+ _addonAuthorContainer: ".abuse-report-header .addon-author-box",
+ _addonIconElement: ".abuse-report-header img.addon-icon",
+ _addonNameElement: ".abuse-report-header .addon-name",
+ _linkAddonAuthor: ".abuse-report-header .addon-author-box a.author",
+ });
+ }
+
+ connectedCallback() {
+ this.render();
+
+ this.addEventListener("click", this);
+
+ // Start listening to keydown events (to close the modal
+ // when Escape has been pressed and to handling the keyboard
+ // navigation).
+ document.addEventListener("keydown", this);
+ }
+
+ disconnectedCallback() {
+ this.textContent = "";
+ this.removeEventListener("click", this);
+ document.removeEventListener("keydown", this);
+ }
+
+ handleEvent(evt) {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+
+ switch (evt.type) {
+ case "keydown":
+ if (evt.key === "Escape") {
+ // Prevent Esc to close the panel if the textarea is
+ // empty.
+ if (this.message && !this._submitPanel.hidden) {
+ return;
+ }
+ this.cancel();
+ }
+ if (!IS_DIALOG_WINDOW) {
+ // Workaround keyboard navigation issues when
+ // the panel is running in its own dialog window.
+ this.handleKeyboardNavigation(evt);
+ }
+ break;
+ case "click":
+ if (evt.target === this._iconClose || evt.target === this._btnCancel) {
+ // NOTE: clear the focus on the clicked element to ensure that
+ // -moz-focusring pseudo class is not still set on the element
+ // when the panel is going to be shown again (See Bug 1560949).
+ evt.target.blur();
+ this.cancel();
+ }
+ if (evt.target === this._btnNext) {
+ this.switchToSubmitMode();
+ }
+ if (evt.target === this._btnGoBack) {
+ this.switchToListMode();
+ }
+ if (evt.target === this._btnSubmit) {
+ this.submit();
+ }
+ if (evt.target.localName === "a") {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const url = evt.target.getAttribute("href");
+ // Ignore if url is empty.
+ if (url) {
+ openWebLink(url);
+ }
+ }
+ break;
+ }
+ }
+
+ handleKeyboardNavigation(evt) {
+ if (
+ evt.keyCode !== evt.DOM_VK_TAB ||
+ evt.altKey ||
+ evt.controlKey ||
+ evt.metaKey
+ ) {
+ return;
+ }
+
+ const fm = Services.focus;
+ const backward = evt.shiftKey;
+
+ const isFirstFocusableElement = el => {
+ // Also consider the document body as a valid first focusable element.
+ if (el === document.body) {
+ return true;
+ }
+ // XXXrpl unfortunately there is no way to get the first focusable element
+ // without asking the focus manager to move focus to it (similar strategy
+ // is also being used in about:prefereces subdialog.js).
+ const rv = el == fm.moveFocus(window, null, fm.MOVEFOCUS_FIRST, 0);
+ fm.setFocus(el, 0);
+ return rv;
+ };
+
+ // If the focus is exiting the panel while navigating
+ // backward, focus the previous element sibling on the
+ // Firefox UI.
+ if (backward && isFirstFocusableElement(evt.target)) {
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
+ const chromeWin = window.windowRoot.ownerGlobal;
+ Services.focus.moveFocus(
+ chromeWin,
+ null,
+ Services.focus.MOVEFOCUS_BACKWARD,
+ Services.focus.FLAG_BYKEY
+ );
+ }
+ }
+
+ render() {
+ this.textContent = "";
+ const formTemplate = document.importNode(this.template.content, true);
+ if (IS_DIALOG_WINDOW) {
+ this.appendChild(formTemplate);
+ } else {
+ // Append the report form inside a modal overlay when the report panel
+ // is a sub-frame of the about:addons tab.
+ const modalTemplate = document.importNode(
+ this.modalTemplate.content,
+ true
+ );
+
+ this.appendChild(modalTemplate);
+ this.querySelector(".modal-panel-container").appendChild(formTemplate);
+
+ // Add the card styles to the form.
+ this.querySelector("form").classList.add("card");
+ }
+ }
+
+ async update() {
+ if (!this.addon) {
+ return;
+ }
+
+ const {
+ addonId,
+ addonType,
+ _addonAuthorContainer,
+ _addonIconElement,
+ _addonNameElement,
+ _linkAddonAuthor,
+ _reasonsPanel,
+ _submitPanel,
+ } = this;
+
+ // Ensure that the first step of the abuse submission is the one
+ // currently visible.
+ this.switchToListMode();
+
+ // Cancel the abuse report if the addon is not an extension or theme.
+ if (!AbuseReporter.isSupportedAddonType(addonType)) {
+ Cu.reportError(
+ new Error(
+ `Closing abuse report panel on unexpected addon type: ${addonType}`
+ )
+ );
+ this.cancel();
+ return;
+ }
+
+ _addonNameElement.textContent = this.addonName;
+
+ if (this.authorName) {
+ _linkAddonAuthor.href = this.authorURL || this.homepageURL;
+ _linkAddonAuthor.textContent = this.authorName;
+ document.l10n.setAttributes(
+ _linkAddonAuthor.parentNode,
+ "abuse-report-addon-authored-by",
+ { "author-name": this.authorName }
+ );
+ _addonAuthorContainer.hidden = false;
+ } else {
+ _addonAuthorContainer.hidden = true;
+ }
+
+ _addonIconElement.setAttribute("src", this.iconURL);
+
+ _reasonsPanel.addonType = this.addonType;
+ _reasonsPanel.update();
+
+ _submitPanel.addonType = this.addonType;
+ _submitPanel.reason = this.reason;
+ _submitPanel.extensionSupportURL = this.supportURL;
+ _submitPanel.update();
+
+ this.focus();
+
+ dispatchCustomEvent(this, "abuse-report:updated", {
+ addonId,
+ panel: "reasons",
+ });
+ }
+
+ setAbuseReport(abuseReport) {
+ this._report = abuseReport;
+ // Clear the textarea from any previously entered content.
+ this._submitPanel.clear();
+
+ if (abuseReport) {
+ this.update();
+ this.hidden = false;
+ } else {
+ this.hidden = true;
+ }
+ }
+
+ focus() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ if (this._reasonsPanel.hidden) {
+ const { _textarea } = this;
+ _textarea.focus();
+ _textarea.select();
+ } else {
+ const { _radioCheckedReason } = this;
+ if (_radioCheckedReason) {
+ _radioCheckedReason.focus();
+ }
+ }
+ }
+
+ cancel() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._report = null;
+ dispatchCustomEvent(this, "abuse-report:cancel");
+ }
+
+ submit() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._report.setMessage(this.message);
+ this._report.setReason(this.reason);
+ dispatchCustomEvent(this, "abuse-report:submit", {
+ addonId: this.addonId,
+ report: this._report,
+ });
+ }
+
+ switchToSubmitMode() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._submitPanel.reason = this.reason;
+ this._submitPanel.update();
+ this._reasonsPanel.hidden = true;
+ this._reasonsPanelButtons.hidden = true;
+ this._submitPanel.hidden = false;
+ this._submitPanelButtons.hidden = false;
+ // Adjust the focused element when switching to the submit panel.
+ this.focus();
+ dispatchCustomEvent(this, "abuse-report:updated", {
+ addonId: this.addonId,
+ panel: "submit",
+ });
+ }
+
+ switchToListMode() {
+ if (!this.isConnected || !this.addon) {
+ return;
+ }
+ this._submitPanel.hidden = true;
+ this._submitPanelButtons.hidden = true;
+ this._reasonsPanel.hidden = false;
+ this._reasonsPanelButtons.hidden = false;
+ // Adjust the focused element when switching back to the list of reasons.
+ this.focus();
+ dispatchCustomEvent(this, "abuse-report:updated", {
+ addonId: this.addonId,
+ panel: "reasons",
+ });
+ }
+
+ get addon() {
+ return this._report?.addon;
+ }
+
+ get addonId() {
+ return this.addon?.id;
+ }
+
+ get addonName() {
+ return this.addon?.name;
+ }
+
+ get addonType() {
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ if (this.addon?.type === "sitepermission-deprecated") {
+ return "sitepermission";
+ }
+ return this.addon?.type;
+ }
+
+ get addonCreator() {
+ return this.addon?.creator;
+ }
+
+ get homepageURL() {
+ return this.addon?.homepageURL || this.authorURL || "";
+ }
+
+ get authorName() {
+ // The author name may be missing on some of the test extensions
+ // (or for temporarily installed add-ons).
+ return this.addonCreator?.name || "";
+ }
+
+ get authorURL() {
+ return this.addonCreator?.url || "";
+ }
+
+ get iconURL() {
+ if (this.addonType === "sitepermission") {
+ return "chrome://mozapps/skin/extensions/category-sitepermission.svg";
+ }
+ return (
+ this.addon?.iconURL ||
+ // Some extensions (e.g. static theme addons) may not have an icon,
+ // and so we fallback to use the generic extension icon.
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg"
+ );
+ }
+
+ get supportURL() {
+ let url = this.addon?.supportURL || this.homepageURL || "";
+ if (!url && this.addonType === "sitepermission" && this.addon?.siteOrigin) {
+ return this.addon.siteOrigin;
+ }
+ return url;
+ }
+
+ get message() {
+ return this._form.elements.message.value;
+ }
+
+ get reason() {
+ return this._form.elements.reason.value;
+ }
+
+ get modalTemplate() {
+ return document.getElementById("tmpl-modal");
+ }
+
+ get template() {
+ return document.getElementById("tmpl-abuse-report");
+ }
+}
+
+customElements.define("abuse-report-reason-listitem", AbuseReasonListItem, {
+ extends: "li",
+});
+customElements.define(
+ "abuse-report-reason-suggestions",
+ AbuseReasonSuggestions
+);
+customElements.define("abuse-report-reasons-panel", AbuseReasonsPanel);
+customElements.define("abuse-report-submit-panel", AbuseSubmitPanel);
+customElements.define("addon-abuse-report", AbuseReport);
+
+// The panel has been opened in a new dialog window.
+if (IS_DIALOG_WINDOW) {
+ // CSS customizations when panel is in its own window
+ // (vs. being an about:addons subframe).
+ document.documentElement.className = "dialog-window";
+
+ const { report, deferredReport, deferredReportPanel } =
+ window.arguments[0].wrappedJSObject;
+
+ window.addEventListener(
+ "unload",
+ () => {
+ // If the window has been closed resolve the deferredReport
+ // promise and reject the deferredReportPanel one, in case
+ // they haven't been resolved yet.
+ deferredReport.resolve({ userCancelled: true });
+ deferredReportPanel.reject(new Error("report dialog closed"));
+ },
+ { once: true }
+ );
+
+ document.l10n.setAttributes(
+ document.querySelector("head > title"),
+ "abuse-report-dialog-title",
+ {
+ "addon-name": report.addon.name,
+ }
+ );
+
+ const el = document.querySelector("addon-abuse-report");
+ el.addEventListener("abuse-report:submit", () => {
+ deferredReport.resolve({
+ userCancelled: false,
+ report,
+ });
+ });
+ el.addEventListener(
+ "abuse-report:cancel",
+ () => {
+ // Resolve the report panel deferred (in case the report
+ // has been cancelled automatically before it has been fully
+ // rendered, e.g. in case of non-supported addon types).
+ deferredReportPanel.resolve(el);
+ // Resolve the deferred report as cancelled.
+ deferredReport.resolve({ userCancelled: true });
+ },
+ { once: true }
+ );
+
+ // Adjust window size (if needed) once the fluent strings have been
+ // added to the document and the document has been flushed.
+ el.addEventListener(
+ "abuse-report:updated",
+ async () => {
+ const form = document.querySelector("form");
+ await document.l10n.translateFragment(form);
+ const { scrollWidth, scrollHeight } = await window.promiseDocumentFlushed(
+ () => form
+ );
+ // Resolve promiseReportPanel once the panel completed the initial render
+ // (used in tests).
+ deferredReportPanel.resolve(el);
+ if (
+ window.innerWidth !== scrollWidth ||
+ window.innerHeight !== scrollHeight
+ ) {
+ const width = window.outerWidth - window.innerWidth + scrollWidth;
+ const height = window.outerHeight - window.innerHeight + scrollHeight;
+ window.resizeTo(width, height);
+ }
+ },
+ { once: true }
+ );
+ el.setAbuseReport(report);
+}
diff --git a/toolkit/mozapps/extensions/content/abuse-reports.js b/toolkit/mozapps/extensions/content/abuse-reports.js
new file mode 100644
index 0000000000..c6461a071b
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/abuse-reports.js
@@ -0,0 +1,376 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+/* import-globals-from aboutaddonsCommon.js */
+/* exported openAbuseReport */
+/* global windowRoot */
+
+/**
+ * This script is part of the HTML about:addons page and it provides some
+ * helpers used for the Abuse Reporting submission (and related message bars).
+ */
+
+const { AbuseReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+);
+
+// Message Bars definitions.
+const ABUSE_REPORT_MESSAGE_BARS = {
+ // Idle message-bar (used while the submission is still ongoing).
+ submitting: {
+ actions: ["cancel"],
+ l10n: {
+ id: "abuse-report-messagebar-submitting2",
+ actionIds: {
+ cancel: "abuse-report-messagebar-action-cancel",
+ },
+ },
+ },
+ // Submitted report message-bar.
+ submitted: {
+ actions: ["remove", "keep"],
+ dismissable: true,
+ l10n: {
+ id: "abuse-report-messagebar-submitted2",
+ actionIdsPerAddonType: {
+ extension: {
+ remove: "abuse-report-messagebar-action-remove-extension",
+ keep: "abuse-report-messagebar-action-keep-extension",
+ },
+ sitepermission: {
+ remove: "abuse-report-messagebar-action-remove-sitepermission",
+ keep: "abuse-report-messagebar-action-keep-sitepermission",
+ },
+ theme: {
+ remove: "abuse-report-messagebar-action-remove-theme",
+ keep: "abuse-report-messagebar-action-keep-theme",
+ },
+ },
+ },
+ },
+ // Submitted report message-bar (with no remove actions).
+ "submitted-no-remove-action": {
+ dismissable: true,
+ l10n: { id: "abuse-report-messagebar-submitted-noremove2" },
+ },
+ // Submitted report and remove addon message-bar.
+ "submitted-and-removed": {
+ dismissable: true,
+ l10n: {
+ idsPerAddonType: {
+ extension: "abuse-report-messagebar-removed-extension2",
+ sitepermission: "abuse-report-messagebar-removed-sitepermission2",
+ theme: "abuse-report-messagebar-removed-theme2",
+ },
+ },
+ },
+ // The "aborted report" message bar is rendered as a generic informative one,
+ // because aborting a report is triggered by a user choice.
+ ERROR_ABORTED_SUBMIT: {
+ type: "info",
+ dismissable: true,
+ l10n: { id: "abuse-report-messagebar-aborted2" },
+ },
+ // Errors message bars.
+ ERROR_ADDON_NOTFOUND: {
+ type: "error",
+ dismissable: true,
+ l10n: { id: "abuse-report-messagebar-error2" },
+ },
+ ERROR_CLIENT: {
+ type: "error",
+ dismissable: true,
+ l10n: { id: "abuse-report-messagebar-error2" },
+ },
+ ERROR_NETWORK: {
+ actions: ["retry", "cancel"],
+ type: "error",
+ l10n: {
+ id: "abuse-report-messagebar-error2",
+ actionIds: {
+ retry: "abuse-report-messagebar-action-retry",
+ cancel: "abuse-report-messagebar-action-cancel",
+ },
+ },
+ },
+ ERROR_RECENT_SUBMIT: {
+ actions: ["retry", "cancel"],
+ type: "error",
+ l10n: {
+ id: "abuse-report-messagebar-error-recent-submit2",
+ actionIds: {
+ retry: "abuse-report-messagebar-action-retry",
+ cancel: "abuse-report-messagebar-action-cancel",
+ },
+ },
+ },
+ ERROR_SERVER: {
+ actions: ["retry", "cancel"],
+ type: "error",
+ l10n: {
+ id: "abuse-report-messagebar-error2",
+ actionIds: {
+ retry: "abuse-report-messagebar-action-retry",
+ cancel: "abuse-report-messagebar-action-cancel",
+ },
+ },
+ },
+ ERROR_UNKNOWN: {
+ actions: ["retry", "cancel"],
+ type: "error",
+ l10n: {
+ id: "abuse-report-messagebar-error2",
+ actionIds: {
+ retry: "abuse-report-messagebar-action-retry",
+ cancel: "abuse-report-messagebar-action-cancel",
+ },
+ },
+ },
+};
+
+async function openAbuseReport({ addonId, reportEntryPoint }) {
+ try {
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ reportEntryPoint,
+ window.docShell.chromeEventHandler
+ );
+
+ // Warn the user before the about:addons tab while an
+ // abuse report dialog is still open, and close the
+ // report dialog if the user choose to close the related
+ // about:addons tab.
+ const beforeunloadListener = evt => evt.preventDefault();
+ const unloadListener = () => reportDialog.close();
+ const clearUnloadListeners = () => {
+ window.removeEventListener("beforeunload", beforeunloadListener);
+ window.removeEventListener("unload", unloadListener);
+ };
+ window.addEventListener("beforeunload", beforeunloadListener);
+ window.addEventListener("unload", unloadListener);
+
+ reportDialog.promiseReport
+ .then(
+ report => {
+ if (report) {
+ submitReport({ report });
+ }
+ },
+ err => {
+ Cu.reportError(
+ `Unexpected abuse report panel error: ${err} :: ${err.stack}`
+ );
+ reportDialog.close();
+ }
+ )
+ .then(clearUnloadListeners);
+ } catch (err) {
+ // Log the detailed error to the browser console.
+ Cu.reportError(err);
+ document.dispatchEvent(
+ new CustomEvent("abuse-report:create-error", {
+ detail: {
+ addonId,
+ addon: err.addon,
+ errorType: err.errorType,
+ },
+ })
+ );
+ }
+}
+
+// Unlike the openAbuseReport function, technically this method wouldn't need
+// to be async, but it is so that both the implementations will be providing
+// the same type signatures (returning a promise) to the callers, independently
+// from which abuse reporting feature is enabled.
+async function openAbuseReportAMOForm({ addonId, reportEntryPoint }) {
+ const amoUrl = AbuseReporter.getAMOFormURL({ addonId });
+ windowRoot.ownerGlobal.openTrustedLinkIn(amoUrl, "tab", {
+ // Make sure the newly open tab is going to be focused, independently
+ // from general user prefs.
+ forceForeground: true,
+ });
+}
+
+window.openAbuseReport = AbuseReporter.amoFormEnabled
+ ? openAbuseReportAMOForm
+ : openAbuseReport;
+
+// Helper function used to create abuse report message bars in the
+// HTML about:addons page.
+function createReportMessageBar(
+ definitionId,
+ { addonId, addonName, addonType },
+ { onclose, onaction } = {}
+) {
+ const barInfo = ABUSE_REPORT_MESSAGE_BARS[definitionId];
+ if (!barInfo) {
+ throw new Error(`message-bar definition not found: ${definitionId}`);
+ }
+ const { dismissable, actions, type, l10n } = barInfo;
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ const mappingAddonType =
+ addonType === "sitepermission-deprecated" ? "sitepermission" : addonType;
+
+ const getMessageL10n = () => {
+ return l10n.idsPerAddonType
+ ? l10n.idsPerAddonType[mappingAddonType]
+ : l10n.id;
+ };
+ const getActionL10n = action => {
+ return l10n.actionIdsPerAddonType
+ ? l10n.actionIdsPerAddonType[mappingAddonType][action]
+ : l10n.actionIds[action];
+ };
+
+ const messagebar = document.createElement("moz-message-bar");
+
+ document.l10n.setAttributes(messagebar, getMessageL10n(), {
+ "addon-name": addonName || addonId,
+ });
+ messagebar.setAttribute("data-l10n-attrs", "message");
+
+ actions?.forEach(action => {
+ const buttonEl = document.createElement("button");
+ buttonEl.addEventListener("click", () => onaction && onaction(action));
+ document.l10n.setAttributes(buttonEl, getActionL10n(action));
+ buttonEl.setAttribute("slot", "actions");
+ messagebar.appendChild(buttonEl);
+ });
+
+ messagebar.setAttribute("type", type || "info");
+ messagebar.dismissable = dismissable;
+ messagebar.addEventListener("message-bar:close", onclose, { once: true });
+
+ document.getElementById("abuse-reports-messages").append(messagebar);
+
+ document.dispatchEvent(
+ new CustomEvent("abuse-report:new-message-bar", {
+ detail: { definitionId, messagebar },
+ })
+ );
+ return messagebar;
+}
+
+async function submitReport({ report }) {
+ const { addon } = report;
+ const addonId = addon.id;
+ const addonName = addon.name;
+ const addonType = addon.type;
+
+ // Ensure that the tab that originated the report dialog is selected
+ // when the user is submitting the report.
+ const { gBrowser } = window.windowRoot.ownerGlobal;
+ if (gBrowser && gBrowser.getTabForBrowser) {
+ let tab = gBrowser.getTabForBrowser(window.docShell.chromeEventHandler);
+ gBrowser.selectedTab = tab;
+ }
+
+ // Create a message bar while we are still submitting the report.
+ const mbSubmitting = createReportMessageBar(
+ "submitting",
+ { addonId, addonName, addonType },
+ {
+ onaction: action => {
+ if (action === "cancel") {
+ report.abort();
+ mbSubmitting.remove();
+ }
+ },
+ }
+ );
+
+ try {
+ await report.submit();
+ mbSubmitting.remove();
+
+ // Create a submitted message bar when the submission has been
+ // successful.
+ let barId;
+ if (
+ !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL) &&
+ !isPending(addon, "uninstall")
+ ) {
+ // Do not offer remove action if the addon can't be uninstalled.
+ barId = "submitted-no-remove-action";
+ } else if (report.reportEntryPoint === "uninstall") {
+ // With reportEntryPoint "uninstall" a specific message bar
+ // is going to be used.
+ barId = "submitted-and-removed";
+ } else {
+ // All the other reportEntryPoint ("menu" and "toolbar_context_menu")
+ // use the same kind of message bar.
+ barId = "submitted";
+ }
+
+ const mbInfo = createReportMessageBar(
+ barId,
+ {
+ addonId,
+ addonName,
+ addonType,
+ },
+ {
+ onaction: action => {
+ mbInfo.remove();
+ // action "keep" doesn't require any further action,
+ // just handle "remove".
+ if (action === "remove") {
+ report.addon.uninstall(true);
+ }
+ },
+ }
+ );
+ } catch (err) {
+ // Log the complete error in the console.
+ console.error("Error submitting abuse report for", addonId, err);
+ mbSubmitting.remove();
+ // The report has a submission error, create a error message bar which
+ // may optionally allow the user to retry to submit the same report.
+ const barId =
+ err.errorType in ABUSE_REPORT_MESSAGE_BARS
+ ? err.errorType
+ : "ERROR_UNKNOWN";
+
+ const mbError = createReportMessageBar(
+ barId,
+ {
+ addonId,
+ addonName,
+ addonType,
+ },
+ {
+ onaction: action => {
+ mbError.remove();
+ switch (action) {
+ case "retry":
+ submitReport({ report });
+ break;
+ case "cancel":
+ report.abort();
+ break;
+ }
+ },
+ }
+ );
+ }
+}
+
+document.addEventListener("abuse-report:submit", ({ detail }) => {
+ submitReport(detail);
+});
+
+document.addEventListener("abuse-report:create-error", ({ detail }) => {
+ const { addonId, addon, errorType } = detail;
+ const barId =
+ errorType in ABUSE_REPORT_MESSAGE_BARS ? errorType : "ERROR_UNKNOWN";
+ createReportMessageBar(barId, {
+ addonId,
+ addonName: addon && addon.name,
+ addonType: addon && addon.type,
+ });
+});
diff --git a/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js b/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js
new file mode 100644
index 0000000000..0a4f3f749c
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/drag-drop-addon-installer.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* import-globals-from aboutaddonsCommon.js */
+
+"use strict";
+
+class DragDropAddonInstaller extends HTMLElement {
+ connectedCallback() {
+ window.addEventListener("drop", this);
+ }
+
+ disconnectedCallback() {
+ window.removeEventListener("drop", this);
+ }
+
+ canInstallFromEvent(e) {
+ let types = e.dataTransfer.types;
+ return (
+ types.includes("text/uri-list") ||
+ types.includes("text/x-moz-url") ||
+ types.includes("application/x-moz-file")
+ );
+ }
+
+ handleEvent(e) {
+ if (!XPINSTALL_ENABLED) {
+ // Nothing to do if we can't install add-ons.
+ return;
+ }
+
+ if (e.type == "drop" && this.canInstallFromEvent(e)) {
+ this.onDrop(e);
+ }
+ }
+
+ async onDrop(e) {
+ e.preventDefault();
+
+ let dataTransfer = e.dataTransfer;
+ let browser = getBrowserElement();
+ let urls = [];
+
+ // Convert every dropped item into a url, without this should be sync.
+ for (let i = 0; i < dataTransfer.mozItemCount; i++) {
+ let url = dataTransfer.mozGetDataAt("text/uri-list", i);
+ if (!url) {
+ url = dataTransfer.mozGetDataAt("text/x-moz-url", i);
+ }
+ if (url) {
+ url = url.split("\n")[0];
+ } else {
+ let file = dataTransfer.mozGetDataAt("application/x-moz-file", i);
+ if (file) {
+ url = Services.io.newFileURI(file).spec;
+ }
+ }
+
+ if (url) {
+ urls.push(url);
+ }
+ }
+
+ // Install the add-ons, the await clears the event data so we do this last.
+ for (let url of urls) {
+ let install = await AddonManager.getInstallForURL(url, {
+ telemetryInfo: {
+ source: "about:addons",
+ method: "drag-and-drop",
+ },
+ });
+
+ AddonManager.installAddonFromAOM(
+ browser,
+ document.documentURIObject,
+ install
+ );
+ }
+ }
+}
+customElements.define("drag-drop-addon-installer", DragDropAddonInstaller);
diff --git a/toolkit/mozapps/extensions/content/shortcuts.css b/toolkit/mozapps/extensions/content/shortcuts.css
new file mode 100644
index 0000000000..d96845f9f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.css
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.shortcut.card {
+ margin-bottom: 16px;
+}
+
+.shortcut.card:first-of-type {
+ margin-top: 8px;
+}
+
+.shortcut.card:hover {
+ box-shadow: var(--card-shadow);
+}
+
+.shortcut.card .card-heading-icon {
+ width: 24px;
+ height: 24px;
+ margin-inline-end: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+.card-heading {
+ display: flex;
+ font-weight: 600;
+}
+
+.shortcuts-empty-label {
+ margin-top: 16px;
+}
+
+.shortcut-row {
+ display: flex;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.shortcut.card:not([expanded]) > .shortcut-row[hide-before-expand] {
+ display: none;
+}
+
+.shortcut-label {
+ flex-grow: 1;
+}
+
+.shortcut-remove-button {
+ background-image: url("chrome://global/skin/icons/delete.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ min-width: 32px;
+}
+
+.shortcut-input[shortcut=""] + .shortcut-remove-button {
+ visibility: hidden;
+}
+
+.expand-row {
+ display: flex;
+ justify-content: center;
+}
+
+.expand-button {
+ margin: 8px 0 0;
+}
+
+.expand-button[warning]:not(:focus) {
+ outline: 2px solid var(--yellow-60);
+ outline-offset: -1px;
+ box-shadow: 0 0 0 4px var(--yellow-60-a30);
+}
+
+.shortcut-input {
+ /* Shortcuts should always be left-to-right. */
+ direction: ltr;
+ text-align: match-parent;
+}
+
+.extension-heading {
+ display: flex;
+}
+
+.error-message {
+ --error-background: var(--red-60);
+ --warning-background: var(--yellow-50);
+ --warning-text-color: var(--yellow-90);
+ color: white;
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ visibility: hidden;
+}
+
+.error-message-icon {
+ margin-inline-start: 10px;
+ width: 14px;
+ height: 8px;
+ fill: var(--error-background);
+ stroke: var(--error-background);
+ -moz-context-properties: fill, stroke;
+}
+
+.error-message[type="warning"] > .error-message-icon {
+ fill: var(--warning-background);
+ stroke: var(--warning-background);
+}
+
+.error-message-label {
+ background-color: var(--error-background);
+ border-radius: 2px;
+ margin: 0;
+ margin-inline-end: 8px;
+ max-width: 300px;
+ padding: 5px 10px;
+ word-wrap: break-word;
+}
+
+.error-message[type="warning"] > .error-message-label {
+ background-color: var(--warning-background);
+ color: var(--warning-text-color);
+}
+
+.error-message-arrow {
+ background-color: var(--error-background);
+ content: "";
+ max-height: 8px;
+ width: 8px;
+ transform: translate(4px, -6px) rotate(45deg);
+ position: absolute;
+}
+
+/* The margin between message bars. */
+message-bar-stack > * {
+ margin-bottom: 8px;
+}
diff --git a/toolkit/mozapps/extensions/content/shortcuts.js b/toolkit/mozapps/extensions/content/shortcuts.js
new file mode 100644
index 0000000000..59420226df
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.js
@@ -0,0 +1,658 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* import-globals-from aboutaddonsCommon.js */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionShortcutKeyMap: "resource://gre/modules/ExtensionShortcuts.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+});
+
+{
+ const FALLBACK_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+ const COLLAPSE_OPTIONS = {
+ limit: 5, // We only want to show 5 when collapsed.
+ allowOver: 1, // Avoid collapsing to hide 1 row.
+ };
+
+ let templatesLoaded = false;
+ let shortcutKeyMap = new ExtensionShortcutKeyMap();
+ const templates = {};
+
+ function loadTemplates() {
+ if (templatesLoaded) {
+ return;
+ }
+ templatesLoaded = true;
+
+ templates.view = document.getElementById("shortcut-view");
+ templates.card = document.getElementById("shortcut-card-template");
+ templates.row = document.getElementById("shortcut-row-template");
+ templates.noAddons = document.getElementById("shortcuts-no-addons");
+ templates.expandRow = document.getElementById("expand-row-template");
+ templates.noShortcutAddons = document.getElementById(
+ "shortcuts-no-commands-template"
+ );
+ }
+
+ function extensionForAddonId(id) {
+ let policy = WebExtensionPolicy.getByID(id);
+ return policy && policy.extension;
+ }
+
+ let builtInNames = new Map([
+ ["_execute_action", "shortcuts-browserAction2"],
+ ["_execute_browser_action", "shortcuts-browserAction2"],
+ ["_execute_page_action", "shortcuts-pageAction"],
+ ["_execute_sidebar_action", "shortcuts-sidebarAction"],
+ ]);
+ let getCommandDescriptionId = command => {
+ if (!command.description && builtInNames.has(command.name)) {
+ return builtInNames.get(command.name);
+ }
+ return null;
+ };
+
+ const _functionKeys = [
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ "F5",
+ "F6",
+ "F7",
+ "F8",
+ "F9",
+ "F10",
+ "F11",
+ "F12",
+ ];
+ const functionKeys = new Set(_functionKeys);
+ const validKeys = new Set([
+ "Home",
+ "End",
+ "PageUp",
+ "PageDown",
+ "Insert",
+ "Delete",
+ "0",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ ..._functionKeys,
+ "MediaNextTrack",
+ "MediaPlayPause",
+ "MediaPrevTrack",
+ "MediaStop",
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "Up",
+ "Down",
+ "Left",
+ "Right",
+ "Comma",
+ "Period",
+ "Space",
+ ]);
+
+ /**
+ * Trim a valid prefix from an event string.
+ *
+ * "Digit3" ~> "3"
+ * "ArrowUp" ~> "Up"
+ * "W" ~> "W"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The trimmed string, or unchanged.
+ */
+ function trimPrefix(string) {
+ return string.replace(/^(?:Digit|Numpad|Arrow)/, "");
+ }
+
+ const remapKeys = {
+ ",": "Comma",
+ ".": "Period",
+ " ": "Space",
+ };
+ /**
+ * Map special keys to their shortcut name.
+ *
+ * "," ~> "Comma"
+ * " " ~> "Space"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The remapped string, or unchanged.
+ */
+ function remapKey(string) {
+ if (remapKeys.hasOwnProperty(string)) {
+ return remapKeys[string];
+ }
+ return string;
+ }
+
+ const keyOptions = [
+ e => String.fromCharCode(e.which), // A letter?
+ e => e.code.toUpperCase(), // A letter.
+ e => trimPrefix(e.code), // Digit3, ArrowUp, Numpad9.
+ e => trimPrefix(e.key), // Digit3, ArrowUp, Numpad9.
+ e => remapKey(e.key), // Comma, Period, Space.
+ ];
+ /**
+ * Map a DOM event to a shortcut string character.
+ *
+ * For example:
+ *
+ * "a" ~> "A"
+ * "Digit3" ~> "3"
+ * "," ~> "Comma"
+ *
+ * @param {object} event A KeyboardEvent.
+ * @returns {string} A string corresponding to the pressed key.
+ */
+ function getStringForEvent(event) {
+ for (let option of keyOptions) {
+ let value = option(event);
+ if (validKeys.has(value)) {
+ return value;
+ }
+ }
+
+ return "";
+ }
+
+ function getShortcutValue(shortcut) {
+ if (!shortcut) {
+ // Ensure the shortcut is a string, even if it is unset.
+ return null;
+ }
+
+ let modifiers = shortcut.split("+");
+ let key = modifiers.pop();
+
+ if (modifiers.length) {
+ let modifiersAttribute = ShortcutUtils.getModifiersAttribute(modifiers);
+ let displayString =
+ ShortcutUtils.getModifierString(modifiersAttribute) + key;
+ return displayString;
+ }
+
+ if (functionKeys.has(key)) {
+ return key;
+ }
+
+ return null;
+ }
+
+ let error;
+
+ function setError(...args) {
+ setInputMessage("error", ...args);
+ }
+
+ function setWarning(...args) {
+ setInputMessage("warning", ...args);
+ }
+
+ function setInputMessage(type, input, messageId, args) {
+ let { x, y, height, right } = input.getBoundingClientRect();
+ error.style.top = `${y + window.scrollY + height - 5}px`;
+
+ if (document.dir == "ltr") {
+ error.style.left = `${x}px`;
+ error.style.right = null;
+ } else {
+ error.style.right = `${document.documentElement.clientWidth - right}px`;
+ error.style.left = null;
+ }
+
+ error.setAttribute("type", type);
+ document.l10n.setAttributes(
+ error.querySelector(".error-message-label"),
+ messageId,
+ args
+ );
+ error.style.visibility = "visible";
+ }
+
+ function inputBlurred(e) {
+ error.style.visibility = "hidden";
+ e.target.value = getShortcutValue(e.target.getAttribute("shortcut"));
+ }
+
+ function onFocus(e) {
+ e.target.value = "";
+
+ let warning = e.target.getAttribute("warning");
+ if (warning) {
+ setWarning(e.target, warning);
+ }
+ }
+
+ function getShortcutForEvent(e) {
+ let modifierMap;
+
+ if (AppConstants.platform == "macosx") {
+ modifierMap = {
+ MacCtrl: e.ctrlKey,
+ Alt: e.altKey,
+ Command: e.metaKey,
+ Shift: e.shiftKey,
+ };
+ } else {
+ modifierMap = {
+ Ctrl: e.ctrlKey,
+ Alt: e.altKey,
+ Shift: e.shiftKey,
+ };
+ }
+
+ return Object.entries(modifierMap)
+ .filter(([key, isDown]) => isDown)
+ .map(([key]) => key)
+ .concat(getStringForEvent(e))
+ .join("+");
+ }
+
+ async function buildDuplicateShortcutsMap(addons) {
+ await shortcutKeyMap.buildForAddonIds(addons.map(addon => addon.id));
+ }
+
+ function recordShortcut(shortcut, addonName, commandName) {
+ shortcutKeyMap.recordShortcut(shortcut, addonName, commandName);
+ }
+
+ function removeShortcut(shortcut, addonName, commandName) {
+ shortcutKeyMap.removeShortcut(shortcut, addonName, commandName);
+ }
+
+ function getAddonName(shortcut) {
+ return shortcutKeyMap.getFirstAddonName(shortcut);
+ }
+
+ function setDuplicateWarnings() {
+ let warningHolder = document.getElementById("duplicate-warning-messages");
+ clearWarnings(warningHolder);
+ for (let [shortcut, addons] of shortcutKeyMap) {
+ if (addons.size > 1) {
+ warningHolder.appendChild(createDuplicateWarningBar(shortcut));
+ markDuplicates(shortcut);
+ }
+ }
+ }
+
+ function clearWarnings(warningHolder) {
+ warningHolder.textContent = "";
+ let inputs = document.querySelectorAll(".shortcut-input[warning]");
+ for (let input of inputs) {
+ input.removeAttribute("warning");
+ let row = input.closest(".shortcut-row");
+ if (row.hasAttribute("hide-before-expand")) {
+ row
+ .closest(".card")
+ .querySelector(".expand-button")
+ .removeAttribute("warning");
+ }
+ }
+ }
+
+ function createDuplicateWarningBar(shortcut) {
+ let messagebar = document.createElement("moz-message-bar");
+ messagebar.setAttribute("type", "warning");
+
+ document.l10n.setAttributes(
+ messagebar,
+ "shortcuts-duplicate-warning-message2",
+ { shortcut }
+ );
+ messagebar.setAttribute("data-l10n-attrs", "message");
+
+ return messagebar;
+ }
+
+ function markDuplicates(shortcut) {
+ let inputs = document.querySelectorAll(
+ `.shortcut-input[shortcut="${shortcut}"]`
+ );
+ for (let input of inputs) {
+ input.setAttribute("warning", "shortcuts-duplicate");
+ let row = input.closest(".shortcut-row");
+ if (row.hasAttribute("hide-before-expand")) {
+ row
+ .closest(".card")
+ .querySelector(".expand-button")
+ .setAttribute("warning", "shortcuts-duplicate");
+ }
+ }
+ }
+
+ function onShortcutChange(e) {
+ let input = e.target;
+
+ if (e.key == "Escape") {
+ input.blur();
+ return;
+ }
+
+ if (e.key == "Tab") {
+ return;
+ }
+
+ if (!e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
+ if (e.key == "Delete" || e.key == "Backspace") {
+ // Avoid triggering back-navigation.
+ e.preventDefault();
+ assignShortcutToInput(input, "");
+ return;
+ }
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Some system actions aren't in the keyset, handle them independantly.
+ if (ShortcutUtils.getSystemActionForEvent(e)) {
+ e.defaultCancelled = true;
+ setError(input, "shortcuts-system");
+ return;
+ }
+
+ let shortcutString = getShortcutForEvent(e);
+ input.value = getShortcutValue(shortcutString);
+
+ if (e.type == "keyup" || !shortcutString.length) {
+ return;
+ }
+
+ let validation = ShortcutUtils.validate(shortcutString);
+ switch (validation) {
+ case ShortcutUtils.IS_VALID:
+ // Show an error if this is already a system shortcut.
+ let chromeWindow = window.windowRoot.ownerGlobal;
+ if (ShortcutUtils.isSystem(chromeWindow, shortcutString)) {
+ setError(input, "shortcuts-system");
+ break;
+ }
+
+ // Check if shortcut is already assigned.
+ if (shortcutKeyMap.has(shortcutString)) {
+ setError(input, "shortcuts-exists", {
+ addon: getAddonName(shortcutString),
+ });
+ } else {
+ // Update the shortcut if it isn't reserved or assigned.
+ assignShortcutToInput(input, shortcutString);
+ }
+ break;
+ case ShortcutUtils.MODIFIER_REQUIRED:
+ if (AppConstants.platform == "macosx") {
+ setError(input, "shortcuts-modifier-mac");
+ } else {
+ setError(input, "shortcuts-modifier-other");
+ }
+ break;
+ case ShortcutUtils.INVALID_COMBINATION:
+ setError(input, "shortcuts-invalid");
+ break;
+ case ShortcutUtils.INVALID_KEY:
+ setError(input, "shortcuts-letter");
+ break;
+ }
+ }
+
+ function onShortcutRemove(e) {
+ let removeButton = e.target;
+ let input = removeButton.parentNode.querySelector(".shortcut-input");
+ if (input.getAttribute("shortcut")) {
+ input.value = "";
+ assignShortcutToInput(input, "");
+ }
+ }
+
+ function assignShortcutToInput(input, shortcutString) {
+ let addonId = input.closest(".card").getAttribute("addon-id");
+ let extension = extensionForAddonId(addonId);
+
+ let oldShortcut = input.getAttribute("shortcut");
+ let addonName = input.closest(".card").getAttribute("addon-name");
+ let commandName = input.getAttribute("name");
+
+ removeShortcut(oldShortcut, addonName, commandName);
+ recordShortcut(shortcutString, addonName, commandName);
+
+ // This is async, but we're not awaiting it to keep the handler sync.
+ extension.shortcuts.updateCommand({
+ name: commandName,
+ shortcut: shortcutString,
+ });
+ input.setAttribute("shortcut", shortcutString);
+ input.blur();
+ setDuplicateWarnings();
+ }
+
+ function renderNoShortcutAddons(addons) {
+ let fragment = document.importNode(
+ templates.noShortcutAddons.content,
+ true
+ );
+ let list = fragment.querySelector(".shortcuts-no-commands-list");
+ for (let addon of addons) {
+ let addonItem = document.createElement("li");
+ addonItem.textContent = addon.name;
+ addonItem.setAttribute("addon-id", addon.id);
+ list.appendChild(addonItem);
+ }
+
+ return fragment;
+ }
+
+ async function renderAddons(addons) {
+ let frag = document.createDocumentFragment();
+ let noShortcutAddons = [];
+
+ await buildDuplicateShortcutsMap(addons);
+
+ let isDuplicate = command => {
+ if (command.shortcut) {
+ let dupes = shortcutKeyMap.get(command.shortcut);
+ return dupes.size > 1;
+ }
+ return false;
+ };
+
+ for (let addon of addons) {
+ let extension = extensionForAddonId(addon.id);
+
+ // Skip this extension if it isn't a webextension.
+ if (!extension) {
+ continue;
+ }
+
+ if (extension.shortcuts) {
+ let card = document.importNode(
+ templates.card.content,
+ true
+ ).firstElementChild;
+ let icon = AddonManager.getPreferredIconURL(addon, 24, window);
+ card.setAttribute("addon-id", addon.id);
+ card.setAttribute("addon-name", addon.name);
+ card.querySelector(".addon-icon").src = icon || FALLBACK_ICON;
+ card.querySelector(".addon-name").textContent = addon.name;
+
+ let commands = await extension.shortcuts.allCommands();
+
+ // Sort the commands so the ones with shortcuts are at the top.
+ commands.sort((a, b) => {
+ if (isDuplicate(a) && isDuplicate(b)) {
+ return 0;
+ }
+ if (isDuplicate(a)) {
+ return -1;
+ }
+ if (isDuplicate(b)) {
+ return 1;
+ }
+ // Boolean compare the shortcuts to see if they're both set or unset.
+ if (!a.shortcut == !b.shortcut) {
+ return 0;
+ }
+ if (a.shortcut) {
+ return -1;
+ }
+ return 1;
+ });
+
+ let { limit, allowOver } = COLLAPSE_OPTIONS;
+ let willHideCommands = commands.length > limit + allowOver;
+ let firstHiddenInput;
+
+ for (let i = 0; i < commands.length; i++) {
+ let command = commands[i];
+
+ let row = document.importNode(
+ templates.row.content,
+ true
+ ).firstElementChild;
+
+ if (willHideCommands && i >= limit) {
+ row.setAttribute("hide-before-expand", "true");
+ }
+
+ let label = row.querySelector(".shortcut-label");
+ let descriptionId = getCommandDescriptionId(command);
+ if (descriptionId) {
+ document.l10n.setAttributes(label, descriptionId);
+ } else {
+ label.textContent = command.description || command.name;
+ }
+ let input = row.querySelector(".shortcut-input");
+ input.value = getShortcutValue(command.shortcut);
+ input.setAttribute("name", command.name);
+ input.setAttribute("shortcut", command.shortcut);
+ input.addEventListener("keydown", onShortcutChange);
+ input.addEventListener("keyup", onShortcutChange);
+ input.addEventListener("blur", inputBlurred);
+ input.addEventListener("focus", onFocus);
+
+ let removeButton = row.querySelector(".shortcut-remove-button");
+ removeButton.addEventListener("click", onShortcutRemove);
+
+ if (willHideCommands && i == limit) {
+ firstHiddenInput = input;
+ }
+
+ card.appendChild(row);
+ }
+
+ // Add an expand button, if needed.
+ if (willHideCommands) {
+ let row = document.importNode(templates.expandRow.content, true);
+ let button = row.querySelector(".expand-button");
+ let numberToShow = commands.length - limit;
+ let setLabel = type => {
+ document.l10n.setAttributes(
+ button,
+ `shortcuts-card-${type}-button`,
+ {
+ numberToShow,
+ }
+ );
+ };
+
+ setLabel("expand");
+ button.addEventListener("click", event => {
+ let expanded = card.hasAttribute("expanded");
+ if (expanded) {
+ card.removeAttribute("expanded");
+ setLabel("expand");
+ } else {
+ card.setAttribute("expanded", "true");
+ setLabel("collapse");
+ // If this as a keyboard event then focus the next input.
+ if (event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ firstHiddenInput.focus();
+ }
+ }
+ });
+ card.appendChild(row);
+ }
+
+ frag.appendChild(card);
+ } else if (!addon.hidden) {
+ noShortcutAddons.push({ id: addon.id, name: addon.name });
+ }
+ }
+
+ if (noShortcutAddons.length) {
+ frag.appendChild(renderNoShortcutAddons(noShortcutAddons));
+ }
+
+ return frag;
+ }
+
+ class AddonShortcuts extends HTMLElement {
+ connectedCallback() {
+ setDuplicateWarnings();
+ }
+
+ disconnectedCallback() {
+ error = null;
+ }
+
+ async render() {
+ loadTemplates();
+ let allAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ let addons = allAddons
+ .filter(addon => addon.isActive)
+ .sort((a, b) => a.name.localeCompare(b.name));
+ let frag;
+
+ if (addons.length) {
+ frag = await renderAddons(addons);
+ } else {
+ frag = document.importNode(templates.noAddons.content, true);
+ }
+
+ this.textContent = "";
+ this.appendChild(document.importNode(templates.view.content, true));
+ error = this.querySelector(".error-message");
+ this.appendChild(frag);
+ }
+ }
+ customElements.define("addon-shortcuts", AddonShortcuts);
+}
diff --git a/toolkit/mozapps/extensions/content/view-controller.js b/toolkit/mozapps/extensions/content/view-controller.js
new file mode 100644
index 0000000000..978a44d176
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/view-controller.js
@@ -0,0 +1,204 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+/* import-globals-from /toolkit/content/customElements.js */
+/* import-globals-from aboutaddonsCommon.js */
+/* exported loadView */
+
+// Used by external callers to load a specific view into the manager
+function loadView(viewId) {
+ if (!gViewController.readyForLoadView) {
+ throw new Error("loadView called before about:addons is initialized");
+ }
+ gViewController.loadView(viewId);
+}
+
+/**
+ * Helper for saving and restoring the scroll offsets when a previously loaded
+ * view is accessed again.
+ */
+var ScrollOffsets = {
+ _key: null,
+ _offsets: new Map(),
+ canRestore: true,
+
+ setView(historyEntryId) {
+ this._key = historyEntryId;
+ this.canRestore = true;
+ },
+
+ getPosition() {
+ if (!this.canRestore) {
+ return { top: 0, left: 0 };
+ }
+ let { scrollTop: top, scrollLeft: left } = document.documentElement;
+ return { top, left };
+ },
+
+ save() {
+ if (this._key) {
+ this._offsets.set(this._key, this.getPosition());
+ }
+ },
+
+ restore() {
+ let { top = 0, left = 0 } = this._offsets.get(this._key) || {};
+ window.scrollTo({ top, left, behavior: "auto" });
+ },
+};
+
+var gViewController = {
+ currentViewId: null,
+ readyForLoadView: false,
+ get defaultViewId() {
+ if (!isDiscoverEnabled()) {
+ return "addons://list/extension";
+ }
+ return "addons://discover/";
+ },
+ isLoading: true,
+ // All historyEntryId values must be unique within one session, because the
+ // IDs are used to map history entries to page state. It is not possible to
+ // see whether a historyEntryId was used in history entries before this page
+ // was loaded, so start counting from a random value to avoid collisions.
+ // This is used for scroll offsets in aboutaddons.js
+ nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32),
+ views: {},
+
+ initialize(container) {
+ this.container = container;
+
+ window.addEventListener("popstate", this);
+ window.addEventListener("unload", this, { once: true });
+ Services.obs.addObserver(this, "EM-ping");
+ },
+
+ handleEvent(e) {
+ if (e.type == "popstate") {
+ this.renderState(e.state);
+ return;
+ }
+
+ if (e.type == "unload") {
+ Services.obs.removeObserver(this, "EM-ping");
+ // eslint-disable-next-line no-useless-return
+ return;
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "EM-ping") {
+ this.readyForLoadView = true;
+ Services.obs.notifyObservers(window, "EM-pong");
+ }
+ },
+
+ notifyEMLoaded() {
+ this.readyForLoadView = true;
+ Services.obs.notifyObservers(window, "EM-loaded");
+ },
+
+ notifyEMUpdateCheckFinished() {
+ // Notify the observer about a completed update check (currently only used in tests).
+ Services.obs.notifyObservers(null, "EM-update-check-finished");
+ },
+
+ defineView(viewName, renderFunction) {
+ if (this.views[viewName]) {
+ throw new Error(
+ `about:addons view ${viewName} should not be defined twice`
+ );
+ }
+ this.views[viewName] = renderFunction;
+ },
+
+ parseViewId(viewId) {
+ const matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/;
+ const [, viewType, viewParam] = viewId.match(matchRegex) || [];
+ return { type: viewType, param: decodeURIComponent(viewParam) };
+ },
+
+ loadView(viewId, replace = false) {
+ viewId = viewId.startsWith("addons://") ? viewId : `addons://${viewId}`;
+ if (viewId == this.currentViewId) {
+ return Promise.resolve();
+ }
+
+ // Always rewrite history state instead of pushing incorrect state for initial load.
+ replace = replace || !this.currentViewId;
+
+ const state = {
+ view: viewId,
+ previousView: replace ? null : this.currentViewId,
+ historyEntryId: ++this.nextHistoryEntryId,
+ };
+ if (replace) {
+ history.replaceState(state, "");
+ } else {
+ history.pushState(state, "");
+ }
+ return this.renderState(state);
+ },
+
+ async renderState(state) {
+ let { param, type } = this.parseViewId(state.view);
+
+ if (!type || this.views[type] == null) {
+ console.warn(`No view for ${type} ${param}, switching to default`);
+ this.resetState();
+ return;
+ }
+
+ ScrollOffsets.save();
+ ScrollOffsets.setView(state.historyEntryId);
+
+ this.currentViewId = state.view;
+ this.isLoading = true;
+
+ // Perform tasks before view load
+ document.dispatchEvent(
+ new CustomEvent("view-selected", {
+ detail: { id: state.view, param, type },
+ })
+ );
+
+ // Render the fragment
+ this.container.setAttribute("current-view", type);
+ let fragment = await this.views[type](param);
+
+ // Clear and append the fragment
+ if (fragment) {
+ this.container.textContent = "";
+ this.container.append(fragment);
+
+ // Most content has been rendered at this point. The only exception are
+ // recommendations in the discovery pane and extension/theme list, because
+ // they rely on remote data. If loaded before, then these may be rendered
+ // within one tick, so wait a frame before restoring scroll offsets.
+ await new Promise(resolve => {
+ window.requestAnimationFrame(() => {
+ // Double requestAnimationFrame in case we reflow.
+ window.requestAnimationFrame(() => {
+ ScrollOffsets.restore();
+ resolve();
+ });
+ });
+ });
+ } else {
+ // Reset to default view if no given content
+ this.resetState();
+ return;
+ }
+
+ this.isLoading = false;
+
+ document.dispatchEvent(new CustomEvent("view-loaded"));
+ },
+
+ resetState() {
+ return this.loadView(this.defaultViewId, true);
+ },
+};
diff --git a/toolkit/mozapps/extensions/default-theme/icon.svg b/toolkit/mozapps/extensions/default-theme/icon.svg
new file mode 100644
index 0000000000..ecf787db73
--- /dev/null
+++ b/toolkit/mozapps/extensions/default-theme/icon.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="63" height="63" viewBox="0 0 63 63" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M53.8717 54C66.0976 41.5959 66.0425 21.6295 53.7065 9.29348C41.3705 -3.04253 21.4041 -3.09758 9 9.12833L53.8717 54Z" fill="#23222B"/>
+ <path d="M9.12833 9C-3.09758 21.4041 -3.04253 41.3705 9.29348 53.7065C21.6295 66.0425 41.5959 66.0976 54 53.8717L9.12833 9Z" fill="#F0F0F4"/>
+</svg>
diff --git a/toolkit/mozapps/extensions/default-theme/manifest.json b/toolkit/mozapps/extensions/default-theme/manifest.json
new file mode 100644
index 0000000000..f89a4236ff
--- /dev/null
+++ b/toolkit/mozapps/extensions/default-theme/manifest.json
@@ -0,0 +1,95 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "default-theme@mozilla.org"
+ }
+ },
+
+ "name": "System theme — auto",
+ "description": "Follow the operating system setting for buttons, menus, and windows.",
+ "author": "Mozilla",
+ "version": "1.3",
+
+ "icons": { "32": "icon.svg" },
+
+ "theme": {},
+
+ "dark_theme": {
+ "colors": {
+ "tab_background_text": "#fbfbfe",
+ "tab_selected": "rgb(66,65,77)",
+ "tab_text": "rgb(251,251,254)",
+ "icons": "rgb(251,251,254)",
+ "frame": "#1c1b22",
+ "popup": "rgb(66,65,77)",
+ "popup_text": "rgb(251,251,254)",
+ "popup_border": "rgb(82,82,94)",
+ "popup_highlight": "rgb(43,42,51)",
+ "tab_line": "transparent",
+ "toolbar": "#2b2a33",
+ "toolbar_top_separator": "transparent",
+ "toolbar_bottom_separator": "hsl(240, 5%, 5%)",
+ "toolbar_field": "rgb(28,27,34)",
+ "toolbar_field_border": "transparent",
+ "toolbar_field_text": "rgb(251,251,254)",
+ "toolbar_field_focus": "rgb(66,65,77)",
+ "toolbar_text": "rgb(251, 251, 254)",
+ "ntp_background": "rgb(43, 42, 51)",
+ "ntp_text": "rgb(251, 251, 254)",
+ "sidebar": "#38383D",
+ "sidebar_text": "rgb(249, 249, 250)",
+ "sidebar_border": "rgba(255, 255, 255, 0.1)",
+ "button": "rgb(43,42,51)",
+ "button_hover": "rgb(82,82,94)",
+ "button_active": "rgb(91,91,102)",
+ "button_primary": "rgb(0, 221, 255)",
+ "button_primary_hover": "rgb(128, 235, 255)",
+ "button_primary_active": "rgb(170, 242, 255)",
+ "button_primary_color": "rgb(43, 42, 51)",
+ "input_background": "#42414D",
+ "input_color": "rgb(251,251,254)",
+ "input_border": "#8f8f9d",
+ "urlbar_popup_separator": "rgb(82,82,94)",
+ "appmenu_update_icon_color": "#54FFBD",
+ "appmenu_info_icon_color": "#80EBFF",
+ "tab_icon_overlay_stroke": "rgb(66,65,77)",
+ "tab_icon_overlay_fill": "rgb(251,251,254)"
+ },
+ "properties": {
+ "panel_hover": "color-mix(in srgb, currentColor 9%, transparent)",
+ "panel_active": "color-mix(in srgb, currentColor 14%, transparent)",
+ "panel_active_darker": "color-mix(in srgb, currentColor 25%, transparent)",
+ "toolbar_field_icon_opacity": "1",
+ "zap_gradient": "linear-gradient(90deg, #9059FF 0%, #FF4AA2 52.08%, #FFBD4F 100%)"
+ }
+ },
+
+ "theme_experiment": {
+ "colors": {
+ "button": "--button-bgcolor",
+ "button_hover": "--button-hover-bgcolor",
+ "button_active": "--button-active-bgcolor",
+ "button_primary": "--button-primary-bgcolor",
+ "button_primary_hover": "--button-primary-hover-bgcolor",
+ "button_primary_active": "--button-primary-active-bgcolor",
+ "button_primary_color": "--button-primary-color",
+ "input_background": "--input-bgcolor",
+ "input_color": "--input-color",
+ "input_border": "--input-border-color",
+ "urlbar_popup_separator": "--urlbarView-separator-color",
+ "appmenu_update_icon_color": "--panel-banner-item-update-supported-bgcolor",
+ "appmenu_info_icon_color": "--panel-banner-item-info-icon-bgcolor",
+ "tab_icon_overlay_stroke": "--tab-icon-overlay-stroke",
+ "tab_icon_overlay_fill": "--tab-icon-overlay-fill"
+ },
+ "properties": {
+ "panel_hover": "--panel-item-hover-bgcolor",
+ "panel_active": "--arrowpanel-dimmed-further",
+ "panel_active_darker": "--panel-item-active-bgcolor",
+ "toolbar_field_icon_opacity": "--urlbar-icon-fill-opacity",
+ "zap_gradient": "--panel-separator-zap-gradient"
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/default-theme/preview.svg b/toolkit/mozapps/extensions/default-theme/preview.svg
new file mode 100644
index 0000000000..4d59fdcc6f
--- /dev/null
+++ b/toolkit/mozapps/extensions/default-theme/preview.svg
@@ -0,0 +1,46 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg width="680" height="92" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <rect width="680" height="92" fill="#F0F0F4" />
+ <g filter="url(#filter0_dd)">
+ <rect x="28" y="5" width="166" height="34" rx="4" fill="white" />
+ </g>
+ <rect x="51" y="20" width="121" height="4" rx="2" fill="#15141A" />
+ <rect x="221" y="20" width="121" height="4" rx="2" fill="#15141A" />
+ <rect y="44" width="680" height="48" fill="#F9F9FB" />
+ <circle cx="24" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <circle cx="60" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <rect x="114" y="52" width="488" height="32" rx="4" fill="#F0F0F4" />
+ <circle cx="130" cy="68" r="6.25" stroke="#5B5B66" stroke-width="1.5" />
+ <rect x="146" y="66" width="308" height="4" rx="2" fill="#5B5B66" />
+ <mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="680" height="92">
+ <path d="M680 92V0H334C321.85 0 312 9.84974 312 22C312 34.1503 302.15 44 290 44H252C238.745 44 228 54.7452 228 68C228 81.2548 217.255 92 204 92H680Z" fill="#C4C4C4" />
+ </mask>
+ <g mask="url(#mask0)">
+ <rect width="680" height="92" fill="#1C1B22" />
+ <rect x="221" y="20" width="121" height="4" rx="2" fill="#B8B7BB" />
+ <rect y="44" width="680" height="48" fill="#2B2A33" />
+ <line x1="663" y1="73.75" x2="649" y2="73.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="67.75" x2="649" y2="67.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <line x1="663" y1="61.75" x2="649" y2="61.75" stroke="#FBFBFE" stroke-width="1.5" />
+ <rect x="114" y="52" width="488" height="32" rx="4" fill="#1C1B22" />
+ <rect x="146" y="66" width="308" height="4" rx="2" fill="white" />
+ </g>
+ <defs>
+ <filter id="filter0_dd" x="24" y="1" width="174" height="42" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+ <feFlood flood-opacity="0" result="BackgroundImageFix" />
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+ <feOffset />
+ <feGaussianBlur stdDeviation="2" />
+ <feColorMatrix type="matrix" values="0 0 0 0 0.501961 0 0 0 0 0.501961 0 0 0 0 0.556863 0 0 0 0.5 0" />
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" />
+ <feOffset />
+ <feGaussianBlur stdDeviation="0.5" />
+ <feColorMatrix type="matrix" values="0 0 0 0 0.501961 0 0 0 0 0.501961 0 0 0 0 0.556863 0 0 0 0.9 0" />
+ <feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
+ <feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
+ </filter>
+ </defs>
+</svg>
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json b/toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json
new file mode 100644
index 0000000000..f31b9fa97b
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings-JSONSchema.json
@@ -0,0 +1,83 @@
+{
+ "type": "object",
+ "required": ["id"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "default": "AddonManagerSettings",
+ "description": "The default id should NOT be changed, unless there is a specific need to create separate collection entries which target or exclude specific Firefox versions."
+ },
+ "filter_expression": {
+ "type": "string",
+ "optional": true,
+ "description": "This is NOT directly used by AMRemoteSettings, but has special functionality in Remote Settings.\nSee https://remote-settings.readthedocs.io/en/latest/target-filters.html#how"
+ },
+ "quarantinedDomains": {
+ "$ref": "#/definitions/quarantinedDomains",
+ "optional": true
+ },
+ "installTriggerDeprecation": {
+ "$ref": "#/definitions/installTriggerDeprecation",
+ "optional": true
+ }
+ },
+ "definitions": {
+ "quarantinedDomains": {
+ "oneOf": [
+ {
+ "type": "null",
+ "title": "Omit quarantinedDomains settings"
+ },
+ {
+ "type": "object",
+ "title": "Include quarantinedDomains settings",
+ "required": ["extensions.quarantinedDomains.list"],
+ "properties": {
+ "extensions.quarantinedDomains.list": {
+ "type": "string",
+ "default": "",
+ "maxLength": 1048576,
+ "description": "Set of domains to be quarantined separated by a comma (e.g. 'domain1.org,domain2.com'). NOTE: this pref value should be set to a ASCII encoded string and its size smaller than 1MB"
+ }
+ },
+ "additionalProperties": false
+ }
+ ],
+ "default": null,
+ "description": "These settings provide a set of domain names to be quarantined (restricted by default to unverified extensions, which only the app or the user may grant back). IMPORTANT: The add-ons team should be consulted before introducing any new entry of this type."
+ },
+ "installTriggerDeprecation": {
+ "oneOf": [
+ {
+ "type": "null",
+ "title": "Omit installTriggerDeprecation settings"
+ },
+ {
+ "type": "object",
+ "title": "Include installTriggerDeprecation settings",
+ "required": [
+ "extensions.InstallTrigger.enabled",
+ "extensions.InstallTriggerImpl.enabled"
+ ],
+ "properties": {
+ "extensions.InstallTrigger.enabled": {
+ "type": "boolean",
+ "default": true,
+ "optional": true,
+ "description": "Show/Hide the InstallTrigger global completely (both the global and its methods will not be accessible anymore). IMPORTANT: The webcompat team should be consulted before turning this to false, because it may also potentially impact UA detection for some websites."
+ },
+ "extensions.InstallTriggerImpl.enabled": {
+ "type": "boolean",
+ "default": false,
+ "optional": true,
+ "description": "Show/Hide the InstallTrigger methods. The InstallTrigger global will remain visible but set to null."
+ }
+ },
+ "additionalProperties": false
+ }
+ ],
+ "default": null,
+ "description": "These settings control the visibility of the InstallTrigger global and its methods."
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json b/toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json
new file mode 100644
index 0000000000..513449da92
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings-UISchema.json
@@ -0,0 +1,10 @@
+{
+ "installTriggerDeprecation": {
+ "extensions.InstallTrigger.enabled": {
+ "ui:widget": "radio"
+ },
+ "extensions.InstallTriggerImpl.enabled": {
+ "ui:widget": "radio"
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst b/toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst
new file mode 100644
index 0000000000..52dc507330
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings-overview.rst
@@ -0,0 +1,177 @@
+AMRemoteSettings Overview
+=========================
+
+``AMRemoteSettings`` is a singleton that is responsible for fetching data from a RemoteSettings collection named
+``"main/addons-manager-settings"`` to remotely control a set of Add-ons related preferences (hardcoded inside
+the AMRemoteSettings definition part of a given Firefox release).
+
+``AMRemoteSettings`` will process the collection data when the RemoteSettings ``"sync"`` event is fired and on
+late browser startup if there is already some data stored locally in the collection (and so if the pref value is
+manually changed, then it would be set again to the value stored in the RemoteSettings collection data, either
+on the next browser startup or on the next time the ``"sync"`` event is fired again).
+
+.. warning::
+ Any about:config preference that AMRemoteSettings singleton sets while processing the RemoteSettings collection data
+ are **never cleared automatically** (e.g. if in a future Firefox version AMRemoteSettings does not handle a particular
+ settings anymore, or if the RemoteSettings entry is removed from the collection on the RemoteSettings service side).
+
+ In practice this makes this feature a good choice when the remotely controlled about:config preferences is related to:
+
+ * Firefox Add-ons features in the process of being deprecated and then fully removed in a future Firefox release
+
+ * or controlled settings that are never removed from the ``"main/addons-manager-settings"`` collection.
+
+ In general before changing controlled settings in the ``"main/addons-manager-settings"`` collection it is advisable to:
+
+ * make sure to default value for the related about:config preferences has been changed in mozilla-central first,
+ and to let it ride the release train and get some backing time on release;
+
+ * only after that consider changing the value controlled by ``"main/addons-manager-settings"`` collection,
+ to set the same value on older Firefox releases where the previous default value was set.
+
+The ``AMRemoteSettings`` singleton queries RemoteSettings and processes all the entries got from the
+``"main/addons-manager-settings"`` collection, besides entries that may be filtered out by RemoteSettings based on
+the ``"filter_expression"`` property (See https://remote-settings.readthedocs.io/en/latest/target-filters.html#how).
+
+Each of the entries created in the ``"main/addons-manager-settings"`` collection and is expected to match the JSONSchema
+described in the :ref:`JSON Schema section below<JSON Schema>`.
+
+For each entry found in the collection, only the properties that are explicitly included in the
+``AMRemoteSettings.RS_ENTRIES_MAP`` object are going to be processed (e.g. new Firefox versions may have introduced new
+ones that older Firefox version will just ignore):
+
+* Each of the ``AMRemoteSettings.RS_ENTRIES_MAP`` properties:
+
+ * represents a group of settings (e.g. the property named ``"installTriggerDeprecation"``) is responsible of controlling
+ about:config preferences related to the InstallTrigger deprecation
+
+ * is set to an array of string, which lists the about:config preferences names that can actually be controlled by the
+ related group of settings (e.g. ``"installTriggerDeprecation"`` can only control two preferences,
+ ``"extensions.InstallTrigger.enabled"`` and ``"extensions.InstallTriggerImpl.enabled"``, that are controlling the
+ InstallTrigger and InstallTrigger's methods availability).
+
+.. warning::
+ Any other about:config preference names that are not listed explicitly in the ``AMRemoteSettings.RS_ENTRIES_MAP`` config
+ is expected to be ignored, even if allowed by a new version of the collection's JSONSchema (this is the expected behavior
+ and prevents the introduction of unexpected behaviors on older Firefox versions that may not be expecting new settings groups
+ that may be introduced in Firefox releases that followed it).
+
+ Any about:config preference with an unexpected value type is going to be ignored as well (look to the ``AMRemoteSettings.processEntries``
+ to confirm which preferences values types are already expected and handled accordingly).
+
+.. _Controlled Settings Groups:
+
+AMRemoteSettings - Controlled Settings Groups
+=============================================
+
+installTriggerDeprecation
+-------------------------
+
+Group of settings to control InstallTrigger deprecation (Bug 1754441)
+
+- **extensions.InstallTrigger.enabled** (boolean), controls the availability of the InstallTrigger global:
+
+ - Turning this to false will be hiding it completely
+
+ .. note::
+ Turning this off can potentially impact UA detection on website being using it to detect
+ Firefox. The WebCompat team should be consulted before setting this to `false` by default in
+ Nightly or across all Firefox >= 100 versions through the ``"addons-manager-settings"``
+ RemoteSettings collection).
+
+- **extensions.InstallTriggerImpl.enabled** (boolean): controls the availability of the InstallTrigger methods:
+
+ - Turning this to false will hide all the InstallTrigger implementation, preventing using it to
+ trigger the addon install flow, while the InstallTrigger global will still exists but be set to null.
+
+quarantinedDomains
+------------------
+
+Group of settings to control the list of quarantined domains (Bug 1832791)
+
+- **extensions.quarantinedDomains.list** (string), controls the list of domains to be quarantined
+
+ .. note::
+ The WebExtensions and Add-ons Operations teams should be consulted before applying changes to
+ the list of quarantined domains.
+
+How to define new remotely controlled settings
+----------------------------------------------
+
+.. note::
+ Please update the :ref:`JSON Schema` and :ref:`UI Schema` in this documentation page updated when the ones used on the
+ RemoteSettings service side have been changed, and document new controlled settings in the section :ref:`Controlled Settings Groups`.
+
+* Confirm that the :ref:`JSON Schema` and :ref:`UI Schema` included in this page are in sync with the one actually used on the
+ RemoteSettings service side, and use it as the starting point to update it to include a new type on remotely controlled setting:
+
+ * choose a new unique string for the group of settings to be used in the ``definitions`` and ``properties``
+ objects (any that isn't already used in the existing JSON Schema ``definitions``), possibly choosing a name
+ that helps to understand what the purpose of the entry.
+
+ * add a new JSONSchema for the new group of settings in the ``definitions`` property
+
+ * each of the properties included in the new definition should be named after the name of the about:config pref
+ being controlled, their types should match the type expected by the pref (e.g. ``"boolean"`` for a boolean preference).
+
+ * make sure to add a description property to the definition and to each of the controlled preferences, which should
+ describe what is the settings group controlling and what is the expected behavior on the values allowed.
+
+* Add a new entry to ``"AMRemoteSettings.RS_ENTRIES_MAP"`` with the choosen ``"id"`` as its key and
+ the array of the about:config preferences names are its value.
+
+* If the value type of a controlled preference isn't yet supported, the method ``AMRemoteSettings.processEntries`` has to be
+ updated to handle the new value type (otherwise the preference value will be just ignored).
+
+* Add a new test to cover the expected behaviors on the new remotely controlled settings, the following RemoteSettings
+ documentation page provides some useful pointers:
+ * https://firefox-source-docs.mozilla.org/services/settings/index.html#unit-tests
+
+* Refer to the RemoteSettings docs for more details about managing the JSONSchema for the ``"main/addons-manager-settings"``
+ collection and how to test it interactively in a Firefox instance:
+ * https://remote-settings.readthedocs.io/en/latest/getting-started.html
+ * https://firefox-source-docs.mozilla.org/services/settings/index.html#create-new-remote-settings
+ * https://firefox-source-docs.mozilla.org/services/settings/index.html#remote-settings-dev-tools
+
+.. _JSON Schema:
+
+AMRemoteSettings - JSON Schema
+==============================
+
+The entries part of the ``"addons-manager-settings"`` collection are validated using a JSON Schema structured as follows:
+
+* The mandatory ``"id"`` property
+ * defaults to `"AddonManagerSettings"` (which enforces only one entry in the collection as the preferred use case)
+ * **should NOT be changed unless there is a specific need to create separate collection entries which target or exclude specific Firefox versions.**
+ * when changed and multiple entries are created in this collection, it is advisable to:
+
+ * set the id to a short string value that make easier to understand the purpose of the additional entry in the collection
+ (eg. `AddonManagerSettings-fx98-99` for an entry created that targets Firefox 98 and 99)
+ * make sure only one applied to each targeted Firefox version ranges, or at least that each entry is controlling a different settings group
+
+* Each supported group of controlled settings is described by its own property (e.g. ``"installTriggerDeprecation"``)
+
+ * JSON Schema for each group of settings is defined in an entry of the ``"definitions"`` property.
+
+ * The definition for each of the groups defined in the schema should be defined as a ``"oneOf"`` array including an entry
+ of ``"type": "null"`` and ``"default"` set to ``null`` to omit the group of settings by default in new records.
+
+ * In addition to the ``"type": "null"`` schema, each group of settings is expected to include in the ``"oneOf"`` array
+ a second entry of ``"type": "object"`` and the controlled about:config preferences part of the group listed in
+ the ``"properties"``.
+
+.. literalinclude :: ./AMRemoteSettings-JSONSchema.json
+ :language: json
+
+UI Schema
+---------
+
+In addition to the JSON Schema, a separate json called ``"UI schema"`` is associated to the ``"addons-manager-settings"`` collection,
+and it can be used to customize the form auto-generated based on the JSONSchema data.
+
+.. note::
+ Extending this schema is only needed if it can help to make the RemoteSettings collection easier to manage
+ and less error prone.
+
+.. literalinclude :: ./AMRemoteSettings-UISchema.json
+ :language: json
diff --git a/toolkit/mozapps/extensions/docs/AMRemoteSettings.rst b/toolkit/mozapps/extensions/docs/AMRemoteSettings.rst
new file mode 100644
index 0000000000..a555f426cc
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AMRemoteSettings.rst
@@ -0,0 +1,5 @@
+AMRemoteSettings Reference
+==========================
+
+.. js:autoclass:: AMRemoteSettings
+ :members:
diff --git a/toolkit/mozapps/extensions/docs/AddonManager.rst b/toolkit/mozapps/extensions/docs/AddonManager.rst
new file mode 100644
index 0000000000..9a5db82830
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/AddonManager.rst
@@ -0,0 +1,4 @@
+AddonManager Reference
+======================
+.. js:autoclass:: AddonManager
+ :members:
diff --git a/toolkit/mozapps/extensions/docs/SystemAddons.rst b/toolkit/mozapps/extensions/docs/SystemAddons.rst
new file mode 100644
index 0000000000..a8524a44b6
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/SystemAddons.rst
@@ -0,0 +1,275 @@
+System Add-ons Overview
+=======================
+
+System add-ons are a method for shipping extensions to Firefox that:
+
+* are hidden from the about:addons UI
+* cannot be user disabled
+* can be updated restartlessly based on criteria Mozilla sets
+
+Generally these are considered to be built-in features to Firefox, and the
+fact that they are extensions and can be updated restartlessly are implementation
+details as far as users are concerned.
+
+If you'd like to ship an add-on with Firefox or as an update (either to an existing
+feature or as a "hotfix" to patch critical problems in the wild) please contact the
+GoFaster team: https://mail.mozilla.org/listinfo/gofaster
+
+The add-ons themselves are either legacy Firefox add-ons or WebExtensions.
+They must be:
+
+* restartless
+* multi-process compatible
+
+Other than these restrictions there is nothing special or different about
+the extensions themselves.
+
+It is possible to override an installed system add-on by installing a different add-on
+with the same ID into a higher priority location.
+
+Available locations, starting from the highest priority include:
+
+1) temporary install (about:debugging)
+2) normal user install into profile (about:addons or AMO/TestPilot/etc.)
+3) system add-on updates
+4) built-in system add-ons
+
+This makes it possible for a developer or user to override a system add-on
+by installing an add-on with the same ID from AMO or TestPilot or as a temporary
+add-on.
+
+Default, built-in system add-ons
+--------------------------------
+
+The set of **default** system add-ons are checked into `mozilla-central` under
+`./browser/extensions`. These get placed into the `features` directory of the
+application directory at build time.
+
+System add-on updates
+---------------------
+
+System add-on **updates** are served via Mozilla's Automatic Update Service
+(AUS, aka `Balrog`_). These are installed into the users profile under the `features`
+directory.
+
+Updates must be specifically signed by Mozilla - the signature that addons.mozilla.org
+uses will not work for system add-ons.
+
+As noted above, these updates may override a built-in system add-on, or they may
+be a new install. Updates are always served as a set - if any add-on in the set
+fails to install or upgrade, then the whole set fails. This is to leave Firefox
+in a consistent state.
+
+System add-on updates are removed when the Firefox application version changes,
+to avoid compatibility problems - for instance a user downgrading to an earlier
+version of Firefox than the update supports will end up with a disabled update
+rather than falling back to the built-in version.
+
+Firefox System Add-on Update Protocol
+=====================================
+This section describes the protocol that Firefox uses when retrieving updates
+from AUS, and the expected behavior of Firefox based on the updater service's response.
+
+.. _Balrog: https://wiki.mozilla.org/Balrog
+
+Update Request
+--------------
+To determine what updates to install, Firefox makes an HTTP **GET** request to
+AUS once a day via a URL of the form::
+
+ https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml
+
+The path segments surrounded by ``%`` symbols are variable fields that Firefox
+fills in with information about itself and the environment it's running in:
+
+``VERSION``
+ Firefox version number
+``BUILD_ID``
+ Build ID
+``BUILD_TARGET``
+ Build target
+``LOCALE``
+ Build locale
+``CHANNEL``
+ Update channel
+``OS_VERSION``
+ OS Version
+``DISTRIBUTION``
+ Firefox Distribution
+``DISTRIBUTION_VERSION``
+ Firefox Distribution version
+
+Update Response
+---------------
+AUS should respond with an XML document that looks something like this:
+
+.. code-block:: xml
+
+ <?xml version="1.0"?>
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+* The root element is ``<updates>``, used for all updater responses.
+* The only child of ``<updates>`` is ``<addons>``, which represents a list of
+ system add-ons to update.
+* Within ``<addons>`` are several ``<addon>`` tags, each one corresponding to a
+ system add-on to update.
+
+``<addon>`` tags **must** have the following attributes:
+
+``id``
+ The extension ID
+``URL``
+ URL to a signed XPI of the specified add-on version to download
+``hashFunction``
+ Identifier of the hash function used to generate the hashValue attribute.
+``hashValue``
+ Hash of the XPI file linked from the URL attribute, calculated using the function specified in the hashValue attribute.
+``size``
+ Size (in bytes) of the XPI file linked from the URL attribute.
+``version``
+ Version number of the add-on
+
+Update Behavior
+---------------
+After receiving the update response, Firefox modifies the **update** add-ons
+according to the following algorithm:
+
+1. If the ``<addons>`` tag is empty (``<addons></addons>``) in the response,
+ **remove all system add-on updates**.
+2. If no add-ons were specified in the response (i.e. the ``<addons>`` tag
+ is not present), do nothing and finish.
+3. If the **update** add-on set is equal to the set of add-ons specified in the
+ update response, do nothing and finish.
+4. If the set of **default** add-ons is equal to the set of add-ons specified in
+ the update response, remove all the **update** add-ons and finish.
+5. Download each add-on specified in the update response and store them in the
+ "downloaded add-on set". A failed download **must** abort the entire system
+ add-on update.
+6. Validate the downloaded add-ons. The following **must** be true for all
+ downloaded add-ons, or the update process is aborted:
+
+ a. The ID and version of the downloaded add-on must match the specified ID or
+ version in the update response.
+ b. The hash provided in the update response must match the downloaded add-on
+ file.
+ c. The downloaded add-on file size must match the size given in the update
+ response.
+ d. The add-on must be compatible with Firefox (i.e. it must not be for a
+ different application, such as Thunderbird).
+ e. The add-on must be packed (i.e. be an XPI file).
+ f. The add-on must be restartless.
+ g. The add-on must be signed by the system add-on root certificate.
+
+6. Once all downloaded add-ons are validated, install them into the profile
+ directory as part of the **update** set.
+
+Notes on the update process:
+
+* Add-ons are considered "equal" if they have the same ID and version number.
+
+Examples
+--------
+The follow section describes common situations that we have or expect to run
+into and how the protocol described above handles them.
+
+For simplicity, unless otherwise specified, all examples assume that there are
+two system add-ons in existence: **FlyWeb** and **Pocket**.
+
+Basic
+~~~~~
+A user has Firefox 45, which shipped with FlyWeb 1.0 and Pocket 1.0. We want to
+update users to FlyWeb 2.0. AUS sends out the following update response:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-2.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="2.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+Firefox will download FlyWeb 2.0 and Pocket 1.0 and store them in the profile directory.
+
+Missing Add-on
+~~~~~~~~~~~~~~
+A user has Firefox 45, which shipped with FlyWeb 1.0 and Pocket 1.0. We want to
+update users to FlyWeb 2.0, but accidentally forget to specify Pocket in the
+update response. AUS sends out the following:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-2.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="2.0"/>
+ </addons>
+ </updates>
+
+Firefox will download FlyWeb 2.0 and store it in the profile directory. Pocket
+1.0 from the **default** location will be used.
+
+Remove all system add-on updates
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+A response from AUS with an empty add-on set will *remove all system add-on
+updates*:
+
+.. code-block:: xml
+
+ <updates>
+ <addons></addons>
+ </updates>
+
+Rollout
+~~~~~~~
+A user has Firefox 45, which shipped with FlyWeb 1.0 and Pocket 1.0. We want to
+rollout FlyWeb 2.0 at a 10% sample rate. 10% of the time, AUS sends out:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-2.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="2.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+With this response, Firefox will download Pocket 1.0 and FlyWeb 2.0 and install
+them into the profile directory.
+
+The other 90% of the time, AUS sends out an empty response:
+
+.. code-block:: xml
+
+ <updates></updates>
+
+With the empty response, Firefox will not make any changes. This means users who
+haven’t seen the 10% update response will stay on FlyWeb 1.0, and users who have
+seen it will stay on FlyWeb 2.0.
+
+Once we’re happy with the rollout and want to switch to 100%, AUS will send the
+10% update response to 100% of users, upgrading everyone to FlyWeb 2.0.
+
+Rollback
+~~~~~~~~
+This example continues from the “Rollout” example. If, during the 10% rollout,
+we find a major issue with FlyWeb 2.0, we want to roll all users back to FlyWeb 1.0.
+AUS sends out the following:
+
+.. code-block:: xml
+
+ <updates>
+ <addons>
+ <addon id="flyweb@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/flyweb/flyweb@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ <addon id="pocket@mozilla.org" URL="https://ftp.mozilla.org/pub/system-addons/pocket/pocket@mozilla.org-1.0.xpi" hashFunction="sha512" hashValue="abcdef123" size="1234" version="1.0"/>
+ </addons>
+ </updates>
+
+For users who have updated, Firefox will download FlyWeb 1.0 and Pocket 1.0 and
+install them into the profile directory. For users that haven’t yet updated,
+Firefox will see that the **default** add-on set matches the set in the update
+ping and clear the **update** add-on set.
diff --git a/toolkit/mozapps/extensions/docs/index.rst b/toolkit/mozapps/extensions/docs/index.rst
new file mode 100644
index 0000000000..551cdd0ebc
--- /dev/null
+++ b/toolkit/mozapps/extensions/docs/index.rst
@@ -0,0 +1,21 @@
+==============
+Add-on Manager
+==============
+
+Overview docs
+-------------
+
+.. toctree::
+ :maxdepth: 1
+
+ AMRemoteSettings-overview
+ SystemAddons
+
+API References
+--------------
+
+.. toctree::
+ :maxdepth: 1
+
+ AddonManager
+ AMRemoteSettings
diff --git a/toolkit/mozapps/extensions/extensions.manifest b/toolkit/mozapps/extensions/extensions.manifest
new file mode 100644
index 0000000000..cd040a71f1
--- /dev/null
+++ b/toolkit/mozapps/extensions/extensions.manifest
@@ -0,0 +1,9 @@
+#ifndef MOZ_WIDGET_ANDROID
+category update-timer addonManager @mozilla.org/addons/integration;1,getService,addon-background-update-timer,extensions.update.interval,86400
+#endif
+#ifndef MOZ_THUNDERBIRD
+#ifndef MOZ_WIDGET_ANDROID
+category addon-provider-module GMPProvider resource://gre/modules/addons/GMPProvider.sys.mjs
+category addon-provider-module SitePermsAddonProvider resource://gre/modules/addons/SitePermsAddonProvider.sys.mjs
+#endif
+#endif
diff --git a/toolkit/mozapps/extensions/gen_built_in_addons.py b/toolkit/mozapps/extensions/gen_built_in_addons.py
new file mode 100644
index 0000000000..9e8078f2bc
--- /dev/null
+++ b/toolkit/mozapps/extensions/gen_built_in_addons.py
@@ -0,0 +1,99 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import argparse
+import json
+import os.path
+import sys
+
+import buildconfig
+import mozpack.path as mozpath
+
+from mozpack.copier import FileRegistry
+from mozpack.manifests import InstallManifest
+
+
+# A list of build manifests, and their relative base paths, from which to
+# extract lists of install files. These vary depending on which backend we're
+# using, so nonexistent manifests are ignored.
+manifest_paths = (
+ ("", "_build_manifests/install/dist_bin"),
+ ("", "faster/install_dist_bin"),
+ ("browser", "faster/install_dist_bin_browser"),
+)
+
+
+def get_registry(paths):
+ used_paths = set()
+
+ registry = FileRegistry()
+ for base, path in paths:
+ full_path = mozpath.join(buildconfig.topobjdir, path)
+ if not os.path.exists(full_path):
+ continue
+
+ used_paths.add(full_path)
+
+ reg = FileRegistry()
+ InstallManifest(full_path).populate_registry(reg)
+
+ for p, f in reg:
+ path = mozpath.join(base, p)
+ try:
+ registry.add(path, f)
+ except Exception:
+ pass
+
+ return registry, used_paths
+
+
+def get_child(base, path):
+ """Returns the nearest parent of `path` which is an immediate child of
+ `base`"""
+
+ dirname = mozpath.dirname(path)
+ while dirname != base:
+ path = dirname
+ dirname = mozpath.dirname(path)
+ return path
+
+
+def main(output, *args):
+ parser = argparse.ArgumentParser(
+ description="Produces a JSON manifest of built-in add-ons"
+ )
+ parser.add_argument(
+ "--features",
+ type=str,
+ dest="featuresdir",
+ action="store",
+ help=("The distribution sub-directory " "containing feature add-ons"),
+ )
+ args = parser.parse_args(args)
+
+ registry, inputs = get_registry(manifest_paths)
+
+ dicts = {}
+ for path in registry.match("dictionaries/*.dic"):
+ base, ext = os.path.splitext(mozpath.basename(path))
+ dicts[base] = path
+
+ listing = {
+ "dictionaries": dicts,
+ }
+
+ if args.featuresdir:
+ features = set()
+ for p in registry.match("%s/*" % args.featuresdir):
+ features.add(mozpath.basename(get_child(args.featuresdir, p)))
+
+ listing["system"] = sorted(features)
+
+ json.dump(listing, output, sort_keys=True)
+
+ return inputs
+
+
+if __name__ == "__main__":
+ main(sys.stdout, *sys.argv[1:])
diff --git a/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs
new file mode 100644
index 0000000000..e854e04b3c
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs
@@ -0,0 +1,1257 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+// The current platform as specified in the AMO API:
+// http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#addon-detail-platform
+ChromeUtils.defineLazyGetter(lazy, "PLATFORM", () => {
+ let platform = Services.appinfo.OS;
+ switch (platform) {
+ case "Darwin":
+ return "mac";
+
+ case "Linux":
+ return "linux";
+
+ case "Android":
+ return "android";
+
+ case "WINNT":
+ return "windows";
+ }
+ return platform;
+});
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "getAddonsCacheEnabled",
+ PREF_GETADDONS_CACHE_ENABLED
+);
+
+const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
+const PREF_GETADDONS_CACHE_ID_ENABLED =
+ "extensions.%ID%.getAddons.cache.enabled";
+const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
+const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
+const PREF_GETADDONS_BROWSESEARCHRESULTS =
+ "extensions.getAddons.search.browseURL";
+const PREF_GETADDONS_DB_SCHEMA = "extensions.getAddons.databaseSchema";
+const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
+const PREF_GET_BROWSER_MAPPINGS = "extensions.getAddons.browserMappings.url";
+
+const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
+const PREF_METADATA_UPDATETHRESHOLD_SEC =
+ "extensions.getAddons.cache.updateThreshold";
+const DEFAULT_METADATA_UPDATETHRESHOLD_SEC = 172800; // two days
+
+const DEFAULT_CACHE_TYPES = "extension,theme,locale,dictionary";
+
+const FILE_DATABASE = "addons.json";
+const DB_SCHEMA = 6;
+const DB_MIN_JSON_SCHEMA = 5;
+const DB_BATCH_TIMEOUT_MS = 50;
+
+const BLANK_DB = function () {
+ return {
+ addons: new Map(),
+ schema: DB_SCHEMA,
+ };
+};
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.repository";
+
+// Create a new logger for use by the Addons Repository
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+function convertHTMLToPlainText(html) {
+ if (!html) {
+ return html;
+ }
+ var converter = Cc[
+ "@mozilla.org/widget/htmlformatconverter;1"
+ ].createInstance(Ci.nsIFormatConverter);
+
+ var input = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ input.data = html.replace(/\n/g, "<br>");
+
+ var output = {};
+ converter.convert("text/html", input, "text/plain", output);
+
+ if (output.value instanceof Ci.nsISupportsString) {
+ return output.value.data.replace(/\r\n/g, "\n");
+ }
+ return html;
+}
+
+async function getAddonsToCache(aIds) {
+ let types = Services.prefs.getStringPref(
+ PREF_GETADDONS_CACHE_TYPES,
+ DEFAULT_CACHE_TYPES
+ );
+
+ types = types.split(",");
+
+ let addons = await lazy.AddonManager.getAddonsByIDs(aIds);
+ let enabledIds = [];
+
+ for (let [i, addon] of addons.entries()) {
+ var preference = PREF_GETADDONS_CACHE_ID_ENABLED.replace("%ID%", aIds[i]);
+ // If the preference doesn't exist caching is enabled by default
+ if (!Services.prefs.getBoolPref(preference, true)) {
+ continue;
+ }
+
+ // The add-ons manager may not know about this ID yet if it is a pending
+ // install. In that case we'll just cache it regardless
+
+ // Don't cache add-ons of the wrong types
+ if (addon && !types.includes(addon.type)) {
+ continue;
+ }
+
+ // Don't cache system add-ons
+ if (addon && addon.isSystem) {
+ continue;
+ }
+
+ enabledIds.push(aIds[i]);
+ }
+
+ return enabledIds;
+}
+
+function AddonSearchResult(aId) {
+ this.id = aId;
+ this.icons = {};
+ this._unsupportedProperties = {};
+}
+
+AddonSearchResult.prototype = {
+ /**
+ * The ID of the add-on
+ */
+ id: null,
+
+ /**
+ * The add-on type (e.g. "extension" or "theme")
+ */
+ type: null,
+
+ /**
+ * The name of the add-on
+ */
+ name: null,
+
+ /**
+ * The version of the add-on
+ */
+ version: null,
+
+ /**
+ * The creator of the add-on
+ */
+ creator: null,
+
+ /**
+ * The developers of the add-on
+ */
+ developers: null,
+
+ /**
+ * A short description of the add-on
+ */
+ description: null,
+
+ /**
+ * The full description of the add-on
+ */
+ fullDescription: null,
+
+ /**
+ * The end-user licensing agreement (EULA) of the add-on
+ */
+ eula: null,
+
+ /**
+ * The url of the add-on's icon
+ */
+ get iconURL() {
+ return this.icons && this.icons[32];
+ },
+
+ /**
+ * The URLs of the add-on's icons, as an object with icon size as key
+ */
+ icons: null,
+
+ /**
+ * An array of screenshot urls for the add-on
+ */
+ screenshots: null,
+
+ /**
+ * The homepage for the add-on
+ */
+ homepageURL: null,
+
+ /**
+ * The support URL for the add-on
+ */
+ supportURL: null,
+
+ /**
+ * The contribution url of the add-on
+ */
+ contributionURL: null,
+
+ /**
+ * The rating of the add-on, 0-5
+ */
+ averageRating: null,
+
+ /**
+ * The number of reviews for this add-on
+ */
+ reviewCount: null,
+
+ /**
+ * The URL to the list of reviews for this add-on
+ */
+ reviewURL: null,
+
+ /**
+ * The number of times the add-on was downloaded the current week
+ */
+ weeklyDownloads: null,
+
+ /**
+ * The URL to the AMO detail page of this (listed) add-on
+ */
+ amoListingURL: null,
+
+ /**
+ * AddonInstall object generated from the add-on XPI url
+ */
+ install: null,
+
+ /**
+ * nsIURI storing where this add-on was installed from
+ */
+ sourceURI: null,
+
+ /**
+ * The Date that the add-on was most recently updated
+ */
+ updateDate: null,
+
+ toJSON() {
+ let json = {};
+
+ for (let property of Object.keys(this)) {
+ let value = this[property];
+ if (property.startsWith("_") || typeof value === "function") {
+ continue;
+ }
+
+ try {
+ switch (property) {
+ case "sourceURI":
+ json.sourceURI = value ? value.spec : "";
+ break;
+
+ case "updateDate":
+ json.updateDate = value ? value.getTime() : "";
+ break;
+
+ default:
+ json[property] = value;
+ }
+ } catch (ex) {
+ logger.warn("Error writing property value for " + property);
+ }
+ }
+
+ for (let property of Object.keys(this._unsupportedProperties)) {
+ let value = this._unsupportedProperties[property];
+ if (!property.startsWith("_")) {
+ json[property] = value;
+ }
+ }
+
+ return json;
+ },
+};
+
+/**
+ * The add-on repository is a source of add-ons that can be installed. It can
+ * be searched in three ways. The first takes a list of IDs and returns a
+ * list of the corresponding add-ons. The second returns a list of add-ons that
+ * come highly recommended. This list should change frequently. The third is to
+ * search for specific search terms entered by the user. Searches are
+ * asynchronous and results should be passed to the provided callback object
+ * when complete. The results passed to the callback should only include add-ons
+ * that are compatible with the current application and are not already
+ * installed.
+ */
+export var AddonRepository = {
+ /**
+ * The homepage for visiting this repository. If the corresponding preference
+ * is not defined, defaults to about:blank.
+ */
+ get homepageURL() {
+ let url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {});
+ return url != null ? url : "about:blank";
+ },
+
+ get appIsShuttingDown() {
+ return Services.startup.shuttingDown;
+ },
+
+ /**
+ * Retrieves the url that can be visited to see search results for the given
+ * terms. If the corresponding preference is not defined, defaults to
+ * about:blank.
+ *
+ * @param aSearchTerms
+ * Search terms used to search the repository
+ */
+ getSearchURL(aSearchTerms) {
+ let url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, {
+ TERMS: aSearchTerms,
+ });
+ return url != null ? url : "about:blank";
+ },
+
+ /**
+ * Whether caching is currently enabled
+ */
+ get cacheEnabled() {
+ return lazy.getAddonsCacheEnabled;
+ },
+
+ /**
+ * Shut down AddonRepository
+ * return: promise{integer} resolves with the result of flushing
+ * the AddonRepository database
+ */
+ shutdown() {
+ return AddonDatabase.shutdown(false);
+ },
+
+ metadataAge() {
+ let now = Math.round(Date.now() / 1000);
+ let lastUpdate = Services.prefs.getIntPref(PREF_METADATA_LASTUPDATE, 0);
+ return Math.max(0, now - lastUpdate);
+ },
+
+ isMetadataStale() {
+ let threshold = Services.prefs.getIntPref(
+ PREF_METADATA_UPDATETHRESHOLD_SEC,
+ DEFAULT_METADATA_UPDATETHRESHOLD_SEC
+ );
+ return this.metadataAge() > threshold;
+ },
+
+ /**
+ * Asynchronously get a cached add-on by id. The add-on (or null if the
+ * add-on is not found) is passed to the specified callback. If caching is
+ * disabled, null is passed to the specified callback.
+ *
+ * The callback variant exists only for existing code in XPIProvider.sys.mjs
+ * and XPIDatabase.sys.mjs that requires a synchronous callback, yuck.
+ *
+ * @param aId
+ * The id of the add-on to get
+ */
+ async getCachedAddonByID(aId, aCallback) {
+ if (!aId || !this.cacheEnabled) {
+ if (aCallback) {
+ aCallback(null);
+ }
+ return null;
+ }
+
+ if (aCallback && AddonDatabase._loaded) {
+ let addon = AddonDatabase.getAddon(aId);
+ aCallback(addon);
+ return addon;
+ }
+
+ await AddonDatabase.openConnection();
+
+ let addon = AddonDatabase.getAddon(aId);
+ if (aCallback) {
+ aCallback(addon);
+ }
+ return addon;
+ },
+
+ /*
+ * Clear and delete the AddonRepository database
+ * @return Promise{null} resolves when the database is deleted
+ */
+ _clearCache() {
+ return AddonDatabase.delete().then(() =>
+ lazy.AddonManagerPrivate.updateAddonRepositoryData()
+ );
+ },
+
+ /*
+ * Create a ServiceRequest instance.
+ * @return ServiceRequest returns a ServiceRequest instance.
+ */
+ _createServiceRequest() {
+ return new lazy.ServiceRequest({ mozAnon: true });
+ },
+
+ /**
+ * Fetch data from an API where the results may span multiple "pages".
+ * This function will take care of issuing multiple requests until all
+ * the results have been fetched, and will coalesce them all into a
+ * single return value. The handling here is specific to the way AMO
+ * implements paging (ie a JSON result with a "next" property).
+ *
+ * @param {string} pref
+ * The pref name that contains the API URL to call.
+ * @param {object} params
+ * A key-value object that contains the parameters to replace
+ * in the API URL.
+ * @param {function} handler
+ * This function will be called once per page of results,
+ * it should return an array of objects (the type depends
+ * on the particular API being called of course).
+ *
+ * @returns Promise{array} An array of all the individual results from
+ * the API call(s).
+ */
+ _fetchPaged(pref, params, handler) {
+ const startURL = this._formatURLPref(pref, params);
+
+ let results = [];
+ const fetchNextPage = url => {
+ return new Promise((resolve, reject) => {
+ if (this.appIsShuttingDown) {
+ logger.debug(
+ "Rejecting AddonRepository._fetchPaged call, shutdown already in progress"
+ );
+ reject(
+ new Error(
+ `Reject ServiceRequest for "${url}", shutdown already in progress`
+ )
+ );
+ return;
+ }
+ let request = this._createServiceRequest();
+ request.mozBackgroundRequest = true;
+ request.open("GET", url, true);
+ request.responseType = "json";
+
+ request.addEventListener("error", aEvent => {
+ reject(new Error(`GET ${url} failed`));
+ });
+ request.addEventListener("timeout", aEvent => {
+ reject(new Error(`GET ${url} timed out`));
+ });
+ request.addEventListener("load", aEvent => {
+ let response = request.response;
+ if (!response || (request.status != 200 && request.status != 0)) {
+ reject(new Error(`GET ${url} failed (status ${request.status})`));
+ return;
+ }
+
+ try {
+ results.push(...handler(response.results));
+ } catch (err) {
+ reject(err);
+ }
+
+ if (response.next) {
+ resolve(fetchNextPage(response.next));
+ }
+
+ resolve(results);
+ });
+
+ request.send(null);
+ });
+ };
+
+ return fetchNextPage(startURL);
+ },
+
+ /**
+ * Fetch metadata for a given set of addons from AMO.
+ *
+ * @param aIDs
+ * The array of ids to retrieve metadata for.
+ * @returns {array<AddonSearchResult>}
+ */
+ async getAddonsByIDs(aIDs) {
+ const idCheck = aIDs.map(id => {
+ if (id.startsWith("rta:")) {
+ return atob(id.split(":")[1]);
+ }
+ return id;
+ });
+
+ const addons = await this._fetchPaged(
+ PREF_GETADDONS_BYIDS,
+ { IDS: aIDs.join(",") },
+ results =>
+ results
+ .map(entry => this._parseAddon(entry))
+ // Only return the add-ons corresponding the IDs passed to this method.
+ .filter(addon => idCheck.includes(addon.id))
+ );
+
+ return addons;
+ },
+
+ /**
+ * Fetch the Firefox add-ons mapped to the list of extension IDs for the
+ * browser ID passed to this method.
+ *
+ * See: https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#browser-mappings
+ *
+ * @param browserID
+ * The browser ID used to retrieve the mapping of IDs.
+ * @param extensionIDs
+ * The array of browser (non-Firefox) extension IDs to retrieve
+ * metadata for.
+ * @returns {object} result
+ * The result of the mapping.
+ * @returns {array<AddonSearchResult>} result.addons
+ * The AddonSearchResults for the addons that were successfully mapped.
+ * @returns {array<string>} result.matchedIDs
+ * The IDs of the extensions that were successfully matched to
+ * equivalents that can be installed in this browser. These are
+ * the IDs before matching to equivalents.
+ * @returns {array<string>} result.unmatchedIDs
+ * The IDs of the extensions that were not matched to equivalents.
+ */
+ async getMappedAddons(browserID, extensionIDs) {
+ let matchedExtensionIDs = new Set();
+ let unmatchedExtensionIDs = new Set(extensionIDs);
+
+ const addonIds = await this._fetchPaged(
+ PREF_GET_BROWSER_MAPPINGS,
+ { BROWSER: browserID },
+ results =>
+ results
+ // Filter out all the entries with an extension ID not in the list
+ // passed to the method.
+ .filter(entry => {
+ if (unmatchedExtensionIDs.has(entry.extension_id)) {
+ unmatchedExtensionIDs.delete(entry.extension_id);
+ matchedExtensionIDs.add(entry.extension_id);
+ return true;
+ }
+ return false;
+ })
+ // Return the add-on ID (stored as `guid` on AMO).
+ .map(entry => entry.addon_guid)
+ );
+
+ if (!addonIds.length) {
+ return {
+ addons: [],
+ matchedIDs: [],
+ unmatchedIDs: [...unmatchedExtensionIDs],
+ };
+ }
+
+ return {
+ addons: await this.getAddonsByIDs(addonIds),
+ matchedIDs: [...matchedExtensionIDs],
+ unmatchedIDs: [...unmatchedExtensionIDs],
+ };
+ },
+
+ /**
+ * Asynchronously add add-ons to the cache corresponding to the specified
+ * ids. If caching is disabled, the cache is unchanged.
+ *
+ * @param aIds
+ * The array of add-on ids to add to the cache
+ * @returns {array<AddonSearchResult>} Add-ons to add to the cache.
+ */
+ async cacheAddons(aIds) {
+ logger.debug(
+ "cacheAddons: enabled " + this.cacheEnabled + " IDs " + aIds.toSource()
+ );
+ if (!this.cacheEnabled) {
+ return [];
+ }
+
+ let ids = await getAddonsToCache(aIds);
+
+ // If there are no add-ons to cache, act as if caching is disabled
+ if (!ids.length) {
+ return [];
+ }
+
+ let addons = [];
+ try {
+ addons = await this.getAddonsByIDs(ids);
+ } catch (err) {
+ logger.error(`Error in addon metadata check: ${err.message}`);
+ }
+ if (addons.length) {
+ await AddonDatabase.update(addons);
+ }
+ return addons;
+ },
+
+ /**
+ * Get all installed addons from the AddonManager singleton.
+ *
+ * @return Promise{array<AddonWrapper>} Resolves to an array of AddonWrapper instances.
+ */
+ _getAllInstalledAddons() {
+ return lazy.AddonManager.getAllAddons();
+ },
+
+ /**
+ * Performs the periodic background update check.
+ *
+ * In Firefox Desktop builds, the background update check is triggered on a
+ * daily basis as part of the AOM background update check and registered
+ * from: `toolkit/mozapps/extensions/extensions.manifest`
+ *
+ * In GeckoView builds, add-ons are checked for updates individually. The
+ * `AddonRepository.backgroundUpdateCheck()` method is called by the
+ * `updateWebExtension()` method defined in `GeckoViewWebExtensions.sys.mjs`
+ * but only when `AddonRepository.isMetadataStale()` returns true.
+ *
+ * @return Promise{null} Resolves when the metadata update is complete.
+ */
+ async backgroundUpdateCheck() {
+ let shutter = (async () => {
+ if (this.appIsShuttingDown) {
+ logger.debug(
+ "Returning earlier from backgroundUpdateCheck, shutdown already in progress"
+ );
+ return;
+ }
+
+ let allAddons = await this._getAllInstalledAddons();
+
+ // Completely remove cache if caching is not enabled
+ if (!this.cacheEnabled) {
+ logger.debug("Clearing cache because it is disabled");
+ await this._clearCache();
+ return;
+ }
+
+ let ids = allAddons.map(a => a.id);
+ logger.debug("Repopulate add-on cache with " + ids.toSource());
+
+ let addonsToCache = await getAddonsToCache(ids);
+
+ // Completely remove cache if there are no add-ons to cache
+ if (!addonsToCache.length) {
+ logger.debug("Clearing cache because 0 add-ons were requested");
+ await this._clearCache();
+ return;
+ }
+
+ let addons;
+ try {
+ addons = await this.getAddonsByIDs(addonsToCache);
+ } catch (err) {
+ // This is likely to happen if the server is unreachable, e.g. when
+ // there is no network connectivity.
+ logger.error(`Error in addon metadata lookup: ${err.message}`);
+ // Return now to avoid calling repopulate with an empty array;
+ // doing so would clear the cache.
+ return;
+ }
+
+ AddonDatabase.repopulate(addons);
+
+ // Always call AddonManager updateAddonRepositoryData after we refill the cache
+ await lazy.AddonManagerPrivate.updateAddonRepositoryData();
+ })();
+ lazy.AddonManager.beforeShutdown.addBlocker(
+ "AddonRepository Background Updater",
+ shutter
+ );
+ await shutter;
+ lazy.AddonManager.beforeShutdown.removeBlocker(shutter);
+ },
+
+ /*
+ * Creates an AddonSearchResult by parsing an entry from the AMO API.
+ *
+ * @param aEntry
+ * An entry from the AMO search API to parse.
+ * @return Result object containing the parsed AddonSearchResult
+ */
+ _parseAddon(aEntry) {
+ let addon = new AddonSearchResult(aEntry.guid);
+
+ addon.name = aEntry.name;
+ if (typeof aEntry.current_version == "object") {
+ addon.version = String(aEntry.current_version.version);
+ if (Array.isArray(aEntry.current_version.files)) {
+ for (let file of aEntry.current_version.files) {
+ if (file.platform == "all" || file.platform == lazy.PLATFORM) {
+ if (file.url) {
+ addon.sourceURI = lazy.NetUtil.newURI(file.url);
+ }
+ break;
+ }
+ }
+ }
+ }
+ addon.homepageURL = aEntry.homepage;
+ addon.supportURL = aEntry.support_url;
+ addon.amoListingURL = aEntry.url;
+
+ addon.description = convertHTMLToPlainText(aEntry.summary);
+ addon.fullDescription = convertHTMLToPlainText(aEntry.description);
+
+ addon.weeklyDownloads = aEntry.weekly_downloads;
+
+ switch (aEntry.type) {
+ case "persona":
+ case "statictheme":
+ addon.type = "theme";
+ break;
+
+ case "language":
+ addon.type = "locale";
+ break;
+
+ default:
+ addon.type = aEntry.type;
+ break;
+ }
+
+ if (Array.isArray(aEntry.authors)) {
+ let authors = aEntry.authors.map(
+ author =>
+ new lazy.AddonManagerPrivate.AddonAuthor(author.name, author.url)
+ );
+ if (authors.length) {
+ addon.creator = authors[0];
+ addon.developers = authors.slice(1);
+ }
+ }
+
+ if (typeof aEntry.previews == "object") {
+ addon.screenshots = aEntry.previews.map(shot => {
+ let safeSize = orig =>
+ Array.isArray(orig) && orig.length >= 2 ? orig : [null, null];
+ let imageSize = safeSize(shot.image_size);
+ let thumbSize = safeSize(shot.thumbnail_size);
+ return new lazy.AddonManagerPrivate.AddonScreenshot(
+ shot.image_url,
+ imageSize[0],
+ imageSize[1],
+ shot.thumbnail_url,
+ thumbSize[0],
+ thumbSize[1],
+ shot.caption
+ );
+ });
+ }
+
+ addon.contributionURL = aEntry.contributions_url;
+
+ if (typeof aEntry.ratings == "object") {
+ addon.averageRating = Math.min(5, aEntry.ratings.average);
+ addon.reviewCount = aEntry.ratings.text_count;
+ }
+
+ addon.reviewURL = aEntry.ratings_url;
+ if (aEntry.last_updated) {
+ addon.updateDate = new Date(aEntry.last_updated);
+ }
+
+ addon.icons = aEntry.icons || {};
+
+ return addon;
+ },
+
+ // Create url from preference, returning null if preference does not exist
+ _formatURLPref(aPreference, aSubstitutions = {}) {
+ let url = Services.prefs.getCharPref(aPreference, "");
+ if (!url) {
+ logger.warn("_formatURLPref: Couldn't get pref: " + aPreference);
+ return null;
+ }
+
+ url = url.replace(/%([A-Z_]+)%/g, function (aMatch, aKey) {
+ return aKey in aSubstitutions
+ ? encodeURIComponent(aSubstitutions[aKey])
+ : aMatch;
+ });
+
+ return Services.urlFormatter.formatURL(url);
+ },
+
+ flush() {
+ return AddonDatabase.flush();
+ },
+
+ async getAvailableLangpacks() {
+ // This should be the API endpoint documented at:
+ // http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#language-tools
+ let url = this._formatURLPref(PREF_GET_LANGPACKS);
+
+ let response = await fetch(url, { credentials: "omit" });
+ if (!response.ok) {
+ throw new Error("fetching available language packs failed");
+ }
+
+ let data = await response.json();
+
+ let result = [];
+ for (let entry of data.results) {
+ if (
+ !entry.current_compatible_version ||
+ !entry.current_compatible_version.files
+ ) {
+ continue;
+ }
+
+ for (let file of entry.current_compatible_version.files) {
+ if (
+ file.platform == "all" ||
+ file.platform == Services.appinfo.OS.toLowerCase()
+ ) {
+ result.push({
+ target_locale: entry.target_locale,
+ url: file.url,
+ hash: file.hash,
+ });
+ }
+ }
+ }
+
+ return result;
+ },
+};
+
+var AddonDatabase = {
+ connectionPromise: null,
+ _loaded: false,
+ _saveTask: null,
+ _blockerAdded: false,
+
+ // the in-memory database
+ DB: BLANK_DB(),
+
+ /**
+ * A getter to retrieve the path to the DB
+ */
+ get jsonFile() {
+ return PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ FILE_DATABASE
+ );
+ },
+
+ /**
+ * Asynchronously opens a new connection to the database file.
+ *
+ * @return {Promise} a promise that resolves to the database.
+ */
+ openConnection() {
+ if (!this.connectionPromise) {
+ this.connectionPromise = (async () => {
+ let inputDB, schema;
+
+ try {
+ let data = await IOUtils.readUTF8(this.jsonFile);
+ inputDB = JSON.parse(data);
+
+ if (
+ !inputDB.hasOwnProperty("addons") ||
+ !Array.isArray(inputDB.addons)
+ ) {
+ throw new Error("No addons array.");
+ }
+
+ if (!inputDB.hasOwnProperty("schema")) {
+ throw new Error("No schema specified.");
+ }
+
+ schema = parseInt(inputDB.schema, 10);
+
+ if (!Number.isInteger(schema) || schema < DB_MIN_JSON_SCHEMA) {
+ throw new Error("Invalid schema value.");
+ }
+ } catch (e) {
+ if (e.name == "NotFoundError") {
+ logger.debug("No " + FILE_DATABASE + " found.");
+ } else {
+ logger.error(
+ `Malformed ${FILE_DATABASE}: ${e} - resetting to empty`
+ );
+ }
+
+ // Create a blank addons.json file
+ this.save();
+
+ Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
+ this._loaded = true;
+ return this.DB;
+ }
+
+ Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA);
+
+ // Convert the addon objects as necessary
+ // and store them in our in-memory copy of the database.
+ for (let addon of inputDB.addons) {
+ let id = addon.id;
+
+ let entry = this._parseAddon(addon);
+ this.DB.addons.set(id, entry);
+ }
+
+ this._loaded = true;
+ return this.DB;
+ })();
+ }
+
+ return this.connectionPromise;
+ },
+
+ /**
+ * Asynchronously shuts down the database connection and releases all
+ * cached objects
+ *
+ * @param aCallback
+ * An optional callback to call once complete
+ * @param aSkipFlush
+ * An optional boolean to skip flushing data to disk. Useful
+ * when the database is going to be deleted afterwards.
+ */
+ shutdown(aSkipFlush) {
+ if (!this.connectionPromise) {
+ return Promise.resolve();
+ }
+
+ this.connectionPromise = null;
+ this._loaded = false;
+
+ if (aSkipFlush) {
+ return Promise.resolve();
+ }
+
+ return this.flush();
+ },
+
+ /**
+ * Asynchronously deletes the database, shutting down the connection
+ * first if initialized
+ *
+ * @param aCallback
+ * An optional callback to call once complete
+ * @return Promise{null} resolves when the database has been deleted
+ */
+ delete(aCallback) {
+ this.DB = BLANK_DB();
+
+ if (this._saveTask) {
+ this._saveTask.disarm();
+ this._saveTask = null;
+ }
+
+ // shutdown(true) never rejects
+ this._deleting = this.shutdown(true)
+ .then(() => IOUtils.remove(this.jsonFile))
+ .catch(error =>
+ logger.error(
+ "Unable to delete Addon Repository file " + this.jsonFile,
+ error
+ )
+ )
+ .then(() => (this._deleting = null))
+ .then(aCallback);
+
+ return this._deleting;
+ },
+
+ async _saveNow() {
+ let json = {
+ schema: this.DB.schema,
+ addons: Array.from(this.DB.addons.values()),
+ };
+
+ await IOUtils.writeUTF8(this.jsonFile, JSON.stringify(json), {
+ tmpPath: `${this.jsonFile}.tmp`,
+ });
+ },
+
+ save() {
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(
+ () => this._saveNow(),
+ DB_BATCH_TIMEOUT_MS
+ );
+
+ if (!this._blockerAdded) {
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "Flush AddonRepository",
+ () => this.flush()
+ );
+ this._blockerAdded = true;
+ }
+ }
+ this._saveTask.arm();
+ },
+
+ /**
+ * Flush any pending I/O on the addons.json file
+ * @return: Promise{null}
+ * Resolves when the pending I/O (writing out or deleting
+ * addons.json) completes
+ */
+ flush() {
+ if (this._deleting) {
+ return this._deleting;
+ }
+
+ if (this._saveTask) {
+ let promise = this._saveTask.finalize();
+ this._saveTask = null;
+ return promise;
+ }
+
+ return Promise.resolve();
+ },
+
+ /**
+ * Get an individual addon entry from the in-memory cache.
+ * Note: calling this function before the database is read will
+ * return undefined.
+ *
+ * @param {string} aId The id of the addon to retrieve.
+ */
+ getAddon(aId) {
+ return this.DB.addons.get(aId);
+ },
+
+ /**
+ * Asynchronously repopulates the database so it only contains the
+ * specified add-ons
+ *
+ * @param {array<AddonSearchResult>} aAddons
+ * Add-ons to repopulate the database with.
+ */
+ repopulate(aAddons) {
+ this.DB = BLANK_DB();
+ this._update(aAddons);
+
+ let now = Math.round(Date.now() / 1000);
+ logger.debug(
+ "Cache repopulated, setting " + PREF_METADATA_LASTUPDATE + " to " + now
+ );
+ Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, now);
+ },
+
+ /**
+ * Asynchronously insert new addons into the database.
+ *
+ * @param {array<AddonSearchResult>} aAddons
+ * Add-ons to insert/update in the database
+ */
+ async update(aAddons) {
+ await this.openConnection();
+
+ this._update(aAddons);
+ },
+
+ /**
+ * Merge the given addons into the database.
+ *
+ * @param {array<AddonSearchResult>} aAddons
+ * Add-ons to insert/update in the database
+ */
+ _update(aAddons) {
+ for (let addon of aAddons) {
+ this.DB.addons.set(addon.id, this._parseAddon(addon));
+ }
+
+ this.save();
+ },
+
+ /*
+ * Creates an AddonSearchResult by parsing an object structure
+ * retrieved from the DB JSON representation.
+ *
+ * @param aObj
+ * The object to parse
+ * @return Returns an AddonSearchResult object.
+ */
+ _parseAddon(aObj) {
+ if (aObj instanceof AddonSearchResult) {
+ return aObj;
+ }
+
+ let id = aObj.id;
+ if (!aObj.id) {
+ return null;
+ }
+
+ let addon = new AddonSearchResult(id);
+
+ for (let expectedProperty of Object.keys(AddonSearchResult.prototype)) {
+ if (
+ !(expectedProperty in aObj) ||
+ typeof aObj[expectedProperty] === "function"
+ ) {
+ continue;
+ }
+
+ let value = aObj[expectedProperty];
+
+ try {
+ switch (expectedProperty) {
+ case "sourceURI":
+ addon.sourceURI = value ? lazy.NetUtil.newURI(value) : null;
+ break;
+
+ case "creator":
+ addon.creator = value ? this._makeDeveloper(value) : null;
+ break;
+
+ case "updateDate":
+ addon.updateDate = value ? new Date(value) : null;
+ break;
+
+ case "developers":
+ if (!addon.developers) {
+ addon.developers = [];
+ }
+ for (let developer of value) {
+ addon.developers.push(this._makeDeveloper(developer));
+ }
+ break;
+
+ case "screenshots":
+ if (!addon.screenshots) {
+ addon.screenshots = [];
+ }
+ for (let screenshot of value) {
+ addon.screenshots.push(this._makeScreenshot(screenshot));
+ }
+ break;
+
+ case "icons":
+ if (!addon.icons) {
+ addon.icons = {};
+ }
+ for (let size of Object.keys(aObj.icons)) {
+ addon.icons[size] = aObj.icons[size];
+ }
+ break;
+
+ case "iconURL":
+ break;
+
+ default:
+ addon[expectedProperty] = value;
+ }
+ } catch (ex) {
+ logger.warn(
+ "Error in parsing property value for " + expectedProperty + " | " + ex
+ );
+ }
+
+ // delete property from obj to indicate we've already
+ // handled it. The remaining public properties will
+ // be stored separately and just passed through to
+ // be written back to the DB.
+ delete aObj[expectedProperty];
+ }
+
+ // Copy remaining properties to a separate object
+ // to prevent accidental access on downgraded versions.
+ // The properties will be merged in the same object
+ // prior to being written back through toJSON.
+ for (let remainingProperty of Object.keys(aObj)) {
+ switch (typeof aObj[remainingProperty]) {
+ case "boolean":
+ case "number":
+ case "string":
+ case "object":
+ // these types are accepted
+ break;
+ default:
+ continue;
+ }
+
+ if (!remainingProperty.startsWith("_")) {
+ addon._unsupportedProperties[remainingProperty] =
+ aObj[remainingProperty];
+ }
+ }
+
+ return addon;
+ },
+
+ /**
+ * Make a developer object from a vanilla
+ * JS object from the JSON database
+ *
+ * @param aObj
+ * The JS object to use
+ * @return The created developer
+ */
+ _makeDeveloper(aObj) {
+ let name = aObj.name;
+ let url = aObj.url;
+ return new lazy.AddonManagerPrivate.AddonAuthor(name, url);
+ },
+
+ /**
+ * Make a screenshot object from a vanilla
+ * JS object from the JSON database
+ *
+ * @param aObj
+ * The JS object to use
+ * @return The created screenshot
+ */
+ _makeScreenshot(aObj) {
+ let url = aObj.url;
+ let width = aObj.width;
+ let height = aObj.height;
+ let thumbnailURL = aObj.thumbnailURL;
+ let thumbnailWidth = aObj.thumbnailWidth;
+ let thumbnailHeight = aObj.thumbnailHeight;
+ let caption = aObj.caption;
+ return new lazy.AddonManagerPrivate.AddonScreenshot(
+ url,
+ width,
+ height,
+ thumbnailURL,
+ thumbnailWidth,
+ thumbnailHeight,
+ caption
+ );
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs
new file mode 100644
index 0000000000..09bb0adc97
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs";
+
+const PREF_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
+const PREF_ALLOW_EXPERIMENTS = "extensions.experiments.enabled";
+const PREF_EM_SIDELOAD_SCOPES = "extensions.sideloadScopes";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+const PREF_UPDATE_REQUIREBUILTINCERTS = "extensions.update.requireBuiltInCerts";
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+
+export var AddonSettings = {};
+
+// Make a non-changable property that can't be manipulated from other
+// code in the app.
+function makeConstant(name, value) {
+ Object.defineProperty(AddonSettings, name, {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value,
+ });
+}
+
+if (AppConstants.MOZ_REQUIRE_SIGNING && !Cu.isInAutomation) {
+ makeConstant("REQUIRE_SIGNING", true);
+ makeConstant("LANGPACKS_REQUIRE_SIGNING", true);
+} else {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "REQUIRE_SIGNING",
+ PREF_SIGNATURES_REQUIRED,
+ false
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "LANGPACKS_REQUIRE_SIGNING",
+ PREF_LANGPACK_SIGNATURES,
+ false
+ );
+}
+
+/**
+ * Require the use of certs shipped with Firefox for
+ * addon install and update, if the distribution does
+ * not require addon signing and is not ESR.
+ */
+XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "INSTALL_REQUIREBUILTINCERTS",
+ PREF_INSTALL_REQUIREBUILTINCERTS,
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr")
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "UPDATE_REQUIREBUILTINCERTS",
+ PREF_UPDATE_REQUIREBUILTINCERTS,
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr")
+);
+
+// Whether or not we're running in GeckoView embedded in an Android app
+if (Cu.isInAutomation) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "IS_EMBEDDED",
+ PREF_IS_EMBEDDED,
+ false
+ );
+} else {
+ makeConstant("IS_EMBEDDED", AppConstants.platform === "android");
+}
+
+/**
+ * AddonSettings.EXPERIMENTS_ENABLED
+ *
+ * Experimental APIs are always available to privileged signed addons.
+ * This constant makes an optional preference available to enable experimental
+ * APIs for developement purposes.
+ *
+ * Two features are toggled with this preference:
+ *
+ * 1. The ability to load an extension that contains an experimental
+ * API but is not privileged.
+ * 2. The ability to load an unsigned extension that gains privilege
+ * if it is temporarily loaded (e.g. via about:debugging).
+ *
+ * MOZ_REQUIRE_SIGNING is set to zero in unbranded builds, we also
+ * ensure nightly, dev-ed and our test infrastructure have access to
+ * the preference.
+ *
+ * Official releases ignore this preference.
+ */
+if (
+ !AppConstants.MOZ_REQUIRE_SIGNING ||
+ AppConstants.NIGHTLY_BUILD ||
+ AppConstants.MOZ_DEV_EDITION ||
+ Cu.isInAutomation
+) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "EXPERIMENTS_ENABLED",
+ PREF_ALLOW_EXPERIMENTS,
+ true
+ );
+} else {
+ makeConstant("EXPERIMENTS_ENABLED", false);
+}
+
+if (AppConstants.MOZ_DEV_EDITION) {
+ makeConstant("DEFAULT_THEME_ID", "firefox-compact-dark@mozilla.org");
+} else {
+ makeConstant("DEFAULT_THEME_ID", "default-theme@mozilla.org");
+}
+
+// SCOPES_SIDELOAD is a bitflag for what scopes we will load new extensions from when we scan the directories.
+// If a build allows sideloading, or we're in automation, we'll also allow use of the preference.
+if (AppConstants.MOZ_ALLOW_ADDON_SIDELOAD || Cu.isInAutomation) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ AddonSettings,
+ "SCOPES_SIDELOAD",
+ PREF_EM_SIDELOAD_SCOPES,
+ AppConstants.MOZ_ALLOW_ADDON_SIDELOAD
+ ? AddonManager.SCOPE_ALL
+ : AddonManager.SCOPE_PROFILE
+ );
+} else {
+ makeConstant("SCOPES_SIDELOAD", AddonManager.SCOPE_PROFILE);
+}
diff --git a/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs
new file mode 100644
index 0000000000..7b30daa0e2
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs
@@ -0,0 +1,1876 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint "mozilla/no-aArgs": 1 */
+/* eslint "no-unused-vars": [2, {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */
+/* eslint "semi": [2, "always"] */
+/* eslint "valid-jsdoc": [2, {requireReturn: false}] */
+
+const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1";
+
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionAddonObserver: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+ FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+
+ XPCShellContentUtils:
+ "resource://testing-common/XPCShellContentUtils.sys.mjs",
+
+ getAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+ updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+});
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+const nsFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+const ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+const ZipWriter = Components.Constructor(
+ "@mozilla.org/zipwriter;1",
+ "nsIZipWriter",
+ "open"
+);
+
+function isRegExp(val) {
+ return val && typeof val === "object" && typeof val.test === "function";
+}
+
+class MockBarrier {
+ constructor(name) {
+ this.name = name;
+ this.blockers = [];
+ }
+
+ addBlocker(name, blocker, options) {
+ this.blockers.push({ name, blocker, options });
+ }
+
+ async trigger() {
+ await Promise.all(
+ this.blockers.map(async ({ blocker, name }) => {
+ try {
+ if (typeof blocker == "function") {
+ await blocker();
+ } else {
+ await blocker;
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ dump(
+ `Shutdown blocker '${name}' for ${this.name} threw error: ${e} :: ${e.stack}\n`
+ );
+ }
+ })
+ );
+
+ this.blockers = [];
+ }
+}
+
+// Mock out AddonManager's reference to the AsyncShutdown module so we can shut
+// down AddonManager from the test
+export var MockAsyncShutdown = {
+ profileBeforeChange: new MockBarrier("profileBeforeChange"),
+ profileChangeTeardown: new MockBarrier("profileChangeTeardown"),
+ quitApplicationGranted: new MockBarrier("quitApplicationGranted"),
+ // We can use the real Barrier
+ Barrier: AsyncShutdown.Barrier,
+};
+
+AddonManagerPrivate.overrideAsyncShutdown(MockAsyncShutdown);
+
+class AddonsList {
+ constructor(file) {
+ this.extensions = [];
+ this.themes = [];
+ this.xpis = [];
+
+ if (!file.exists()) {
+ return;
+ }
+
+ let data = lazy.aomStartup.readStartupData();
+
+ for (let loc of Object.values(data)) {
+ let dir = loc.path && new nsFile(loc.path);
+
+ for (let addon of Object.values(loc.addons)) {
+ let file;
+ if (dir) {
+ file = dir.clone();
+ try {
+ file.appendRelativePath(addon.path);
+ } catch (e) {
+ file = new nsFile(addon.path);
+ }
+ } else if (addon.path) {
+ file = new nsFile(addon.path);
+ }
+
+ if (!file) {
+ continue;
+ }
+
+ this.xpis.push(file);
+
+ if (addon.enabled) {
+ addon.type = addon.type || "extension";
+
+ if (addon.type == "theme") {
+ this.themes.push(file);
+ } else {
+ this.extensions.push(file);
+ }
+ }
+ }
+ }
+ }
+
+ hasItem(type, dir, id) {
+ var path = dir.clone();
+ path.append(id);
+
+ var xpiPath = dir.clone();
+ xpiPath.append(`${id}.xpi`);
+
+ return this[type].some(file => {
+ if (!file.exists()) {
+ throw new Error(
+ `Non-existent path found in addonStartup.json: ${file.path}`
+ );
+ }
+
+ if (file.isDirectory()) {
+ return file.equals(path);
+ }
+ if (file.isFile()) {
+ return file.equals(xpiPath);
+ }
+ return false;
+ });
+ }
+
+ hasTheme(dir, id) {
+ return this.hasItem("themes", dir, id);
+ }
+
+ hasExtension(dir, id) {
+ return this.hasItem("extensions", dir, id);
+ }
+}
+
+// The number of resetXPIExports calls.
+//
+// This is added to the URL of the modules once resetXPIExports is called,
+// so that they become different module instances for each reset, and also the
+// suffix is not used outside of tests.
+let resetXPIExportsCount = 0;
+
+// Reset all properties of XPIExports to lazy getters, with new module URIs,
+// in order to simulate the shutdown+restart situation.
+function resetXPIExports(XPIExports) {
+ resetXPIExportsCount++;
+
+ const suffix = "?" + resetXPIExportsCount;
+
+ // The list of lazy getters should be in sync with XPIExports.sys.mjs.
+ //
+ // eslint-disable-next-line mozilla/lazy-getter-object-name
+ ChromeUtils.defineESModuleGetters(XPIExports, {
+ // XPIDatabase.sys.mjs
+ AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+ BuiltInThemesHelpers:
+ "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+ XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+ XPIDatabaseReconcile:
+ "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix,
+
+ // XPIInstall.sys.mjs
+ UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
+ XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
+ verifyBundleSignedState:
+ "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix,
+
+ // XPIProvider.sys.mjs
+ XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
+ XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix,
+ });
+}
+
+export var AddonTestUtils = {
+ addonIntegrationService: null,
+ addonsList: null,
+ appInfo: null,
+ addonStartup: null,
+ collectedTelemetryEvents: [],
+ testScope: null,
+ testUnpacked: false,
+ useRealCertChecks: false,
+ usePrivilegedSignatures: true,
+ certSignatureDate: null,
+ overrideEntry: null,
+
+ maybeInit(testScope) {
+ if (this.testScope != testScope) {
+ this.init(testScope);
+ }
+ },
+
+ init(testScope, enableLogging = true) {
+ if (this.testScope === testScope) {
+ return;
+ }
+ this.testScope = testScope;
+
+ // Get the profile directory for tests to use.
+ this.profileDir = testScope.do_get_profile();
+
+ this.profileExtensions = this.profileDir.clone();
+ this.profileExtensions.append("extensions");
+
+ this.addonStartup = this.profileDir.clone();
+ this.addonStartup.append("addonStartup.json.lz4");
+
+ // Register a temporary directory for the tests.
+ this.tempDir = this.profileDir.clone();
+ this.tempDir.append("temp");
+ this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ this.registerDirectory("TmpD", this.tempDir);
+
+ // Create a replacement app directory for the tests.
+ const appDirForAddons = this.profileDir.clone();
+ appDirForAddons.append("appdir-addons");
+ appDirForAddons.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ this.registerDirectory("XREAddonAppDir", appDirForAddons);
+
+ // Enable more extensive EM logging.
+ if (enableLogging) {
+ Services.prefs.setBoolPref("extensions.logging.enabled", true);
+ }
+
+ // By default only load extensions from the profile install location
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // By default don't disable add-ons from any scope
+ Services.prefs.setIntPref("extensions.autoDisableScopes", 0);
+
+ // And scan for changes at startup
+ Services.prefs.setIntPref("extensions.startupScanScopes", 15);
+
+ // By default, don't cache add-ons in AddonRepository.jsm
+ Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false);
+
+ // Point update checks to the local machine for fast failures
+ Services.prefs.setCharPref(
+ "extensions.update.url",
+ "http://127.0.0.1/updateURL"
+ );
+ Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://127.0.0.1/updateBackgroundURL"
+ );
+ Services.prefs.setCharPref(
+ "services.settings.server",
+ "data:,#remote-settings-dummy/v1"
+ );
+
+ // By default ignore bundled add-ons
+ Services.prefs.setBoolPref("extensions.installDistroAddons", false);
+
+ // Ensure signature checks are enabled by default
+ Services.prefs.setBoolPref("xpinstall.signatures.required", true);
+
+ // Make sure that a given path does not exist
+ function pathShouldntExist(file) {
+ if (file.exists()) {
+ throw new Error(
+ `Test cleanup: path ${file.path} exists when it should not`
+ );
+ }
+ }
+
+ testScope.registerCleanupFunction(() => {
+ // Force a GC to ensure that anything holding a ref to temp file releases it.
+ // XXX This shouldn't be needed here, since cleanupTempXPIs() does a GC if
+ // something fails; see bug 1761255
+ this.info(`Force a GC`);
+ Cu.forceGC();
+
+ this.cleanupTempXPIs();
+
+ let ignoreEntries = new Set();
+ {
+ // FileTestUtils lazily creates a directory to hold the temporary files
+ // it creates. If that directory exists, ignore it.
+ let { value } = Object.getOwnPropertyDescriptor(
+ lazy.FileTestUtils,
+ "_globalTemporaryDirectory"
+ );
+ if (value) {
+ ignoreEntries.add(value.leafName);
+ }
+ }
+
+ // Check that the temporary directory is empty
+ var entries = [];
+ for (let { leafName } of this.iterDirectory(this.tempDir)) {
+ if (!ignoreEntries.has(leafName)) {
+ entries.push(leafName);
+ }
+ }
+ if (entries.length) {
+ throw new Error(
+ `Found unexpected files in temporary directory: ${entries.join(", ")}`
+ );
+ }
+
+ try {
+ appDirForAddons.remove(true);
+ } catch (ex) {
+ testScope.info(`Got exception removing addon app dir: ${ex}`);
+ }
+
+ // ensure no leftover files in the system addon upgrade location
+ let featuresDir = this.profileDir.clone();
+ featuresDir.append("features");
+ // upgrade directories will be in UUID folders under features/
+ for (let dir of this.iterDirectory(featuresDir)) {
+ dir.append("stage");
+ pathShouldntExist(dir);
+ }
+
+ // ensure no leftover files in the user addon location
+ let testDir = this.profileDir.clone();
+ testDir.append("extensions");
+ testDir.append("trash");
+ pathShouldntExist(testDir);
+
+ testDir.leafName = "staged";
+ pathShouldntExist(testDir);
+
+ return this.promiseShutdownManager();
+ });
+ },
+
+ initMochitest(testScope) {
+ if (this.testScope === testScope) {
+ return;
+ }
+ this.testScope = testScope;
+
+ this.profileDir = FileUtils.getDir("ProfD", []);
+
+ this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);
+
+ this.tempDir = FileUtils.getDir("TmpD", []);
+ this.tempDir.append("addons-mochitest");
+ this.tempDir.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+
+ testScope.registerCleanupFunction(() => {
+ // Defer testScope cleanup until the last cleanup function has run.
+ testScope.registerCleanupFunction(() => {
+ this.testScope = null;
+ });
+ this.cleanupTempXPIs();
+ try {
+ this.tempDir.remove(true);
+ } catch (e) {
+ Cu.reportError(e);
+ }
+ });
+ },
+
+ /**
+ * Iterates over the entries in a given directory.
+ *
+ * Fails silently if the given directory does not exist.
+ *
+ * @param {nsIFile} dir
+ * Directory to iterate.
+ */
+ *iterDirectory(dir) {
+ let dirEnum;
+ try {
+ dirEnum = dir.directoryEntries;
+ let file;
+ while ((file = dirEnum.nextFile)) {
+ yield file;
+ }
+ } catch (e) {
+ if (dir.exists()) {
+ Cu.reportError(e);
+ }
+ } finally {
+ if (dirEnum) {
+ dirEnum.close();
+ }
+ }
+ },
+
+ /**
+ * Creates a new HttpServer for testing, and begins listening on the
+ * specified port. Automatically shuts down the server when the test
+ * unit ends.
+ *
+ * @param {object} [options = {}]
+ * The options object.
+ * @param {integer} [options.port = -1]
+ * The port to listen on. If omitted, listen on a random
+ * port. The latter is the preferred behavior.
+ * @param {sequence<string>?} [options.hosts = null]
+ * A set of hosts to accept connections to. Support for this is
+ * implemented using a proxy filter.
+ *
+ * @returns {HttpServer}
+ * The HTTP server instance.
+ */
+ createHttpServer(...args) {
+ lazy.XPCShellContentUtils.ensureInitialized(this.testScope);
+ return lazy.XPCShellContentUtils.createHttpServer(...args);
+ },
+
+ registerJSON(...args) {
+ return lazy.XPCShellContentUtils.registerJSON(...args);
+ },
+
+ info(msg) {
+ // info() for mochitests, do_print for xpcshell.
+ let print = this.testScope.info || this.testScope.do_print;
+ print(msg);
+ },
+
+ cleanupTempXPIs() {
+ let didGC = false;
+
+ for (let file of this.tempXPIs.splice(0)) {
+ if (file.exists()) {
+ try {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ file.remove(false);
+ } catch (e) {
+ if (didGC) {
+ Cu.reportError(`Failed to remove ${file.path}: ${e}`);
+ } else {
+ // Bug 1606684 - Sometimes XPI files are still in use by a process
+ // after the test has been finished. Force a GC once and try again.
+ this.info(`Force a GC`);
+ Cu.forceGC();
+ didGC = true;
+
+ try {
+ file.remove(false);
+ } catch (e) {
+ Cu.reportError(`Failed to remove ${file.path} after GC: ${e}`);
+ }
+ }
+ }
+ }
+ }
+ },
+
+ createAppInfo(ID, name, version, platformVersion = "1.0") {
+ lazy.updateAppInfo({
+ ID,
+ name,
+ version,
+ platformVersion,
+ crashReporter: true,
+ });
+ this.appInfo = lazy.getAppInfo();
+ },
+
+ getManifestURI(file) {
+ if (file.isDirectory()) {
+ file.leafName = "manifest.json";
+ if (file.exists()) {
+ return NetUtil.newURI(file);
+ }
+
+ throw new Error("No manifest file present");
+ }
+
+ let zip = ZipReader(file);
+ try {
+ let uri = NetUtil.newURI(file);
+
+ if (zip.hasEntry("manifest.json")) {
+ return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`);
+ }
+
+ throw new Error("No manifest file present");
+ } finally {
+ zip.close();
+ }
+ },
+
+ getIDFromExtension(file) {
+ return this.getIDFromManifest(this.getManifestURI(file));
+ },
+
+ async getIDFromManifest(manifestURI) {
+ let body = await fetch(manifestURI.spec);
+ let manifest = await body.json();
+ try {
+ if (manifest.browser_specific_settings?.gecko?.id) {
+ return manifest.browser_specific_settings.gecko.id;
+ }
+ return manifest.applications.gecko.id;
+ } catch (e) {
+ // IDs for WebExtensions are extracted from the certificate when
+ // not present in the manifest, so just generate a random one.
+ return Services.uuid.generateUUID().number;
+ }
+ },
+
+ overrideCertDB() {
+ let verifyCert = async (file, result, cert, callback) => {
+ if (
+ result == Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED &&
+ !this.useRealCertChecks &&
+ callback.wrappedJSObject
+ ) {
+ // Bypassing XPConnect allows us to create a fake x509 certificate from JS
+ callback = callback.wrappedJSObject;
+
+ try {
+ let id;
+ try {
+ let manifestURI = this.getManifestURI(file);
+ id = await this.getIDFromManifest(manifestURI);
+ } catch (err) {
+ if (file.leafName.endsWith(".xpi")) {
+ id = file.leafName.slice(0, -4);
+ }
+ }
+
+ let fakeCert = { commonName: id };
+ if (this.usePrivilegedSignatures) {
+ let privileged =
+ typeof this.usePrivilegedSignatures == "function"
+ ? this.usePrivilegedSignatures(id)
+ : this.usePrivilegedSignatures;
+ if (privileged === "system") {
+ fakeCert.organizationalUnit = "Mozilla Components";
+ } else if (privileged) {
+ fakeCert.organizationalUnit = "Mozilla Extensions";
+ }
+ }
+ if (this.certSignatureDate) {
+ // addon.signedDate is derived from this, used by the blocklist.
+ fakeCert.validity = {
+ notBefore: this.certSignatureDate * 1000,
+ };
+ }
+
+ return [callback, Cr.NS_OK, fakeCert];
+ } catch (e) {
+ // If there is any error then just pass along the original results
+ } finally {
+ // Make sure to close the open zip file or it will be locked.
+ if (file.isFile()) {
+ Services.obs.notifyObservers(
+ file,
+ "flush-cache-entry",
+ "cert-override"
+ );
+ }
+ }
+ }
+
+ return [callback, result, cert];
+ };
+
+ let FakeCertDB = {
+ init() {
+ for (let property of Object.keys(
+ this._genuine.QueryInterface(Ci.nsIX509CertDB)
+ )) {
+ if (property in this) {
+ continue;
+ }
+
+ if (typeof this._genuine[property] == "function") {
+ this[property] = this._genuine[property].bind(this._genuine);
+ }
+ }
+ },
+
+ openSignedAppFileAsync(root, file, callback) {
+ // First try calling the real cert DB
+ this._genuine.openSignedAppFileAsync(
+ root,
+ file,
+ (result, zipReader, cert) => {
+ verifyCert(file.clone(), result, cert, callback).then(
+ ([callback, result, cert]) => {
+ callback.openSignedAppFileFinished(result, zipReader, cert);
+ }
+ );
+ }
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIX509CertDB"]),
+ };
+
+ // Unregister the real database. This only works because the add-ons manager
+ // hasn't started up and grabbed the certificate database yet.
+ lazy.MockRegistrar.register(CERTDB_CONTRACTID, FakeCertDB);
+
+ // Initialize the mock service.
+ Cc[CERTDB_CONTRACTID].getService();
+ FakeCertDB.init();
+ },
+
+ /**
+ * Load the data from the specified files into the *real* blocklist providers.
+ * Loads using loadBlocklistRawData, which will treat this as an update.
+ *
+ * @param {nsIFile} dir
+ * The directory in which the files live.
+ * @param {string} prefix
+ * a prefix for the files which ought to be loaded.
+ * This method will suffix -extensions.json
+ * to the prefix it is given, and attempt to load it.
+ * If it exists, its data will be dumped into
+ * the respective store, and the update handler
+ * will be called.
+ */
+ async loadBlocklistData(dir, prefix) {
+ let loadedData = {};
+ let fileSuffix = "extensions";
+ const fileName = `${prefix}-${fileSuffix}.json`;
+
+ try {
+ loadedData[fileSuffix] = await IOUtils.readJSON(
+ PathUtils.join(dir.path, fileName)
+ );
+ this.info(`Loaded ${fileName}`);
+ } catch (e) {}
+
+ return this.loadBlocklistRawData(loadedData);
+ },
+
+ /**
+ * Load the following data into the *real* blocklist providers.
+ * Fires update methods as would happen if this data came from
+ * an actual blocklist update, etc.
+ *
+ * @param {object} data
+ * The data to load.
+ */
+ async loadBlocklistRawData(data) {
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ const blocklistMapping = {
+ extensions: BlocklistPrivate.ExtensionBlocklistRS,
+ extensionsMLBF: BlocklistPrivate.ExtensionBlocklistMLBF,
+ };
+
+ for (const [dataProp, blocklistObj] of Object.entries(blocklistMapping)) {
+ let newData = data[dataProp];
+ if (!newData) {
+ continue;
+ }
+ if (!Array.isArray(newData)) {
+ throw new Error(
+ "Expected an array of new items to put in the " +
+ dataProp +
+ " blocklist!"
+ );
+ }
+ for (let item of newData) {
+ if (!item.id) {
+ item.id = Services.uuid.generateUUID().number.slice(1, -1);
+ }
+ if (!item.last_modified) {
+ item.last_modified = Date.now();
+ }
+ }
+ blocklistObj.ensureInitialized();
+ let db = await blocklistObj._client.db;
+ const collectionTimestamp = Math.max(
+ ...newData.map(r => r.last_modified)
+ );
+ await db.importChanges({}, collectionTimestamp, newData, {
+ clear: true,
+ });
+ // We manually call _onUpdate... which is evil, but at the moment kinto doesn't have
+ // a better abstraction unless you want to mock your own http server to do the update.
+ await blocklistObj._onUpdate();
+ }
+ },
+
+ /**
+ * Starts up the add-on manager as if it was started by the application.
+ *
+ * @param {Object} params
+ * The new params are in an object and new code should use that.
+ * @param {boolean} params.earlyStartup
+ * Notifies early startup phase. default is true
+ * @param {boolean} params.lateStartup
+ * Notifies late startup phase which ensures addons are started or
+ * listeners are primed. default is true
+ * @param {boolean} params.newVersion
+ * If provided, the application version is changed to this string
+ * before the AddonManager is started.
+ */
+ async promiseStartupManager(params) {
+ if (this.addonIntegrationService) {
+ throw new Error(
+ "Attempting to startup manager that was already started."
+ );
+ }
+ // Support old arguments
+ if (typeof params != "object") {
+ params = {
+ newVersion: arguments[0],
+ };
+ }
+ let { earlyStartup = true, lateStartup = true, newVersion } = params;
+
+ lateStartup = earlyStartup && lateStartup;
+
+ if (newVersion) {
+ this.appInfo.version = newVersion;
+ this.appInfo.platformVersion = newVersion;
+ }
+
+ // AddonListeners are removed when the addonManager is shutdown,
+ // ensure the Extension observer is added. We call uninit in
+ // promiseShutdown to allow re-initialization.
+ lazy.ExtensionAddonObserver.init();
+
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ XPIExports.XPIInternal.overrideAsyncShutdown(MockAsyncShutdown);
+
+ XPIExports.XPIInternal.BootstrapScope.prototype._beforeCallBootstrapMethod =
+ (method, params, reason) => {
+ try {
+ this.emit("bootstrap-method", { method, params, reason });
+ } catch (e) {
+ try {
+ this.testScope.do_throw(e);
+ } catch (e) {
+ // Le sigh.
+ }
+ }
+ };
+
+ this.addonIntegrationService = Cc[
+ "@mozilla.org/addons/integration;1"
+ ].getService(Ci.nsIObserver);
+
+ this.addonIntegrationService.observe(null, "addons-startup", null);
+
+ this.emit("addon-manager-started");
+
+ await Promise.all(XPIExports.XPIProvider.startupPromises);
+
+ // Load the add-ons list as it was after extension registration
+ await this.loadAddonsList(true);
+
+ // Wait for all add-ons to finish starting up before resolving.
+ await Promise.all(
+ Array.from(
+ XPIExports.XPIProvider.activeAddons.values(),
+ addon => addon.startupPromise
+ )
+ );
+ if (earlyStartup) {
+ lazy.ExtensionTestCommon.notifyEarlyStartup();
+ }
+ if (lateStartup) {
+ lazy.ExtensionTestCommon.notifyLateStartup();
+ }
+ },
+
+ async promiseShutdownManager({
+ clearOverrides = true,
+ clearL10nRegistry = true,
+ } = {}) {
+ if (!this.addonIntegrationService) {
+ return false;
+ }
+
+ if (this.overrideEntry && clearOverrides) {
+ this.overrideEntry.destruct();
+ this.overrideEntry = null;
+ }
+
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+
+ // Ensure some startup observers in XPIProvider are released.
+ Services.obs.notifyObservers(null, "test-load-xpi-database");
+
+ // Note: the code here used to trigger observer notifications such as
+ // "quit-application-granted". That was removed because of unwanted side
+ // effects in other components. The MockAsyncShutdown triggers here are very
+ // specific and only affect the AddonManager/XPIProvider internals.
+ await MockAsyncShutdown.quitApplicationGranted.trigger();
+
+ // If XPIDatabase.asyncLoadDB() has been called before, then _dbPromise is
+ // a promise, potentially still pending. Wait for it to settle before
+ // triggering profileBeforeChange, because the latter can trigger errors in
+ // the pending asyncLoadDB() by an indirect call to XPIDatabase.shutdown().
+ await XPIExports.XPIDatabase._dbPromise;
+
+ await MockAsyncShutdown.profileBeforeChange.trigger();
+ await MockAsyncShutdown.profileChangeTeardown.trigger();
+
+ this.emit("addon-manager-shutdown");
+
+ this.addonIntegrationService = null;
+
+ // Load the add-ons list as it was after application shutdown
+ await this.loadAddonsList();
+
+ // Flush the jar cache entries for each bootstrapped XPI so that
+ // we don't run into file locking issues on Windows.
+ for (let file of this.addonsList.xpis) {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ }
+
+ // Clear L10nRegistry entries so restaring the AOM will work correctly with locales.
+ if (clearL10nRegistry) {
+ L10nRegistry.getInstance().clearSources();
+ }
+
+ // Clear any crash report annotations
+ this.appInfo.annotations = {};
+
+ // Force the XPIProvider provider to reload to better
+ // simulate real-world usage.
+
+ // This would be cleaner if I could get it as the rejection reason from
+ // the AddonManagerInternal.shutdown() promise
+ let shutdownError = XPIExports.XPIDatabase._saveError;
+
+ AddonManagerPrivate.unregisterProvider(XPIExports.XPIProvider);
+
+ resetXPIExports(XPIExports);
+
+ lazy.ExtensionAddonObserver.uninit();
+
+ lazy.ExtensionTestCommon.resetStartupPromises();
+
+ if (shutdownError) {
+ throw shutdownError;
+ }
+
+ return true;
+ },
+
+ /**
+ * Asynchronously restart the AddonManager. If newVersion is provided,
+ * simulate an application upgrade (or downgrade) where the version
+ * is changed to newVersion when re-started.
+ *
+ * @param {Object} params
+ * The new params are in an object and new code should use that.
+ * See promiseStartupManager for param details.
+ */
+ async promiseRestartManager(params) {
+ await this.promiseShutdownManager({ clearOverrides: false });
+ await this.promiseStartupManager(params);
+ },
+
+ /**
+ * If promiseStartupManager is called with earlyStartup: false, then
+ * use this to notify early startup.
+ *
+ * @returns {Promise} resolves when notification is complete
+ */
+ notifyEarlyStartup() {
+ return lazy.ExtensionTestCommon.notifyEarlyStartup();
+ },
+
+ /**
+ * If promiseStartupManager is called with lateStartup: false, then
+ * use this to notify late startup. You should also call early startup
+ * if necessary.
+ *
+ * @returns {Promise} resolves when notification is complete
+ */
+ notifyLateStartup() {
+ return lazy.ExtensionTestCommon.notifyLateStartup();
+ },
+
+ async loadAddonsList(flush = false) {
+ if (flush) {
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ XPIExports.XPIInternal.XPIStates.save();
+ await XPIExports.XPIInternal.XPIStates._jsonFile._save();
+ }
+
+ this.addonsList = new AddonsList(this.addonStartup);
+ },
+
+ /**
+ * Writes the given data to a file in the given zip file.
+ *
+ * @param {string|nsIFile} zipFile
+ * The zip file to write to.
+ * @param {Object} files
+ * An object containing filenames and the data to write to the
+ * corresponding paths in the zip file.
+ * @param {integer} [flags = 0]
+ * Additional flags to open the file with.
+ */
+ writeFilesToZip(zipFile, files, flags = 0) {
+ if (typeof zipFile == "string") {
+ zipFile = nsFile(zipFile);
+ }
+
+ var zipW = ZipWriter(
+ zipFile,
+ FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | flags
+ );
+
+ for (let [path, data] of Object.entries(files)) {
+ if (
+ typeof data === "object" &&
+ ChromeUtils.getClassName(data) === "Object"
+ ) {
+ data = JSON.stringify(data);
+ }
+ if (!(data instanceof ArrayBuffer)) {
+ data = new TextEncoder().encode(data).buffer;
+ }
+
+ let stream = ArrayBufferInputStream(data, 0, data.byteLength);
+
+ // Note these files are being created in the XPI archive with date
+ // 1 << 49, which is a valid time for ZipWriter.
+ zipW.addEntryStream(
+ path,
+ Math.pow(2, 49),
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ }
+
+ zipW.close();
+ },
+
+ async promiseWriteFilesToZip(zip, files, flags) {
+ await IOUtils.makeDirectory(PathUtils.parent(zip));
+
+ this.writeFilesToZip(zip, files, flags);
+
+ return Promise.resolve(nsFile(zip));
+ },
+
+ async promiseWriteFilesToDir(dir, files) {
+ await IOUtils.makeDirectory(dir);
+
+ for (let [path, data] of Object.entries(files)) {
+ path = path.split("/");
+ let leafName = path.pop();
+
+ // Create parent directories, if necessary.
+ let dirPath = dir;
+ for (let subDir of path) {
+ dirPath = PathUtils.join(dirPath, subDir);
+ await PathUtils.makeDirectory(dirPath);
+ }
+
+ const leafPath = PathUtils.join(dirPath, leafName);
+ if (
+ typeof data == "object" &&
+ ChromeUtils.getClassName(data) == "Object"
+ ) {
+ await IOUtils.writeJSON(leafPath, data);
+ } else if (typeof data == "string") {
+ await IOUtils.writeUTF8(leafPath, data);
+ }
+ }
+
+ return nsFile(dir);
+ },
+
+ promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) {
+ if (unpacked) {
+ let path = PathUtils.join(dir, id);
+
+ return this.promiseWriteFilesToDir(path, files);
+ }
+
+ let xpi = PathUtils.join(dir, `${id}.xpi`);
+
+ return this.promiseWriteFilesToZip(xpi, files);
+ },
+
+ tempXPIs: [],
+
+ allocTempXPIFile() {
+ let file = this.tempDir.clone();
+ let uuid = Services.uuid.generateUUID().number.slice(1, -1);
+ file.append(`${uuid}.xpi`);
+
+ this.tempXPIs.push(file);
+
+ return file;
+ },
+
+ /**
+ * Creates an XPI file for some manifest data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param {object} files
+ * The object holding data about the add-on
+ * @return {nsIFile} A file pointing to the created XPI file
+ */
+ createTempXPIFile(files) {
+ let file = this.allocTempXPIFile();
+ this.writeFilesToZip(file.path, files);
+ return file;
+ },
+
+ /**
+ * Creates an XPI file for some WebExtension data in the temporary directory and
+ * returns the nsIFile for it. The file will be deleted when the test completes.
+ *
+ * @param {Object} data
+ * The object holding data about the add-on, as expected by
+ * |ExtensionTestCommon.generateXPI|.
+ * @return {nsIFile} A file pointing to the created XPI file
+ */
+ createTempWebExtensionFile(data) {
+ let file = lazy.ExtensionTestCommon.generateXPI(data);
+ this.tempXPIs.push(file);
+ return file;
+ },
+
+ /**
+ * Creates an XPI with the given files and installs it.
+ *
+ * @param {object} files
+ * A files object as would be passed to {@see #createTempXPI}.
+ * @returns {Promise}
+ * A promise which resolves when the add-on is installed.
+ */
+ promiseInstallXPI(files) {
+ return this.promiseInstallFile(this.createTempXPIFile(files));
+ },
+
+ /**
+ * Creates an extension proxy file.
+ * See: https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file
+ *
+ * @param {nsIFile} dir
+ * The directory to add the proxy file to.
+ * @param {nsIFile} addon
+ * An nsIFile for the add-on file that this is a proxy file for.
+ * @param {string} id
+ * A string to use for the add-on ID.
+ * @returns {Promise} Resolves when the file has been created.
+ */
+ promiseWriteProxyFileToDir(dir, addon, id) {
+ let files = {
+ [id]: addon.path,
+ };
+
+ return this.promiseWriteFilesToDir(dir.path, files);
+ },
+
+ /**
+ * Manually installs an XPI file into an install location by either copying the
+ * XPI there or extracting it depending on whether unpacking is being tested
+ * or not.
+ *
+ * @param {nsIFile} xpiFile
+ * The XPI file to install.
+ * @param {nsIFile} [installLocation = this.profileExtensions]
+ * The install location (an nsIFile) to install into.
+ * @param {string} [id]
+ * The ID to install as.
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, install as an unpacked directory, rather than a
+ * packed XPI.
+ * @returns {nsIFile}
+ * A file pointing to the installed location of the XPI file or
+ * unpacked directory.
+ */
+ async manuallyInstall(
+ xpiFile,
+ installLocation = this.profileExtensions,
+ id = null,
+ unpacked = this.testUnpacked
+ ) {
+ if (id == null) {
+ id = await this.getIDFromExtension(xpiFile);
+ }
+
+ if (unpacked) {
+ let dir = installLocation.clone();
+ dir.append(id);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let zip = ZipReader(xpiFile);
+ for (let entry of zip.findEntries(null)) {
+ let target = dir.clone();
+ for (let part of entry.split("/")) {
+ target.append(part);
+ }
+ if (!target.parent.exists()) {
+ target.parent.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ }
+ try {
+ zip.extract(entry, target);
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY &&
+ !(target.exists() && target.isDirectory())
+ ) {
+ throw e;
+ }
+ }
+ target.permissions |= FileUtils.PERMS_FILE;
+ }
+ zip.close();
+
+ return dir;
+ }
+
+ let target = installLocation.clone();
+ target.append(`${id}.xpi`);
+ xpiFile.copyTo(target.parent, target.leafName);
+ return target;
+ },
+
+ /**
+ * Manually uninstalls an add-on by removing its files from the install
+ * location.
+ *
+ * @param {nsIFile} installLocation
+ * The nsIFile of the install location to remove from.
+ * @param {string} id
+ * The ID of the add-on to remove.
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, uninstall an unpacked directory, rather than a
+ * packed XPI.
+ */
+ manuallyUninstall(installLocation, id, unpacked = this.testUnpacked) {
+ let file = this.getFileForAddon(installLocation, id, unpacked);
+
+ // In reality because the app is restarted a flush isn't necessary for XPIs
+ // removed outside the app, but for testing we must flush manually.
+ if (file.isFile()) {
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+ }
+
+ file.remove(true);
+ },
+
+ /**
+ * Gets the nsIFile for where an add-on is installed. It may point to a file or
+ * a directory depending on whether add-ons are being installed unpacked or not.
+ *
+ * @param {nsIFile} dir
+ * The nsIFile for the install location
+ * @param {string} id
+ * The ID of the add-on
+ * @param {boolean} [unpacked = this.testUnpacked]
+ * If true, return the path to an unpacked directory, rather than a
+ * packed XPI.
+ * @returns {nsIFile}
+ * A file pointing to the XPI file or unpacked directory where
+ * the add-on should be installed.
+ */
+ getFileForAddon(dir, id, unpacked = this.testUnpacked) {
+ dir = dir.clone();
+ if (unpacked) {
+ dir.append(id);
+ } else {
+ dir.append(`${id}.xpi`);
+ }
+ return dir;
+ },
+
+ /**
+ * Sets the last modified time of the extension, usually to trigger an update
+ * of its metadata.
+ *
+ * @param {nsIFile} ext A file pointing to either the packed extension or its unpacked directory.
+ * @param {number} time The time to which we set the lastModifiedTime of the extension
+ *
+ * @deprecated Please use promiseSetExtensionModifiedTime instead
+ */
+ setExtensionModifiedTime(ext, time) {
+ ext.lastModifiedTime = time;
+ if (ext.isDirectory()) {
+ for (let file of this.iterDirectory(ext)) {
+ this.setExtensionModifiedTime(file, time);
+ }
+ }
+ },
+
+ async promiseSetExtensionModifiedTime(path, time) {
+ await IOUtils.setModificationTime(path, time);
+
+ const stat = await IOUtils.stat(path);
+ if (stat.type !== "directory") {
+ return;
+ }
+
+ const children = await IOUtils.getChildren(path);
+
+ try {
+ await Promise.all(
+ children.map(entry => this.promiseSetExtensionModifiedTime(entry, time))
+ );
+ } catch (ex) {
+ if (DOMException.isInstance(ex)) {
+ return;
+ }
+ throw ex;
+ }
+ },
+
+ registerDirectory(key, dir) {
+ var dirProvider = {
+ getFile(prop, persistent) {
+ persistent.value = false;
+ if (prop == key) {
+ return dir.clone();
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ Services.dirsvc.registerProvider(dirProvider);
+
+ try {
+ Services.dirsvc.undefine(key);
+ } catch (e) {
+ // This throws if the key is not already registered, but that
+ // doesn't matter.
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ }
+ },
+
+ /**
+ * Returns a promise that resolves when the given add-on event is fired. The
+ * resolved value is an array of arguments passed for the event.
+ *
+ * @param {string} event
+ * The name of the AddonListener event handler method for which
+ * an event is expected.
+ * @param {function} checkFn [optional]
+ * A function to check if this is the right event. Should return true
+ * for the event that it wants, false otherwise. Will be passed
+ * all the relevant arguments.
+ * If not passed, any event will do to resolve the promise.
+ * @returns {Promise<Array>}
+ * Resolves to an array containing the event handler's
+ * arguments the first time it is called.
+ */
+ promiseAddonEvent(event, checkFn) {
+ return new Promise(resolve => {
+ let listener = {
+ [event](...args) {
+ if (typeof checkFn == "function" && !checkFn(...args)) {
+ return;
+ }
+ AddonManager.removeAddonListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+ });
+ },
+
+ promiseInstallEvent(event, checkFn) {
+ return new Promise(resolve => {
+ let listener = {
+ [event](...args) {
+ if (typeof checkFn == "function" && !checkFn(...args)) {
+ return;
+ }
+ AddonManager.removeInstallListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+ },
+
+ /**
+ * A helper method to install AddonInstall and wait for completion.
+ *
+ * @param {AddonInstall} install
+ * The add-on to install.
+ * @returns {Promise<AddonInstall>}
+ * Resolves when the install completes, either successfully or
+ * in failure.
+ */
+ promiseCompleteInstall(install) {
+ let listener;
+ return new Promise(resolve => {
+ let installPromise;
+ listener = {
+ onDownloadFailed: resolve,
+ onDownloadCancelled: resolve,
+ onInstallFailed: resolve,
+ onInstallCancelled: resolve,
+ onInstallEnded() {
+ // onInstallEnded is called right when an add-on has been installed.
+ // install() may still be pending, e.g. for updates, and be awaiting
+ // the completion of the update, part of which is the removal of the
+ // temporary XPI file of the downloaded update. To avoid intermittent
+ // test failures due to lingering temporary files, await install().
+ resolve(installPromise);
+ },
+ onInstallPostponed: resolve,
+ };
+
+ install.addListener(listener);
+ installPromise = install.install();
+ }).then(() => {
+ install.removeListener(listener);
+ return install;
+ });
+ },
+
+ /**
+ * A helper method to install a file.
+ *
+ * @param {nsIFile} file
+ * The file to install
+ * @param {boolean} [ignoreIncompatible = false]
+ * Optional parameter to ignore add-ons that are incompatible
+ * with the application
+ * @param {Object} [installTelemetryInfo = undefined]
+ * Optional parameter to set the install telemetry info for the
+ * installed addon
+ * @returns {Promise}
+ * Resolves when the install has completed.
+ */
+ async promiseInstallFile(
+ file,
+ ignoreIncompatible = false,
+ installTelemetryInfo
+ ) {
+ let install = await AddonManager.getInstallForFile(
+ file,
+ null,
+ installTelemetryInfo
+ );
+ if (!install) {
+ throw new Error(`No AddonInstall created for ${file.path}`);
+ }
+
+ if (install.state != AddonManager.STATE_DOWNLOADED) {
+ throw new Error(
+ `Expected file to be downloaded for install of ${file.path}`
+ );
+ }
+
+ if (ignoreIncompatible && install.addon.appDisabled) {
+ return null;
+ }
+
+ await install.install();
+ return install;
+ },
+
+ /**
+ * A helper method to install an array of files.
+ *
+ * @param {Iterable<nsIFile>} files
+ * The files to install
+ * @param {boolean} [ignoreIncompatible = false]
+ * Optional parameter to ignore add-ons that are incompatible
+ * with the application
+ * @returns {Promise}
+ * Resolves when the installs have completed.
+ */
+ promiseInstallAllFiles(files, ignoreIncompatible = false) {
+ return Promise.all(
+ Array.from(files, file =>
+ this.promiseInstallFile(file, ignoreIncompatible)
+ )
+ );
+ },
+
+ promiseCompleteAllInstalls(installs) {
+ return Promise.all(Array.from(installs, this.promiseCompleteInstall));
+ },
+
+ /**
+ * @property {number} updateReason
+ * The default update reason for {@see promiseFindAddonUpdates}
+ * calls. May be overwritten by tests which primarily check for
+ * updates with a particular reason.
+ */
+ updateReason: AddonManager.UPDATE_WHEN_PERIODIC_UPDATE,
+
+ /**
+ * Returns a promise that will be resolved when an add-on update check is
+ * complete. The value resolved will be an AddonInstall if a new version was
+ * found.
+ *
+ * @param {object} addon The add-on to find updates for.
+ * @param {integer} reason The type of update to find.
+ * @param {Array} args Additional args to pass to `checkUpdates` after
+ * the update reason.
+ * @return {Promise<object>} an object containing information about the update.
+ */
+ promiseFindAddonUpdates(
+ addon,
+ reason = AddonTestUtils.updateReason,
+ ...args
+ ) {
+ // Retrieve the test assertion helper from the testScope
+ // (which is `equal` in xpcshell-test and `is` in mochitest)
+ let equal = this.testScope.equal || this.testScope.is;
+ return new Promise((resolve, reject) => {
+ let result = {};
+ addon.findUpdates(
+ {
+ onNoCompatibilityUpdateAvailable(addon2) {
+ if ("compatibilityUpdate" in result) {
+ throw new Error("Saw multiple compatibility update events");
+ }
+ equal(addon, addon2, "onNoCompatibilityUpdateAvailable");
+ result.compatibilityUpdate = false;
+ },
+
+ onCompatibilityUpdateAvailable(addon2) {
+ if ("compatibilityUpdate" in result) {
+ throw new Error("Saw multiple compatibility update events");
+ }
+ equal(addon, addon2, "onCompatibilityUpdateAvailable");
+ result.compatibilityUpdate = true;
+ },
+
+ onNoUpdateAvailable(addon2) {
+ if ("updateAvailable" in result) {
+ throw new Error("Saw multiple update available events");
+ }
+ equal(addon, addon2, "onNoUpdateAvailable");
+ result.updateAvailable = false;
+ },
+
+ onUpdateAvailable(addon2, install) {
+ if ("updateAvailable" in result) {
+ throw new Error("Saw multiple update available events");
+ }
+ equal(addon, addon2, "onUpdateAvailable");
+ result.updateAvailable = install;
+ },
+
+ onUpdateFinished(addon2, error) {
+ equal(addon, addon2, "onUpdateFinished");
+ if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
+ resolve(result);
+ } else {
+ result.error = error;
+ reject(result);
+ }
+ },
+ },
+ reason,
+ ...args
+ );
+ });
+ },
+
+ /**
+ * Monitors console output for the duration of a task, and returns a promise
+ * which resolves to a tuple containing a list of all console messages
+ * generated during the task's execution, and the result of the task itself.
+ *
+ * @param {function} task
+ * The task to run while monitoring console output. May be
+ * an async function, or an ordinary function which returns a promose.
+ * @return {Promise<[Array<nsIConsoleMessage>, *]>}
+ * Resolves to an object containing a `messages` property, with
+ * the array of console messages emitted during the execution
+ * of the task, and a `result` property, containing the task's
+ * return value.
+ */
+ async promiseConsoleOutput(task) {
+ const DONE = "=== xpcshell test console listener done ===";
+
+ let listener,
+ messages = [];
+ let awaitListener = new Promise(resolve => {
+ listener = msg => {
+ if (msg == DONE) {
+ resolve();
+ } else {
+ 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);
+ }
+ },
+
+ /**
+ * An object describing an expected or forbidden console message. Each
+ * property in the object corresponds to a property with the same name
+ * in a console message. If the value in the pattern object is a
+ * regular expression, it must match the value of the corresponding
+ * console message property. If it is any other value, it must be
+ * strictly equal to the correspondng console message property.
+ *
+ * @typedef {object} ConsoleMessagePattern
+ */
+
+ /**
+ * Checks the list of messages returned from `promiseConsoleOutput`
+ * against the given set of expected messages.
+ *
+ * This is roughly equivalent to the expected and forbidden message
+ * matching functionality of SimpleTest.monitorConsole.
+ *
+ * @param {Array<object>} messages
+ * The array of console messages to match.
+ * @param {object} options
+ * Options describing how to perform the match.
+ * @param {Array<ConsoleMessagePattern>} [options.expected = []]
+ * An array of messages which must appear in `messages`. The
+ * matching messages in the `messages` array must appear in the
+ * same order as the patterns in the `expected` array.
+ * @param {Array<ConsoleMessagePattern>} [options.forbidden = []]
+ * An array of messages which must not appear in the `messages`
+ * array.
+ * @param {bool} [options.forbidUnexpected = false]
+ * If true, the `messages` array must not contain any messages
+ * which are not matched by the given `expected` patterns.
+ */
+ checkMessages(
+ messages,
+ { expected = [], forbidden = [], forbidUnexpected = false }
+ ) {
+ function msgMatches(msg, expectedMsg) {
+ for (let [prop, pattern] of Object.entries(expectedMsg)) {
+ if (isRegExp(pattern) && typeof msg[prop] === "string") {
+ if (!pattern.test(msg[prop])) {
+ return false;
+ }
+ } else if (msg[prop] !== pattern) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function validateOptionFormat(optionName, optionValue) {
+ for (let item of optionValue) {
+ if (!item || typeof item !== "object" || isRegExp(item)) {
+ throw new Error(
+ `Unexpected format in AddonTestUtils.checkMessages "${optionName}" parameter`
+ );
+ }
+ }
+ }
+
+ validateOptionFormat("expected", expected);
+ validateOptionFormat("forbidden", forbidden);
+
+ let i = 0;
+ for (let msg of messages) {
+ if (forbidden.some(pat => msgMatches(msg, pat))) {
+ this.testScope.ok(false, `Got forbidden console message: ${msg}`);
+ continue;
+ }
+
+ if (i < expected.length && msgMatches(msg, expected[i])) {
+ this.info(`Matched expected console message: ${msg}`);
+ i++;
+ } else if (forbidUnexpected) {
+ this.testScope.ok(false, `Got unexpected console message: ${msg}`);
+ }
+ }
+ for (let pat of expected.slice(i)) {
+ this.testScope.ok(
+ false,
+ `Did not get expected console message: ${uneval(pat)}`
+ );
+ }
+ },
+
+ /**
+ * Asserts that the expected installTelemetryInfo properties are available
+ * on the AddonWrapper or AddonInstall objects.
+ *
+ * @param {AddonWrapper|AddonInstall} addonOrInstall
+ * The addon or addonInstall object to check.
+ * @param {Object} expectedInstallInfo
+ * The expected installTelemetryInfo properties
+ * (every property can be a primitive value or a regular expression).
+ * @param {string} [msg]
+ * Optional assertion message suffix.
+ */
+ checkInstallInfo(addonOrInstall, expectedInstallInfo, msg = undefined) {
+ const installInfo = addonOrInstall.installTelemetryInfo;
+ const { Assert } = this.testScope;
+
+ msg = msg ? ` ${msg}` : "";
+
+ for (const key of Object.keys(expectedInstallInfo)) {
+ const actual = installInfo[key];
+ let expected = expectedInstallInfo[key];
+
+ // Assert the property value using a regular expression.
+ if (expected && typeof expected.test == "function") {
+ Assert.ok(
+ expected.test(actual),
+ `${key} value "${actual}" has the value expected "${expected}"${msg}`
+ );
+ } else {
+ Assert.deepEqual(
+ actual,
+ expected,
+ `Got the expected value for ${key}${msg}`
+ );
+ }
+ }
+ },
+
+ /**
+ * Helper to wait for a webextension to completely start
+ *
+ * @param {string} [id]
+ * An optional extension id to look for.
+ *
+ * @returns {Promise<Extension>}
+ * A promise that resolves with the extension, once it is started.
+ */
+ promiseWebExtensionStartup(id) {
+ return new Promise(resolve => {
+ lazy.Management.on("ready", function listener(event, extension) {
+ if (!id || extension.id == id) {
+ lazy.Management.off("ready", listener);
+ resolve(extension);
+ }
+ });
+ });
+ },
+
+ /**
+ * Wait until an extension with a search provider has been loaded.
+ * This should be called after the extension has started, but before shutdown.
+ *
+ * @param {object} extension
+ * The return value of ExtensionTestUtils.loadExtension.
+ * For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js
+ * For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.jsm
+ * @param {object} [options]
+ * Optional options.
+ * @param {boolean} [options.expectPending = false]
+ * Whether to expect the search provider to still be starting up.
+ */
+ async waitForSearchProviderStartup(
+ extension,
+ { expectPending = false } = {}
+ ) {
+ // In xpcshell tests, equal/ok are defined in the global scope.
+ let { equal, ok } = this.testScope;
+ if (!equal || !ok) {
+ // In mochitests, these are available via Assert.sys.mjs.
+ let { Assert } = this.testScope;
+ equal = Assert.equal.bind(Assert);
+ ok = Assert.ok.bind(Assert);
+ }
+
+ equal(
+ extension.state,
+ "running",
+ "Search provider extension should be running"
+ );
+ ok(extension.id, "Extension ID of search provider should be set");
+
+ // The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js
+ let { pendingSearchSetupTasks } = lazy.Management.global;
+ let searchStartupPromise = pendingSearchSetupTasks.get(extension.id);
+ if (expectPending) {
+ ok(
+ searchStartupPromise,
+ "Search provider registration should be in progress"
+ );
+ }
+ return searchStartupPromise;
+ },
+
+ /**
+ * Initializes the URLPreloader, which is required in order to load
+ * built_in_addons.json.
+ */
+ initializeURLPreloader() {
+ lazy.aomStartup.initializeURLPreloader();
+ },
+
+ /**
+ * Override chrome URL for specifying allowed built-in add-ons.
+ *
+ * @param {object} data - An object specifying which add-on IDs are permitted
+ * to load, for instance: { "system": ["id1", "..."] }
+ */
+ async overrideBuiltIns(data) {
+ this.initializeURLPreloader();
+
+ let file = this.tempDir.clone();
+ file.append("override.txt");
+ this.tempXPIs.push(file);
+
+ let manifest = Services.io.newFileURI(file);
+ await IOUtils.writeJSON(file.path, data);
+ this.overrideEntry = lazy.aomStartup.registerChrome(manifest, [
+ [
+ "override",
+ "chrome://browser/content/built_in_addons.json",
+ Services.io.newFileURI(file).spec,
+ ],
+ ]);
+ },
+
+ // AMTelemetry events helpers.
+
+ /**
+ * Formerly this function re-routed telemetry events. Now it just ensures
+ * that there are no unexamined events after the test file is exiting.
+ */
+ hookAMTelemetryEvents() {
+ this.testScope.registerCleanupFunction(() => {
+ this.testScope.Assert.deepEqual(
+ [],
+ this.getAMTelemetryEvents(),
+ "No unexamined telemetry events after test is finished"
+ );
+ });
+ },
+
+ /**
+ * Retrive any AMTelemetry event collected and clears _all_ telemetry events.
+ *
+ * @returns {Array<Object>}
+ * The array of the collected telemetry data.
+ */
+ getAMTelemetryEvents() {
+ // This duplicates some logic from TelemetryTestUtils.
+ let snapshots = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ /* clear = */ true
+ );
+ let events = (snapshots.parent ?? [])
+ .filter(entry => entry[1] == "addonsManager")
+ .map(entry => ({
+ // The callers don't expect the timestamp or the category.
+ method: entry[2],
+ object: entry[3],
+ value: entry[4],
+ extra: entry[5],
+ }));
+
+ return events;
+ },
+
+ /**
+ * @param {string|string[]} events - The event(s) to retrieve.
+ * @param {object} [filter] - key/value pairs to filter events.
+ * @returns {object[]} Collected extra objects from events.
+ */
+ getAMGleanEvents(events, filter = {}) {
+ let result = [];
+ for (let event of [].concat(events)) {
+ result = result.concat(Glean.addonsManager[event].testGetValue() ?? []);
+ }
+
+ // When combining multiple events, we want them in chronological order.
+ result.sort((a, b) => a.timestamp - b.timestamp);
+
+ result = result.filter(e =>
+ Object.keys(filter).every(key => e.extra[key] === filter[key])
+ );
+
+ // We (usually) don't care about install_id, so drop it to ease comparison.
+ result.forEach(e => delete e.extra.install_id);
+
+ // For Glean events, all data is in the extra object.
+ return result.map(e => e.extra);
+ },
+};
+
+for (let [key, val] of Object.entries(AddonTestUtils)) {
+ if (typeof val == "function") {
+ AddonTestUtils[key] = val.bind(AddonTestUtils);
+ }
+}
+
+EventEmitter.decorate(AddonTestUtils);
diff --git a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
new file mode 100644
index 0000000000..a3935a26f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs
@@ -0,0 +1,643 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The AddonUpdateChecker is responsible for retrieving the update information
+ * from an add-on's remote update manifest.
+ */
+
+const TIMEOUT = 60 * 1000;
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.update-checker";
+
+// Create a new logger for use by the Addons Update Checker
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Sanitizes the update URL in an update item, as returned by
+ * parseRDFManifest and parseJSONManifest. Ensures that:
+ *
+ * - The URL is secure, or secured by a strong enough hash.
+ * - The security principal of the update manifest has permission to
+ * load the URL.
+ *
+ * @param aUpdate
+ * The update item to sanitize.
+ * @param aRequest
+ * The XMLHttpRequest used to load the manifest.
+ * @param aHashPattern
+ * The regular expression used to validate the update hash.
+ * @param aHashString
+ * The human-readable string specifying which hash functions
+ * are accepted.
+ */
+function sanitizeUpdateURL(aUpdate, aRequest, aHashPattern, aHashString) {
+ if (aUpdate.updateURL) {
+ let scriptSecurity = Services.scriptSecurityManager;
+ let principal = scriptSecurity.getChannelURIPrincipal(aRequest.channel);
+ try {
+ // This logs an error on failure, so no need to log it a second time
+ scriptSecurity.checkLoadURIStrWithPrincipal(
+ principal,
+ aUpdate.updateURL,
+ scriptSecurity.DISALLOW_SCRIPT
+ );
+ } catch (e) {
+ delete aUpdate.updateURL;
+ return;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aUpdate.updateURL.startsWith("https:") &&
+ !aHashPattern.test(aUpdate.updateHash)
+ ) {
+ logger.warn(
+ `Update link ${aUpdate.updateURL} is not secure and is not verified ` +
+ `by a strong enough hash (needs to be ${aHashString}).`
+ );
+ delete aUpdate.updateURL;
+ delete aUpdate.updateHash;
+ }
+ }
+}
+
+/**
+ * Parses an JSON update manifest into an array of update objects.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aRequest
+ * The XMLHttpRequest that has retrieved the update manifest
+ * @param aManifestData
+ * The pre-parsed manifest, as a JSON object tree
+ * @return an array of update objects
+ * @throws if the update manifest is invalid in any way
+ */
+function parseJSONManifest(aId, aRequest, aManifestData) {
+ let TYPE_CHECK = {
+ array: val => Array.isArray(val),
+ object: val => val && typeof val == "object" && !Array.isArray(val),
+ };
+
+ function getProperty(aObj, aProperty, aType, aDefault = undefined) {
+ if (!(aProperty in aObj)) {
+ return aDefault;
+ }
+
+ let value = aObj[aProperty];
+
+ let matchesType =
+ aType in TYPE_CHECK ? TYPE_CHECK[aType](value) : typeof value == aType;
+ if (!matchesType) {
+ throw Components.Exception(
+ `Update manifest property '${aProperty}' has incorrect type (expected ${aType})`
+ );
+ }
+
+ return value;
+ }
+
+ function getRequiredProperty(aObj, aProperty, aType) {
+ let value = getProperty(aObj, aProperty, aType);
+ if (value === undefined) {
+ throw Components.Exception(
+ `Update manifest is missing a required ${aProperty} property.`
+ );
+ }
+ return value;
+ }
+
+ let manifest = aManifestData;
+
+ if (!TYPE_CHECK.object(manifest)) {
+ throw Components.Exception(
+ "Root element of update manifest must be a JSON object literal"
+ );
+ }
+
+ // The set of add-ons this manifest has updates for
+ let addons = getProperty(manifest, "addons", "object");
+
+ if (!addons) {
+ let keys = Object.keys(manifest);
+ if (keys.length) {
+ // "addons" property is optional. The presence of other properties may be
+ // a sign of a mistake, so print a warning to help with debugging.
+ logger.warn(
+ `Update manifest for ${aId} is missing the "addons" property, found ${keys} instead.`
+ );
+ } else {
+ // If the add-on isn't listed, the update server may return an empty
+ // response.
+ logger.warn(`Received empty update manifest for ${aId}.`);
+ }
+ return [];
+ }
+
+ // The entry for this particular add-on
+ let addon = getProperty(addons, aId, "object");
+
+ // A missing entry doesn't count as a failure, just as no avialable update
+ // information
+ if (!addon) {
+ logger.warn("Update manifest did not contain an entry for " + aId);
+ return [];
+ }
+
+ // The list of available updates
+ let updates = getProperty(addon, "updates", "array", []);
+
+ let results = [];
+
+ for (let update of updates) {
+ let version = getRequiredProperty(update, "version", "string");
+
+ logger.debug(`Found an update entry for ${aId} version ${version}`);
+
+ let applications = getProperty(update, "applications", "object", {
+ gecko: {},
+ });
+
+ // "gecko" is currently the only supported application entry. If
+ // it's missing, skip this update.
+ if (!("gecko" in applications)) {
+ logger.debug(
+ "gecko not in application entry, skipping update of ${addon}"
+ );
+ continue;
+ }
+
+ let app = getProperty(applications, "gecko", "object");
+
+ let appEntry = {
+ id: TOOLKIT_ID,
+ minVersion: getProperty(
+ app,
+ "strict_min_version",
+ "string",
+ lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion
+ ),
+ maxVersion: "*",
+ };
+
+ let result = {
+ id: aId,
+ version,
+ updateURL: getProperty(update, "update_link", "string"),
+ updateHash: getProperty(update, "update_hash", "string"),
+ updateInfoURL: getProperty(update, "update_info_url", "string"),
+ strictCompatibility: false,
+ targetApplications: [appEntry],
+ };
+
+ if ("strict_max_version" in app) {
+ if ("advisory_max_version" in app) {
+ logger.warn(
+ "Ignoring 'advisory_max_version' update manifest property for " +
+ aId +
+ " property since 'strict_max_version' also present"
+ );
+ }
+
+ appEntry.maxVersion = getProperty(app, "strict_max_version", "string");
+ result.strictCompatibility = appEntry.maxVersion != "*";
+ } else if ("advisory_max_version" in app) {
+ appEntry.maxVersion = getProperty(app, "advisory_max_version", "string");
+ }
+
+ // Add an app entry for the current API ID, too, so that it overrides any
+ // existing app-specific entries, which would take priority over the toolkit
+ // entry.
+ //
+ // Note: This currently only has any effect on legacy extensions (mainly
+ // those used in tests), since WebExtensions cannot yet specify app-specific
+ // compatibility ranges.
+ result.targetApplications.push(
+ Object.assign({}, appEntry, { id: Services.appinfo.ID })
+ );
+
+ // The JSON update protocol requires an SHA-2 hash. RDF still
+ // supports SHA-1, for compatibility reasons.
+ sanitizeUpdateURL(result, aRequest, /^sha(256|512):/, "sha256 or sha512");
+
+ results.push(result);
+ }
+ return results;
+}
+
+/**
+ * Starts downloading an update manifest and then passes it to an appropriate
+ * parser to convert to an array of update objects
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the update manifest
+ * @param aObserver
+ * An observer to pass results to
+ */
+function UpdateParser(aId, aUrl, aObserver) {
+ this.id = aId;
+ this.observer = aObserver;
+ this.url = aUrl;
+
+ logger.debug("Requesting " + aUrl);
+ try {
+ this.request = new lazy.ServiceRequest({ mozAnon: true });
+ this.request.open("GET", this.url, true);
+ this.request.channel.notificationCallbacks =
+ new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ this.request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to cache.
+ this.request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ this.request.overrideMimeType("text/plain");
+ this.request.timeout = TIMEOUT;
+ this.request.addEventListener("load", () => this.onLoad());
+ this.request.addEventListener("error", () => this.onError());
+ this.request.addEventListener("timeout", () => this.onTimeout());
+ this.request.send(null);
+ } catch (e) {
+ logger.error(`Failed to request update manifest for ${this.id}`, e);
+ }
+}
+
+UpdateParser.prototype = {
+ id: null,
+ observer: null,
+ request: null,
+ url: null,
+
+ /**
+ * Called when the manifest has been successfully loaded.
+ */
+ onLoad() {
+ let request = this.request;
+ this.request = null;
+ this._doneAt = new Error("place holder");
+
+ try {
+ lazy.CertUtils.checkCert(
+ request.channel,
+ !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ logger.warn("Request failed: " + this.url + " - " + e);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ if (!Components.isSuccessCode(request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + request.status);
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let channel = request.channel;
+ if (channel instanceof Ci.nsIHttpChannel && !channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ channel.responseStatus +
+ ": " +
+ channel.responseStatusText
+ );
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ return;
+ }
+
+ let results;
+ try {
+ let json = JSON.parse(request.responseText);
+ results = parseJSONManifest(this.id, request, json);
+ } catch (e) {
+ logger.warn(
+ `onUpdateCheckComplete failed to parse update manifest for ${this.id}`,
+ e
+ );
+ this.notifyError(lazy.AddonManager.ERROR_PARSE_ERROR);
+ return;
+ }
+
+ if ("onUpdateCheckComplete" in this.observer) {
+ try {
+ this.observer.onUpdateCheckComplete(results);
+ } catch (e) {
+ logger.warn(
+ `onUpdateCheckComplete notification failed for ${this.id}`,
+ e
+ );
+ }
+ } else {
+ logger.warn(
+ "onUpdateCheckComplete may not properly cancel",
+ new Error("stack marker")
+ );
+ }
+ },
+
+ /**
+ * Called when the request times out
+ */
+ onTimeout() {
+ this.request = null;
+ this._doneAt = new Error("Timed out");
+ logger.warn("Request for " + this.url + " timed out");
+ this.notifyError(lazy.AddonManager.ERROR_TIMEOUT);
+ },
+
+ /**
+ * Called when the manifest failed to load.
+ */
+ onError() {
+ if (!Components.isSuccessCode(this.request.status)) {
+ logger.warn("Request failed: " + this.url + " - " + this.request.status);
+ } else if (this.request.channel instanceof Ci.nsIHttpChannel) {
+ try {
+ if (this.request.channel.requestSucceeded) {
+ logger.warn(
+ "Request failed: " +
+ this.url +
+ " - " +
+ this.request.channel.responseStatus +
+ ": " +
+ this.request.channel.responseStatusText
+ );
+ }
+ } catch (e) {
+ logger.warn("HTTP Request failed for an unknown reason");
+ }
+ } else {
+ logger.warn("Request failed for an unknown reason");
+ }
+
+ this.request = null;
+ this._doneAt = new Error("UP_onError");
+
+ this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR);
+ },
+
+ /**
+ * Helper method to notify the observer that an error occurred.
+ */
+ notifyError(aStatus) {
+ if ("onUpdateCheckError" in this.observer) {
+ try {
+ this.observer.onUpdateCheckError(aStatus);
+ } catch (e) {
+ logger.warn("onUpdateCheckError notification failed", e);
+ }
+ }
+ },
+
+ /**
+ * Called to cancel an in-progress update check.
+ */
+ cancel() {
+ if (!this.request) {
+ logger.error("Trying to cancel already-complete request", this._doneAt);
+ return;
+ }
+ this.request.abort();
+ this.request = null;
+ this._doneAt = new Error("UP_cancel");
+ this.notifyError(lazy.AddonManager.ERROR_CANCELLED);
+ },
+};
+
+/**
+ * Tests if an update matches a version of the application or platform
+ *
+ * @param aUpdate
+ * The available update
+ * @param aAppVersion
+ * The application version to use
+ * @param aPlatformVersion
+ * The platform version to use
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return true if the update is compatible with the application/platform
+ */
+function matchesVersions(
+ aUpdate,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+) {
+ if (aUpdate.strictCompatibility && !aIgnoreStrictCompat) {
+ aIgnoreMaxVersion = false;
+ }
+
+ let result = false;
+ for (let app of aUpdate.targetApplications) {
+ if (app.id == Services.appinfo.ID) {
+ return (
+ Services.vc.compare(aAppVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aAppVersion, app.maxVersion) <= 0)
+ );
+ }
+ if (app.id == TOOLKIT_ID) {
+ result =
+ Services.vc.compare(aPlatformVersion, app.minVersion) >= 0 &&
+ (aIgnoreMaxVersion ||
+ Services.vc.compare(aPlatformVersion, app.maxVersion) <= 0);
+ }
+ }
+ return result;
+}
+
+export var AddonUpdateChecker = {
+ /**
+ * Retrieves the best matching compatibility update for the application from
+ * a list of available update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aVersion
+ * The version of the add-on to get new compatibility information for
+ * @param aIgnoreCompatibility
+ * An optional parameter to get the first compatibility update that
+ * is compatible with any version of the application or toolkit
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * Ignore maxVersion when testing if an update matches. Optional.
+ * @param aIgnoreStrictCompat
+ * Ignore strictCompatibility when testing if an update matches. Optional.
+ * @return an update object if one matches or null if not
+ */
+ getCompatibilityUpdate(
+ aUpdates,
+ aVersion,
+ aIgnoreCompatibility,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ for (let update of aUpdates) {
+ if (Services.vc.compare(update.version, aVersion) == 0) {
+ if (aIgnoreCompatibility) {
+ for (let targetApp of update.targetApplications) {
+ let id = targetApp.id;
+ if (id == Services.appinfo.ID || id == TOOLKIT_ID) {
+ return update;
+ }
+ }
+ } else if (
+ matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ return update;
+ }
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Asynchronously returns the newest available update from a list of update objects.
+ *
+ * @param aUpdates
+ * An array of update objects
+ * @param aAddon
+ * The add-on that is being updated.
+ * @param aAppVersion
+ * The version of the application or null to use the current version
+ * @param aPlatformVersion
+ * The version of the platform or null to use the current version
+ * @param aIgnoreMaxVersion
+ * When determining compatible updates, ignore maxVersion. Optional.
+ * @param aIgnoreStrictCompat
+ * When determining compatible updates, ignore strictCompatibility. Optional.
+ * @return an update object if one matches or null if not
+ */
+ async getNewestCompatibleUpdate(
+ aUpdates,
+ aAddon,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ ) {
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let newestVersion = aAddon.version;
+ let newest = null;
+ let blocked = null;
+ let blockedState;
+ for (let update of aUpdates) {
+ if (!update.updateURL) {
+ continue;
+ }
+ if (Services.vc.compare(newestVersion, update.version) >= 0) {
+ // Update older than add-on version or older than previous result.
+ continue;
+ }
+ if (
+ !matchesVersions(
+ update,
+ aAppVersion,
+ aPlatformVersion,
+ aIgnoreMaxVersion,
+ aIgnoreStrictCompat
+ )
+ ) {
+ continue;
+ }
+ let state = await lazy.Blocklist.getAddonBlocklistState(
+ update,
+ aAppVersion,
+ aPlatformVersion
+ );
+ if (state != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) {
+ if (
+ !blocked ||
+ Services.vc.compare(blocked.version, update.version) < 0
+ ) {
+ blocked = update;
+ blockedState = state;
+ }
+ continue;
+ }
+ newest = update;
+ newestVersion = update.version;
+ }
+ if (
+ blocked &&
+ (!newest || Services.vc.compare(blocked.version, newestVersion) >= 0)
+ ) {
+ // If |newest| has a higher version than |blocked|, then the add-on would
+ // not be considered for installation. But if |blocked| would otherwise
+ // be eligible for installation, then report to telemetry that installation
+ // has been blocked because of the blocklist.
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ {
+ id: aAddon.id,
+ version: blocked.version,
+ blocklistState: blockedState,
+ },
+ "addon_update_check"
+ );
+ }
+ return newest;
+ },
+
+ /**
+ * Starts an update check.
+ *
+ * @param aId
+ * The ID of the add-on being checked for updates
+ * @param aUrl
+ * The URL of the add-on's update manifest
+ * @param aObserver
+ * An observer to notify of results
+ * @return UpdateParser so that the caller can use UpdateParser.cancel() to shut
+ * down in-progress update requests
+ */
+ checkForUpdates(aId, aUrl, aObserver) {
+ return new UpdateParser(aId, aUrl, aObserver);
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs
new file mode 100644
index 0000000000..aaac109fc0
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs
@@ -0,0 +1,934 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ GMPInstallManager: "resource://gre/modules/GMPInstallManager.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+import {
+ GMPPrefs,
+ GMPUtils,
+ OPEN_H264_ID,
+ WIDEVINE_L1_ID,
+ WIDEVINE_L3_ID,
+} from "resource://gre/modules/GMPUtils.sys.mjs";
+
+const SEC_IN_A_DAY = 24 * 60 * 60;
+// How long to wait after a user enabled EME before attempting to download CDMs.
+const GMP_CHECK_DELAY = 10 * 1000; // milliseconds
+
+const XHTML = "http://www.w3.org/1999/xhtml";
+
+const NS_GRE_DIR = "GreD";
+const CLEARKEY_PLUGIN_ID = "gmp-clearkey";
+const CLEARKEY_VERSION = "0.1";
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+
+const GMP_LICENSE_INFO = "plugins-gmp-license-info";
+const GMP_PRIVACY_INFO = "plugins-gmp-privacy-info";
+const GMP_LEARN_MORE = "learn_more_label";
+
+const GMP_PLUGINS = [
+ {
+ id: OPEN_H264_ID,
+ name: "plugins-openh264-name",
+ description: "plugins-openh264-description",
+ level: "",
+ libName: "gmpopenh264",
+ // The following licenseURL is part of an awful hack to include the OpenH264
+ // license without having bug 624602 fixed yet, and intentionally ignores
+ // localisation.
+ licenseURL: "chrome://mozapps/content/extensions/OpenH264-license.txt",
+ homepageURL: "https://www.openh264.org/",
+ },
+ {
+ id: WIDEVINE_L1_ID,
+ name: "plugins-widevine-name",
+ description: "plugins-widevine-description",
+ level: "L1",
+ libName: "Google.Widevine.CDM",
+ licenseURL: "https://www.google.com/policies/privacy/",
+ homepageURL: "https://www.widevine.com/",
+ isEME: true,
+ },
+ {
+ id: WIDEVINE_L3_ID,
+ name: "plugins-widevine-name",
+ description: "plugins-widevine-description",
+ level: "L3",
+ libName: "widevinecdm",
+ licenseURL: "https://www.google.com/policies/privacy/",
+ homepageURL: "https://www.widevine.com/",
+ isEME: true,
+ },
+];
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "addonsBundle",
+ () => new Localization(["toolkit/about/aboutAddons.ftl"], true)
+);
+ChromeUtils.defineLazyGetter(lazy, "gmpService", () =>
+ Cc["@mozilla.org/gecko-media-plugin-service;1"].getService(
+ Ci.mozIGeckoMediaPluginChromeService
+ )
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "gmpProviderEnabled",
+ GMPPrefs.KEY_PROVIDER_ENABLED
+);
+
+var gLogger;
+var gLogAppenderDump = null;
+
+function configureLogging() {
+ if (!gLogger) {
+ gLogger = lazy.Log.repository.getLogger("Toolkit.GMP");
+ gLogger.addAppender(
+ new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter())
+ );
+ }
+ gLogger.level = GMPPrefs.getInt(
+ GMPPrefs.KEY_LOGGING_LEVEL,
+ lazy.Log.Level.Warn
+ );
+
+ let logDumping = GMPPrefs.getBool(GMPPrefs.KEY_LOGGING_DUMP, false);
+ if (logDumping != !!gLogAppenderDump) {
+ if (logDumping) {
+ gLogAppenderDump = new lazy.Log.DumpAppender(
+ new lazy.Log.BasicFormatter()
+ );
+ gLogger.addAppender(gLogAppenderDump);
+ } else {
+ gLogger.removeAppender(gLogAppenderDump);
+ gLogAppenderDump = null;
+ }
+ }
+}
+
+/**
+ * The GMPWrapper provides the info for the various GMP plugins to public
+ * callers through the API.
+ */
+function GMPWrapper(aPluginInfo, aRawPluginInfo) {
+ this._plugin = aPluginInfo;
+ this._rawPlugin = aRawPluginInfo;
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.GMP",
+ "GMPWrapper(" + this._plugin.id + ") "
+ );
+ Services.prefs.addObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id),
+ this,
+ true
+ );
+ Services.prefs.addObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id),
+ this,
+ true
+ );
+ if (this._plugin.isEME) {
+ Services.prefs.addObserver(GMPPrefs.KEY_EME_ENABLED, this, true);
+ Services.obs.addObserver(this, "EMEVideo:CDMMissing");
+ }
+}
+
+GMPWrapper.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ // An active task that checks for plugin updates and installs them.
+ _updateTask: null,
+ _gmpPath: null,
+ _isUpdateCheckPending: false,
+
+ set gmpPath(aPath) {
+ this._gmpPath = aPath;
+ },
+ get gmpPath() {
+ if (!this._gmpPath && this.isInstalled) {
+ this._gmpPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id)
+ );
+ }
+ return this._gmpPath;
+ },
+
+ get id() {
+ return this._plugin.id;
+ },
+ get libName() {
+ return this._plugin.libName;
+ },
+ get type() {
+ return "plugin";
+ },
+ get isGMPlugin() {
+ return true;
+ },
+ get name() {
+ return this._plugin.name;
+ },
+ get creator() {
+ return null;
+ },
+ get homepageURL() {
+ return this._plugin.homepageURL;
+ },
+
+ get description() {
+ return this._plugin.description;
+ },
+ get fullDescription() {
+ return null;
+ },
+
+ getFullDescription(doc) {
+ let plugin = this._rawPlugin;
+
+ let frag = doc.createDocumentFragment();
+ for (let [urlProp, labelId] of [
+ ["learnMoreURL", GMP_LEARN_MORE],
+ [
+ "licenseURL",
+ this.id == WIDEVINE_L1_ID || this.id == WIDEVINE_L3_ID
+ ? GMP_PRIVACY_INFO
+ : GMP_LICENSE_INFO,
+ ],
+ ]) {
+ if (plugin[urlProp]) {
+ let a = doc.createElementNS(XHTML, "a");
+ a.href = plugin[urlProp];
+ a.target = "_blank";
+ a.textContent = lazy.addonsBundle.formatValueSync(labelId);
+
+ if (frag.childElementCount) {
+ frag.append(
+ doc.createElementNS(XHTML, "br"),
+ doc.createElementNS(XHTML, "br")
+ );
+ }
+ frag.append(a);
+ }
+ }
+
+ return frag;
+ },
+
+ get version() {
+ return GMPPrefs.getString(
+ GMPPrefs.KEY_PLUGIN_VERSION,
+ null,
+ this._plugin.id
+ );
+ },
+
+ get isActive() {
+ return (
+ !this.appDisabled &&
+ !this.userDisabled &&
+ !GMPUtils.isPluginHidden(this._plugin)
+ );
+ },
+ get appDisabled() {
+ if (
+ this._plugin.isEME &&
+ !GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)
+ ) {
+ // If "media.eme.enabled" is false, all EME plugins are disabled.
+ return true;
+ }
+ return false;
+ },
+
+ get userDisabled() {
+ return !GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_ENABLED,
+ true,
+ this._plugin.id
+ );
+ },
+ set userDisabled(aVal) {
+ GMPPrefs.setBool(
+ GMPPrefs.KEY_PLUGIN_ENABLED,
+ aVal === false,
+ this._plugin.id
+ );
+ },
+
+ async enable() {
+ this.userDisabled = false;
+ },
+ async disable() {
+ this.userDisabled = true;
+ },
+
+ get blocklistState() {
+ return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ },
+ get size() {
+ return 0;
+ },
+ get scope() {
+ return lazy.AddonManager.SCOPE_APPLICATION;
+ },
+ get pendingOperations() {
+ return lazy.AddonManager.PENDING_NONE;
+ },
+
+ get operationsRequiringRestart() {
+ return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+ },
+
+ get permissions() {
+ let permissions = 0;
+ if (!this.appDisabled) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ permissions |= this.userDisabled
+ ? lazy.AddonManager.PERM_CAN_ENABLE
+ : lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ return permissions;
+ },
+
+ get updateDate() {
+ let time = Number(
+ GMPPrefs.getInt(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, 0, this._plugin.id)
+ );
+ if (this.isInstalled) {
+ return new Date(time * 1000);
+ }
+ return null;
+ },
+
+ get isCompatible() {
+ return true;
+ },
+
+ get isPlatformCompatible() {
+ return true;
+ },
+
+ get providesUpdatesSecurely() {
+ return true;
+ },
+
+ get foreignInstall() {
+ return false;
+ },
+
+ get installTelemetryInfo() {
+ return { source: "gmp-plugin" };
+ },
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ },
+
+ get applyBackgroundUpdates() {
+ if (!GMPPrefs.isSet(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id)) {
+ return lazy.AddonManager.AUTOUPDATE_DEFAULT;
+ }
+
+ return GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_AUTOUPDATE,
+ true,
+ this._plugin.id
+ )
+ ? lazy.AddonManager.AUTOUPDATE_ENABLE
+ : lazy.AddonManager.AUTOUPDATE_DISABLE;
+ },
+
+ set applyBackgroundUpdates(aVal) {
+ if (aVal == lazy.AddonManager.AUTOUPDATE_DEFAULT) {
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id);
+ } else if (aVal == lazy.AddonManager.AUTOUPDATE_ENABLE) {
+ GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, this._plugin.id);
+ } else if (aVal == lazy.AddonManager.AUTOUPDATE_DISABLE) {
+ GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, this._plugin.id);
+ }
+ },
+
+ /**
+ * Called by the addon manager to update GMP addons. For example this will be
+ * used if a user manually checks for GMP plugin updates by using the
+ * menu in about:addons.
+ *
+ * This function is not used if MediaKeySystemAccess is requested and
+ * Widevine is not yet installed, or if the user toggles prefs to enable EME.
+ * For the function used in those cases see `checkForUpdates`.
+ */
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - reason=" + aReason
+ );
+
+ // In the case of GMP addons we do not wish to implement AddonInstall, as
+ // we don't want to display information as in a normal addon install such
+ // as a download progress bar. As such, we short circuit our
+ // listeners by indicating that no updates exist (though some may).
+ lazy.AddonManagerPrivate.callNoUpdateListeners(this, aListener);
+
+ if (aReason === lazy.AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) {
+ if (!lazy.AddonManager.shouldAutoUpdate(this)) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - no autoupdate"
+ );
+ return Promise.resolve(false);
+ }
+
+ let secSinceLastCheck =
+ Date.now() / 1000 -
+ Services.prefs.getIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0);
+ if (secSinceLastCheck <= SEC_IN_A_DAY) {
+ this._log.trace(
+ "findUpdates() - " +
+ this._plugin.id +
+ " - last check was less then a day ago"
+ );
+ return Promise.resolve(false);
+ }
+ } else if (aReason !== lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED) {
+ this._log.trace(
+ "findUpdates() - " +
+ this._plugin.id +
+ " - the given reason to update is not supported"
+ );
+ return Promise.resolve(false);
+ }
+
+ if (this._updateTask !== null) {
+ this._log.trace(
+ "findUpdates() - " + this._plugin.id + " - update task already running"
+ );
+ return this._updateTask;
+ }
+
+ this._updateTask = (async () => {
+ this._log.trace("findUpdates() - updateTask");
+ try {
+ let installManager = new lazy.GMPInstallManager();
+ let res = await installManager.checkForAddons();
+ let update = res.addons.find(addon => addon.id === this._plugin.id);
+ if (update && update.isValid && !update.isInstalled) {
+ this._log.trace(
+ "findUpdates() - found update for " +
+ this._plugin.id +
+ ", installing"
+ );
+ await installManager.installAddon(update);
+ } else {
+ this._log.trace("findUpdates() - no updates for " + this._plugin.id);
+ }
+ this._log.info(
+ "findUpdates() - updateTask succeeded for " + this._plugin.id
+ );
+ } catch (e) {
+ this._log.error(
+ "findUpdates() - updateTask for " + this._plugin.id + " threw",
+ e
+ );
+ throw e;
+ } finally {
+ this._updateTask = null;
+ }
+ return true;
+ })();
+
+ return this._updateTask;
+ },
+
+ get pluginLibraries() {
+ if (this.isInstalled) {
+ let path = this.version;
+ return [path];
+ }
+ return [];
+ },
+ get pluginFullpath() {
+ if (this.isInstalled) {
+ let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ this.version
+ );
+ return [path];
+ }
+ return [];
+ },
+
+ get isInstalled() {
+ return this.version && !!this.version.length;
+ },
+
+ _handleEnabledChanged() {
+ this._log.info(
+ "_handleEnabledChanged() id=" +
+ this._plugin.id +
+ " isActive=" +
+ this.isActive
+ );
+
+ lazy.AddonManagerPrivate.callAddonListeners(
+ this.isActive ? "onEnabling" : "onDisabling",
+ this,
+ false
+ );
+ if (this._gmpPath) {
+ if (this.isActive) {
+ this._log.info(
+ "onPrefEnabledChanged() - adding gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.addPluginDirectory(this._gmpPath);
+ } else {
+ this._log.info(
+ "onPrefEnabledChanged() - removing gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.removePluginDirectory(this._gmpPath);
+ }
+ }
+ lazy.AddonManagerPrivate.callAddonListeners(
+ this.isActive ? "onEnabled" : "onDisabled",
+ this
+ );
+ },
+
+ onPrefEMEGlobalEnabledChanged() {
+ this._log.info(
+ "onPrefEMEGlobalEnabledChanged() id=" +
+ this._plugin.id +
+ " appDisabled=" +
+ this.appDisabled +
+ " isActive=" +
+ this.isActive +
+ " hidden=" +
+ GMPUtils.isPluginHidden(this._plugin)
+ );
+
+ lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "appDisabled",
+ ]);
+ // If EME or the GMP itself are disabled, uninstall the GMP.
+ // Otherwise, check for updates, so we download and install the GMP.
+ if (this.appDisabled) {
+ this.uninstallPlugin();
+ } else if (!GMPUtils.isPluginHidden(this._plugin)) {
+ lazy.AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ this,
+ null,
+ false
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this);
+ this.checkForUpdates(GMP_CHECK_DELAY);
+ }
+ if (!this.userDisabled) {
+ this._handleEnabledChanged();
+ }
+ },
+
+ /**
+ * This is called if prefs are changed to enable EME, or if Widevine
+ * MediaKeySystemAccess is requested but the Widevine CDM is not installed.
+ *
+ * For the function used by the addon manager see `findUpdates`.
+ */
+ checkForUpdates(delay) {
+ if (this._isUpdateCheckPending) {
+ return;
+ }
+ this._isUpdateCheckPending = true;
+ GMPPrefs.reset(GMPPrefs.KEY_UPDATE_LAST_CHECK, null);
+ // Delay this in case the user changes his mind and doesn't want to
+ // enable EME after all.
+ lazy.setTimeout(() => {
+ if (!this.appDisabled) {
+ let gmpInstallManager = new lazy.GMPInstallManager();
+ // We don't really care about the results, if someone is interested
+ // they can check the log.
+ gmpInstallManager.simpleCheckAndInstall().catch(() => {});
+ }
+ this._isUpdateCheckPending = false;
+ }, delay);
+ },
+
+ onPrefEnabledChanged() {
+ if (!this._plugin.isEME || !this.appDisabled) {
+ this._handleEnabledChanged();
+ }
+ },
+
+ onPrefVersionChanged() {
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+ if (this._gmpPath) {
+ this._log.info(
+ "onPrefVersionChanged() - unregistering gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.removeAndDeletePluginDirectory(
+ this._gmpPath,
+ true /* can defer */
+ );
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+
+ lazy.AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ this,
+ null,
+ false
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false);
+ this._gmpPath = null;
+ if (this.isInstalled) {
+ this._gmpPath = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ this._plugin.id,
+ GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id)
+ );
+ }
+ if (this._gmpPath && this.isActive) {
+ this._log.info(
+ "onPrefVersionChanged() - registering gmp directory " + this._gmpPath
+ );
+ lazy.gmpService.addPluginDirectory(this._gmpPath);
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this);
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ let pref = data;
+ if (
+ pref ==
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id)
+ ) {
+ this.onPrefEnabledChanged();
+ } else if (
+ pref ==
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id)
+ ) {
+ this.onPrefVersionChanged();
+ } else if (pref == GMPPrefs.KEY_EME_ENABLED) {
+ this.onPrefEMEGlobalEnabledChanged();
+ }
+ } else if (topic == "EMEVideo:CDMMissing") {
+ this.checkForUpdates(0);
+ }
+ },
+
+ uninstallPlugin() {
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+ if (this.gmpPath) {
+ this._log.info(
+ "uninstallPlugin() - unregistering gmp directory " + this.gmpPath
+ );
+ lazy.gmpService.removeAndDeletePluginDirectory(this.gmpPath);
+ }
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_HASHVALUE, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_ABI, this.id);
+ GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, this.id);
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+ },
+
+ shutdown() {
+ Services.prefs.removeObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id),
+ this
+ );
+ Services.prefs.removeObserver(
+ GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id),
+ this
+ );
+ if (this._plugin.isEME) {
+ Services.prefs.removeObserver(GMPPrefs.KEY_EME_ENABLED, this);
+ Services.obs.removeObserver(this, "EMEVideo:CDMMissing");
+ }
+ return this._updateTask;
+ },
+
+ _arePluginFilesOnDisk() {
+ let fileExists = function (aGmpPath, aFileName) {
+ let f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ let path = PathUtils.join(aGmpPath, aFileName);
+ f.initWithPath(path);
+ return f.exists();
+ };
+
+ let libName =
+ AppConstants.DLL_PREFIX + this._plugin.libName + AppConstants.DLL_SUFFIX;
+ let infoName;
+ if (
+ this._plugin.id == WIDEVINE_L1_ID ||
+ this._plugin.id == WIDEVINE_L3_ID
+ ) {
+ infoName = "manifest.json";
+ } else {
+ infoName = this._plugin.id.substring(4) + ".info";
+ }
+
+ return (
+ fileExists(this.gmpPath, libName) && fileExists(this.gmpPath, infoName)
+ );
+ },
+
+ validate() {
+ if (!this.isInstalled) {
+ // Not installed -> Valid.
+ return {
+ installed: false,
+ valid: true,
+ };
+ }
+
+ let expectedABI = GMPUtils._expectedABI(this._plugin);
+ let abi = GMPPrefs.getString(
+ GMPPrefs.KEY_PLUGIN_ABI,
+ expectedABI,
+ this._plugin.id
+ );
+ if (abi != expectedABI) {
+ // ABI doesn't match. Possibly this is a profile migrated across platforms
+ // or from 32 -> 64 bit.
+ return {
+ installed: true,
+ mismatchedABI: true,
+ valid: false,
+ };
+ }
+
+ // Installed -> Check if files are missing.
+ let filesOnDisk = this._arePluginFilesOnDisk();
+ return {
+ installed: true,
+ valid: filesOnDisk,
+ };
+ },
+};
+
+var GMPProvider = {
+ get name() {
+ return "GMPProvider";
+ },
+
+ _plugins: null,
+
+ startup() {
+ configureLogging();
+ this._log = lazy.Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.GMP",
+ "GMPProvider."
+ );
+ this.buildPluginList();
+ this.ensureProperCDMInstallState();
+
+ Services.prefs.addObserver(GMPPrefs.KEY_LOG_BASE, configureLogging);
+
+ for (let plugin of this._plugins.values()) {
+ let wrapper = plugin.wrapper;
+ let gmpPath = wrapper.gmpPath;
+ let isEnabled = wrapper.isActive;
+ this._log.trace(
+ "startup - enabled=" + isEnabled + ", gmpPath=" + gmpPath
+ );
+
+ if (gmpPath && isEnabled) {
+ let validation = wrapper.validate();
+ if (validation.mismatchedABI) {
+ this._log.info(
+ "startup - gmp " + plugin.id + " mismatched ABI, uninstalling"
+ );
+ wrapper.uninstallPlugin();
+ continue;
+ }
+ if (!validation.valid) {
+ this._log.info(
+ "startup - gmp " + plugin.id + " invalid, uninstalling"
+ );
+ wrapper.uninstallPlugin();
+ continue;
+ }
+ this._log.info("startup - adding gmp directory " + gmpPath);
+ try {
+ lazy.gmpService.addPluginDirectory(gmpPath);
+ } catch (e) {
+ if (e.name != "NS_ERROR_NOT_AVAILABLE") {
+ throw e;
+ }
+ this._log.warn(
+ "startup - adding gmp directory failed with " +
+ e.name +
+ " - sandboxing not available?",
+ e
+ );
+ }
+ }
+ }
+
+ try {
+ let greDir = Services.dirsvc.get(NS_GRE_DIR, Ci.nsIFile);
+ let path = greDir.path;
+ if (
+ GMPUtils._isWindowsOnARM64() &&
+ GMPPrefs.getBool(
+ GMPPrefs.KEY_PLUGIN_ALLOW_X64_ON_ARM64,
+ true,
+ CLEARKEY_PLUGIN_ID
+ )
+ ) {
+ path = PathUtils.join(path, "i686");
+ }
+ let clearkeyPath = PathUtils.join(
+ path,
+ CLEARKEY_PLUGIN_ID,
+ CLEARKEY_VERSION
+ );
+ this._log.info("startup - adding clearkey CDM directory " + clearkeyPath);
+ lazy.gmpService.addPluginDirectory(clearkeyPath);
+ } catch (e) {
+ this._log.warn("startup - adding clearkey CDM failed", e);
+ }
+ },
+
+ shutdown() {
+ this._log.trace("shutdown");
+ Services.prefs.removeObserver(GMPPrefs.KEY_LOG_BASE, configureLogging);
+
+ let shutdownTask = (async () => {
+ this._log.trace("shutdown - shutdownTask");
+ let shutdownSucceeded = true;
+
+ for (let plugin of this._plugins.values()) {
+ try {
+ await plugin.wrapper.shutdown();
+ } catch (e) {
+ shutdownSucceeded = false;
+ }
+ }
+
+ this._plugins = null;
+
+ if (!shutdownSucceeded) {
+ throw new Error("Shutdown failed");
+ }
+ })();
+
+ return shutdownTask;
+ },
+
+ async getAddonByID(aId) {
+ if (!this.isEnabled) {
+ return null;
+ }
+
+ let plugin = this._plugins.get(aId);
+ if (plugin && !GMPUtils.isPluginHidden(plugin)) {
+ return plugin.wrapper;
+ }
+ return null;
+ },
+
+ async getAddonsByTypes(aTypes) {
+ if (!this.isEnabled || (aTypes && !aTypes.includes("plugin"))) {
+ return [];
+ }
+
+ let results = Array.from(this._plugins.values())
+ .filter(p => !GMPUtils.isPluginHidden(p))
+ .map(p => p.wrapper);
+
+ return results;
+ },
+
+ get isEnabled() {
+ return lazy.gmpProviderEnabled;
+ },
+
+ buildPluginList() {
+ this._plugins = new Map();
+ for (let aPlugin of GMP_PLUGINS) {
+ let plugin = {
+ id: aPlugin.id,
+ name: lazy.addonsBundle.formatValueSync(aPlugin.name),
+ description: lazy.addonsBundle.formatValueSync(aPlugin.description),
+ libName: aPlugin.libName,
+ homepageURL: aPlugin.homepageURL,
+ optionsURL: aPlugin.optionsURL,
+ wrapper: null,
+ isEME: aPlugin.isEME,
+ };
+ plugin.wrapper = new GMPWrapper(plugin, aPlugin);
+ this._plugins.set(plugin.id, plugin);
+ }
+ },
+
+ ensureProperCDMInstallState() {
+ if (!GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)) {
+ for (let plugin of this._plugins.values()) {
+ if (plugin.isEME && plugin.wrapper.isInstalled) {
+ lazy.gmpService.addPluginDirectory(plugin.wrapper.gmpPath);
+ plugin.wrapper.uninstallPlugin();
+ }
+ }
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+ lazy.AddonManagerPrivate.registerProvider(GMPProvider, ["plugin"]);
+ Services.obs.notifyObservers(null, "gmp-provider-registered");
+
+ Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ }
+ },
+
+ addObserver() {
+ Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ },
+};
+
+GMPProvider.addObserver();
+
+// For test use only.
+export const GMPTestUtils = {
+ /**
+ * Used to override the GMP service with a mock.
+ *
+ * @param {object} mockService
+ * The mocked gmpService object.
+ * @param {function} callback
+ * Method called with the overridden gmpService. The override
+ * is undone after the callback returns.
+ */
+ async overrideGmpService(mockService, callback) {
+ let originalGmpService = lazy.gmpService;
+ lazy.gmpService = mockService;
+ try {
+ return await callback();
+ } finally {
+ lazy.gmpService = originalGmpService;
+ }
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
new file mode 100644
index 0000000000..1615a551c8
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs
@@ -0,0 +1,601 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+import { CertUtils } from "resource://gre/modules/CertUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs",
+});
+
+// This will inherit settings from the "addons" logger.
+var logger = Log.repository.getLogger("addons.productaddons");
+// We want to set the level of this logger independent from its parent to help
+// debug things like GMP updates. Link this to its own level pref.
+logger.manageLevelFromPref("extensions.logging.productaddons.level");
+
+/**
+ * Number of milliseconds after which we need to cancel `downloadXMLWithRequest`
+ * and `conservativeFetch`.
+ *
+ * Bug 1087674 suggests that the XHR/ServiceRequest we use in
+ * `downloadXMLWithRequest` may never terminate in presence of network nuisances
+ * (e.g. strange antivirus behavior). This timeout is a defensive measure to
+ * ensure that we fail cleanly in such case.
+ */
+const TIMEOUT_DELAY_MS = 20000;
+
+/**
+ * Gets the status of an XMLHttpRequest either directly or from its underlying
+ * channel.
+ *
+ * @param request
+ * The XMLHttpRequest.
+ * @returns {Object} result - An object containing the results.
+ * @returns {integer} result.status - Request status code, if available, else the channel nsresult.
+ * @returns {integer} result.channelStatus - Channel nsresult.
+ * @returns {integer} result.errorCode - Request error code.
+ */
+function getRequestStatus(request) {
+ let status = null;
+ let errorCode = null;
+ let channelStatus = null;
+
+ try {
+ status = request.status;
+ } catch (e) {}
+ try {
+ errorCode = request.errorCode;
+ } catch (e) {}
+ try {
+ channelStatus = request.channel.QueryInterface(Ci.nsIRequest).status;
+ } catch (e) {}
+
+ if (status == null) {
+ status = channelStatus;
+ }
+
+ return { status, channelStatus, errorCode };
+}
+
+/**
+ * A wrapper around `ServiceRequest` that behaves like a limited `fetch()`.
+ * This doesn't handle headers like fetch, but can be expanded as callers need.
+ *
+ * Use this in order to leverage the `beConservative` flag, for
+ * example to avoid using HTTP3 to fetch critical data.
+ *
+ * @param input a resource
+ * @returns a Response object
+ */
+async function conservativeFetch(input) {
+ return new Promise(function (resolve, reject) {
+ const request = new lazy.ServiceRequest({ mozAnon: true });
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.onerror = () => {
+ let err = new TypeError("NetworkError: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ reject(err);
+ };
+ request.ontimeout = () => {
+ let err = new TypeError("Timeout: Network request failed");
+ err.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ reject(err);
+ };
+ request.onabort = () => {
+ let err = new DOMException("Aborted", "AbortError");
+ err.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ reject(err);
+ };
+ request.onload = () => {
+ const responseAttributes = {
+ status: request.status,
+ statusText: request.statusText,
+ url: request.responseURL,
+ };
+ resolve(new Response(request.response, responseAttributes));
+ };
+
+ const method = "GET";
+
+ request.open(method, input, true);
+
+ request.send();
+ });
+}
+
+/**
+ * Verifies the content signature on GMP's update.xml. When we fetch update.xml
+ * balrog should send back content signature headers, which this function
+ * is used to verify.
+ *
+ * @param data
+ * The data received from balrog. I.e. the xml contents of update.xml.
+ * @param contentSignatureHeader
+ * The contents of the 'content-signature' header received along with
+ * `data`.
+ * @return A promise that will resolve to nothing if the signature verification
+ * succeeds, or rejects on failure, with an Error that sets its
+ * addonCheckerErr property disambiguate failure cases and a message
+ * explaining the error.
+ */
+async function verifyGmpContentSignature(data, contentSignatureHeader) {
+ if (!contentSignatureHeader) {
+ logger.warn(
+ "Unexpected missing content signature header during content signature validation"
+ );
+ let err = new Error(
+ "Content signature validation failed: missing content signature header"
+ );
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+ // Split out the header. It should contain a the following fields, separated by a semicolon
+ // - x5u - a URI to the cert chain. See also https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.5
+ // - p384ecdsa - the signature to verify. See also https://github.com/mozilla-services/autograph/blob/main/signer/contentsignaturepki/README.md
+ const headerFields = contentSignatureHeader
+ .split(";") // Split fields...
+ .map(s => s.trim()) // Remove whitespace...
+ .map(s => [
+ // Break each field into it's name and value. This more verbose version is
+ // used instead of `split()` to handle values that contain = characters. This
+ // shouldn't happen for the signature because it's base64_url (no = padding),
+ // but it's not clear if it's possible for the x5u URL (as part of a query).
+ // Guard anyway, better safe than sorry.
+ s.substring(0, s.indexOf("=")), // Get field name...
+ s.substring(s.indexOf("=") + 1), // and field value.
+ ]);
+
+ let x5u;
+ let signature;
+ for (const [fieldName, fieldValue] of headerFields) {
+ if (fieldName == "x5u") {
+ x5u = fieldValue;
+ } else if (fieldName == "p384ecdsa") {
+ // The signature needs to contain 'p384ecdsa', so stich it back together.
+ signature = `p384ecdsa=${fieldValue}`;
+ }
+ }
+
+ if (!x5u) {
+ logger.warn("Unexpected missing x5u during content signature validation");
+ let err = Error("Content signature validation failed: missing x5u");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ if (!signature) {
+ logger.warn(
+ "Unexpected missing signature during content signature validation"
+ );
+ let err = Error("Content signature validation failed: missing signature");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR;
+ throw err;
+ }
+
+ // The x5u field should contain the location of the cert chain, fetch it.
+ // Use `conservativeFetch` so we get conservative behaviour and ensure (more)
+ // reliable fetching.
+ const certChain = await (await conservativeFetch(x5u)).text();
+
+ const verifier = Cc[
+ "@mozilla.org/security/contentsignatureverifier;1"
+ ].createInstance(Ci.nsIContentSignatureVerifier);
+
+ // See bug 1771992. In the future, this may need to handle staging and dev
+ // environments in addition to just production and testing.
+ let root = Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ root = Ci.nsIX509CertDB.AppXPCShellRoot;
+ }
+
+ let valid;
+ try {
+ valid = await verifier.asyncVerifyContentSignature(
+ data,
+ signature,
+ certChain,
+ "aus.content-signature.mozilla.org",
+ root
+ );
+ } catch (err) {
+ logger.warn(`Unexpected error while validating content signature: ${err}`);
+ let newErr = new Error(`Content signature validation failed: ${err}`);
+ newErr.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ throw newErr;
+ }
+
+ if (!valid) {
+ logger.warn("Unexpected invalid content signature found during validation");
+ let err = new Error("Content signature is not valid");
+ err.addonCheckerErr = ProductAddonChecker.VERIFICATION_INVALID_ERR;
+ throw err;
+ }
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @return a promise that resolves to the ServiceRequest request on success or
+ * rejects with a JS exception in case of error.
+ */
+function downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null
+) {
+ return new Promise((resolve, reject) => {
+ let request = new lazy.ServiceRequest();
+ // This is here to let unit test code override the ServiceRequest.
+ if (request.wrappedJSObject) {
+ request = request.wrappedJSObject;
+ }
+ request.open("GET", url, true);
+ request.channel.notificationCallbacks = new CertUtils.BadCertHandler(
+ allowNonBuiltIn
+ );
+ // Prevent the request from reading from the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ // Don't send any cookies
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ request.timeout = TIMEOUT_DELAY_MS;
+
+ request.overrideMimeType("text/xml");
+ // The Cache-Control header is only interpreted by proxies and the
+ // final destination. It does not help if a resource is already
+ // cached locally.
+ request.setRequestHeader("Cache-Control", "no-cache");
+ // HTTP/1.0 servers might not implement Cache-Control and
+ // might only implement Pragma: no-cache
+ request.setRequestHeader("Pragma", "no-cache");
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading XML, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ if (event.type == "error") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR;
+ } else if (event.type == "abort") {
+ ex.addonCheckerErr = ProductAddonChecker.ABORT_ERR;
+ } else if (event.type == "timeout") {
+ ex.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR;
+ }
+ reject(ex);
+ };
+
+ let success = event => {
+ logger.info("Completed downloading document");
+ let request = event.target;
+
+ try {
+ CertUtils.checkCert(request.channel, allowNonBuiltIn, allowedCerts);
+ } catch (ex) {
+ logger.error("Request failed certificate checks: " + ex);
+ ex.status = getRequestStatus(request).requestStatus;
+ ex.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR;
+ reject(ex);
+ return;
+ }
+
+ resolve(request);
+ };
+
+ request.addEventListener("error", fail);
+ request.addEventListener("abort", fail);
+ request.addEventListener("timeout", fail);
+ request.addEventListener("load", success);
+
+ logger.info("sending request to: " + url);
+ request.send(null);
+ });
+}
+
+/**
+ * Downloads an XML document from a URL optionally testing the SSL certificate
+ * for certain attributes, and/or testing the content signature.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to the DOM document downloaded or rejects
+ * with a JS exception in case of error.
+ */
+async function downloadXML(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+) {
+ let request = await downloadXMLWithRequest(
+ url,
+ allowNonBuiltIn,
+ allowedCerts
+ );
+ if (verifyContentSignature) {
+ await verifyGmpContentSignature(
+ request.response,
+ request.getResponseHeader("content-signature")
+ );
+ }
+ return request.responseXML;
+}
+
+/**
+ * Parses a list of add-ons from a DOM document.
+ *
+ * @param document
+ * The DOM document to parse.
+ * @return null if there is no <addons> element otherwise an object containing
+ * an array of the addons listed and a field notifying whether the
+ * fallback was used.
+ */
+function parseXML(document) {
+ // Check that the root element is correct
+ if (document.documentElement.localName != "updates") {
+ let err = new Error(
+ "got node name: " +
+ document.documentElement.localName +
+ ", expected: updates"
+ );
+ err.addonCheckerErr = ProductAddonChecker.XML_PARSE_ERR;
+ throw err;
+ }
+
+ // Check if there are any addons elements in the updates element
+ let addons = document.querySelector("updates:root > addons");
+ if (!addons) {
+ return null;
+ }
+
+ let results = [];
+ let addonList = document.querySelectorAll("updates:root > addons > addon");
+ for (let addonElement of addonList) {
+ let addon = {};
+
+ for (let name of [
+ "id",
+ "URL",
+ "hashFunction",
+ "hashValue",
+ "version",
+ "size",
+ ]) {
+ if (addonElement.hasAttribute(name)) {
+ addon[name] = addonElement.getAttribute(name);
+ }
+ }
+ addon.size = Number(addon.size) || undefined;
+
+ results.push(addon);
+ }
+
+ return {
+ usedFallback: false,
+ addons: results,
+ };
+}
+
+/**
+ * Downloads file from a URL using ServiceRequest.
+ *
+ * @param url
+ * The url to download from.
+ * @param options (optional)
+ * @param options.httpsOnlyNoUpgrade
+ * Prevents upgrade to https:// when HTTPS-Only Mode is enabled.
+ * @return a promise that resolves to the path of a temporary file or rejects
+ * with a JS exception in case of error.
+ */
+function downloadFile(url, options = { httpsOnlyNoUpgrade: false }) {
+ return new Promise((resolve, reject) => {
+ let sr = new lazy.ServiceRequest();
+
+ sr.onload = function (response) {
+ logger.info("downloadFile File download. status=" + sr.status);
+ if (sr.status != 200 && sr.status != 206) {
+ reject(Components.Exception("File download failed", sr.status));
+ return;
+ }
+ (async function () {
+ const path = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "tmpaddon"
+ );
+ logger.info(`Downloaded file will be saved to ${path}`);
+ await IOUtils.write(path, new Uint8Array(sr.response));
+
+ return path;
+ })().then(resolve, reject);
+ };
+
+ let fail = event => {
+ let request = event.target;
+ let status = getRequestStatus(request);
+ let message =
+ "Failed downloading via ServiceRequest, status: " +
+ status.status +
+ ", channelStatus: " +
+ status.channelStatus +
+ ", errorCode: " +
+ status.errorCode +
+ ", reason: " +
+ event.type;
+ logger.warn(message);
+ let ex = new Error(message);
+ ex.status = status.status;
+ reject(ex);
+ };
+ sr.addEventListener("error", fail);
+ sr.addEventListener("abort", fail);
+
+ sr.responseType = "arraybuffer";
+ try {
+ sr.open("GET", url);
+ if (options.httpsOnlyNoUpgrade) {
+ sr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT;
+ }
+ // Allow deprecated HTTP request from SystemPrincipal
+ sr.channel.loadInfo.allowDeprecatedSystemRequests = true;
+ sr.send(null);
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+}
+
+/**
+ * Verifies that a downloaded file matches what was expected.
+ *
+ * @param properties
+ * The properties to check, `hashFunction` with `hashValue`
+ * are supported. Any properties missing won't be checked.
+ * @param path
+ * The path of the file to check.
+ * @return a promise that resolves if the file matched or rejects with a JS
+ * exception in case of error.
+ */
+var verifyFile = async function (properties, path) {
+ if (properties.size !== undefined) {
+ let stat = await IOUtils.stat(path);
+ if (stat.size != properties.size) {
+ throw new Error(
+ "Downloaded file was " +
+ stat.size +
+ " bytes but expected " +
+ properties.size +
+ " bytes."
+ );
+ }
+ }
+
+ if (properties.hashFunction !== undefined) {
+ let expectedDigest = properties.hashValue.toLowerCase();
+ let digest = await IOUtils.computeHexDigest(path, properties.hashFunction);
+ if (digest != expectedDigest) {
+ throw new Error(
+ "Hash was `" + digest + "` but expected `" + expectedDigest + "`."
+ );
+ }
+ }
+};
+
+export const ProductAddonChecker = {
+ // More specific error names to help debug and report failures.
+ NETWORK_REQUEST_ERR: "NetworkRequestError",
+ NETWORK_TIMEOUT_ERR: "NetworkTimeoutError",
+ ABORT_ERR: "AbortError", // Doesn't have network prefix to work with existing convention.
+ VERIFICATION_MISSING_DATA_ERR: "VerificationMissingDataError",
+ VERIFICATION_FAILED_ERR: "VerificationFailedError",
+ VERIFICATION_INVALID_ERR: "VerificationInvalidError",
+ XML_PARSE_ERR: "XMLParseError",
+
+ /**
+ * Downloads a list of add-ons from a URL optionally testing the SSL
+ * certificate for certain attributes, and/or testing the content signature.
+ *
+ * @param url
+ * The url to download from.
+ * @param allowNonBuiltIn
+ * Whether to trust SSL certificates without a built-in CA issuer.
+ * @param allowedCerts
+ * The list of certificate attributes to match the SSL certificate
+ * against or null to skip checks.
+ * @param verifyContentSignature
+ * When true, will verify the content signature information from the
+ * response header. Failure to verify will result in an error.
+ * @return a promise that resolves to an object containing the list of add-ons
+ * and whether the local fallback was used, or rejects with a JS
+ * exception in case of error. In the case of an error, a best effort
+ * is made to set the error addonCheckerErr property to one of the
+ * more specific names used by the product addon checker.
+ */
+ getProductAddonList(
+ url,
+ allowNonBuiltIn = false,
+ allowedCerts = null,
+ verifyContentSignature = false
+ ) {
+ return downloadXML(
+ url,
+ allowNonBuiltIn,
+ allowedCerts,
+ verifyContentSignature
+ ).then(parseXML);
+ },
+
+ /**
+ * Downloads an add-on to a local file and checks that it matches the expected
+ * file. The caller is responsible for deleting the temporary file returned.
+ *
+ * @param addon
+ * The addon to download.
+ * @param options (optional)
+ * @param options.httpsOnlyNoUpgrade
+ * Prevents upgrade to https:// when HTTPS-Only Mode is enabled.
+ * @return a promise that resolves to the temporary file downloaded or rejects
+ * with a JS exception in case of error.
+ */
+ async downloadAddon(addon, options = { httpsOnlyNoUpgrade: false }) {
+ let path = await downloadFile(addon.URL, options);
+ try {
+ await verifyFile(addon, path);
+ return path;
+ } catch (e) {
+ await IOUtils.remove(path);
+ throw e;
+ }
+ },
+};
+
+// For test use only.
+export const ProductAddonCheckerTestUtils = {
+ /**
+ * Used to override ServiceRequest calls with a mock request.
+ * @param mockRequest The mocked ServiceRequest object.
+ * @param callback Method called with the overridden ServiceRequest. The override
+ * is undone after the callback returns.
+ */
+ async overrideServiceRequest(mockRequest, callback) {
+ let originalServiceRequest = lazy.ServiceRequest;
+ lazy.ServiceRequest = function () {
+ return mockRequest;
+ };
+ try {
+ return await callback();
+ } finally {
+ lazy.ServiceRequest = originalServiceRequest;
+ }
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
new file mode 100644
index 0000000000..ccac484a1e
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
@@ -0,0 +1,661 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
+import {
+ GATED_PERMISSIONS,
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ SITEPERMS_ADDON_TYPE,
+ isGatedPermissionType,
+ isKnownPublicSuffix,
+ isPrincipalInSitePermissionsBlocklist,
+} from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+});
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "addonsBundle",
+ () => new Localization(["toolkit/about/aboutAddons.ftl"], true)
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SITEPERMS_ADDON_PROVIDER_ENABLED",
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ false
+);
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org";
+
+// Generate a per-session random salt, which is then used to generate
+// per-siteOrigin hashed strings used as the addon id in SitePermsAddonWrapper constructor
+// (expected to be matching new addon id generated for the same siteOrigin during
+// the same browsing session and different ones in new browsing sessions).
+//
+// NOTE: `generateSalt` is exported for testing purpose, should not be
+// used outside of tests.
+let SALT;
+export function generateSalt() {
+ // Throw if we're not in test and SALT is already defined
+ if (
+ typeof SALT !== "undefined" &&
+ !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")
+ ) {
+ throw new Error("This should only be called from XPCShell tests");
+ }
+ SALT = crypto.getRandomValues(new Uint8Array(12)).join("");
+}
+
+function getSalt() {
+ if (!SALT) {
+ generateSalt();
+ }
+ return SALT;
+}
+
+class SitePermsAddonWrapper {
+ // An array of nsIPermission granted for the siteOrigin.
+ // We can't use a Set as handlePermissionChange might be called with different
+ // nsIPermission instance for the same permission (in the generic sense)
+ #permissions = [];
+
+ // This will be set to true in the `uninstall` method to recognize when a perm-changed notification
+ // is actually triggered by the SitePermsAddonWrapper uninstall method itself.
+ isUninstalling = false;
+
+ /**
+ * @param {string} siteOriginNoSuffix: The origin this addon is installed for
+ * WITHOUT the suffix generated from the
+ * origin attributes (see:
+ * nsIPrincipal.siteOriginNoSuffix).
+ * @param {Array<nsIPermission>} permissions: An array of the initial
+ * permissions the user granted
+ * for the addon origin.
+ */
+ constructor(siteOriginNoSuffix, permissions = []) {
+ this.siteOrigin = siteOriginNoSuffix;
+ this.principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ this.siteOrigin
+ );
+ // Use a template string for the concat in case `siteOrigin` isn't a string.
+ const saltedValue = `${this.siteOrigin}${getSalt()}`;
+ this.id = `${computeSha256HashAsString(
+ saltedValue
+ )}${SITEPERMS_ADDON_ID_SUFFIX}`;
+
+ for (const perm of permissions) {
+ this.#permissions.push(perm);
+ }
+ }
+
+ get isUninstalled() {
+ return this.#permissions.length === 0;
+ }
+
+ /**
+ * Returns the list of gated permissions types granted for the instance's origin
+ *
+ * @return {Array<String>}
+ */
+ get sitePermissions() {
+ return Array.from(new Set(this.#permissions.map(perm => perm.type)));
+ }
+
+ /**
+ * Update #permissions, and calls `uninstall` if there are no remaining gated permissions
+ * granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated
+ * permission.
+ *
+ * @param {nsIPermission} permission: The permission being added/removed
+ * @param {String} action: The action perm-changed notifies us about
+ */
+ handlePermissionChange(permission, action) {
+ if (action == "added") {
+ this.#permissions.push(permission);
+ } else if (action == "deleted") {
+ // We want to remove the registered permission for the right principal (looking into originSuffix so we
+ // can unregister revoked permission on a specific context, private window, ...).
+ this.#permissions = this.#permissions.filter(
+ perm =>
+ !(
+ perm.type == permission.type &&
+ perm.principal.originSuffix === permission.principal.originSuffix
+ )
+ );
+
+ if (this.#permissions.length === 0) {
+ this.uninstall();
+ }
+ }
+ }
+
+ get type() {
+ return SITEPERMS_ADDON_TYPE;
+ }
+
+ get name() {
+ return lazy.addonsBundle.formatValueSync("addon-sitepermission-host", {
+ host: this.principal.host,
+ });
+ }
+
+ get creator() {}
+
+ get homepageURL() {}
+
+ get description() {}
+
+ get fullDescription() {}
+
+ get version() {
+ // We consider the previous implementation attempt (signed addons) to be the initial version,
+ // hence the 2.0 for this approach.
+ return "2.0";
+ }
+ get updateDate() {}
+
+ get isActive() {
+ return true;
+ }
+
+ get appDisabled() {
+ return false;
+ }
+
+ get userDisabled() {
+ return false;
+ }
+ set userDisabled(aVal) {}
+
+ get size() {
+ return 0;
+ }
+
+ async updateBlocklistState(options = {}) {}
+
+ get blocklistState() {
+ return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ }
+
+ get scope() {
+ return lazy.AddonManager.SCOPE_APPLICATION;
+ }
+
+ get pendingOperations() {
+ return lazy.AddonManager.PENDING_NONE;
+ }
+
+ get operationsRequiringRestart() {
+ return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+ }
+
+ get permissions() {
+ // The addon only supports PERM_CAN_UNINSTALL and no other AOM permission.
+ return lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+
+ get signedState() {
+ // Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string
+ return lazy.AddonManager.SIGNEDSTATE_MISSING;
+ }
+
+ async enable() {}
+
+ async disable() {}
+
+ /**
+ * Uninstall the addon, calling AddonManager hooks and removing all granted permissions.
+ *
+ * @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal.
+ */
+ async uninstall() {
+ if (this.isUninstalling) {
+ return;
+ }
+ try {
+ this.isUninstalling = true;
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ this,
+ false
+ );
+ const permissions = [...this.#permissions];
+ for (const permission of permissions) {
+ try {
+ Services.perms.removeFromPrincipal(
+ permission.principal,
+ permission.type
+ );
+ // Only remove the permission from the array if it was successfully removed from the principal
+ this.#permissions.splice(this.#permissions.indexOf(permission), 1);
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ }
+ lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+ } finally {
+ this.isUninstalling = false;
+ }
+ }
+
+ get isCompatible() {
+ return true;
+ }
+
+ get isPlatformCompatible() {
+ return true;
+ }
+
+ get providesUpdatesSecurely() {
+ return true;
+ }
+
+ get foreignInstall() {
+ return false;
+ }
+
+ get installTelemetryInfo() {
+ return { source: "siteperm-addon-provider", method: "synthetic-install" };
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ }
+}
+
+class SitePermsAddonInstalling extends SitePermsAddonWrapper {
+ #install = null;
+
+ /**
+ * @param {string} siteOriginNoSuffix: The origin this addon is installed
+ * for, WITHOUT the suffix generated from
+ * the origin attributes (see:
+ * nsIPrincipal.siteOriginNoSuffix).
+ * @param {SitePermsAddonInstall} install: The SitePermsAddonInstall instance
+ * calling this constructor.
+ */
+ constructor(siteOriginNoSuffix, install) {
+ // SitePermsAddonWrapper expect an array of nsIPermission as its second parameter.
+ // Since we don't have a proper permission here, we pass an object with the properties
+ // being used in the class.
+ const permission = {
+ principal: install.principal,
+ type: install.newSitePerm,
+ };
+
+ super(siteOriginNoSuffix, [permission]);
+ this.#install = install;
+ }
+
+ get existingAddon() {
+ return SitePermsAddonProvider.wrappersMapByOrigin.get(this.siteOrigin);
+ }
+
+ uninstall() {
+ // While about:addons tab is already open, new addon cards for newly installed
+ // addons are created from the `onInstalled` AOM events, for the `SitePermsAddonWrapper`
+ // the `onInstalling` and `onInstalled` events are emitted by `SitePermsAddonInstall`
+ // and the addon instance is going to be a `SitePermsAddonInstalling` instance if
+ // there wasn't an AddonCard for the same addon id yet.
+ //
+ // To make sure that all permissions will be uninstalled if a user uninstall the
+ // addon from an AddonCard created from a `SitePermsAddonInstalling` instance,
+ // we forward calls to the uninstall method of the existing `SitePermsAddonWrapper`
+ // instance being tracked by the `SitePermsAddonProvider`.
+ // If there isn't any then removing only the single permission added along with the
+ // `SitePremsAddonInstalling` is going to be enough.
+ if (this.existingAddon) {
+ return this.existingAddon.uninstall();
+ }
+
+ return super.uninstall();
+ }
+
+ validInstallOrigins() {
+ // Always return true from here,
+ // actual checks are done from AddonManagerInternal.getSitePermsAddonInstallForWebpage
+ return true;
+ }
+}
+
+// Numeric id included in the install telemetry events to correlate multiple events related
+// to the same install or update flow.
+let nextInstallId = 0;
+
+class SitePermsAddonInstall {
+ #listeners = new Set();
+ #installEvents = {
+ INSTALL_CANCELLED: "onInstallCancelled",
+ INSTALL_ENDED: "onInstallEnded",
+ INSTALL_FAILED: "onInstallFailed",
+ };
+
+ /**
+ * @param {nsIPrincipal} installingPrincipal
+ * @param {String} sitePerm
+ */
+ constructor(installingPrincipal, sitePerm) {
+ this.principal = installingPrincipal;
+ this.newSitePerm = sitePerm;
+ this.state = lazy.AddonManager.STATE_DOWNLOADED;
+ this.addon = new SitePermsAddonInstalling(
+ this.principal.siteOriginNoSuffix,
+ this
+ );
+ this.installId = ++nextInstallId;
+ }
+
+ get installTelemetryInfo() {
+ return this.addon.installTelemetryInfo;
+ }
+
+ async checkPrompt() {
+ // `promptHandler` can be set from `AddonManagerInternal.setupPromptHandler`
+ if (this.promptHandler) {
+ let info = {
+ // TODO: Investigate if we need to handle addon "update", i.e. granting new
+ // gated permission on an origin other permissions were already granted for (Bug 1790778).
+ existingAddon: null,
+ addon: this.addon,
+ icon: "chrome://mozapps/skin/extensions/category-sitepermission.svg",
+ // Used in AMTelemetry to detect the install flow related to this prompt.
+ install: this,
+ };
+
+ try {
+ await this.promptHandler(info);
+ } catch (err) {
+ if (this.error < 0) {
+ this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
+ // In some cases onOperationCancelled is called during failures
+ // to install/uninstall/enable/disable addons. We may need to
+ // do that here in the future.
+ this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
+ } else if (this.state !== lazy.AddonManager.STATE_CANCELLED) {
+ this.cancel();
+ }
+ return;
+ }
+ }
+
+ this.state = lazy.AddonManager.STATE_PROMPTS_DONE;
+ this.install();
+ }
+
+ install() {
+ if (this.state === lazy.AddonManager.STATE_PROMPTS_DONE) {
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this.addon);
+ Services.perms.addFromPrincipal(
+ this.principal,
+ this.newSitePerm,
+ Services.perms.ALLOW_ACTION
+ );
+ this.state = lazy.AddonManager.STATE_INSTALLED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_ENDED);
+ lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
+ this.addon.install = null;
+ return;
+ }
+
+ if (this.state !== lazy.AddonManager.STATE_DOWNLOADED) {
+ this.state = lazy.AddonManager.STATE_INSTALL_FAILED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_FAILED);
+ return;
+ }
+
+ this.checkPrompt();
+ }
+
+ cancel() {
+ // This method can be called if the install is already cancelled.
+ // We don't want to go further in such case as it would lead to duplicated Telemetry events.
+ if (this.state == lazy.AddonManager.STATE_CANCELLED) {
+ console.error("SitePermsAddonInstall#cancel called twice on ", this);
+ return;
+ }
+
+ this.state = lazy.AddonManager.STATE_CANCELLED;
+ this.#callInstallListeners(this.#installEvents.INSTALL_CANCELLED);
+ }
+
+ /**
+ * Add a listener for the install events
+ *
+ * @param {Object} listener
+ * @param {Function} [listener.onDownloadEnded]
+ * @param {Function} [listener.onInstallCancelled]
+ * @param {Function} [listener.onInstallEnded]
+ * @param {Function} [listener.onInstallFailed]
+ */
+ addListener(listener) {
+ this.#listeners.add(listener);
+ }
+
+ /**
+ * Remove a listener
+ *
+ * @param {Object} listener: The same object reference that was used for `addListener`
+ */
+ removeListener(listener) {
+ this.#listeners.delete(listener);
+ }
+
+ /**
+ * Call the listeners callbacks for a given event.
+ *
+ * @param {String} eventName: The event to fire. Should be one of `this.#installEvents`
+ */
+ #callInstallListeners(eventName) {
+ if (!Object.values(this.#installEvents).includes(eventName)) {
+ console.warn(`Unknown "${eventName}" "event`);
+ return;
+ }
+
+ lazy.AddonManagerPrivate.callInstallListeners(
+ eventName,
+ Array.from(this.#listeners),
+ this
+ );
+ }
+}
+
+const SitePermsAddonProvider = {
+ get name() {
+ return "SitePermsAddonProvider";
+ },
+
+ wrappersMapByOrigin: new Map(),
+
+ /**
+ * Update wrappersMapByOrigin on perm-changed
+ *
+ * @param {nsIPermission} permission: The permission being added/removed
+ * @param {String} action: The action perm-changed notifies us about
+ */
+ handlePermissionChange(permission, action = "added") {
+ // Bail out if it it's not a gated perm
+ if (!isGatedPermissionType(permission.type)) {
+ return;
+ }
+
+ // Gated APIs should probably not be available on non-secure origins,
+ // but let's double check here.
+ if (permission.principal.scheme !== "https") {
+ return;
+ }
+
+ if (isPrincipalInSitePermissionsBlocklist(permission.principal)) {
+ return;
+ }
+
+ const { siteOriginNoSuffix } = permission.principal;
+
+ // Install origin cannot be on a known etld (e.g. github.io).
+ // We shouldn't get a permission change for those here, but let's
+ // be extra safe
+ if (isKnownPublicSuffix(siteOriginNoSuffix)) {
+ return;
+ }
+
+ // Pipe the change to the existing addon if there is one.
+ if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ this.wrappersMapByOrigin
+ .get(siteOriginNoSuffix)
+ .handlePermissionChange(permission, action);
+ }
+
+ if (action == "added") {
+ // We only have one SitePermsAddon per origin, handling multiple permissions.
+ if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ return;
+ }
+
+ const addonWrapper = new SitePermsAddonWrapper(siteOriginNoSuffix, [
+ permission,
+ ]);
+ this.wrappersMapByOrigin.set(siteOriginNoSuffix, addonWrapper);
+ return;
+ }
+
+ if (action == "deleted") {
+ if (!this.wrappersMapByOrigin.has(siteOriginNoSuffix)) {
+ return;
+ }
+ // Only remove the addon if it doesn't have any permissions left.
+ if (!this.wrappersMapByOrigin.get(siteOriginNoSuffix).isUninstalled) {
+ return;
+ }
+ this.wrappersMapByOrigin.delete(siteOriginNoSuffix);
+ }
+ },
+
+ /**
+ * Returns a Promise that resolves when handled the list of gated permissions
+ * and setup ther observer for the "perm-changed" event.
+ *
+ * @returns Promise
+ */
+ lazyInit() {
+ if (!this._initPromise) {
+ this._initPromise = new Promise(resolve => {
+ // Build the initial list of addons per origin
+ const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS);
+ for (const perm of perms) {
+ this.handlePermissionChange(perm);
+ }
+ Services.obs.addObserver(this, "perm-changed");
+ resolve();
+ });
+ }
+ return this._initPromise;
+ },
+
+ shutdown() {
+ if (this._initPromise) {
+ Services.obs.removeObserver(this, "perm-changed");
+ }
+ this.wrappersMapByOrigin.clear();
+ this._initPromise = null;
+ },
+
+ /**
+ * Get a SitePermsAddonWrapper from an extension id
+ *
+ * @param {String|null|undefined} id: The extension id,
+ * @returns {SitePermsAddonWrapper|undefined}
+ */
+ async getAddonByID(id) {
+ await this.lazyInit();
+ if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) {
+ return undefined;
+ }
+
+ for (const addon of this.wrappersMapByOrigin.values()) {
+ if (addon.id === id) {
+ return addon;
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Get a list of SitePermsAddonWrapper for a given list of extension types.
+ *
+ * @param {Array<String>|null|undefined} types: If null or undefined is passed,
+ * the callsites expect to get all the addons from the provider, without
+ * any filtering.
+ * @returns {Array<SitePermsAddonWrapper>}
+ */
+ async getAddonsByTypes(types) {
+ if (
+ !this.isEnabled ||
+ // `types` can be null/undefined, and in such case we _do_ want to return the addons.
+ (Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE))
+ ) {
+ return [];
+ }
+
+ await this.lazyInit();
+ return Array.from(this.wrappersMapByOrigin.values());
+ },
+
+ /**
+ * Create and return a SitePermsAddonInstall instance for a permission on a given principal
+ *
+ * @param {nsIPrincipal} installingPrincipal
+ * @param {String} sitePerm
+ * @returns {SitePermsAddonInstall}
+ */
+ getSitePermsAddonInstallForWebpage(installingPrincipal, sitePerm) {
+ return new SitePermsAddonInstall(installingPrincipal, sitePerm);
+ },
+
+ get isEnabled() {
+ return lazy.SITEPERMS_ADDON_PROVIDER_ENABLED;
+ },
+
+ observe(subject, topic, data) {
+ if (!this.isEnabled) {
+ return;
+ }
+
+ if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+ Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+
+ lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [
+ SITEPERMS_ADDON_TYPE,
+ ]);
+ Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered");
+ } else if (topic === "perm-changed") {
+ if (data === "cleared") {
+ // In such case, `subject` is null, but we can simply uninstall all existing addons.
+ for (const addon of this.wrappersMapByOrigin.values()) {
+ addon.uninstall();
+ }
+ this.wrappersMapByOrigin.clear();
+ return;
+ }
+
+ const perm = subject.QueryInterface(Ci.nsIPermission);
+ this.handlePermissionChange(perm, data);
+ }
+ },
+
+ addFirstContentProcessObserver() {
+ Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+ },
+};
+
+// We want to register the SitePermsAddonProvider once the first content process gets created
+// (and only if the feature is also enabled through the "dom.sitepermsaddon-provider.enabled"
+// about:config pref).
+SitePermsAddonProvider.addFirstContentProcessObserver();
diff --git a/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs
new file mode 100644
index 0000000000..5d1d2c1970
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs
@@ -0,0 +1,3832 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 contains most of the logic required to maintain the
+ * extensions database, including querying and modifying extension
+ * metadata. In general, we try to avoid loading it during startup when
+ * at all possible. Please keep that in mind when deciding whether to
+ * add code here or elsewhere.
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"],
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
+ PermissionsUtils: "resource://gre/modules/PermissionsUtils.sys.mjs",
+ QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+});
+
+// WARNING: BuiltInThemes.sys.mjs may be provided by the host application (e.g.
+// Firefox), or it might not exist at all. Use with caution, as we don't
+// want things to completely fail if that module can't be loaded.
+ChromeUtils.defineLazyGetter(lazy, "BuiltInThemes", () => {
+ try {
+ let { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+ );
+ return BuiltInThemes;
+ } catch (e) {
+ Cu.reportError(`Unable to load BuiltInThemes.sys.mjs: ${e}`);
+ }
+ return undefined;
+});
+
+// A set of helpers to account from a single place that in some builds
+// (e.g. GeckoView and Thunderbird) the BuiltInThemes module may either
+// not be bundled at all or not be exposing the same methods provided
+// by the module as defined in Firefox Desktop.
+export const BuiltInThemesHelpers = {
+ getLocalizedColorwayGroupName(addonId) {
+ return lazy.BuiltInThemes?.getLocalizedColorwayGroupName?.(addonId);
+ },
+
+ getLocalizedColorwayDescription(addonId) {
+ return lazy.BuiltInThemes?.getLocalizedColorwayGroupDescription?.(addonId);
+ },
+
+ isActiveTheme(addonId) {
+ return lazy.BuiltInThemes?.isActiveTheme?.(addonId);
+ },
+
+ isRetainedExpiredTheme(addonId) {
+ return lazy.BuiltInThemes?.isRetainedExpiredTheme?.(addonId);
+ },
+
+ themeIsExpired(addonId) {
+ return lazy.BuiltInThemes?.themeIsExpired?.(addonId);
+ },
+
+ // Helper function called form XPInstall.sys.mjs to remove from the retained
+ // themes list the built-in colorways theme that have been migrated to a non
+ // built-in.
+ unretainMigratedColorwayTheme(addonId) {
+ lazy.BuiltInThemes?.unretainMigratedColorwayTheme?.(addonId);
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ BuiltInThemesHelpers,
+ "isColorwayMigrationEnabled",
+ "browser.theme.colorway-migration",
+ false
+);
+
+// A temporary hidden pref just meant to be used as a last resort, in case
+// we need to force-disable the "per-addon quarantined domains user controls"
+// feature during the beta cycle, e.g. if unexpected issues are caught late and
+// it shouldn't ride the train.
+//
+// TODO(Bug 1839616): remove this pref after the user controls features have been
+// released.
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isQuarantineUIDisabled",
+ "extensions.quarantinedDomains.uiDisabled",
+ false
+);
+
+const { nsIBlocklistService } = Ci;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.xpi-utils";
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+// Create a new logger for use by the Addons XPI Provider Utils
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+const FILE_JSON_DB = "extensions.json";
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_EM_AUTO_DISABLED_SCOPES = "extensions.autoDisableScopes";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_XPI_PERMISSIONS_BRANCH = "xpinstall.";
+const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
+const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
+const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
+const KEY_APP_BUILTINS = "app-builtin";
+const KEY_APP_SYSTEM_LOCAL = "app-system-local";
+const KEY_APP_SYSTEM_SHARE = "app-system-share";
+const KEY_APP_GLOBAL = "app-global";
+const KEY_APP_PROFILE = "app-profile";
+const KEY_APP_TEMPORARY = "app-temporary";
+
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+// Properties to cache and reload when an addon installation is pending
+const PENDING_INSTALL_METADATA = [
+ "syncGUID",
+ "targetApplications",
+ "userDisabled",
+ "softDisabled",
+ "embedderDisabled",
+ "sourceURI",
+ "releaseNotesURI",
+ "installDate",
+ "updateDate",
+ "applyBackgroundUpdates",
+ "installTelemetryInfo",
+];
+
+// Properties to save in JSON file
+const PROP_JSON_FIELDS = [
+ "id",
+ "syncGUID",
+ "version",
+ "type",
+ "loader",
+ "updateURL",
+ "installOrigins",
+ "manifestVersion",
+ "optionsURL",
+ "optionsType",
+ "optionsBrowserStyle",
+ "aboutURL",
+ "defaultLocale",
+ "visible",
+ "active",
+ "userDisabled",
+ "appDisabled",
+ "embedderDisabled",
+ "pendingUninstall",
+ "installDate",
+ "updateDate",
+ "applyBackgroundUpdates",
+ "path",
+ "skinnable",
+ "sourceURI",
+ "releaseNotesURI",
+ "softDisabled",
+ "foreignInstall",
+ "strictCompatibility",
+ "locales",
+ "targetApplications",
+ "targetPlatforms",
+ "signedState",
+ "signedDate",
+ "seen",
+ "dependencies",
+ "incognito",
+ "userPermissions",
+ "optionalPermissions",
+ "sitePermissions",
+ "siteOrigin",
+ "icons",
+ "iconURL",
+ "blocklistState",
+ "blocklistURL",
+ "startupData",
+ "previewImage",
+ "hidden",
+ "installTelemetryInfo",
+ "recommendationState",
+ "rootURI",
+];
+
+const SIGNED_TYPES = new Set([
+ "extension",
+ "locale",
+ "theme",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+]);
+
+// Time to wait before async save of XPI JSON database, in milliseconds
+const ASYNC_SAVE_DELAY_MS = 20;
+
+const l10n = new Localization(["browser/appExtensionFields.ftl"], true);
+
+/**
+ * Schedules an idle task, and returns a promise which resolves to an
+ * IdleDeadline when an idle slice is available. The caller should
+ * perform all of its idle work in the same micro-task, before the
+ * deadline is reached.
+ *
+ * @returns {Promise<IdleDeadline>}
+ */
+function promiseIdleSlice() {
+ return new Promise(resolve => {
+ ChromeUtils.idleDispatch(resolve);
+ });
+}
+
+let arrayForEach = Function.call.bind(Array.prototype.forEach);
+
+/**
+ * Loops over the given array, in the same way as Array forEach, but
+ * splitting the work among idle tasks.
+ *
+ * @param {Array} array
+ * The array to loop over.
+ * @param {function} func
+ * The function to call on each array element.
+ * @param {integer} [taskTimeMS = 5]
+ * The minimum time to allocate to each task. If less time than
+ * this is available in a given idle slice, and there are more
+ * elements to loop over, they will be deferred until the next
+ * idle slice.
+ */
+async function idleForEach(array, func, taskTimeMS = 5) {
+ let deadline;
+ for (let i = 0; i < array.length; i++) {
+ if (!deadline || deadline.timeRemaining() < taskTimeMS) {
+ deadline = await promiseIdleSlice();
+ }
+ func(array[i], i);
+ }
+}
+
+/**
+ * Asynchronously fill in the _repositoryAddon field for one addon
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to annotate.
+ * @returns {AddonInternal}
+ * The annotated add-on.
+ */
+async function getRepositoryAddon(aAddon) {
+ if (aAddon) {
+ aAddon._repositoryAddon = await lazy.AddonRepository.getCachedAddonByID(
+ aAddon.id
+ );
+ }
+ return aAddon;
+}
+
+/**
+ * Copies properties from one object to another. If no target object is passed
+ * a new object will be created and returned.
+ *
+ * @param {object} aObject
+ * An object to copy from
+ * @param {string[]} aProperties
+ * An array of properties to be copied
+ * @param {object?} [aTarget]
+ * An optional target object to copy the properties to
+ * @returns {Object}
+ * The object that the properties were copied onto
+ */
+function copyProperties(aObject, aProperties, aTarget) {
+ if (!aTarget) {
+ aTarget = {};
+ }
+ aProperties.forEach(function (aProp) {
+ if (aProp in aObject) {
+ aTarget[aProp] = aObject[aProp];
+ }
+ });
+ return aTarget;
+}
+
+// Maps instances of AddonInternal to AddonWrapper
+const wrapperMap = new WeakMap();
+let addonFor = wrapper => wrapperMap.get(wrapper);
+
+const EMPTY_ARRAY = Object.freeze([]);
+
+let AddonWrapper;
+
+/**
+ * The AddonInternal is an internal only representation of add-ons. It
+ * may have come from the database or an extension manifest.
+ */
+export class AddonInternal {
+ constructor(addonData) {
+ this._wrapper = null;
+ this._selectedLocale = null;
+ this.active = false;
+ this.visible = false;
+ this.userDisabled = false;
+ this.appDisabled = false;
+ this.softDisabled = false;
+ this.embedderDisabled = false;
+ this.blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+ this.blocklistURL = null;
+ this.sourceURI = null;
+ this.releaseNotesURI = null;
+ this.foreignInstall = false;
+ this.seen = true;
+ this.skinnable = false;
+ this.startupData = null;
+ this._hidden = false;
+ this.installTelemetryInfo = null;
+ this.rootURI = null;
+ this._updateInstall = null;
+ this.recommendationState = null;
+
+ this.inDatabase = false;
+
+ /**
+ * @property {Array<string>} dependencies
+ * An array of bootstrapped add-on IDs on which this add-on depends.
+ * The add-on will remain appDisabled if any of the dependent
+ * add-ons is not installed and enabled.
+ */
+ this.dependencies = EMPTY_ARRAY;
+
+ if (addonData) {
+ copyProperties(addonData, PROP_JSON_FIELDS, this);
+ this.location = addonData.location;
+
+ if (!this.dependencies) {
+ this.dependencies = [];
+ }
+ Object.freeze(this.dependencies);
+
+ if (this.location) {
+ this.addedToDatabase();
+ }
+
+ this.sourceBundle = addonData._sourceBundle;
+ }
+ }
+
+ get sourceBundle() {
+ return this._sourceBundle;
+ }
+
+ set sourceBundle(file) {
+ this._sourceBundle = file;
+ if (file) {
+ this.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ file,
+ ""
+ ).spec;
+ }
+ }
+
+ get wrapper() {
+ if (!this._wrapper) {
+ this._wrapper = new AddonWrapper(this);
+ }
+ return this._wrapper;
+ }
+
+ get resolvedRootURI() {
+ return XPIExports.XPIInternal.maybeResolveURI(
+ Services.io.newURI(this.rootURI)
+ );
+ }
+
+ get isBuiltinColorwayTheme() {
+ return (
+ this.type === "theme" &&
+ this.location.isBuiltin &&
+ this.id.endsWith("-colorway@mozilla.org")
+ );
+ }
+
+ /**
+ * Validate a list of origins are contained in the installOrigins array (defined in manifest.json).
+ *
+ * SitePermission addons are a special case, where the triggering install site may be a subdomain
+ * of a valid xpi origin.
+ *
+ * @param {Object} origins Object containing URIs related to install.
+ * @params {nsIURI} origins.installFrom The nsIURI of the website that has triggered the install flow.
+ * @params {nsIURI} origins.source The nsIURI where the xpi is hosted.
+ * @returns {boolean}
+ */
+ validInstallOrigins({ installFrom, source }) {
+ if (
+ !Services.prefs.getBoolPref("extensions.install_origins.enabled", true)
+ ) {
+ return true;
+ }
+
+ let { installOrigins, manifestVersion } = this;
+ if (!installOrigins) {
+ // Install origins are mandatory in MV3 and optional
+ // in MV2. Old addons need to keep installing per the
+ // old install flow.
+ return manifestVersion < 3;
+ }
+ // An empty install_origins prevents any install from 3rd party websites.
+ if (!installOrigins.length) {
+ return false;
+ }
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ if (this.type == "sitepermission-deprecated") {
+ // NOTE: This may move into a check for all addons later.
+ for (let origin of installOrigins) {
+ let host = new URL(origin).host;
+ // install_origin cannot be on a known etld (e.g. github.io).
+ if (Services.eTLD.getKnownPublicSuffixFromHost(host) == host) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed from the install_origin ${host} that is an eTLD`
+ );
+ return false;
+ }
+ }
+
+ if (!installOrigins.includes(new URL(source.spec).origin)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, "${source.spec}" is not included in the Addon install_origins`
+ );
+ return false;
+ }
+
+ if (lazy.ThirdPartyUtil.isThirdPartyURI(source, installFrom)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, installFrom "${installFrom.spec}" is third party to the Addon install_origins`
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ for (const [name, uri] of Object.entries({ installFrom, source })) {
+ if (!installOrigins.includes(new URL(uri.spec).origin)) {
+ logger.warn(
+ `Addon ${this.id} Installation not allowed, ${name} "${uri.spec}" is not included in the Addon install_origins`
+ );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ addedToDatabase() {
+ this._key = `${this.location.name}:${this.id}`;
+ this.inDatabase = true;
+ }
+
+ get isWebExtension() {
+ return this.loader == null;
+ }
+
+ get selectedLocale() {
+ if (this._selectedLocale) {
+ return this._selectedLocale;
+ }
+
+ /**
+ * this.locales is a list of objects that have property `locales`.
+ * It's value is an array of locale codes.
+ *
+ * First, we reduce this nested structure to a flat list of locale codes.
+ */
+ const locales = [].concat(...this.locales.map(loc => loc.locales));
+
+ let requestedLocales = Services.locale.requestedLocales;
+
+ /**
+ * If en-US is not in the list, add it as the last fallback.
+ */
+ if (!requestedLocales.includes("en-US")) {
+ requestedLocales.push("en-US");
+ }
+
+ /**
+ * Then we negotiate best locale code matching the app locales.
+ */
+ let bestLocale = Services.locale.negotiateLanguages(
+ requestedLocales,
+ locales,
+ "und",
+ Services.locale.langNegStrategyLookup
+ )[0];
+
+ /**
+ * If no match has been found, we'll assign the default locale as
+ * the selected one.
+ */
+ if (bestLocale === "und") {
+ this._selectedLocale = this.defaultLocale;
+ } else {
+ /**
+ * Otherwise, we'll go through all locale entries looking for the one
+ * that has the best match in it's locales list.
+ */
+ this._selectedLocale = this.locales.find(loc =>
+ loc.locales.includes(bestLocale)
+ );
+ }
+
+ return this._selectedLocale;
+ }
+
+ get providesUpdatesSecurely() {
+ return !this.updateURL || this.updateURL.startsWith("https:");
+ }
+
+ get isCorrectlySigned() {
+ switch (this.location.name) {
+ case KEY_APP_SYSTEM_PROFILE:
+ // Add-ons installed via Normandy must be signed by the system
+ // key or the "Mozilla Extensions" key.
+ return [
+ lazy.AddonManager.SIGNEDSTATE_SYSTEM,
+ lazy.AddonManager.SIGNEDSTATE_PRIVILEGED,
+ ].includes(this.signedState);
+ case KEY_APP_SYSTEM_ADDONS:
+ // System add-ons must be signed by the system key.
+ return this.signedState == lazy.AddonManager.SIGNEDSTATE_SYSTEM;
+
+ case KEY_APP_SYSTEM_DEFAULTS:
+ case KEY_APP_BUILTINS:
+ case KEY_APP_TEMPORARY:
+ // Temporary and built-in add-ons do not require signing.
+ return true;
+
+ case KEY_APP_SYSTEM_SHARE:
+ case KEY_APP_SYSTEM_LOCAL:
+ // On UNIX platforms except OSX, an additional location for system
+ // add-ons exists in /usr/{lib,share}/mozilla/extensions. Add-ons
+ // installed there do not require signing.
+ if (Services.appinfo.OS != "Darwin") {
+ return true;
+ }
+ break;
+ }
+
+ if (this.signedState === lazy.AddonManager.SIGNEDSTATE_NOT_REQUIRED) {
+ return true;
+ }
+ return this.signedState > lazy.AddonManager.SIGNEDSTATE_MISSING;
+ }
+
+ get isCompatible() {
+ return this.isCompatibleWith();
+ }
+
+ get isPrivileged() {
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: this.signedState,
+ builtIn: this.location.isBuiltin,
+ temporarilyInstalled: this.location.isTemporary,
+ });
+ }
+
+ get hidden() {
+ return (
+ this.location.hidden ||
+ // The hidden flag is intended to only be used for features that are part
+ // of the application. Temporary add-ons should not be hidden.
+ (this._hidden && this.isPrivileged && !this.location.isTemporary) ||
+ false
+ );
+ }
+
+ set hidden(val) {
+ this._hidden = val;
+ }
+
+ get disabled() {
+ return (
+ this.userDisabled ||
+ this.appDisabled ||
+ this.softDisabled ||
+ this.embedderDisabled
+ );
+ }
+
+ get isPlatformCompatible() {
+ if (!this.targetPlatforms.length) {
+ return true;
+ }
+
+ let matchedOS = false;
+
+ // If any targetPlatform matches the OS and contains an ABI then we will
+ // only match a targetPlatform that contains both the current OS and ABI
+ let needsABI = false;
+
+ // Some platforms do not specify an ABI, test against null in that case.
+ let abi = null;
+ try {
+ abi = Services.appinfo.XPCOMABI;
+ } catch (e) {}
+
+ // Something is causing errors in here
+ try {
+ for (let platform of this.targetPlatforms) {
+ if (platform.os == Services.appinfo.OS) {
+ if (platform.abi) {
+ needsABI = true;
+ if (platform.abi === abi) {
+ return true;
+ }
+ } else {
+ matchedOS = true;
+ }
+ }
+ }
+ } catch (e) {
+ let message =
+ "Problem with addon " +
+ this.id +
+ " targetPlatforms " +
+ JSON.stringify(this.targetPlatforms);
+ logger.error(message, e);
+ lazy.AddonManagerPrivate.recordException("XPI", message, e);
+ // don't trust this add-on
+ return false;
+ }
+
+ return matchedOS && !needsABI;
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ let app = this.matchingTargetApplication;
+ if (!app) {
+ return false;
+ }
+
+ // set reasonable defaults for minVersion and maxVersion
+ let minVersion = app.minVersion || "0";
+ let maxVersion = app.maxVersion || "*";
+
+ if (!aAppVersion) {
+ aAppVersion = Services.appinfo.version;
+ }
+ if (!aPlatformVersion) {
+ aPlatformVersion = Services.appinfo.platformVersion;
+ }
+
+ let version;
+ if (app.id == Services.appinfo.ID) {
+ version = aAppVersion;
+ } else if (app.id == TOOLKIT_ID) {
+ version = aPlatformVersion;
+ }
+
+ // Only extensions and dictionaries can be compatible by default; themes
+ // and language packs always use strict compatibility checking.
+ // Dictionaries are compatible by default unless requested by the dictinary.
+ if (
+ !this.strictCompatibility &&
+ (!lazy.AddonManager.strictCompatibility || this.type == "dictionary")
+ ) {
+ return Services.vc.compare(version, minVersion) >= 0;
+ }
+
+ return (
+ Services.vc.compare(version, minVersion) >= 0 &&
+ Services.vc.compare(version, maxVersion) <= 0
+ );
+ }
+
+ get matchingTargetApplication() {
+ let app = null;
+ for (let targetApp of this.targetApplications) {
+ if (targetApp.id == Services.appinfo.ID) {
+ return targetApp;
+ }
+ if (targetApp.id == TOOLKIT_ID) {
+ app = targetApp;
+ }
+ }
+ return app;
+ }
+
+ async findBlocklistEntry() {
+ return lazy.Blocklist.getAddonBlocklistEntry(this.wrapper);
+ }
+
+ async updateBlocklistState(options = {}) {
+ if (this.location.isSystem || this.location.isBuiltin) {
+ return;
+ }
+
+ let { applySoftBlock = true, updateDatabase = true } = options;
+
+ let oldState = this.blocklistState;
+
+ let entry = await this.findBlocklistEntry();
+ let newState = entry ? entry.state : Services.blocklist.STATE_NOT_BLOCKED;
+
+ this.blocklistState = newState;
+ this.blocklistURL = entry && entry.url;
+
+ let userDisabled, softDisabled;
+ // After a blocklist update, the blocklist service manually applies
+ // new soft blocks after displaying a UI, in which cases we need to
+ // skip updating it here.
+ if (applySoftBlock && oldState != newState) {
+ if (newState == Services.blocklist.STATE_SOFTBLOCKED) {
+ if (this.type == "theme") {
+ userDisabled = true;
+ } else {
+ softDisabled = !this.userDisabled;
+ }
+ } else {
+ softDisabled = false;
+ }
+ }
+
+ if (this.inDatabase && updateDatabase) {
+ await XPIDatabase.updateAddonDisabledState(this, {
+ userDisabled,
+ softDisabled,
+ });
+ XPIDatabase.saveChanges();
+ } else {
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ if (userDisabled !== undefined) {
+ this.userDisabled = userDisabled;
+ }
+ if (softDisabled !== undefined) {
+ this.softDisabled = softDisabled;
+ }
+ }
+ }
+
+ recordAddonBlockChangeTelemetry(reason) {
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(this.wrapper, reason);
+ }
+
+ async setUserDisabled(val, allowSystemAddons = false) {
+ if (val == (this.userDisabled || this.softDisabled)) {
+ return;
+ }
+
+ if (this.inDatabase) {
+ // System add-ons should not be user disabled, as there is no UI to
+ // re-enable them.
+ if (this.location.isSystem && !allowSystemAddons) {
+ throw new Error(`Cannot disable system add-on ${this.id}`);
+ }
+ await XPIDatabase.updateAddonDisabledState(this, { userDisabled: val });
+ } else {
+ this.userDisabled = val;
+ // When enabling remove the softDisabled flag
+ if (!val) {
+ this.softDisabled = false;
+ }
+ }
+ }
+
+ applyCompatibilityUpdate(aUpdate, aSyncCompatibility) {
+ let wasCompatible = this.isCompatible;
+
+ for (let targetApp of this.targetApplications) {
+ for (let updateTarget of aUpdate.targetApplications) {
+ if (
+ targetApp.id == updateTarget.id &&
+ (aSyncCompatibility ||
+ Services.vc.compare(targetApp.maxVersion, updateTarget.maxVersion) <
+ 0)
+ ) {
+ targetApp.minVersion = updateTarget.minVersion;
+ targetApp.maxVersion = updateTarget.maxVersion;
+
+ if (this.inDatabase) {
+ XPIDatabase.saveChanges();
+ }
+ }
+ }
+ }
+
+ if (wasCompatible != this.isCompatible) {
+ if (this.inDatabase) {
+ XPIDatabase.updateAddonDisabledState(this);
+ } else {
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ }
+ }
+ }
+
+ toJSON() {
+ let obj = copyProperties(this, PROP_JSON_FIELDS);
+ obj.location = this.location.name;
+ return obj;
+ }
+
+ /**
+ * When an add-on install is pending its metadata will be cached in a file.
+ * This method reads particular properties of that metadata that may be newer
+ * than that in the extension manifest, like compatibility information.
+ *
+ * @param {Object} aObj
+ * A JS object containing the cached metadata
+ */
+ importMetadata(aObj) {
+ for (let prop of PENDING_INSTALL_METADATA) {
+ if (!(prop in aObj)) {
+ continue;
+ }
+
+ this[prop] = aObj[prop];
+ }
+
+ // Compatibility info may have changed so update appDisabled
+ this.appDisabled = !XPIDatabase.isUsableAddon(this);
+ }
+
+ permissions() {
+ let permissions = 0;
+
+ // Add-ons that aren't installed cannot be modified in any way
+ if (!this.inDatabase) {
+ return permissions;
+ }
+
+ if (!this.appDisabled) {
+ if (this.userDisabled || this.softDisabled) {
+ permissions |= lazy.AddonManager.PERM_CAN_ENABLE;
+ } else if (this.type != "theme" || this.id != DEFAULT_THEME_ID) {
+ // We do not expose disabling the default theme.
+ permissions |= lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ }
+
+ // Add-ons that are in locked install locations, or are pending uninstall
+ // cannot be uninstalled or upgraded. One caveat is extensions sideloaded
+ // from non-profile locations. Since Firefox 73(?), new sideloaded extensions
+ // from outside the profile have not been installed so any such extensions
+ // must be from an older profile. Users may uninstall such an extension which
+ // removes the related state from this profile but leaves the actual file alone
+ // (since it is outside this profile and may be in use in other profiles)
+ let changesAllowed = !this.location.locked && !this.pendingUninstall;
+ if (changesAllowed) {
+ // System add-on upgrades are triggered through a different mechanism (see updateSystemAddons())
+ // Builtin addons are only upgraded with Firefox (or app) updates.
+ let isSystem = this.location.isSystem || this.location.isBuiltin;
+ // Add-ons that are installed by a file link cannot be upgraded.
+ if (!isSystem && !this.location.isLinkedAddon(this.id)) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ // Allow active and retained colorways builtin themes to be updated to
+ // the same theme hosted on AMO (the PERM_CAN_UPGRADE permission will
+ // ensure we will be asking AMO for an update, then the AMO addon xpi
+ // will be installed in the profile location, overridden in the
+ // `createUpdate` defined in `XPIInstall.sys.mjs` and called from
+ // `UpdateChecker` `onUpdateCheckComplete` method).
+ if (
+ this.isBuiltinColorwayTheme &&
+ BuiltInThemesHelpers.isColorwayMigrationEnabled &&
+ BuiltInThemesHelpers.themeIsExpired(this.id) &&
+ (BuiltInThemesHelpers.isActiveTheme(this.id) ||
+ BuiltInThemesHelpers.isRetainedExpiredTheme(this.id))
+ ) {
+ permissions |= lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ }
+
+ // We allow uninstall of legacy sideloaded extensions, even when in locked locations,
+ // but we do not remove the addon file in that case.
+ let isLegacySideload =
+ this.foreignInstall &&
+ !(this.location.scope & lazy.AddonSettings.SCOPES_SIDELOAD);
+ if (changesAllowed || isLegacySideload) {
+ permissions |= lazy.AddonManager.PERM_API_CAN_UNINSTALL;
+ if (!this.location.isBuiltin) {
+ permissions |= lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+ }
+
+ // The permission to "toggle the private browsing access" is locked down
+ // when the extension has opted out or it gets the permission automatically
+ // on every extension startup (as system, privileged and builtin addons).
+ if (
+ (this.type === "extension" ||
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ this.type == "sitepermission-deprecated") &&
+ this.incognito !== "not_allowed" &&
+ this.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED &&
+ this.signedState !== lazy.AddonManager.SIGNEDSTATE_SYSTEM &&
+ !this.location.isBuiltin
+ ) {
+ permissions |= lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
+ }
+
+ if (Services.policies) {
+ if (!Services.policies.isAllowed(`uninstall-extension:${this.id}`)) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_UNINSTALL;
+ }
+ if (!Services.policies.isAllowed(`disable-extension:${this.id}`)) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_DISABLE;
+ }
+ if (Services.policies.getExtensionSettings(this.id)?.updates_disabled) {
+ permissions &= ~lazy.AddonManager.PERM_CAN_UPGRADE;
+ }
+ }
+
+ return permissions;
+ }
+
+ propagateDisabledState(oldAddon) {
+ if (oldAddon) {
+ this.userDisabled = oldAddon.userDisabled;
+ this.embedderDisabled = oldAddon.embedderDisabled;
+ this.softDisabled = oldAddon.softDisabled;
+ this.blocklistState = oldAddon.blocklistState;
+ }
+ }
+}
+
+/**
+ * The AddonWrapper wraps an Addon to provide the data visible to consumers of
+ * the public API.
+ *
+ * NOTE: Do not add any new logic here. Add it to AddonInternal and expose
+ * through defineAddonWrapperProperty after this class definition.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on object to wrap.
+ */
+AddonWrapper = class {
+ constructor(aAddon) {
+ wrapperMap.set(this, aAddon);
+ }
+
+ get __AddonInternal__() {
+ return addonFor(this);
+ }
+
+ get quarantineIgnoredByApp() {
+ return this.isPrivileged || !!this.recommendationStates?.length;
+ }
+
+ get quarantineIgnoredByUser() {
+ // NOTE: confirm if this getter could be replaced by a
+ // lazy preference getter and the addon wrapper to not be
+ // kept around longer by the pref observer registered
+ // internally by the lazy getter.
+ return lazy.QuarantinedDomains.isUserAllowedAddonId(this.id);
+ }
+
+ set quarantineIgnoredByUser(val) {
+ lazy.QuarantinedDomains.setUserAllowedAddonIdPref(this.id, !!val);
+ }
+
+ get canChangeQuarantineIgnored() {
+ // Never show the quarantined domains user controls UI if the
+ // quarantined domains feature is disabled.
+ return (
+ WebExtensionPolicy.quarantinedDomainsEnabled &&
+ !lazy.isQuarantineUIDisabled &&
+ this.type === "extension" &&
+ !this.quarantineIgnoredByApp
+ );
+ }
+
+ get seen() {
+ return addonFor(this).seen;
+ }
+
+ markAsSeen() {
+ addonFor(this).seen = true;
+ XPIDatabase.saveChanges();
+ }
+
+ get installTelemetryInfo() {
+ const addon = addonFor(this);
+ if (!addon.installTelemetryInfo && addon.location) {
+ if (addon.location.isSystem) {
+ return { source: "system-addon" };
+ }
+
+ if (addon.location.isTemporary) {
+ return { source: "temporary-addon" };
+ }
+ }
+
+ return addon.installTelemetryInfo;
+ }
+
+ get temporarilyInstalled() {
+ return addonFor(this).location.isTemporary;
+ }
+
+ get aboutURL() {
+ return this.isActive ? addonFor(this).aboutURL : null;
+ }
+
+ get optionsURL() {
+ if (!this.isActive) {
+ return null;
+ }
+
+ let addon = addonFor(this);
+ if (addon.optionsURL) {
+ if (this.isWebExtension) {
+ // The internal object's optionsURL property comes from the addons
+ // DB and should be a relative URL. However, extensions with
+ // options pages installed before bug 1293721 was fixed got absolute
+ // URLs in the addons db. This code handles both cases.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ if (!policy) {
+ return null;
+ }
+ let base = policy.getURL();
+ return new URL(addon.optionsURL, base).href;
+ }
+ return addon.optionsURL;
+ }
+
+ return null;
+ }
+
+ get optionsType() {
+ if (!this.isActive) {
+ return null;
+ }
+
+ let addon = addonFor(this);
+ let hasOptionsURL = !!this.optionsURL;
+
+ if (addon.optionsType) {
+ switch (parseInt(addon.optionsType, 10)) {
+ case lazy.AddonManager.OPTIONS_TYPE_TAB:
+ case lazy.AddonManager.OPTIONS_TYPE_INLINE_BROWSER:
+ return hasOptionsURL ? addon.optionsType : null;
+ }
+ return null;
+ }
+
+ return null;
+ }
+
+ get optionsBrowserStyle() {
+ let addon = addonFor(this);
+ return addon.optionsBrowserStyle;
+ }
+
+ get incognito() {
+ return addonFor(this).incognito;
+ }
+
+ async getBlocklistURL() {
+ return addonFor(this).blocklistURL;
+ }
+
+ get iconURL() {
+ return lazy.AddonManager.getPreferredIconURL(this, 48);
+ }
+
+ get icons() {
+ let addon = addonFor(this);
+ let icons = {};
+
+ if (addon._repositoryAddon) {
+ for (let size in addon._repositoryAddon.icons) {
+ icons[size] = addon._repositoryAddon.icons[size];
+ }
+ }
+
+ if (addon.icons) {
+ for (let size in addon.icons) {
+ let path = addon.icons[size].replace(/^\//, "");
+ icons[size] = this.getResourceURI(path).spec;
+ }
+ }
+
+ let canUseIconURLs = this.isActive;
+ if (canUseIconURLs && addon.iconURL) {
+ icons[32] = addon.iconURL;
+ icons[48] = addon.iconURL;
+ }
+
+ Object.freeze(icons);
+ return icons;
+ }
+
+ get screenshots() {
+ let addon = addonFor(this);
+ let repositoryAddon = addon._repositoryAddon;
+ if (repositoryAddon && "screenshots" in repositoryAddon) {
+ let repositoryScreenshots = repositoryAddon.screenshots;
+ if (repositoryScreenshots && repositoryScreenshots.length) {
+ return repositoryScreenshots;
+ }
+ }
+
+ if (addon.previewImage) {
+ let url = this.getResourceURI(addon.previewImage).spec;
+ return [new lazy.AddonManagerPrivate.AddonScreenshot(url)];
+ }
+
+ return null;
+ }
+
+ get recommendationStates() {
+ let addon = addonFor(this);
+ let state = addon.recommendationState;
+ if (
+ state &&
+ state.validNotBefore < addon.updateDate &&
+ state.validNotAfter > addon.updateDate &&
+ addon.isCorrectlySigned &&
+ !this.temporarilyInstalled
+ ) {
+ return state.states;
+ }
+ return [];
+ }
+
+ // NOTE: this boolean getter doesn't return true for all recommendation
+ // states at the moment. For the states actually supported on the autograph
+ // side see:
+ // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460
+ get isRecommended() {
+ return this.recommendationStates.includes("recommended");
+ }
+
+ get canBypassThirdParyInstallPrompt() {
+ // We only bypass if the extension is signed (to support distributions
+ // that turn off the signing requirement) and has recommendation states,
+ // or the extension is signed as privileged.
+ return (
+ this.signedState == lazy.AddonManager.SIGNEDSTATE_PRIVILEGED ||
+ (this.signedState >= lazy.AddonManager.SIGNEDSTATE_SIGNED &&
+ this.recommendationStates.length)
+ );
+ }
+
+ get applyBackgroundUpdates() {
+ return addonFor(this).applyBackgroundUpdates;
+ }
+ set applyBackgroundUpdates(val) {
+ let addon = addonFor(this);
+ if (
+ val != lazy.AddonManager.AUTOUPDATE_DEFAULT &&
+ val != lazy.AddonManager.AUTOUPDATE_DISABLE &&
+ val != lazy.AddonManager.AUTOUPDATE_ENABLE
+ ) {
+ val = val
+ ? lazy.AddonManager.AUTOUPDATE_DEFAULT
+ : lazy.AddonManager.AUTOUPDATE_DISABLE;
+ }
+
+ if (val == addon.applyBackgroundUpdates) {
+ return;
+ }
+
+ XPIDatabase.setAddonProperties(addon, {
+ applyBackgroundUpdates: val,
+ });
+ lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "applyBackgroundUpdates",
+ ]);
+ }
+
+ set syncGUID(val) {
+ let addon = addonFor(this);
+ if (addon.syncGUID == val) {
+ return;
+ }
+
+ if (addon.inDatabase) {
+ XPIDatabase.setAddonSyncGUID(addon, val);
+ }
+
+ addon.syncGUID = val;
+ }
+
+ get install() {
+ let addon = addonFor(this);
+ if (!("_install" in addon) || !addon._install) {
+ return null;
+ }
+ return addon._install.wrapper;
+ }
+
+ get updateInstall() {
+ let addon = addonFor(this);
+ return addon._updateInstall ? addon._updateInstall.wrapper : null;
+ }
+
+ get pendingUpgrade() {
+ let addon = addonFor(this);
+ return addon.pendingUpgrade ? addon.pendingUpgrade.wrapper : null;
+ }
+
+ get scope() {
+ let addon = addonFor(this);
+ if (addon.location) {
+ return addon.location.scope;
+ }
+
+ return lazy.AddonManager.SCOPE_PROFILE;
+ }
+
+ get pendingOperations() {
+ let addon = addonFor(this);
+ let pending = 0;
+ if (!addon.inDatabase) {
+ // Add-on is pending install if there is no associated install (shouldn't
+ // happen here) or if the install is in the process of or has successfully
+ // completed the install. If an add-on is pending install then we ignore
+ // any other pending operations.
+ if (
+ !addon._install ||
+ addon._install.state == lazy.AddonManager.STATE_INSTALLING ||
+ addon._install.state == lazy.AddonManager.STATE_INSTALLED
+ ) {
+ return lazy.AddonManager.PENDING_INSTALL;
+ }
+ } else if (addon.pendingUninstall) {
+ // If an add-on is pending uninstall then we ignore any other pending
+ // operations
+ return lazy.AddonManager.PENDING_UNINSTALL;
+ }
+
+ if (addon.active && addon.disabled) {
+ pending |= lazy.AddonManager.PENDING_DISABLE;
+ } else if (!addon.active && !addon.disabled) {
+ pending |= lazy.AddonManager.PENDING_ENABLE;
+ }
+
+ if (addon.pendingUpgrade) {
+ pending |= lazy.AddonManager.PENDING_UPGRADE;
+ }
+
+ return pending;
+ }
+
+ get operationsRequiringRestart() {
+ return 0;
+ }
+
+ get isDebuggable() {
+ return this.isActive;
+ }
+
+ get permissions() {
+ return addonFor(this).permissions();
+ }
+
+ get isActive() {
+ let addon = addonFor(this);
+ if (!addon.active) {
+ return false;
+ }
+ if (!Services.appinfo.inSafeMode) {
+ return true;
+ }
+ return XPIExports.XPIInternal.canRunInSafeMode(addon);
+ }
+
+ get startupPromise() {
+ let addon = addonFor(this);
+ if (!this.isActive) {
+ return null;
+ }
+
+ let activeAddon = XPIExports.XPIProvider.activeAddons.get(addon.id);
+ if (activeAddon) {
+ return activeAddon.startupPromise || null;
+ }
+ return null;
+ }
+
+ updateBlocklistState(applySoftBlock = true) {
+ return addonFor(this).updateBlocklistState({ applySoftBlock });
+ }
+
+ get userDisabled() {
+ let addon = addonFor(this);
+ return addon.softDisabled || addon.userDisabled;
+ }
+
+ /**
+ * Get the embedderDisabled property for this addon.
+ *
+ * This is intended for embedders of Gecko like GeckoView apps to control
+ * which addons are usable on their app.
+ *
+ * @returns {boolean}
+ */
+ get embedderDisabled() {
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ return undefined;
+ }
+
+ return addonFor(this).embedderDisabled;
+ }
+
+ /**
+ * Set the embedderDisabled property for this addon.
+ *
+ * This is intended for embedders of Gecko like GeckoView apps to control
+ * which addons are usable on their app.
+ *
+ * Embedders can disable addons for various reasons, e.g. the addon is not
+ * compatible with their implementation of the WebExtension API.
+ *
+ * When an addon is embedderDisabled it will behave like it was appDisabled.
+ *
+ * @param {boolean} val
+ * whether this addon should be embedder disabled or not.
+ */
+ async setEmbedderDisabled(val) {
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ throw new Error("Setting embedder disabled while not embedding.");
+ }
+
+ let addon = addonFor(this);
+ if (addon.embedderDisabled == val) {
+ return val;
+ }
+
+ if (addon.inDatabase) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ embedderDisabled: val,
+ });
+ } else {
+ addon.embedderDisabled = val;
+ }
+
+ return val;
+ }
+
+ enable(options = {}) {
+ const { allowSystemAddons = false } = options;
+ return addonFor(this).setUserDisabled(false, allowSystemAddons);
+ }
+
+ disable(options = {}) {
+ const { allowSystemAddons = false } = options;
+ return addonFor(this).setUserDisabled(true, allowSystemAddons);
+ }
+
+ async setSoftDisabled(val) {
+ let addon = addonFor(this);
+ if (val == addon.softDisabled) {
+ return val;
+ }
+
+ if (addon.inDatabase) {
+ // When softDisabling a theme just enable the active theme
+ if (addon.type === "theme" && val && !addon.userDisabled) {
+ if (addon.isWebExtension) {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ softDisabled: val,
+ });
+ }
+ } else {
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ softDisabled: val,
+ });
+ }
+ } else if (!addon.userDisabled) {
+ // Only set softDisabled if not already disabled
+ addon.softDisabled = val;
+ }
+
+ return val;
+ }
+
+ get isPrivileged() {
+ return addonFor(this).isPrivileged;
+ }
+
+ get hidden() {
+ return addonFor(this).hidden;
+ }
+
+ get isSystem() {
+ let addon = addonFor(this);
+ return addon.location.isSystem;
+ }
+
+ get isBuiltin() {
+ return addonFor(this).location.isBuiltin;
+ }
+
+ // Returns true if Firefox Sync should sync this addon. Only addons
+ // in the profile install location are considered syncable.
+ get isSyncable() {
+ let addon = addonFor(this);
+ return addon.location.name == KEY_APP_PROFILE;
+ }
+
+ get userPermissions() {
+ return addonFor(this).userPermissions;
+ }
+
+ get optionalPermissions() {
+ return addonFor(this).optionalPermissions;
+ }
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return addonFor(this).isCompatibleWith(aAppVersion, aPlatformVersion);
+ }
+
+ async uninstall(alwaysAllowUndo) {
+ let addon = addonFor(this);
+ return XPIExports.XPIInstall.uninstallAddon(addon, alwaysAllowUndo);
+ }
+
+ cancelUninstall() {
+ let addon = addonFor(this);
+ XPIExports.XPIInstall.cancelUninstallAddon(addon);
+ }
+
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ new XPIExports.UpdateChecker(
+ addonFor(this),
+ aListener,
+ aReason,
+ aAppVersion,
+ aPlatformVersion
+ );
+ }
+
+ // Returns true if there was an update in progress, false if there was no update to cancel
+ cancelUpdate() {
+ let addon = addonFor(this);
+ if (addon._updateCheck) {
+ addon._updateCheck.cancel();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Reloads the add-on.
+ *
+ * For temporarily installed add-ons, this uninstalls and re-installs the
+ * add-on. Otherwise, the addon is disabled and then re-enabled, and the cache
+ * is flushed.
+ */
+ async reload() {
+ const addon = addonFor(this);
+
+ logger.debug(`reloading add-on ${addon.id}`);
+
+ if (!this.temporarilyInstalled) {
+ await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true });
+ await XPIDatabase.updateAddonDisabledState(addon, {
+ userDisabled: false,
+ });
+ } else {
+ // This function supports re-installing an existing add-on.
+ await lazy.AddonManager.installTemporaryAddon(addon._sourceBundle);
+ }
+ }
+
+ /**
+ * Returns a URI to the selected resource or to the add-on bundle if aPath
+ * is null. URIs to the bundle will always be file: URIs. URIs to resources
+ * will be file: URIs if the add-on is unpacked or jar: URIs if the add-on is
+ * still an XPI file.
+ *
+ * @param {string?} aPath
+ * The path in the add-on to get the URI for or null to get a URI to
+ * the file or directory the add-on is installed as.
+ * @returns {nsIURI}
+ */
+ getResourceURI(aPath) {
+ let addon = addonFor(this);
+ let url = Services.io.newURI(addon.rootURI);
+ if (aPath) {
+ if (aPath.startsWith("/")) {
+ throw new Error("getResourceURI() must receive a relative path");
+ }
+ url = Services.io.newURI(aPath, null, url);
+ }
+ return url;
+ }
+};
+
+function chooseValue(aAddon, aObj, aProp) {
+ let repositoryAddon = aAddon._repositoryAddon;
+ let objValue = aObj[aProp];
+
+ if (
+ repositoryAddon &&
+ aProp in repositoryAddon &&
+ (aProp === "creator" || objValue == null)
+ ) {
+ return [repositoryAddon[aProp], true];
+ }
+
+ return [objValue, false];
+}
+
+function defineAddonWrapperProperty(name, getter) {
+ Object.defineProperty(AddonWrapper.prototype, name, {
+ get: getter,
+ enumerable: true,
+ });
+}
+
+[
+ "id",
+ "syncGUID",
+ "version",
+ "type",
+ "isWebExtension",
+ "isCompatible",
+ "isPlatformCompatible",
+ "providesUpdatesSecurely",
+ "blocklistState",
+ "appDisabled",
+ "softDisabled",
+ "skinnable",
+ "foreignInstall",
+ "strictCompatibility",
+ "updateURL",
+ "installOrigins",
+ "manifestVersion",
+ "validInstallOrigins",
+ "dependencies",
+ "signedState",
+ "sitePermissions",
+ "siteOrigin",
+ "isCorrectlySigned",
+ "isBuiltinColorwayTheme",
+].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ return aProp in addon ? addon[aProp] : undefined;
+ });
+});
+
+[
+ "fullDescription",
+ "supportURL",
+ "contributionURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+ "amoListingURL",
+].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ if (addon._repositoryAddon) {
+ return addon._repositoryAddon[aProp];
+ }
+
+ return null;
+ });
+});
+
+["installDate", "updateDate"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+ // installDate is always set, updateDate is sometimes missing.
+ return new Date(addon[aProp] ?? addon.installDate);
+ });
+});
+
+defineAddonWrapperProperty("signedDate", function () {
+ let addon = addonFor(this);
+ let { signedDate } = addon;
+ if (signedDate != null) {
+ return new Date(signedDate);
+ }
+ return null;
+});
+
+["sourceURI", "releaseNotesURI"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ // Temporary Installed Addons do not have a "sourceURI",
+ // But we can use the "_sourceBundle" as an alternative,
+ // which points to the path of the addon xpi installed
+ // or its source dir (if it has been installed from a
+ // directory).
+ if (aProp == "sourceURI" && this.temporarilyInstalled) {
+ return Services.io.newFileURI(addon._sourceBundle);
+ }
+
+ let [target, fromRepo] = chooseValue(addon, addon, aProp);
+ if (!target) {
+ return null;
+ }
+ if (fromRepo) {
+ return target;
+ }
+ return Services.io.newURI(target);
+ });
+});
+
+// Add to this Map if you need to change an addon's Fluent ID. Keep it in sync
+// with the list in browser_verify_l10n_strings.js
+const updatedAddonFluentIds = new Map([
+ ["extension-default-theme-name", "extension-default-theme-name-auto"],
+]);
+
+["name", "description", "creator", "homepageURL"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ let formattedMessage;
+ // We want to make sure that all built-in themes that are localizable can
+ // actually localized, particularly those for thunderbird and desktop.
+ if (
+ (aProp === "name" || aProp === "description") &&
+ addon.location.name === KEY_APP_BUILTINS &&
+ addon.type === "theme"
+ ) {
+ // Built-in themes are localized with Fluent instead of the WebExtension API.
+ let addonIdPrefix = addon.id.replace("@mozilla.org", "");
+ const colorwaySuffix = "colorway";
+ if (addonIdPrefix.endsWith(colorwaySuffix)) {
+ // FIXME: Depending on BuiltInThemes here is sort of a hack. Bug 1733466
+ // would provide a more generalized way of doing this.
+ if (aProp == "description") {
+ return BuiltInThemesHelpers.getLocalizedColorwayDescription(addon.id);
+ }
+ // Colorway collections are usually divided into and presented as
+ // "groups". A group either contains closely related colorways, e.g.
+ // stemming from the same base color but with different intensities, or
+ // if the current collection doesn't have intensities, each colorway is
+ // their own group. Colorway names combine the group name with an
+ // intensity. Their ids have the format
+ // {colorwayGroup}-{intensity}-colorway@mozilla.org or
+ // {colorwayGroupName}-colorway@mozilla.org). L10n for colorway group
+ // names is optional and falls back on the unlocalized name from the
+ // theme's manifest. The intensity part, if present, must be localized.
+ let localizedColorwayGroupName =
+ BuiltInThemesHelpers.getLocalizedColorwayGroupName(addon.id);
+ let [colorwayGroupName, intensity] = addonIdPrefix.split("-", 2);
+ if (intensity == colorwaySuffix) {
+ // This theme doesn't have an intensity.
+ return localizedColorwayGroupName || addon.defaultLocale.name;
+ }
+ // We're not using toLocaleUpperCase because these color names are
+ // always in English.
+ colorwayGroupName =
+ localizedColorwayGroupName ||
+ colorwayGroupName[0].toUpperCase() + colorwayGroupName.slice(1);
+ let defaultFluentId = `extension-colorways-${intensity}-name`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ [formattedMessage] = l10n.formatMessagesSync([
+ {
+ id: fluentId,
+ args: {
+ "colorway-name": colorwayGroupName,
+ },
+ },
+ ]);
+ } else {
+ let defaultFluentId = `extension-${addonIdPrefix}-${aProp}`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ [formattedMessage] = l10n.formatMessagesSync([{ id: fluentId }]);
+ }
+
+ return formattedMessage.value;
+ }
+
+ let [result, usedRepository] = chooseValue(
+ addon,
+ addon.selectedLocale,
+ aProp
+ );
+
+ if (result == null) {
+ // Legacy add-ons may be partially localized. Fall back to the default
+ // locale ensure that the result is a string where possible.
+ [result, usedRepository] = chooseValue(addon, addon.defaultLocale, aProp);
+ }
+
+ if (result && !usedRepository && aProp == "creator") {
+ return new lazy.AddonManagerPrivate.AddonAuthor(result);
+ }
+
+ return result;
+ });
+});
+
+["developers", "translators", "contributors"].forEach(function (aProp) {
+ defineAddonWrapperProperty(aProp, function () {
+ let addon = addonFor(this);
+
+ let [results, usedRepository] = chooseValue(
+ addon,
+ addon.selectedLocale,
+ aProp
+ );
+
+ if (results && !usedRepository) {
+ results = results.map(function (aResult) {
+ return new lazy.AddonManagerPrivate.AddonAuthor(aResult);
+ });
+ }
+
+ return results;
+ });
+});
+
+/**
+ * @typedef {Map<string, AddonInternal>} AddonDB
+ */
+
+/**
+ * Internal interface: find an addon from an already loaded addonDB.
+ *
+ * @param {AddonDB} addonDB
+ * The add-on database.
+ * @param {function(AddonInternal) : boolean} aFilter
+ * The filter predecate. The first add-on for which it returns
+ * true will be returned.
+ * @returns {AddonInternal?}
+ * The first matching add-on, if one is found.
+ */
+function _findAddon(addonDB, aFilter) {
+ for (let addon of addonDB.values()) {
+ if (aFilter(addon)) {
+ return addon;
+ }
+ }
+ return null;
+}
+
+/**
+ * Internal interface to get a filtered list of addons from a loaded addonDB
+ *
+ * @param {AddonDB} addonDB
+ * The add-on database.
+ * @param {function(AddonInternal) : boolean} aFilter
+ * The filter predecate. Add-ons which match this predicate will
+ * be returned.
+ * @returns {Array<AddonInternal>}
+ * The list of matching add-ons.
+ */
+function _filterDB(addonDB, aFilter) {
+ return Array.from(addonDB.values()).filter(aFilter);
+}
+
+export const XPIDatabase = {
+ // true if the database connection has been opened
+ initialized: false,
+ // The database file
+ jsonFilePath: PathUtils.join(PathUtils.profileDir, FILE_JSON_DB),
+ rebuildingDatabase: false,
+ syncLoadingDB: false,
+ // Add-ons from the database in locations which are no longer
+ // supported.
+ orphanedAddons: [],
+
+ _saveTask: null,
+
+ // Saved error object if we fail to read an existing database
+ _loadError: null,
+
+ // Saved error object if we fail to save the database
+ _saveError: null,
+
+ // Error reported by our most recent attempt to read or write the database, if any
+ get lastError() {
+ if (this._loadError) {
+ return this._loadError;
+ }
+ if (this._saveError) {
+ return this._saveError;
+ }
+ return null;
+ },
+
+ async _saveNow() {
+ try {
+ await IOUtils.writeJSON(this.jsonFilePath, this, {
+ tmpPath: `${this.jsonFilePath}.tmp`,
+ });
+
+ if (!this._schemaVersionSet) {
+ // Update the XPIDB schema version preference the first time we
+ // successfully save the database.
+ logger.debug(
+ "XPI Database saved, setting schema version preference to " +
+ XPIExports.XPIInternal.DB_SCHEMA
+ );
+ Services.prefs.setIntPref(
+ PREF_DB_SCHEMA,
+ XPIExports.XPIInternal.DB_SCHEMA
+ );
+ this._schemaVersionSet = true;
+
+ // Reading the DB worked once, so we don't need the load error
+ this._loadError = null;
+ }
+ } catch (error) {
+ logger.warn("Failed to save XPI database", error);
+ this._saveError = error;
+
+ if (!DOMException.isInstance(error) || error.name !== "AbortError") {
+ throw error;
+ }
+ }
+ },
+
+ /**
+ * Mark the current stored data dirty, and schedule a flush to disk
+ */
+ saveChanges() {
+ if (!this.initialized) {
+ throw new Error("Attempt to use XPI database when it is not initialized");
+ }
+
+ if (XPIExports.XPIProvider._closing) {
+ // use an Error here so we get a stack trace.
+ let err = new Error("XPI database modified after shutdown began");
+ logger.warn(err);
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_late_stack",
+ Log.stackTrace(err)
+ );
+ }
+
+ if (!this._saveTask) {
+ this._saveTask = new lazy.DeferredTask(
+ () => this._saveNow(),
+ ASYNC_SAVE_DELAY_MS
+ );
+ }
+
+ this._saveTask.arm();
+ },
+
+ async finalize() {
+ // handle the "in memory only" and "saveChanges never called" cases
+ if (!this._saveTask) {
+ return;
+ }
+
+ await this._saveTask.finalize();
+ },
+
+ /**
+ * Converts the current internal state of the XPI addon database to
+ * a JSON.stringify()-ready structure
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ if (!this.addonDB) {
+ // We never loaded the database?
+ throw new Error("Attempt to save database without loading it first");
+ }
+
+ let toSave = {
+ schemaVersion: XPIExports.XPIInternal.DB_SCHEMA,
+ addons: Array.from(this.addonDB.values()).filter(
+ addon => !addon.location.isTemporary
+ ),
+ };
+ return toSave;
+ },
+
+ /**
+ * Synchronously loads the database, by running the normal async load
+ * operation with idle dispatch disabled, and spinning the event loop
+ * until it finishes.
+ *
+ * @param {boolean} aRebuildOnError
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ */
+ syncLoadDB(aRebuildOnError) {
+ let err = new Error("Synchronously loading the add-ons database");
+ logger.debug(err.message);
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_sync_stack",
+ Log.stackTrace(err)
+ );
+ try {
+ this.syncLoadingDB = true;
+ XPIExports.XPIInternal.awaitPromise(this.asyncLoadDB(aRebuildOnError));
+ } finally {
+ this.syncLoadingDB = false;
+ }
+ },
+
+ _recordStartupError(reason) {
+ lazy.AddonManagerPrivate.recordSimpleMeasure("XPIDB_startupError", reason);
+ },
+
+ /**
+ * Parse loaded data, reconstructing the database if the loaded data is not valid
+ *
+ * @param {object} aInputAddons
+ * The add-on JSON to parse.
+ * @param {boolean} aRebuildOnError
+ * If true, synchronously reconstruct the database from installed add-ons
+ */
+ async parseDB(aInputAddons, aRebuildOnError) {
+ try {
+ let parseTimer = lazy.AddonManagerPrivate.simpleTimer("XPIDB_parseDB_MS");
+
+ if (!("schemaVersion" in aInputAddons) || !("addons" in aInputAddons)) {
+ let error = new Error("Bad JSON file contents");
+ error.rebuildReason = "XPIDB_rebuildBadJSON_MS";
+ throw error;
+ }
+
+ if (aInputAddons.schemaVersion <= 27) {
+ // Types were translated in bug 857456.
+ for (let addon of aInputAddons.addons) {
+ XPIExports.XPIInternal.migrateAddonLoader(addon);
+ }
+ } else if (
+ aInputAddons.schemaVersion != XPIExports.XPIInternal.DB_SCHEMA
+ ) {
+ // For now, we assume compatibility for JSON data with a
+ // mismatched schema version, though we throw away any fields we
+ // don't know about (bug 902956)
+ this._recordStartupError(
+ `schemaMismatch-${aInputAddons.schemaVersion}`
+ );
+ logger.debug(
+ `JSON schema mismatch: expected ${XPIExports.XPIInternal.DB_SCHEMA}, actual ${aInputAddons.schemaVersion}`
+ );
+ }
+
+ let forEach = this.syncLoadingDB ? arrayForEach : idleForEach;
+
+ // If we got here, we probably have good data
+ // Make AddonInternal instances from the loaded data and save them
+ let addonDB = new Map();
+ await forEach(aInputAddons.addons, loadedAddon => {
+ if (loadedAddon.path) {
+ try {
+ loadedAddon._sourceBundle = new nsIFile(loadedAddon.path);
+ } catch (e) {
+ // We can fail here when the path is invalid, usually from the
+ // wrong OS
+ logger.warn(
+ "Could not find source bundle for add-on " + loadedAddon.id,
+ e
+ );
+ }
+ }
+ loadedAddon.location = XPIExports.XPIInternal.XPIStates.getLocation(
+ loadedAddon.location
+ );
+
+ let newAddon = new AddonInternal(loadedAddon);
+ if (loadedAddon.location) {
+ addonDB.set(newAddon._key, newAddon);
+ } else {
+ this.orphanedAddons.push(newAddon);
+ }
+ });
+
+ parseTimer.done();
+ this.addonDB = addonDB;
+ logger.debug("Successfully read XPI database");
+ this.initialized = true;
+ } catch (e) {
+ if (e.name == "SyntaxError") {
+ logger.error("Syntax error parsing saved XPI JSON data");
+ this._recordStartupError("syntax");
+ } else {
+ logger.error("Failed to load XPI JSON data from profile", e);
+ this._recordStartupError("other");
+ }
+
+ this.timeRebuildDatabase(
+ e.rebuildReason || "XPIDB_rebuildReadFailed_MS",
+ aRebuildOnError
+ );
+ }
+ },
+
+ async maybeIdleDispatch() {
+ if (!this.syncLoadingDB) {
+ await promiseIdleSlice();
+ }
+ },
+
+ /**
+ * Open and read the XPI database asynchronously, upgrading if
+ * necessary. If any DB load operation fails, we need to
+ * synchronously rebuild the DB from the installed extensions.
+ *
+ * @param {boolean} [aRebuildOnError = true]
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ * @returns {Promise<AddonDB>}
+ * Resolves to the Map of loaded JSON data stored in
+ * this.addonDB; rejects in case of shutdown.
+ */
+ asyncLoadDB(aRebuildOnError = true) {
+ // Already started (and possibly finished) loading
+ if (this._dbPromise) {
+ return this._dbPromise;
+ }
+
+ if (XPIExports.XPIProvider._closing) {
+ // use an Error here so we get a stack trace.
+ let err = new Error(
+ "XPIDatabase.asyncLoadDB attempt after XPIProvider shutdown."
+ );
+ logger.warn("Fail to load AddonDB: ${error}", { error: err });
+ lazy.AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_late_load",
+ Log.stackTrace(err)
+ );
+ this._dbPromise = Promise.reject(err);
+
+ XPIExports.XPIInternal.resolveDBReady(this._dbPromise);
+
+ return this._dbPromise;
+ }
+
+ logger.debug(`Starting async load of XPI database ${this.jsonFilePath}`);
+ this._dbPromise = (async () => {
+ try {
+ let json = await IOUtils.readJSON(this.jsonFilePath);
+
+ logger.debug("Finished async read of XPI database, parsing...");
+ await this.maybeIdleDispatch();
+ await this.parseDB(json, true);
+ } catch (error) {
+ if (DOMException.isInstance(error) && error.name === "NotFoundError") {
+ if (Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) {
+ this._recordStartupError("dbMissing");
+ }
+ } else {
+ logger.warn(
+ `Extensions database ${this.jsonFilePath} exists but is not readable; rebuilding`,
+ error
+ );
+ this._loadError = error;
+ }
+ this.timeRebuildDatabase(
+ "XPIDB_rebuildUnreadableDB_MS",
+ aRebuildOnError
+ );
+ }
+ return this.addonDB;
+ })();
+
+ XPIExports.XPIInternal.resolveDBReady(this._dbPromise);
+
+ return this._dbPromise;
+ },
+
+ timeRebuildDatabase(timerName, rebuildOnError) {
+ lazy.AddonManagerPrivate.recordTiming(timerName, () => {
+ return this.rebuildDatabase(rebuildOnError);
+ });
+ },
+
+ /**
+ * Rebuild the database from addon install directories.
+ *
+ * @param {boolean} aRebuildOnError
+ * A boolean indicating whether add-on information should be loaded
+ * from the install locations if the database needs to be rebuilt.
+ * (if false, caller is XPIProvider.checkForChanges() which will rebuild)
+ */
+ rebuildDatabase(aRebuildOnError) {
+ this.addonDB = new Map();
+ this.initialized = true;
+
+ if (XPIExports.XPIInternal.XPIStates.size == 0) {
+ // No extensions installed, so we're done
+ logger.debug("Rebuilding XPI database with no extensions");
+ return;
+ }
+
+ this.rebuildingDatabase = !!aRebuildOnError;
+
+ if (aRebuildOnError) {
+ logger.warn("Rebuilding add-ons database from installed extensions.");
+ try {
+ XPIDatabaseReconcile.processFileChanges({}, false);
+ } catch (e) {
+ logger.error(
+ "Failed to rebuild XPI database from installed extensions",
+ e
+ );
+ }
+ // Make sure to update the active add-ons and add-ons list on shutdown
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ }
+ },
+
+ /**
+ * Shuts down the database connection and releases all cached objects.
+ * Return: Promise{integer} resolves / rejects with the result of the DB
+ * flush after the database is flushed and
+ * all cleanup is done
+ */
+ async shutdown() {
+ logger.debug("shutdown");
+ if (this.initialized) {
+ // If our last database I/O had an error, try one last time to save.
+ if (this.lastError) {
+ this.saveChanges();
+ }
+
+ this.initialized = false;
+
+ // If we're shutting down while still loading, finish loading
+ // before everything else!
+ if (this._dbPromise) {
+ await this._dbPromise;
+ }
+
+ // Await any pending DB writes and finish cleaning up.
+ await this.finalize();
+
+ if (this._saveError) {
+ // If our last attempt to read or write the DB failed, force a new
+ // extensions.ini to be written to disk on the next startup
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ }
+
+ // Clear out the cached addons data loaded from JSON
+ delete this.addonDB;
+ delete this._dbPromise;
+ // same for the deferred save
+ delete this._saveTask;
+ // re-enable the schema version setter
+ delete this._schemaVersionSet;
+ }
+ },
+
+ /**
+ * Verifies that all installed add-ons are still correctly signed.
+ */
+ async verifySignatures() {
+ try {
+ let addons = await this.getAddonList(a => true);
+
+ let changes = {
+ enabled: [],
+ disabled: [],
+ };
+
+ for (let addon of addons) {
+ // The add-on might have vanished, we'll catch that on the next startup
+ if (!addon._sourceBundle || !addon._sourceBundle.exists()) {
+ continue;
+ }
+
+ let signedState = await XPIExports.verifyBundleSignedState(
+ addon._sourceBundle,
+ addon
+ );
+
+ if (signedState != addon.signedState) {
+ addon.signedState = signedState;
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onPropertyChanged",
+ addon.wrapper,
+ ["signedState"]
+ );
+ }
+
+ let disabled = await this.updateAddonDisabledState(addon);
+ if (disabled !== undefined) {
+ changes[disabled ? "disabled" : "enabled"].push(addon.id);
+ }
+ }
+
+ this.saveChanges();
+
+ Services.obs.notifyObservers(
+ null,
+ "xpi-signature-changed",
+ JSON.stringify(changes)
+ );
+ } catch (err) {
+ logger.error("XPI_verifySignature: " + err);
+ }
+ },
+
+ /**
+ * Imports the xpinstall permissions from preferences into the permissions
+ * manager for the user to change later.
+ */
+ importPermissions() {
+ lazy.PermissionsUtils.importFromPrefs(
+ PREF_XPI_PERMISSIONS_BRANCH,
+ XPIExports.XPIInternal.XPI_PERMISSION
+ );
+ },
+
+ /**
+ * Called when a new add-on has been enabled when only one add-on of that type
+ * can be enabled.
+ *
+ * @param {string} aId
+ * The ID of the newly enabled add-on
+ * @param {string} aType
+ * The type of the newly enabled add-on
+ */
+ async addonChanged(aId, aType) {
+ // We only care about themes in this provider
+ if (aType !== "theme") {
+ return;
+ }
+
+ Services.prefs.setCharPref(
+ "extensions.activeThemeID",
+ aId || DEFAULT_THEME_ID
+ );
+
+ let enableTheme;
+
+ let addons = this.getAddonsByType("theme");
+ let updateDisabledStatePromises = [];
+
+ for (let theme of addons) {
+ if (theme.visible) {
+ if (!aId && theme.id == DEFAULT_THEME_ID) {
+ enableTheme = theme;
+ } else if (theme.id != aId && !theme.pendingUninstall) {
+ updateDisabledStatePromises.push(
+ this.updateAddonDisabledState(theme, {
+ userDisabled: true,
+ becauseSelecting: true,
+ })
+ );
+ }
+ }
+ }
+
+ await Promise.all(updateDisabledStatePromises);
+
+ if (enableTheme) {
+ await this.updateAddonDisabledState(enableTheme, {
+ userDisabled: false,
+ becauseSelecting: true,
+ });
+ }
+ },
+
+ SIGNED_TYPES,
+
+ /**
+ * Asynchronously list all addons that match the filter function
+ *
+ * @param {function(AddonInternal) : boolean} aFilter
+ * Function that takes an addon instance and returns
+ * true if that addon should be included in the selected array
+ *
+ * @returns {Array<AddonInternal>}
+ * A Promise that resolves to the list of add-ons matching
+ * aFilter or an empty array if none match
+ */
+ async getAddonList(aFilter) {
+ try {
+ let addonDB = await this.asyncLoadDB();
+ let addonList = _filterDB(addonDB, aFilter);
+ let addons = await Promise.all(
+ addonList.map(addon => getRepositoryAddon(addon))
+ );
+ return addons;
+ } catch (error) {
+ logger.error("getAddonList failed", error);
+ return [];
+ }
+ },
+
+ /**
+ * Get the first addon that matches the filter function
+ *
+ * @param {function(AddonInternal) : boolean} aFilter
+ * Function that takes an addon instance and returns
+ * true if that addon should be selected
+ * @returns {Promise<AddonInternal?>}
+ */
+ getAddon(aFilter) {
+ return this.asyncLoadDB()
+ .then(addonDB => getRepositoryAddon(_findAddon(addonDB, aFilter)))
+ .catch(error => {
+ logger.error("getAddon failed", error);
+ });
+ },
+
+ /**
+ * Asynchronously gets an add-on with a particular ID in a particular
+ * install location.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @param {string} aLocation
+ * The name of the install location
+ * @returns {Promise<AddonInternal?>}
+ */
+ getAddonInLocation(aId, aLocation) {
+ return this.asyncLoadDB().then(addonDB =>
+ getRepositoryAddon(addonDB.get(aLocation + ":" + aId))
+ );
+ },
+
+ /**
+ * Asynchronously get all the add-ons in a particular install location.
+ *
+ * @param {string} aLocation
+ * The name of the install location
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getAddonsInLocation(aLocation) {
+ return this.getAddonList(aAddon => aAddon.location.name == aLocation);
+ },
+
+ /**
+ * Asynchronously gets the add-on with the specified ID that is visible.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @returns {Promise<AddonInternal?>}
+ */
+ getVisibleAddonForID(aId) {
+ return this.getAddon(aAddon => aAddon.id == aId && aAddon.visible);
+ },
+
+ /**
+ * Asynchronously gets the visible add-ons, optionally restricting by type.
+ *
+ * @param {Set<string>?} aTypes
+ * An array of types to include or null to include all types
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getVisibleAddons(aTypes) {
+ return this.getAddonList(
+ aAddon => aAddon.visible && (!aTypes || aTypes.has(aAddon.type))
+ );
+ },
+
+ /**
+ * Synchronously gets all add-ons of a particular type(s).
+ *
+ * @param {Array<string>} aTypes
+ * The type(s) of add-on to retrieve
+ * @returns {Array<AddonInternal>}
+ */
+ getAddonsByType(...aTypes) {
+ if (!this.addonDB) {
+ // jank-tastic! Must synchronously load DB if the theme switches from
+ // an XPI theme to a lightweight theme before the DB has loaded,
+ // because we're called from sync XPIProvider.addonChanged
+ logger.warn(
+ `Synchronous load of XPI database due to ` +
+ `getAddonsByType([${aTypes.join(", ")}]) ` +
+ `Stack: ${Error().stack}`
+ );
+ this.syncLoadDB(true);
+ }
+
+ return _filterDB(this.addonDB, aAddon => aTypes.includes(aAddon.type));
+ },
+
+ /**
+ * Asynchronously gets all add-ons with pending operations.
+ *
+ * @param {Set<string>?} aTypes
+ * The types of add-ons to retrieve or null to get all types
+ * @returns {Promise<Array<AddonInternal>>}
+ */
+ getVisibleAddonsWithPendingOperations(aTypes) {
+ return this.getAddonList(
+ aAddon =>
+ aAddon.visible &&
+ aAddon.pendingUninstall &&
+ (!aTypes || aTypes.has(aAddon.type))
+ );
+ },
+
+ /**
+ * Synchronously gets all add-ons in the database.
+ * This is only called from the preference observer for the default
+ * compatibility version preference, so we can return an empty list if
+ * we haven't loaded the database yet.
+ *
+ * @returns {Array<AddonInternal>}
+ */
+ getAddons() {
+ if (!this.addonDB) {
+ return [];
+ }
+ return _filterDB(this.addonDB, aAddon => true);
+ },
+
+ /**
+ * Called to get an Addon with a particular ID.
+ *
+ * @param {string} aId
+ * The ID of the add-on to retrieve
+ * @returns {Addon?}
+ */
+ async getAddonByID(aId) {
+ let aAddon = await this.getVisibleAddonForID(aId);
+ return aAddon ? aAddon.wrapper : null;
+ },
+
+ /**
+ * Obtain an Addon having the specified Sync GUID.
+ *
+ * @param {string} aGUID
+ * String GUID of add-on to retrieve
+ * @returns {Addon?}
+ */
+ async getAddonBySyncGUID(aGUID) {
+ let addon = await this.getAddon(aAddon => aAddon.syncGUID == aGUID);
+ return addon ? addon.wrapper : null;
+ },
+
+ /**
+ * Called to get Addons of a particular type.
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types to fetch. Can be null to get all types.
+ * @returns {Addon[]}
+ */
+ async getAddonsByTypes(aTypes) {
+ let addons = await this.getVisibleAddons(aTypes ? new Set(aTypes) : null);
+ return addons.map(a => a.wrapper);
+ },
+
+ /**
+ * Returns true if signing is required for the given add-on type.
+ *
+ * @param {string} aType
+ * The add-on type to check.
+ * @returns {boolean}
+ */
+ mustSign(aType) {
+ if (!SIGNED_TYPES.has(aType)) {
+ return false;
+ }
+
+ if (aType == "locale") {
+ return lazy.AddonSettings.LANGPACKS_REQUIRE_SIGNING;
+ }
+
+ return lazy.AddonSettings.REQUIRE_SIGNING;
+ },
+
+ /**
+ * Determine if this addon should be disabled due to being legacy
+ *
+ * @param {Addon} addon The addon to check
+ *
+ * @returns {boolean} Whether the addon should be disabled for being legacy
+ */
+ isDisabledLegacy(addon) {
+ // We still have tests that use a legacy addon type, allow them
+ // if we're in automation. Otherwise, disable if not a webextension.
+ if (!Cu.isInAutomation) {
+ return !addon.isWebExtension;
+ }
+
+ return (
+ !addon.isWebExtension &&
+ addon.type === "extension" &&
+ // Test addons are privileged unless forced otherwise.
+ addon.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED
+ );
+ },
+
+ /**
+ * Calculates whether an add-on should be appDisabled or not.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check
+ * @returns {boolean}
+ * True if the add-on should not be appDisabled
+ */
+ isUsableAddon(aAddon) {
+ if (this.mustSign(aAddon.type) && !aAddon.isCorrectlySigned) {
+ logger.warn(`Add-on ${aAddon.id} is not correctly signed.`);
+ if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
+ logger.warn(`Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`);
+ }
+ return false;
+ }
+
+ if (aAddon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
+ logger.warn(`Add-on ${aAddon.id} is blocklisted.`);
+ return false;
+ }
+
+ // If we can't read it, it's not usable:
+ if (aAddon.brokenManifest) {
+ return false;
+ }
+
+ if (
+ lazy.AddonManager.checkUpdateSecurity &&
+ !aAddon.providesUpdatesSecurely
+ ) {
+ logger.warn(
+ `Updates for add-on ${aAddon.id} must be provided over HTTPS.`
+ );
+ return false;
+ }
+
+ if (!aAddon.isPlatformCompatible) {
+ logger.warn(`Add-on ${aAddon.id} is not compatible with platform.`);
+ return false;
+ }
+
+ if (aAddon.dependencies.length) {
+ let isActive = id => {
+ let active = XPIExports.XPIProvider.activeAddons.get(id);
+ return active && !active._pendingDisable;
+ };
+
+ if (aAddon.dependencies.some(id => !isActive(id))) {
+ return false;
+ }
+ }
+
+ if (this.isDisabledLegacy(aAddon)) {
+ logger.warn(`disabling legacy extension ${aAddon.id}`);
+ return false;
+ }
+
+ if (lazy.AddonManager.checkCompatibility) {
+ if (!aAddon.isCompatible) {
+ logger.warn(
+ `Add-on ${aAddon.id} is not compatible with application version.`
+ );
+ return false;
+ }
+ } else {
+ let app = aAddon.matchingTargetApplication;
+ if (!app) {
+ logger.warn(
+ `Add-on ${aAddon.id} is not compatible with target application.`
+ );
+ return false;
+ }
+ }
+
+ if (aAddon.location.isSystem || aAddon.location.isBuiltin) {
+ return true;
+ }
+
+ if (Services.policies && !Services.policies.mayInstallAddon(aAddon)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Synchronously adds an AddonInternal's metadata to the database.
+ *
+ * @param {AddonInternal} aAddon
+ * AddonInternal to add
+ * @param {string} aPath
+ * The file path of the add-on
+ * @returns {AddonInternal}
+ * the AddonInternal that was added to the database
+ */
+ addToDatabase(aAddon, aPath) {
+ aAddon.addedToDatabase();
+ aAddon.path = aPath;
+ this.addonDB.set(aAddon._key, aAddon);
+ if (aAddon.visible) {
+ this.makeAddonVisible(aAddon);
+ }
+
+ this.saveChanges();
+ return aAddon;
+ },
+
+ /**
+ * Synchronously updates an add-on's metadata in the database. Currently just
+ * removes and recreates.
+ *
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal to be replaced
+ * @param {AddonInternal} aNewAddon
+ * The new AddonInternal to add
+ * @param {string} aPath
+ * The file path of the add-on
+ * @returns {AddonInternal}
+ * The AddonInternal that was added to the database
+ */
+ updateAddonMetadata(aOldAddon, aNewAddon, aPath) {
+ this.removeAddonMetadata(aOldAddon);
+ aNewAddon.syncGUID = aOldAddon.syncGUID;
+ aNewAddon.installDate = aOldAddon.installDate;
+ aNewAddon.applyBackgroundUpdates = aOldAddon.applyBackgroundUpdates;
+ aNewAddon.foreignInstall = aOldAddon.foreignInstall;
+ aNewAddon.seen = aOldAddon.seen;
+ aNewAddon.active =
+ aNewAddon.visible && !aNewAddon.disabled && !aNewAddon.pendingUninstall;
+ aNewAddon.installTelemetryInfo = aOldAddon.installTelemetryInfo;
+
+ return this.addToDatabase(aNewAddon, aPath);
+ },
+
+ /**
+ * Synchronously removes an add-on from the database.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being removed
+ */
+ removeAddonMetadata(aAddon) {
+ this.addonDB.delete(aAddon._key);
+ this.saveChanges();
+ },
+
+ updateXPIStates(addon) {
+ let state = addon.location && addon.location.get(addon.id);
+ if (state) {
+ state.syncWithDB(addon);
+ XPIExports.XPIInternal.XPIStates.save();
+ }
+ },
+
+ /**
+ * Synchronously marks a AddonInternal as visible marking all other
+ * instances with the same ID as not visible.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to make visible
+ */
+ makeAddonVisible(aAddon) {
+ logger.debug("Make addon " + aAddon._key + " visible");
+ for (let [, otherAddon] of this.addonDB) {
+ if (otherAddon.id == aAddon.id && otherAddon._key != aAddon._key) {
+ logger.debug("Hide addon " + otherAddon._key);
+ otherAddon.visible = false;
+ otherAddon.active = false;
+
+ this.updateXPIStates(otherAddon);
+ }
+ }
+ aAddon.visible = true;
+ this.updateXPIStates(aAddon);
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously marks a given add-on ID visible in a given location,
+ * instances with the same ID as not visible.
+ *
+ * @param {string} aId
+ * The ID of the add-on to make visible
+ * @param {XPIStateLocation} aLocation
+ * The location in which to make the add-on visible.
+ * @returns {AddonInternal?}
+ * The add-on instance which was marked visible, if any.
+ */
+ makeAddonLocationVisible(aId, aLocation) {
+ logger.debug(`Make addon ${aId} visible in location ${aLocation}`);
+ let result;
+ for (let [, addon] of this.addonDB) {
+ if (addon.id != aId) {
+ continue;
+ }
+ if (addon.location == aLocation) {
+ logger.debug("Reveal addon " + addon._key);
+ addon.visible = true;
+ addon.active = true;
+ this.updateXPIStates(addon);
+ result = addon;
+ } else {
+ logger.debug("Hide addon " + addon._key);
+ addon.visible = false;
+ addon.active = false;
+ this.updateXPIStates(addon);
+ }
+ }
+ this.saveChanges();
+ return result;
+ },
+
+ /**
+ * Synchronously sets properties for an add-on.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being updated
+ * @param {Object} aProperties
+ * A dictionary of properties to set
+ */
+ setAddonProperties(aAddon, aProperties) {
+ for (let key in aProperties) {
+ aAddon[key] = aProperties[key];
+ }
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously sets the Sync GUID for an add-on.
+ * Only called when the database is already loaded.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal being updated
+ * @param {string} aGUID
+ * GUID string to set the value to
+ * @throws if another addon already has the specified GUID
+ */
+ setAddonSyncGUID(aAddon, aGUID) {
+ // Need to make sure no other addon has this GUID
+ function excludeSyncGUID(otherAddon) {
+ return otherAddon._key != aAddon._key && otherAddon.syncGUID == aGUID;
+ }
+ let otherAddon = _findAddon(this.addonDB, excludeSyncGUID);
+ if (otherAddon) {
+ throw new Error(
+ "Addon sync GUID conflict for addon " +
+ aAddon._key +
+ ": " +
+ otherAddon._key +
+ " already has GUID " +
+ aGUID
+ );
+ }
+ aAddon.syncGUID = aGUID;
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously updates an add-on's active flag in the database.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to update
+ * @param {boolean} aActive
+ * The new active state for the add-on.
+ */
+ updateAddonActive(aAddon, aActive) {
+ logger.debug(
+ "Updating active state for add-on " + aAddon.id + " to " + aActive
+ );
+
+ aAddon.active = aActive;
+ this.saveChanges();
+ },
+
+ /**
+ * Synchronously calculates and updates all the active flags in the database.
+ */
+ updateActiveAddons() {
+ logger.debug("Updating add-on states");
+ for (let [, addon] of this.addonDB) {
+ let newActive =
+ addon.visible && !addon.disabled && !addon.pendingUninstall;
+ if (newActive != addon.active) {
+ addon.active = newActive;
+ this.saveChanges();
+ }
+ }
+
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false);
+ },
+
+ /**
+ * Updates the disabled state for an add-on. Its appDisabled property will be
+ * calculated and if the add-on is changed the database will be saved and
+ * appropriate notifications will be sent out to the registered AddonListeners.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal to update
+ * @param {Object} properties - Properties to set on the addon
+ * @param {boolean?} [properties.userDisabled]
+ * Value for the userDisabled property. If undefined the value will
+ * not change
+ * @param {boolean?} [properties.softDisabled]
+ * Value for the softDisabled property. If undefined the value will
+ * not change. If true this will force userDisabled to be true
+ * @param {boolean?} [properties.embedderDisabled]
+ * Value for the embedderDisabled property. If undefined the value will
+ * not change.
+ * @param {boolean?} [properties.becauseSelecting]
+ * True if we're disabling this add-on because we're selecting
+ * another.
+ * @returns {Promise<boolean?>}
+ * A tri-state indicating the action taken for the add-on:
+ * - undefined: The add-on did not change state
+ * - true: The add-on became disabled
+ * - false: The add-on became enabled
+ * @throws if addon is not a AddonInternal
+ */
+ async updateAddonDisabledState(
+ aAddon,
+ { userDisabled, softDisabled, embedderDisabled, becauseSelecting } = {}
+ ) {
+ if (!aAddon.inDatabase) {
+ throw new Error("Can only update addon states for installed addons.");
+ }
+ if (userDisabled !== undefined && softDisabled !== undefined) {
+ throw new Error(
+ "Cannot change userDisabled and softDisabled at the same time"
+ );
+ }
+
+ if (userDisabled === undefined) {
+ userDisabled = aAddon.userDisabled;
+ } else if (!userDisabled) {
+ // If enabling the add-on then remove softDisabled
+ softDisabled = false;
+ }
+
+ // If not changing softDisabled or the add-on is already userDisabled then
+ // use the existing value for softDisabled
+ if (softDisabled === undefined || userDisabled) {
+ softDisabled = aAddon.softDisabled;
+ }
+
+ if (!lazy.AddonSettings.IS_EMBEDDED) {
+ // If embedderDisabled was accidentally set somehow, this will revert it
+ // back to false.
+ embedderDisabled = false;
+ } else if (embedderDisabled === undefined) {
+ embedderDisabled = aAddon.embedderDisabled;
+ }
+
+ let appDisabled = !this.isUsableAddon(aAddon);
+ // No change means nothing to do here
+ if (
+ aAddon.userDisabled == userDisabled &&
+ aAddon.appDisabled == appDisabled &&
+ aAddon.softDisabled == softDisabled &&
+ aAddon.embedderDisabled == embedderDisabled
+ ) {
+ return undefined;
+ }
+
+ let wasDisabled = aAddon.disabled;
+ let isDisabled =
+ userDisabled || softDisabled || appDisabled || embedderDisabled;
+
+ // If appDisabled changes but addon.disabled doesn't,
+ // no onDisabling/onEnabling is sent - so send a onPropertyChanged.
+ let appDisabledChanged = aAddon.appDisabled != appDisabled;
+
+ // Update the properties in the database.
+ this.setAddonProperties(aAddon, {
+ userDisabled,
+ appDisabled,
+ softDisabled,
+ embedderDisabled,
+ });
+
+ let wrapper = aAddon.wrapper;
+
+ if (appDisabledChanged) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onPropertyChanged",
+ wrapper,
+ ["appDisabled"]
+ );
+ }
+
+ // If the add-on is not visible or the add-on is not changing state then
+ // there is no need to do anything else
+ if (!aAddon.visible || wasDisabled == isDisabled) {
+ return undefined;
+ }
+
+ // Flag that active states in the database need to be updated on shutdown
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+
+ this.updateXPIStates(aAddon);
+
+ // Have we just gone back to the current state?
+ if (isDisabled != aAddon.active) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onOperationCancelled",
+ wrapper
+ );
+ } else {
+ if (isDisabled) {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onDisabling",
+ wrapper,
+ false
+ );
+ } else {
+ lazy.AddonManagerPrivate.callAddonListeners(
+ "onEnabling",
+ wrapper,
+ false
+ );
+ }
+
+ this.updateAddonActive(aAddon, !isDisabled);
+
+ let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(aAddon);
+ if (isDisabled) {
+ await bootstrap.disable();
+ lazy.AddonManagerPrivate.callAddonListeners("onDisabled", wrapper);
+ } else {
+ await bootstrap.startup(
+ XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_ENABLE
+ );
+ lazy.AddonManagerPrivate.callAddonListeners("onEnabled", wrapper);
+ }
+ }
+
+ // Notify any other providers that a new theme has been enabled
+ if (aAddon.type === "theme") {
+ if (!isDisabled) {
+ await lazy.AddonManagerPrivate.notifyAddonChanged(
+ aAddon.id,
+ aAddon.type
+ );
+ } else if (isDisabled && !becauseSelecting) {
+ await lazy.AddonManagerPrivate.notifyAddonChanged(null, "theme");
+ }
+ }
+
+ return isDisabled;
+ },
+
+ /**
+ * Update the appDisabled property for all add-ons.
+ */
+ updateAddonAppDisabledStates() {
+ for (let addon of this.getAddons()) {
+ this.updateAddonDisabledState(addon);
+ }
+ },
+
+ /**
+ * Update the repositoryAddon property for all add-ons.
+ */
+ async updateAddonRepositoryData() {
+ let addons = await this.getVisibleAddons(null);
+ logger.debug(
+ "updateAddonRepositoryData found " + addons.length + " visible add-ons"
+ );
+
+ await Promise.all(
+ addons.map(addon =>
+ lazy.AddonRepository.getCachedAddonByID(addon.id).then(aRepoAddon => {
+ if (aRepoAddon) {
+ logger.debug("updateAddonRepositoryData got info for " + addon.id);
+ addon._repositoryAddon = aRepoAddon;
+ return this.updateAddonDisabledState(addon);
+ }
+ return undefined;
+ })
+ )
+ );
+ },
+
+ /**
+ * Adds the add-on's name and creator to the telemetry payload.
+ *
+ * @param {AddonInternal} aAddon
+ * The addon to record
+ */
+ recordAddonTelemetry(aAddon) {
+ let locale = aAddon.defaultLocale;
+ XPIExports.XPIProvider.addTelemetry(aAddon.id, {
+ name: locale.name,
+ creator: locale.creator,
+ });
+ },
+};
+
+export const XPIDatabaseReconcile = {
+ /**
+ * Returns a map of ID -> add-on. When the same add-on ID exists in multiple
+ * install locations the highest priority location is chosen.
+ *
+ * @param {Map<String, AddonInternal>} addonMap
+ * The add-on map to flatten.
+ * @param {string?} [hideLocation]
+ * An optional location from which to hide any add-ons.
+ * @returns {Map<string, AddonInternal>}
+ */
+ flattenByID(addonMap, hideLocation) {
+ let map = new Map();
+
+ for (let loc of XPIExports.XPIInternal.XPIStates.locations()) {
+ if (loc.name == hideLocation) {
+ continue;
+ }
+
+ let locationMap = addonMap.get(loc.name);
+ if (!locationMap) {
+ continue;
+ }
+
+ for (let [id, addon] of locationMap) {
+ if (!map.has(id)) {
+ map.set(id, addon);
+ }
+ }
+ }
+
+ return map;
+ },
+
+ /**
+ * Finds the visible add-ons from the map.
+ *
+ * @param {Map<String, AddonInternal>} addonMap
+ * The add-on map to filter.
+ * @returns {Map<string, AddonInternal>}
+ */
+ getVisibleAddons(addonMap) {
+ let map = new Map();
+
+ for (let addons of addonMap.values()) {
+ for (let [id, addon] of addons) {
+ if (!addon.visible) {
+ continue;
+ }
+
+ if (map.has(id)) {
+ logger.warn(
+ "Previous database listed more than one visible add-on with id " +
+ id
+ );
+ continue;
+ }
+
+ map.set(id, addon);
+ }
+ }
+
+ return map;
+ },
+
+ /**
+ * Called to add the metadata for an add-on in one of the install locations
+ * to the database. This can be called in three different cases. Either an
+ * add-on has been dropped into the location from outside of Firefox, or
+ * an add-on has been installed through the application, or the database
+ * has been upgraded or become corrupt and add-on data has to be reloaded
+ * into it.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {string} aId
+ * The ID of the add-on
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {AddonInternal?} [aNewAddon]
+ * The manifest for the new add-on if it has already been loaded
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {boolean}
+ * A boolean indicating if flushing caches is required to complete
+ * changing this add-on
+ */
+ addMetadata(
+ aLocation,
+ aId,
+ aAddonState,
+ aNewAddon,
+ aOldAppVersion,
+ aOldPlatformVersion
+ ) {
+ logger.debug(`New add-on ${aId} installed in ${aLocation.name}`);
+
+ // We treat this is a new install if,
+ //
+ // a) It was explicitly registered as a staged install in the last
+ // session, or,
+ // b) We're not currently migrating or rebuilding a corrupt database. In
+ // that case, we can assume this add-on was found during a routine
+ // directory scan.
+ let isNewInstall = !!aNewAddon || !XPIDatabase.rebuildingDatabase;
+
+ // If it's a new install and we haven't yet loaded the manifest then it
+ // must be something dropped directly into the install location
+ let isDetectedInstall = isNewInstall && !aNewAddon;
+
+ // Load the manifest if necessary and sanity check the add-on ID
+ let unsigned;
+ try {
+ // Do not allow third party installs if xpinstall is disabled by policy
+ if (
+ isDetectedInstall &&
+ Services.policies &&
+ !Services.policies.isAllowed("xpinstall")
+ ) {
+ throw new Error(
+ "Extension installs are disabled by enterprise policy."
+ );
+ }
+
+ if (!aNewAddon) {
+ // Load the manifest from the add-on.
+ aNewAddon = XPIExports.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation
+ );
+ }
+ // The add-on in the manifest should match the add-on ID.
+ if (aNewAddon.id != aId) {
+ throw new Error(
+ `Invalid addon ID: expected addon ID ${aId}, found ${aNewAddon.id} in manifest`
+ );
+ }
+
+ unsigned =
+ XPIDatabase.mustSign(aNewAddon.type) && !aNewAddon.isCorrectlySigned;
+ if (unsigned) {
+ throw Error(`Extension ${aNewAddon.id} is not correctly signed`);
+ }
+ } catch (e) {
+ logger.warn(`addMetadata: Add-on ${aId} is invalid`, e);
+
+ // Remove the invalid add-on from the install location if the install
+ // location isn't locked
+ if (aLocation.isLinkedAddon(aId)) {
+ logger.warn("Not uninstalling invalid item because it is a proxy file");
+ } else if (aLocation.locked) {
+ logger.warn(
+ "Could not uninstall invalid item from locked install location"
+ );
+ } else if (unsigned && !isNewInstall) {
+ logger.warn("Not uninstalling existing unsigned add-on");
+ } else if (aLocation.name == KEY_APP_BUILTINS) {
+ // If a builtin has been removed from the build, we need to remove it from our
+ // data sets. We cannot use location.isBuiltin since the system addon locations
+ // mix it up.
+ XPIDatabase.removeAddonMetadata(aAddonState);
+ aLocation.removeAddon(aId);
+ } else {
+ aLocation.installer.uninstallAddon(aId);
+ }
+ return null;
+ }
+
+ // Update the AddonInternal properties.
+ aNewAddon.installDate = aAddonState.mtime;
+ aNewAddon.updateDate = aAddonState.mtime;
+
+ // Assume that add-ons in the system add-ons install location aren't
+ // foreign and should default to enabled.
+ aNewAddon.foreignInstall =
+ isDetectedInstall && !aLocation.isSystem && !aLocation.isBuiltin;
+
+ // appDisabled depends on whether the add-on is a foreignInstall so update
+ aNewAddon.appDisabled = !XPIDatabase.isUsableAddon(aNewAddon);
+
+ if (isDetectedInstall && aNewAddon.foreignInstall) {
+ // Add the installation source info for the sideloaded extension.
+ aNewAddon.installTelemetryInfo = {
+ source: aLocation.name,
+ method: "sideload",
+ };
+
+ // If the add-on is a foreign install and is in a scope where add-ons
+ // that were dropped in should default to disabled then disable it
+ let disablingScopes = Services.prefs.getIntPref(
+ PREF_EM_AUTO_DISABLED_SCOPES,
+ 0
+ );
+ if (aLocation.scope & disablingScopes) {
+ logger.warn(
+ `Disabling foreign installed add-on ${aNewAddon.id} in ${aLocation.name}`
+ );
+ aNewAddon.userDisabled = true;
+ aNewAddon.seen = false;
+ }
+ }
+
+ return XPIDatabase.addToDatabase(aNewAddon, aAddonState.path);
+ },
+
+ /**
+ * Called when an add-on has been removed.
+ *
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ */
+ removeMetadata(aOldAddon) {
+ // This add-on has disappeared
+ logger.debug(
+ "Add-on " + aOldAddon.id + " removed from " + aOldAddon.location.name
+ );
+ XPIDatabase.removeAddonMetadata(aOldAddon);
+ },
+
+ /**
+ * Updates an add-on's metadata and determines. This is called when either the
+ * add-on's install directory path or last modified time has changed.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {AddonInternal?} [aNewAddon]
+ * The manifest for the new add-on if it has already been loaded
+ * @returns {AddonInternal}
+ * The AddonInternal that was added to the database
+ */
+ updateMetadata(aLocation, aOldAddon, aAddonState, aNewAddon) {
+ logger.debug(`Add-on ${aOldAddon.id} modified in ${aLocation.name}`);
+
+ try {
+ // If there isn't an updated install manifest for this add-on then load it.
+ if (!aNewAddon) {
+ aNewAddon = XPIExports.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation,
+ aOldAddon
+ );
+ } else {
+ aNewAddon.rootURI = aOldAddon.rootURI;
+ }
+
+ // The ID in the manifest that was loaded must match the ID of the old
+ // add-on.
+ if (aNewAddon.id != aOldAddon.id) {
+ throw new Error(
+ `Incorrect id in install manifest for existing add-on ${aOldAddon.id}`
+ );
+ }
+ } catch (e) {
+ logger.warn(`updateMetadata: Add-on ${aOldAddon.id} is invalid`, e);
+
+ XPIDatabase.removeAddonMetadata(aOldAddon);
+ aOldAddon.location.removeAddon(aOldAddon.id);
+
+ if (!aLocation.locked) {
+ aLocation.installer.uninstallAddon(aOldAddon.id);
+ } else {
+ logger.warn(
+ "Could not uninstall invalid item from locked install location"
+ );
+ }
+
+ return null;
+ }
+
+ // Set the additional properties on the new AddonInternal
+ aNewAddon.updateDate = aAddonState.mtime;
+
+ XPIExports.XPIProvider.persistStartupData(aNewAddon, aAddonState);
+
+ // Update the database
+ return XPIDatabase.updateAddonMetadata(
+ aOldAddon,
+ aNewAddon,
+ aAddonState.path
+ );
+ },
+
+ /**
+ * Updates an add-on's path for when the add-on has moved in the
+ * filesystem but hasn't changed in any other way.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @returns {AddonInternal}
+ */
+ updatePath(aLocation, aOldAddon, aAddonState) {
+ logger.debug(`Add-on ${aOldAddon.id} moved to ${aAddonState.path}`);
+ aOldAddon.path = aAddonState.path;
+ aOldAddon._sourceBundle = new nsIFile(aAddonState.path);
+ aOldAddon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ aOldAddon._sourceBundle,
+ ""
+ ).spec;
+
+ return aOldAddon;
+ },
+
+ /**
+ * Called when no change has been detected for an add-on's metadata but the
+ * application has changed so compatibility may have changed.
+ *
+ * @param {XPIStateLocation} aLocation
+ * The install location containing the add-on
+ * @param {AddonInternal} aOldAddon
+ * The AddonInternal as it appeared the last time the application
+ * ran
+ * @param {XPIState} aAddonState
+ * The new state of the add-on
+ * @param {boolean} [aReloadMetadata = false]
+ * A boolean which indicates whether metadata should be reloaded from
+ * the addon manifests. Default to false.
+ * @returns {AddonInternal}
+ * The new addon.
+ */
+ updateCompatibility(aLocation, aOldAddon, aAddonState, aReloadMetadata) {
+ logger.debug(
+ `Updating compatibility for add-on ${aOldAddon.id} in ${aLocation.name}`
+ );
+
+ let checkSigning =
+ aOldAddon.signedState === undefined && SIGNED_TYPES.has(aOldAddon.type);
+ // signedDate must be set if signedState is set.
+ let signedDateMissing =
+ aOldAddon.signedDate === undefined &&
+ (aOldAddon.signedState || checkSigning);
+
+ // If maxVersion was inadvertently updated for a locale, force a reload
+ // from the manifest. See Bug 1646016 for details.
+ if (
+ !aReloadMetadata &&
+ aOldAddon.type === "locale" &&
+ aOldAddon.matchingTargetApplication
+ ) {
+ aReloadMetadata = aOldAddon.matchingTargetApplication.maxVersion === "*";
+ }
+
+ let manifest = null;
+ if (checkSigning || aReloadMetadata || signedDateMissing) {
+ try {
+ manifest = XPIExports.XPIInstall.syncLoadManifest(
+ aAddonState,
+ aLocation
+ );
+ } catch (err) {
+ // If we can no longer read the manifest, it is no longer compatible.
+ aOldAddon.brokenManifest = true;
+ aOldAddon.appDisabled = true;
+ return aOldAddon;
+ }
+ }
+
+ // If updating from a version of the app that didn't support signedState
+ // then update that property now
+ if (checkSigning) {
+ aOldAddon.signedState = manifest.signedState;
+ }
+
+ if (signedDateMissing) {
+ aOldAddon.signedDate = manifest.signedDate;
+ }
+
+ // May be updating from a version of the app that didn't support all the
+ // properties of the currently-installed add-ons.
+ if (aReloadMetadata) {
+ // Avoid re-reading these properties from manifest,
+ // use existing addon instead.
+ let remove = [
+ "syncGUID",
+ "foreignInstall",
+ "visible",
+ "active",
+ "userDisabled",
+ "embedderDisabled",
+ "applyBackgroundUpdates",
+ "sourceURI",
+ "releaseNotesURI",
+ "installTelemetryInfo",
+ ];
+
+ // TODO - consider re-scanning for targetApplications for other addon types.
+ if (aOldAddon.type !== "locale") {
+ remove.push("targetApplications");
+ }
+
+ let props = PROP_JSON_FIELDS.filter(a => !remove.includes(a));
+ copyProperties(manifest, props, aOldAddon);
+ }
+
+ aOldAddon.appDisabled = !XPIDatabase.isUsableAddon(aOldAddon);
+
+ return aOldAddon;
+ },
+
+ /**
+ * Returns true if this install location is part of the application
+ * bundle. Add-ons in these locations are expected to change whenever
+ * the application updates.
+ *
+ * @param {XPIStateLocation} location
+ * The install location to check.
+ * @returns {boolean}
+ * True if this location is part of the application bundle.
+ */
+ isAppBundledLocation(location) {
+ return (
+ location.name == KEY_APP_GLOBAL ||
+ location.name == KEY_APP_SYSTEM_DEFAULTS ||
+ location.name == KEY_APP_BUILTINS
+ );
+ },
+
+ /**
+ * Returns true if this install location holds system addons.
+ *
+ * @param {XPIStateLocation} location
+ * The install location to check.
+ * @returns {boolean}
+ * True if this location contains system add-ons.
+ */
+ isSystemAddonLocation(location) {
+ return (
+ location.name === KEY_APP_SYSTEM_DEFAULTS ||
+ location.name === KEY_APP_SYSTEM_ADDONS
+ );
+ },
+
+ /**
+ * Updates the databse metadata for an existing add-on during database
+ * reconciliation.
+ *
+ * @param {AddonInternal} oldAddon
+ * The existing database add-on entry.
+ * @param {XPIState} xpiState
+ * The XPIStates entry for this add-on.
+ * @param {AddonInternal?} newAddon
+ * The new add-on metadata for the add-on, as loaded from a
+ * staged update in addonStartup.json.
+ * @param {boolean} aUpdateCompatibility
+ * true to update add-ons appDisabled property when the application
+ * version has changed
+ * @param {boolean} aSchemaChange
+ * The schema has changed and all add-on manifests should be re-read.
+ * @returns {AddonInternal?}
+ * The updated AddonInternal object for the add-on, if one
+ * could be created.
+ */
+ updateExistingAddon(
+ oldAddon,
+ xpiState,
+ newAddon,
+ aUpdateCompatibility,
+ aSchemaChange
+ ) {
+ XPIDatabase.recordAddonTelemetry(oldAddon);
+
+ let installLocation = oldAddon.location;
+
+ // Update the add-on's database metadata from on-disk metadata if:
+ //
+ // a) The add-on was staged for install in the last session,
+ // b) The add-on has been modified since the last session, or,
+ // c) The app has been updated since the last session, and the
+ // add-on is part of the application bundle (and has therefore
+ // likely been replaced in the update process).
+ if (
+ newAddon ||
+ oldAddon.updateDate != xpiState.mtime ||
+ (aUpdateCompatibility && this.isAppBundledLocation(installLocation))
+ ) {
+ newAddon = this.updateMetadata(
+ installLocation,
+ oldAddon,
+ xpiState,
+ newAddon
+ );
+ } else if (oldAddon.path != xpiState.path) {
+ newAddon = this.updatePath(installLocation, oldAddon, xpiState);
+ } else if (aUpdateCompatibility || aSchemaChange) {
+ newAddon = this.updateCompatibility(
+ installLocation,
+ oldAddon,
+ xpiState,
+ aSchemaChange
+ );
+ } else {
+ newAddon = oldAddon;
+ }
+
+ if (newAddon) {
+ newAddon.rootURI = newAddon.rootURI || xpiState.rootURI;
+ }
+
+ return newAddon;
+ },
+
+ /**
+ * Compares the add-ons that are currently installed to those that were
+ * known to be installed when the application last ran and applies any
+ * changes found to the database.
+ * Always called after XPIDatabase.sys.mjs and extensions.json have been
+ * loaded.
+ *
+ * @param {Object} aManifests
+ * A dictionary of cached AddonInstalls for add-ons that have been
+ * installed
+ * @param {boolean} aUpdateCompatibility
+ * true to update add-ons appDisabled property when the application
+ * version has changed
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {boolean} aSchemaChange
+ * The schema has changed and all add-on manifests should be re-read.
+ * @returns {boolean}
+ * A boolean indicating if a change requiring flushing the caches was
+ * detected
+ */
+ processFileChanges(
+ aManifests,
+ aUpdateCompatibility,
+ aOldAppVersion,
+ aOldPlatformVersion,
+ aSchemaChange
+ ) {
+ let findManifest = (loc, id) => {
+ return (aManifests[loc.name] && aManifests[loc.name][id]) || null;
+ };
+
+ let previousAddons = new lazy.ExtensionUtils.DefaultMap(() => new Map());
+ let currentAddons = new lazy.ExtensionUtils.DefaultMap(() => new Map());
+
+ // Get the previous add-ons from the database and put them into maps by location
+ for (let addon of XPIDatabase.getAddons()) {
+ previousAddons.get(addon.location.name).set(addon.id, addon);
+ }
+
+ // Keep track of add-ons whose blocklist status may have changed. We'll check this
+ // after everything else.
+ let addonsToCheckAgainstBlocklist = [];
+
+ // Build the list of current add-ons into similar maps. When add-ons are still
+ // present we re-use the add-on objects from the database and update their
+ // details directly
+ let addonStates = new Map();
+ for (let location of XPIExports.XPIInternal.XPIStates.locations()) {
+ let locationAddons = currentAddons.get(location.name);
+
+ // Get all the on-disk XPI states for this location, and keep track of which
+ // ones we see in the database.
+ let dbAddons = previousAddons.get(location.name) || new Map();
+ for (let [id, oldAddon] of dbAddons) {
+ // Check if the add-on is still installed
+ let xpiState = location.get(id);
+ if (xpiState && !xpiState.missing) {
+ let newAddon = this.updateExistingAddon(
+ oldAddon,
+ xpiState,
+ findManifest(location, id),
+ aUpdateCompatibility,
+ aSchemaChange
+ );
+ if (newAddon) {
+ locationAddons.set(newAddon.id, newAddon);
+
+ // We need to do a blocklist check later, but the add-on may have changed by then.
+ // Avoid storing the current copy and just get one when we need one instead.
+ addonsToCheckAgainstBlocklist.push(newAddon.id);
+ }
+ } else {
+ // The add-on is in the DB, but not in xpiState (and thus not on disk).
+ this.removeMetadata(oldAddon);
+ }
+ }
+
+ for (let [id, xpiState] of location) {
+ if (locationAddons.has(id) || xpiState.missing) {
+ continue;
+ }
+ let newAddon = findManifest(location, id);
+ let addon = this.addMetadata(
+ location,
+ id,
+ xpiState,
+ newAddon,
+ aOldAppVersion,
+ aOldPlatformVersion
+ );
+ if (addon) {
+ locationAddons.set(addon.id, addon);
+ addonStates.set(addon, xpiState);
+ }
+ }
+
+ if (this.isSystemAddonLocation(location)) {
+ for (let [id, addon] of locationAddons.entries()) {
+ const pref = `extensions.${id.split("@")[0]}.enabled`;
+ addon.userDisabled = !Services.prefs.getBoolPref(pref, true);
+ }
+ }
+ }
+
+ // Validate the updated system add-ons
+ let hideLocation;
+ {
+ let systemAddonLocation = XPIExports.XPIInternal.XPIStates.getLocation(
+ KEY_APP_SYSTEM_ADDONS
+ );
+ let addons = currentAddons.get(systemAddonLocation.name);
+
+ if (!systemAddonLocation.installer.isValid(addons)) {
+ // Hide the system add-on updates if any are invalid.
+ logger.info(
+ "One or more updated system add-ons invalid, falling back to defaults."
+ );
+ hideLocation = systemAddonLocation.name;
+ }
+ }
+
+ // Apply startup changes to any currently-visible add-ons, and
+ // uninstall any which were previously visible, but aren't anymore.
+ let previousVisible = this.getVisibleAddons(previousAddons);
+ let currentVisible = this.flattenByID(currentAddons, hideLocation);
+
+ for (let addon of XPIDatabase.orphanedAddons.splice(0)) {
+ if (addon.visible) {
+ previousVisible.set(addon.id, addon);
+ }
+ }
+
+ let promises = [];
+ for (let [id, addon] of currentVisible) {
+ // If we have a stored manifest for the add-on, it came from the
+ // startup data cache, and supersedes any previous XPIStates entry.
+ let xpiState =
+ !findManifest(addon.location, id) && addonStates.get(addon);
+
+ promises.push(
+ this.applyStartupChange(addon, previousVisible.get(id), xpiState)
+ );
+ previousVisible.delete(id);
+ }
+
+ if (promises.some(p => p)) {
+ XPIExports.XPIInternal.awaitPromise(Promise.all(promises));
+ }
+
+ for (let [id, addon] of previousVisible) {
+ if (addon.location) {
+ if (addon.location.name == KEY_APP_BUILTINS) {
+ continue;
+ }
+ XPIExports.XPIInternal.BootstrapScope.get(addon).uninstall();
+ addon.location.removeAddon(id);
+ addon.visible = false;
+ addon.active = false;
+ }
+
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_UNINSTALLED,
+ id
+ );
+ }
+
+ // Finally update XPIStates to match everything
+ for (let [locationName, locationAddons] of currentAddons) {
+ for (let [id, addon] of locationAddons) {
+ let xpiState = XPIExports.XPIInternal.XPIStates.getAddon(
+ locationName,
+ id
+ );
+ xpiState.syncWithDB(addon);
+ }
+ }
+ XPIExports.XPIInternal.XPIStates.save();
+ XPIDatabase.saveChanges();
+ XPIDatabase.rebuildingDatabase = false;
+
+ if (aUpdateCompatibility || aSchemaChange) {
+ // Do some blocklist checks. These will happen after we've just saved everything,
+ // because they're async and depend on the blocklist loading. When we're done, save
+ // the data if any of the add-ons' blocklist state has changed.
+ lazy.AddonManager.beforeShutdown.addBlocker(
+ "Update add-on blocklist state into add-on DB",
+ (async () => {
+ // Avoid querying the AddonManager immediately to give startup a chance
+ // to complete.
+ await Promise.resolve();
+
+ let addons = await lazy.AddonManager.getAddonsByIDs(
+ addonsToCheckAgainstBlocklist
+ );
+ await Promise.all(
+ addons.map(async addon => {
+ if (!addon) {
+ return;
+ }
+ let oldState = addon.blocklistState;
+ // TODO 1712316: updateBlocklistState with object parameter only
+ // works if addon is an AddonInternal instance. But addon is an
+ // AddonWrapper instead. Consequently updateDate:false is ignored.
+ await addon.updateBlocklistState({ updateDatabase: false });
+ if (oldState !== addon.blocklistState) {
+ lazy.Blocklist.recordAddonBlockChangeTelemetry(
+ addon,
+ "addon_db_modified"
+ );
+ }
+ })
+ );
+
+ XPIDatabase.saveChanges();
+ })()
+ );
+ }
+
+ return true;
+ },
+
+ /**
+ * Applies a startup change for the given add-on.
+ *
+ * @param {AddonInternal} currentAddon
+ * The add-on as it exists in this session.
+ * @param {AddonInternal?} previousAddon
+ * The add-on as it existed in the previous session.
+ * @param {XPIState?} xpiState
+ * The XPIState entry for this add-on, if one exists.
+ * @returns {Promise?}
+ * If an update was performed, returns a promise which resolves
+ * when the appropriate bootstrap methods have been called.
+ */
+ applyStartupChange(currentAddon, previousAddon, xpiState) {
+ let promise;
+ let { id } = currentAddon;
+
+ let isActive = !currentAddon.disabled;
+ let wasActive = previousAddon ? previousAddon.active : currentAddon.active;
+
+ if (previousAddon) {
+ if (previousAddon !== currentAddon) {
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_CHANGED,
+ id
+ );
+
+ // Bug 1664144: If the addon changed on disk we will catch it during
+ // the second scan initiated by getNewSideloads. The addon may have
+ // already started, if so we need to ensure it restarts during the
+ // update, otherwise we're left in a state where the addon is enabled
+ // but not started. We use the bootstrap started state to check that.
+ // isActive alone is not sufficient as that changes the characteristics
+ // of other updates and breaks many tests.
+ let restart =
+ isActive &&
+ XPIExports.XPIInternal.BootstrapScope.get(currentAddon).started;
+ if (restart) {
+ logger.warn(
+ `Updating and restart addon ${previousAddon.id} that changed on disk after being already started.`
+ );
+ }
+ promise = XPIExports.XPIInternal.BootstrapScope.get(
+ previousAddon
+ ).update(currentAddon, restart);
+ }
+
+ if (isActive != wasActive) {
+ let change = isActive
+ ? lazy.AddonManager.STARTUP_CHANGE_ENABLED
+ : lazy.AddonManager.STARTUP_CHANGE_DISABLED;
+ lazy.AddonManagerPrivate.addStartupChange(change, id);
+ }
+ } else if (xpiState && xpiState.wasRestored) {
+ isActive = xpiState.enabled;
+
+ if (currentAddon.isWebExtension && currentAddon.type == "theme") {
+ currentAddon.userDisabled = !isActive;
+ }
+
+ // If the add-on wasn't active and it isn't already disabled in some way
+ // then it was probably either softDisabled or userDisabled
+ if (!isActive && !currentAddon.disabled) {
+ // If the add-on is softblocked then assume it is softDisabled
+ if (
+ currentAddon.blocklistState == Services.blocklist.STATE_SOFTBLOCKED
+ ) {
+ currentAddon.softDisabled = true;
+ } else {
+ currentAddon.userDisabled = true;
+ }
+ }
+ } else {
+ lazy.AddonManagerPrivate.addStartupChange(
+ lazy.AddonManager.STARTUP_CHANGE_INSTALLED,
+ id
+ );
+ let scope = XPIExports.XPIInternal.BootstrapScope.get(currentAddon);
+ scope.install();
+ }
+
+ XPIDatabase.makeAddonVisible(currentAddon);
+ currentAddon.active = isActive;
+ return promise;
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs b/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs
new file mode 100644
index 0000000000..3fdd03e660
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 wraps XPIDatabase, XPIInstall, and XPIProvider modules in order to
+ * allow testing the shutdown+restart situation in AddonTestUtils.sys.mjs.
+ */
+
+// A shared `lazy` object for exports from XPIDatabase, XPIInternal, and
+// XPIProvider modules.
+//
+// Consumers shouldn't store those property values to global variables, except
+// for registering XPIProvider.
+//
+// The list of lazy getters should be in sync with resetXPIExports in
+// AddonTestUtils.sys.mjs.
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ // XPIDatabase.sys.mjs
+ AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+ BuiltInThemesHelpers: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+ XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+ XPIDatabaseReconcile: "resource://gre/modules/addons/XPIDatabase.sys.mjs",
+
+ // XPIInstall.sys.mjs
+ UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs",
+ XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs",
+ verifyBundleSignedState: "resource://gre/modules/addons/XPIInstall.sys.mjs",
+
+ // XPIProvider.sys.mjs
+ XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs",
+ XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs",
+});
+
+export { lazy as XPIExports };
diff --git a/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs
new file mode 100644
index 0000000000..1a80407ad2
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs
@@ -0,0 +1,4897 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 contains most of the logic required to install extensions.
+ * In general, we try to avoid loading it until extension installation
+ * or update is required. Please keep that in mind when deciding whether
+ * to add code here or elsewhere.
+ */
+
+/**
+ * @typedef {number} integer
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs";
+import {
+ computeSha256HashAsString,
+ getHashStringForCrypto,
+} from "resource://gre/modules/addons/crypto-utils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ CertUtils: "resource://gre/modules/CertUtils.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ ProductAddonChecker:
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs",
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "IconDetails", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ ).ExtensionParent.IconDetails;
+});
+
+const { nsIBlocklistService } = Ci;
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+
+const BinaryOutputStream = Components.Constructor(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+);
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+const FileOutputStream = Components.Constructor(
+ "@mozilla.org/network/file-output-stream;1",
+ "nsIFileOutputStream",
+ "init"
+);
+const ZipReader = Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
+});
+
+const PREF_INSTALL_REQUIRESECUREORIGIN =
+ "extensions.install.requireSecureOrigin";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url";
+const PREF_XPI_ENABLED = "xpinstall.enabled";
+const PREF_XPI_DIRECT_WHITELISTED = "xpinstall.whitelist.directRequest";
+const PREF_XPI_FILE_WHITELISTED = "xpinstall.whitelist.fileRequest";
+const PREF_XPI_WHITELIST_REQUIRED = "xpinstall.whitelist.required";
+
+const PREF_SELECTED_THEME = "extensions.activeThemeID";
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+/**
+ * Returns a nsIFile instance for the given path, relative to the given
+ * base file, if provided.
+ *
+ * @param {string} path
+ * The (possibly relative) path of the file.
+ * @param {nsIFile} [base]
+ * An optional file to use as a base path if `path` is relative.
+ * @returns {nsIFile}
+ */
+function getFile(path, base = null) {
+ // First try for an absolute path, as we get in the case of proxy
+ // files. Ideally we would try a relative path first, but on Windows,
+ // paths which begin with a drive letter are valid as relative paths,
+ // and treated as such.
+ try {
+ return new nsIFile(path);
+ } catch (e) {
+ // Ignore invalid relative paths. The only other error we should see
+ // here is EOM, and either way, any errors that we care about should
+ // be re-thrown below.
+ }
+
+ // If the path isn't absolute, we must have a base path.
+ let file = base.clone();
+ file.appendRelativePath(path);
+ return file;
+}
+
+/**
+ * Sends local and remote notifications to flush a JAR file cache entry
+ *
+ * @param {nsIFile} aJarFile
+ * The ZIP/XPI/JAR file as a nsIFile
+ */
+function flushJarCache(aJarFile) {
+ Services.obs.notifyObservers(aJarFile, "flush-cache-entry");
+ Services.ppmm.broadcastAsyncMessage(MSG_JAR_FLUSH, {
+ path: aJarFile.path,
+ });
+}
+
+const PREF_EM_UPDATE_BACKGROUND_URL = "extensions.update.background.url";
+const PREF_EM_UPDATE_URL = "extensions.update.url";
+const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
+
+const KEY_TEMPDIR = "TmpD";
+
+// This is a random number array that can be used as "salt" when generating
+// an automatic ID based on the directory path of an add-on. It will prevent
+// someone from creating an ID for a permanent add-on that could be replaced
+// by a temporary add-on (because that would be confusing, I guess).
+const TEMP_INSTALL_ID_GEN_SESSION = new Uint8Array(
+ Float64Array.of(Math.random()).buffer
+);
+
+const MSG_JAR_FLUSH = "Extension:FlushJarCache";
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest =
+ /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.xpi";
+
+// Create a new logger for use by all objects in this Addons XPI Provider module
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+// Stores the ID of the theme which was selected during the last session,
+// if any. When installing a new built-in theme with this ID, it will be
+// automatically enabled.
+let lastSelectedTheme = null;
+
+function getJarURI(file, path = "") {
+ if (file instanceof Ci.nsIFile) {
+ file = Services.io.newFileURI(file);
+ }
+ if (file instanceof Ci.nsIURI) {
+ file = file.spec;
+ }
+ return Services.io.newURI(`jar:${file}!/${path}`);
+}
+
+let DirPackage;
+let XPIPackage;
+class Package {
+ static get(file) {
+ if (file.isFile()) {
+ return new XPIPackage(file);
+ }
+ return new DirPackage(file);
+ }
+
+ constructor(file, rootURI) {
+ this.file = file;
+ this.filePath = file.path;
+ this.rootURI = rootURI;
+ }
+
+ close() {}
+
+ async readString(...path) {
+ let buffer = await this.readBinary(...path);
+ return new TextDecoder().decode(buffer);
+ }
+
+ async verifySignedState(addonId, addonType, addonLocation) {
+ if (!shouldVerifySignedState(addonType, addonLocation)) {
+ return {
+ signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
+ cert: null,
+ };
+ }
+
+ let root = Ci.nsIX509CertDB.AddonsPublicRoot;
+ if (
+ !AppConstants.MOZ_REQUIRE_SIGNING &&
+ Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)
+ ) {
+ root = Ci.nsIX509CertDB.AddonsStageRoot;
+ }
+
+ return this.verifySignedStateForRoot(addonId, root);
+ }
+
+ flushCache() {}
+}
+
+DirPackage = class DirPackage extends Package {
+ constructor(file) {
+ super(file, Services.io.newFileURI(file));
+ }
+
+ hasResource(...path) {
+ return IOUtils.exists(PathUtils.join(this.filePath, ...path));
+ }
+
+ async iterDirectory(path, callback) {
+ let fullPath = PathUtils.join(this.filePath, ...path);
+
+ let children = await IOUtils.getChildren(fullPath);
+ for (let path of children) {
+ let { type } = await IOUtils.stat(path);
+ callback({
+ isDir: type == "directory",
+ name: PathUtils.filename(path),
+ path,
+ });
+ }
+ }
+
+ iterFiles(callback, path = []) {
+ return this.iterDirectory(path, async entry => {
+ let entryPath = [...path, entry.name];
+ if (entry.isDir) {
+ callback({
+ path: entryPath.join("/"),
+ isDir: true,
+ });
+ await this.iterFiles(callback, entryPath);
+ } else {
+ callback({
+ path: entryPath.join("/"),
+ isDir: false,
+ });
+ }
+ });
+ }
+
+ readBinary(...path) {
+ return IOUtils.read(PathUtils.join(this.filePath, ...path));
+ }
+
+ async verifySignedStateForRoot(addonId, root) {
+ return { signedState: AddonManager.SIGNEDSTATE_UNKNOWN, cert: null };
+ }
+};
+
+XPIPackage = class XPIPackage extends Package {
+ constructor(file) {
+ super(file, getJarURI(file));
+
+ this.zipReader = new ZipReader(file);
+ }
+
+ close() {
+ this.zipReader.close();
+ this.zipReader = null;
+ this.flushCache();
+ }
+
+ async hasResource(...path) {
+ return this.zipReader.hasEntry(path.join("/"));
+ }
+
+ async iterFiles(callback) {
+ for (let path of this.zipReader.findEntries("*")) {
+ let entry = this.zipReader.getEntry(path);
+ callback({
+ path,
+ isDir: entry.isDirectory,
+ });
+ }
+ }
+
+ async readBinary(...path) {
+ let response = await fetch(this.rootURI.resolve(path.join("/")));
+ return response.arrayBuffer();
+ }
+
+ verifySignedStateForRoot(addonId, root) {
+ return new Promise(resolve => {
+ let callback = {
+ openSignedAppFileFinished(aRv, aZipReader, aCert) {
+ if (aZipReader) {
+ aZipReader.close();
+ }
+ resolve({
+ signedState: getSignedStatus(aRv, aCert, addonId),
+ cert: aCert,
+ });
+ },
+ };
+ // This allows the certificate DB to get the raw JS callback object so the
+ // test code can pass through objects that XPConnect would reject.
+ callback.wrappedJSObject = callback;
+
+ lazy.gCertDB.openSignedAppFileAsync(root, this.file, callback);
+ });
+ }
+
+ flushCache() {
+ flushJarCache(this.file);
+ }
+};
+
+/**
+ * Return an object that implements enough of the Package interface
+ * to allow loadManifest() to work for a built-in addon (ie, one loaded
+ * from a resource: url)
+ *
+ * @param {nsIURL} baseURL The URL for the root of the add-on.
+ * @returns {object}
+ */
+function builtinPackage(baseURL) {
+ return {
+ rootURI: baseURL,
+ filePath: baseURL.spec,
+ file: null,
+ verifySignedState() {
+ return {
+ signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
+ cert: null,
+ };
+ },
+ async hasResource(path) {
+ try {
+ let response = await fetch(this.rootURI.resolve(path));
+ return response.ok;
+ } catch (e) {
+ return false;
+ }
+ },
+ };
+}
+
+/**
+ * Determine the reason to pass to an extension's bootstrap methods when
+ * switch between versions.
+ *
+ * @param {string} oldVersion The version of the existing extension instance.
+ * @param {string} newVersion The version of the extension being installed.
+ *
+ * @returns {integer}
+ * BOOSTRAP_REASONS.ADDON_UPGRADE or BOOSTRAP_REASONS.ADDON_DOWNGRADE
+ */
+function newVersionReason(oldVersion, newVersion) {
+ return Services.vc.compare(oldVersion, newVersion) <= 0
+ ? XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_UPGRADE
+ : XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_DOWNGRADE;
+}
+
+// Behaves like Promise.all except waits for all promises to resolve/reject
+// before resolving/rejecting itself
+function waitForAllPromises(promises) {
+ return new Promise((resolve, reject) => {
+ let shouldReject = false;
+ let rejectValue = null;
+
+ let newPromises = promises.map(p =>
+ p.catch(value => {
+ shouldReject = true;
+ rejectValue = value;
+ })
+ );
+ Promise.all(newPromises).then(results =>
+ shouldReject ? reject(rejectValue) : resolve(results)
+ );
+ });
+}
+
+/**
+ * Reads an AddonInternal object from a webextension manifest.json
+ *
+ * @param {Package} aPackage
+ * The install package for the add-on
+ * @param {XPIStateLocation} aLocation
+ * The install location the add-on is installed in, or will be
+ * installed to.
+ * @returns {{ addon: AddonInternal, verifiedSignedState: object}}
+ * @throws if the install manifest in the stream is corrupt or could not
+ * be read
+ */
+async function loadManifestFromWebManifest(aPackage, aLocation) {
+ let verifiedSignedState;
+ const temporarilyInstalled = aLocation.isTemporary;
+ let extension = await lazy.ExtensionData.constructAsync({
+ rootURI: XPIExports.XPIInternal.maybeResolveURI(aPackage.rootURI),
+ temporarilyInstalled,
+ async checkPrivileged(type, id) {
+ verifiedSignedState = await aPackage.verifySignedState(
+ id,
+ type,
+ aLocation
+ );
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: verifiedSignedState.signedState,
+ builtIn: aLocation.isBuiltin,
+ temporarilyInstalled,
+ });
+ },
+ });
+
+ let manifest = await extension.loadManifest();
+
+ // Read the list of available locales, and pre-load messages for
+ // all locales.
+ let locales = !extension.errors.length
+ ? await extension.initAllLocales()
+ : null;
+
+ if (extension.errors.length) {
+ let error = new Error("Extension is invalid");
+ // Add detailed errors on the error object so that the front end can display them
+ // if needed (eg in about:debugging).
+ error.additionalErrors = extension.errors;
+ throw error;
+ }
+
+ // Internally, we use the `applications` key but it is because we assign the value
+ // of `browser_specific_settings` to `applications` in `ExtensionData.parseManifest()`.
+ // Yet, as of MV3, only `browser_specific_settings` is accepted in manifest.json files.
+ let bss = manifest.applications?.gecko || {};
+
+ // A * is illegal in strict_min_version
+ if (bss.strict_min_version?.split(".").some(part => part == "*")) {
+ throw new Error("The use of '*' in strict_min_version is invalid");
+ }
+
+ let addon = new XPIExports.AddonInternal();
+ addon.id = bss.id;
+ addon.version = manifest.version;
+ addon.manifestVersion = manifest.manifest_version;
+ addon.type = extension.type;
+ addon.loader = null;
+ addon.strictCompatibility = true;
+ addon.internalName = null;
+ addon.updateURL = bss.update_url;
+ addon.installOrigins = manifest.install_origins;
+ addon.optionsBrowserStyle = true;
+ addon.optionsURL = null;
+ addon.optionsType = null;
+ addon.aboutURL = null;
+ addon.dependencies = Object.freeze(Array.from(extension.dependencies));
+ addon.startupData = extension.startupData;
+ addon.hidden = extension.isPrivileged && manifest.hidden;
+ addon.incognito = manifest.incognito;
+
+ if (addon.type === "theme" && (await aPackage.hasResource("preview.png"))) {
+ addon.previewImage = "preview.png";
+ }
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ if (addon.type == "sitepermission-deprecated") {
+ addon.sitePermissions = manifest.site_permissions;
+ addon.siteOrigin = manifest.install_origins[0];
+ }
+
+ if (manifest.options_ui) {
+ // Store just the relative path here, the AddonWrapper getURL
+ // wrapper maps this to a full URL.
+ addon.optionsURL = manifest.options_ui.page;
+ if (manifest.options_ui.open_in_tab) {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_TAB;
+ } else {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
+ }
+
+ addon.optionsBrowserStyle = manifest.options_ui.browser_style;
+ }
+
+ // WebExtensions don't use iconURLs
+ addon.iconURL = null;
+ addon.icons = manifest.icons || {};
+ addon.userPermissions = extension.manifestPermissions;
+ addon.optionalPermissions = extension.manifestOptionalPermissions;
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+
+ function getLocale(aLocale) {
+ // Use the raw manifest, here, since we need values with their
+ // localization placeholders still in place.
+ let rawManifest = extension.rawManifest;
+
+ // As a convenience, allow author to be set if its a string bug 1313567.
+ let creator =
+ typeof rawManifest.author === "string" ? rawManifest.author : null;
+ let homepageURL = rawManifest.homepage_url;
+
+ // Allow developer to override creator and homepage_url.
+ if (rawManifest.developer) {
+ if (rawManifest.developer.name) {
+ creator = rawManifest.developer.name;
+ }
+ if (rawManifest.developer.url) {
+ homepageURL = rawManifest.developer.url;
+ }
+ }
+
+ let result = {
+ name: extension.localize(rawManifest.name, aLocale),
+ description: extension.localize(rawManifest.description, aLocale),
+ creator: extension.localize(creator, aLocale),
+ homepageURL: extension.localize(homepageURL, aLocale),
+
+ developers: null,
+ translators: null,
+ contributors: null,
+ locales: [aLocale],
+ };
+ return result;
+ }
+
+ addon.defaultLocale = getLocale(extension.defaultLocale);
+ addon.locales = Array.from(locales.keys(), getLocale);
+
+ delete addon.defaultLocale.locales;
+
+ addon.targetApplications = [
+ {
+ id: TOOLKIT_ID,
+ minVersion: bss.strict_min_version,
+ maxVersion: bss.strict_max_version,
+ },
+ ];
+
+ addon.targetPlatforms = [];
+ // Themes are disabled by default, except when they're installed from a web page.
+ addon.userDisabled = extension.type === "theme";
+ addon.softDisabled =
+ addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
+
+ return { addon, verifiedSignedState };
+}
+
+async function readRecommendationStates(aPackage, aAddonID) {
+ let recommendationData;
+ try {
+ recommendationData = await aPackage.readString(
+ "mozilla-recommendation.json"
+ );
+ } catch (e) {
+ // Ignore I/O errors.
+ return null;
+ }
+
+ try {
+ recommendationData = JSON.parse(recommendationData);
+ } catch (e) {
+ logger.warn("Failed to parse recommendation", e);
+ }
+
+ if (recommendationData) {
+ let { addon_id, states, validity } = recommendationData;
+
+ if (addon_id === aAddonID && Array.isArray(states) && validity) {
+ let validNotAfter = Date.parse(validity.not_after);
+ let validNotBefore = Date.parse(validity.not_before);
+ if (validNotAfter && validNotBefore) {
+ return {
+ validNotAfter,
+ validNotBefore,
+ states,
+ };
+ }
+ }
+ logger.warn(
+ `Invalid recommendation for ${aAddonID}: ${JSON.stringify(
+ recommendationData
+ )}`
+ );
+ }
+
+ return null;
+}
+
+function defineSyncGUID(aAddon) {
+ // Define .syncGUID as a lazy property which is also settable
+ Object.defineProperty(aAddon, "syncGUID", {
+ get: () => {
+ aAddon.syncGUID = Services.uuid.generateUUID().toString();
+ return aAddon.syncGUID;
+ },
+ set: val => {
+ delete aAddon.syncGUID;
+ aAddon.syncGUID = val;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+}
+
+// Generate a unique ID based on the path to this temporary add-on location.
+function generateTemporaryInstallID(aFile) {
+ const hasher = CryptoHash("sha1");
+ const data = new TextEncoder().encode(aFile.path);
+ // Make it so this ID cannot be guessed.
+ const sess = TEMP_INSTALL_ID_GEN_SESSION;
+ hasher.update(sess, sess.length);
+ hasher.update(data, data.length);
+ let id = `${getHashStringForCrypto(hasher)}${
+ XPIExports.XPIInternal.TEMPORARY_ADDON_SUFFIX
+ }`;
+ logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`);
+ return id;
+}
+
+var loadManifest = async function (aPackage, aLocation, aOldAddon) {
+ let addon;
+ let verifiedSignedState;
+ if (await aPackage.hasResource("manifest.json")) {
+ ({ addon, verifiedSignedState } = await loadManifestFromWebManifest(
+ aPackage,
+ aLocation
+ ));
+ } else {
+ // TODO bug 1674799: Remove this unused branch.
+ for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) {
+ if (await aPackage.hasResource(loader.manifestFile)) {
+ addon = await loader.loadManifest(aPackage);
+ addon.loader = loader.name;
+ verifiedSignedState = await aPackage.verifySignedState(
+ addon.id,
+ addon.type,
+ aLocation
+ );
+ break;
+ }
+ }
+ }
+
+ if (!addon) {
+ throw new Error(
+ `File ${aPackage.filePath} does not contain a valid manifest`
+ );
+ }
+
+ addon._sourceBundle = aPackage.file;
+ addon.rootURI = aPackage.rootURI.spec;
+ addon.location = aLocation;
+
+ let { signedState, cert } = verifiedSignedState;
+ addon.signedState = signedState;
+ addon.signedDate = cert?.validity?.notBefore / 1000 || null;
+
+ if (!addon.id) {
+ if (cert) {
+ addon.id = cert.commonName;
+ if (!gIDTest.test(addon.id)) {
+ throw new Error(`Extension is signed with an invalid id (${addon.id})`);
+ }
+ }
+ if (!addon.id && aLocation.isTemporary) {
+ addon.id = generateTemporaryInstallID(aPackage.file);
+ }
+ }
+
+ addon.propagateDisabledState(aOldAddon);
+ if (!aLocation.isSystem && !aLocation.isBuiltin) {
+ if (addon.type === "extension" && !aLocation.isTemporary) {
+ addon.recommendationState = await readRecommendationStates(
+ aPackage,
+ addon.id
+ );
+ }
+
+ await addon.updateBlocklistState();
+ addon.appDisabled = !XPIExports.XPIDatabase.isUsableAddon(addon);
+
+ // Always report when there is an attempt to install a blocked add-on.
+ // (transitions from STATE_BLOCKED to STATE_NOT_BLOCKED are checked
+ // in the individual AddonInstall subclasses).
+ if (addon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
+ addon.recordAddonBlockChangeTelemetry(
+ aOldAddon ? "addon_update" : "addon_install"
+ );
+ }
+ }
+
+ defineSyncGUID(addon);
+
+ return addon;
+};
+
+/**
+ * Loads an add-on's manifest from the given file or directory.
+ *
+ * @param {nsIFile} aFile
+ * The file to load the manifest from.
+ * @param {XPIStateLocation} aLocation
+ * The install location the add-on is installed in, or will be
+ * installed to.
+ * @param {AddonInternal?} aOldAddon
+ * The currently-installed add-on with the same ID, if one exist.
+ * This is used to migrate user settings like the add-on's
+ * disabled state.
+ * @returns {AddonInternal}
+ * The parsed Addon object for the file's manifest.
+ */
+var loadManifestFromFile = async function (aFile, aLocation, aOldAddon) {
+ let pkg = Package.get(aFile);
+ try {
+ let addon = await loadManifest(pkg, aLocation, aOldAddon);
+ return addon;
+ } finally {
+ pkg.close();
+ }
+};
+
+/*
+ * A synchronous method for loading an add-on's manifest. Do not use
+ * this.
+ */
+function syncLoadManifest(state, location, oldAddon) {
+ if (location.name == "app-builtin") {
+ let pkg = builtinPackage(Services.io.newURI(state.rootURI));
+ return XPIExports.XPIInternal.awaitPromise(
+ loadManifest(pkg, location, oldAddon)
+ );
+ }
+
+ let file = new nsIFile(state.path);
+ let pkg = Package.get(file);
+ return XPIExports.XPIInternal.awaitPromise(
+ (async () => {
+ try {
+ let addon = await loadManifest(pkg, location, oldAddon);
+ addon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ file,
+ ""
+ ).spec;
+ return addon;
+ } finally {
+ pkg.close();
+ }
+ })()
+ );
+}
+
+/**
+ * Creates and returns a new unique temporary file. The caller should delete
+ * the file when it is no longer needed.
+ *
+ * @returns {nsIFile}
+ * An nsIFile that points to a randomly named, initially empty file in
+ * the OS temporary files directory
+ */
+function getTemporaryFile() {
+ let file = lazy.FileUtils.getDir(KEY_TEMPDIR, []);
+ let random = Math.round(Math.random() * 36 ** 3).toString(36);
+ file.append(`tmp-${random}.xpi`);
+ file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE);
+ return file;
+}
+
+function getHashForFile(file, algorithm) {
+ let crypto = CryptoHash(algorithm);
+ let fis = new FileInputStream(file, -1, -1, false);
+ try {
+ crypto.updateFromStream(fis, file.fileSize);
+ } finally {
+ fis.close();
+ }
+ return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the signedState for a given return code and certificate by verifying
+ * it against the expected ID.
+ *
+ * @param {nsresult} aRv
+ * The result code returned by the signature checker for the
+ * signature check operation.
+ * @param {nsIX509Cert?} aCert
+ * The certificate the add-on was signed with, if a valid
+ * certificate exists.
+ * @param {string?} aAddonID
+ * The expected ID of the add-on. If passed, this must match the
+ * ID in the certificate's CN field.
+ * @returns {number}
+ * A SIGNEDSTATE result code constant, as defined on the
+ * AddonManager class.
+ */
+function getSignedStatus(aRv, aCert, aAddonID) {
+ let expectedCommonName = aAddonID;
+ if (aAddonID && aAddonID.length > 64) {
+ expectedCommonName = computeSha256HashAsString(aAddonID);
+ }
+
+ switch (aRv) {
+ case Cr.NS_OK:
+ if (expectedCommonName && expectedCommonName != aCert.commonName) {
+ return AddonManager.SIGNEDSTATE_BROKEN;
+ }
+
+ if (aCert.organizationalUnit == "Mozilla Components") {
+ return AddonManager.SIGNEDSTATE_SYSTEM;
+ }
+
+ if (aCert.organizationalUnit == "Mozilla Extensions") {
+ return AddonManager.SIGNEDSTATE_PRIVILEGED;
+ }
+
+ return /preliminary/i.test(aCert.organizationalUnit)
+ ? AddonManager.SIGNEDSTATE_PRELIMINARY
+ : AddonManager.SIGNEDSTATE_SIGNED;
+ case Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED:
+ return AddonManager.SIGNEDSTATE_MISSING;
+ case Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_INVALID:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_MISSING:
+ case Cr.NS_ERROR_SIGNED_JAR_ENTRY_TOO_LARGE:
+ case Cr.NS_ERROR_SIGNED_JAR_UNSIGNED_ENTRY:
+ case Cr.NS_ERROR_SIGNED_JAR_MODIFIED_ENTRY:
+ return AddonManager.SIGNEDSTATE_BROKEN;
+ default:
+ // Any other error indicates that either the add-on isn't signed or it
+ // is signed by a signature that doesn't chain to the trusted root.
+ return AddonManager.SIGNEDSTATE_UNKNOWN;
+ }
+}
+
+function shouldVerifySignedState(aAddonType, aLocation) {
+ // TODO when KEY_APP_SYSTEM_DEFAULTS and KEY_APP_SYSTEM_ADDONS locations
+ // are removed, we need to reorganize the logic here. At that point we
+ // should:
+ // if builtin or MOZ_UNSIGNED_SCOPES return false
+ // if system return true
+ // return SIGNED_TYPES.has(type)
+
+ // We don't care about signatures for default system add-ons
+ if (aLocation.name == XPIExports.XPIInternal.KEY_APP_SYSTEM_DEFAULTS) {
+ return false;
+ }
+
+ // Updated system add-ons should always have their signature checked
+ if (aLocation.isSystem) {
+ return true;
+ }
+
+ if (
+ aLocation.isBuiltin ||
+ aLocation.scope & AppConstants.MOZ_UNSIGNED_SCOPES
+ ) {
+ return false;
+ }
+
+ // Otherwise only check signatures if the add-on is one of the signed
+ // types.
+ return XPIExports.XPIDatabase.SIGNED_TYPES.has(aAddonType);
+}
+
+/**
+ * Verifies that a bundle's contents are all correctly signed by an
+ * AMO-issued certificate
+ *
+ * @param {nsIFile} aBundle
+ * The nsIFile for the bundle to check, either a directory or zip file.
+ * @param {AddonInternal} aAddon
+ * The add-on object to verify.
+ * @returns {Promise<number>}
+ * A Promise that resolves to an AddonManager.SIGNEDSTATE_* constant.
+ */
+export var verifyBundleSignedState = async function (aBundle, aAddon) {
+ let pkg = Package.get(aBundle);
+ try {
+ let { signedState } = await pkg.verifySignedState(
+ aAddon.id,
+ aAddon.type,
+ aAddon.location
+ );
+ return signedState;
+ } finally {
+ pkg.close();
+ }
+};
+
+/**
+ * Replaces %...% strings in an addon url (update and updateInfo) with
+ * appropriate values.
+ *
+ * @param {AddonInternal} aAddon
+ * The AddonInternal representing the add-on
+ * @param {string} aUri
+ * The URI to escape
+ * @param {integer?} aUpdateType
+ * An optional number representing the type of update, only applicable
+ * when creating a url for retrieving an update manifest
+ * @param {string?} aAppVersion
+ * The optional application version to use for %APP_VERSION%
+ * @returns {string}
+ * The appropriately escaped URI.
+ */
+function escapeAddonURI(aAddon, aUri, aUpdateType, aAppVersion) {
+ let uri = AddonManager.escapeAddonURI(aAddon, aUri, aAppVersion);
+
+ // If there is an updateType then replace the UPDATE_TYPE string
+ if (aUpdateType) {
+ uri = uri.replace(/%UPDATE_TYPE%/g, aUpdateType);
+ }
+
+ // If this add-on has compatibility information for either the current
+ // application or toolkit then replace the ITEM_MAXAPPVERSION with the
+ // maxVersion
+ let app = aAddon.matchingTargetApplication;
+ if (app) {
+ var maxVersion = app.maxVersion;
+ } else {
+ maxVersion = "";
+ }
+ uri = uri.replace(/%ITEM_MAXAPPVERSION%/g, maxVersion);
+
+ let compatMode = "normal";
+ if (!AddonManager.checkCompatibility) {
+ compatMode = "ignore";
+ } else if (AddonManager.strictCompatibility) {
+ compatMode = "strict";
+ }
+ uri = uri.replace(/%COMPATIBILITY_MODE%/g, compatMode);
+
+ return uri;
+}
+
+/**
+ * Converts an iterable of addon objects into a map with the add-on's ID as key.
+ *
+ * @param {sequence<AddonInternal>} addons
+ * A sequence of AddonInternal objects.
+ *
+ * @returns {Map<string, AddonInternal>}
+ */
+function addonMap(addons) {
+ return new Map(addons.map(a => [a.id, a]));
+}
+
+async function removeAsync(aFile) {
+ await IOUtils.remove(aFile.path, { ignoreAbsent: true, recursive: true });
+}
+
+/**
+ * Recursively removes a directory or file fixing permissions when necessary.
+ *
+ * @param {nsIFile} aFile
+ * The nsIFile to remove
+ */
+function recursiveRemove(aFile) {
+ let isDir = null;
+
+ try {
+ isDir = aFile.isDirectory();
+ } catch (e) {
+ // If the file has already gone away then don't worry about it, this can
+ // happen on OSX where the resource fork is automatically moved with the
+ // data fork for the file. See bug 733436.
+ if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
+ return;
+ }
+
+ throw e;
+ }
+
+ setFilePermissions(
+ aFile,
+ isDir ? lazy.FileUtils.PERMS_DIRECTORY : lazy.FileUtils.PERMS_FILE
+ );
+
+ try {
+ aFile.remove(true);
+ return;
+ } catch (e) {
+ if (!aFile.isDirectory() || aFile.isSymlink()) {
+ logger.error("Failed to remove file " + aFile.path, e);
+ throw e;
+ }
+ }
+
+ // Use a snapshot of the directory contents to avoid possible issues with
+ // iterating over a directory while removing files from it (the YAFFS2
+ // embedded filesystem has this issue, see bug 772238), and to remove
+ // normal files before their resource forks on OSX (see bug 733436).
+ let entries = Array.from(XPIExports.XPIInternal.iterDirectory(aFile));
+ entries.forEach(recursiveRemove);
+
+ try {
+ aFile.remove(true);
+ } catch (e) {
+ logger.error("Failed to remove empty directory " + aFile.path, e);
+ throw e;
+ }
+}
+
+/**
+ * Sets permissions on a file
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to operate on.
+ * @param {integer} aPermissions
+ * The permissions to set
+ */
+function setFilePermissions(aFile, aPermissions) {
+ try {
+ aFile.permissions = aPermissions;
+ } catch (e) {
+ logger.warn(
+ "Failed to set permissions " +
+ aPermissions.toString(8) +
+ " on " +
+ aFile.path,
+ e
+ );
+ }
+}
+
+/**
+ * Write a given string to a file
+ *
+ * @param {nsIFile} file
+ * The nsIFile instance to write into
+ * @param {string} string
+ * The string to write
+ */
+function writeStringToFile(file, string) {
+ let fileStream = new FileOutputStream(
+ file,
+ lazy.FileUtils.MODE_WRONLY |
+ lazy.FileUtils.MODE_CREATE |
+ lazy.FileUtils.MODE_TRUNCATE,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+
+ try {
+ let binStream = new BinaryOutputStream(fileStream);
+
+ binStream.writeByteArray(new TextEncoder().encode(string));
+ } finally {
+ fileStream.close();
+ }
+}
+
+/**
+ * A safe way to install a file or the contents of a directory to a new
+ * directory. The file or directory is moved or copied recursively and if
+ * anything fails an attempt is made to rollback the entire operation. The
+ * operation may also be rolled back to its original state after it has
+ * completed by calling the rollback method.
+ *
+ * Operations can be chained. Calling move or copy multiple times will remember
+ * the whole set and if one fails all of the operations will be rolled back.
+ */
+function SafeInstallOperation() {
+ this._installedFiles = [];
+ this._createdDirs = [];
+}
+
+SafeInstallOperation.prototype = {
+ _installedFiles: null,
+ _createdDirs: null,
+
+ _installFile(aFile, aTargetDirectory, aCopy) {
+ let oldFile = aCopy ? null : aFile.clone();
+ let newFile = aFile.clone();
+ try {
+ if (aCopy) {
+ newFile.copyTo(aTargetDirectory, null);
+ // copyTo does not update the nsIFile with the new.
+ newFile = getFile(aFile.leafName, aTargetDirectory);
+ // Windows roaming profiles won't properly sync directories if a new file
+ // has an older lastModifiedTime than a previous file, so update.
+ newFile.lastModifiedTime = Date.now();
+ } else {
+ newFile.moveTo(aTargetDirectory, null);
+ }
+ } catch (e) {
+ logger.error(
+ "Failed to " +
+ (aCopy ? "copy" : "move") +
+ " file " +
+ aFile.path +
+ " to " +
+ aTargetDirectory.path,
+ e
+ );
+ throw e;
+ }
+ this._installedFiles.push({ oldFile, newFile });
+ },
+
+ /**
+ * Moves a file or directory into a new directory. If an error occurs then all
+ * files that have been moved will be moved back to their original location.
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to be moved.
+ * @param {nsIFile} aTargetDirectory
+ * The directory to move into, this is expected to be an empty
+ * directory.
+ */
+ moveUnder(aFile, aTargetDirectory) {
+ try {
+ this._installFile(aFile, aTargetDirectory, false);
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Renames a file to a new location. If an error occurs then all
+ * files that have been moved will be moved back to their original location.
+ *
+ * @param {nsIFile} aOldLocation
+ * The old location of the file.
+ * @param {nsIFile} aNewLocation
+ * The new location of the file.
+ */
+ moveTo(aOldLocation, aNewLocation) {
+ try {
+ let oldFile = aOldLocation.clone(),
+ newFile = aNewLocation.clone();
+ oldFile.moveTo(newFile.parent, newFile.leafName);
+ this._installedFiles.push({ oldFile, newFile, isMoveTo: true });
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Copies a file or directory into a new directory. If an error occurs then
+ * all new files that have been created will be removed.
+ *
+ * @param {nsIFile} aFile
+ * The file or directory to be copied.
+ * @param {nsIFile} aTargetDirectory
+ * The directory to copy into, this is expected to be an empty
+ * directory.
+ */
+ copy(aFile, aTargetDirectory) {
+ try {
+ this._installFile(aFile, aTargetDirectory, true);
+ } catch (e) {
+ this.rollback();
+ throw e;
+ }
+ },
+
+ /**
+ * Rolls back all the moves that this operation performed. If an exception
+ * occurs here then both old and new directories are left in an indeterminate
+ * state
+ */
+ rollback() {
+ while (this._installedFiles.length) {
+ let move = this._installedFiles.pop();
+ if (move.isMoveTo) {
+ move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName);
+ } else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) {
+ let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent);
+ oldDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ } else if (!move.oldFile) {
+ // No old file means this was a copied file
+ move.newFile.remove(true);
+ } else {
+ move.newFile.moveTo(move.oldFile.parent, null);
+ }
+ }
+
+ while (this._createdDirs.length) {
+ recursiveRemove(this._createdDirs.pop());
+ }
+ },
+};
+
+// A hash algorithm if the caller of AddonInstall did not specify one.
+const DEFAULT_HASH_ALGO = "sha256";
+
+/**
+ * Base class for objects that manage the installation of an addon.
+ * This class isn't instantiated directly, see the derived classes below.
+ */
+class AddonInstall {
+ /**
+ * Instantiates an AddonInstall.
+ *
+ * @param {XPIStateLocation} installLocation
+ * The install location the add-on will be installed into
+ * @param {nsIURL} url
+ * The nsIURL to get the add-on from. If this is an nsIFileURL then
+ * the add-on will not need to be downloaded
+ * @param {Object} [options = {}]
+ * Additional options for the install
+ * @param {string} [options.hash]
+ * An optional hash for the add-on
+ * @param {AddonInternal} [options.existingAddon]
+ * The add-on this install will update if known
+ * @param {string} [options.name]
+ * An optional name for the add-on
+ * @param {string} [options.type]
+ * An optional type for the add-on
+ * @param {object} [options.icons]
+ * Optional icons for the add-on
+ * @param {string} [options.version]
+ * The expected version for the add-on.
+ * Required for updates, i.e. when existingAddon is set.
+ * @param {Object?} [options.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [options.isUserRequestedUpdate]
+ * An optional boolean, true if the install object is related to a user triggered update.
+ * @param {nsIURL} [options.releaseNotesURI]
+ * An optional nsIURL that release notes where release notes can be retrieved.
+ * @param {function(string) : Promise<void>} [options.promptHandler]
+ * A callback to prompt the user before installing.
+ */
+ constructor(installLocation, url, options = {}) {
+ this.wrapper = new AddonInstallWrapper(this);
+ this.location = installLocation;
+ this.sourceURI = url;
+
+ if (options.hash) {
+ let hashSplit = options.hash.toLowerCase().split(":");
+ this.originalHash = {
+ algorithm: hashSplit[0],
+ data: hashSplit[1],
+ };
+ }
+ this.hash = this.originalHash;
+ this.fileHash = null;
+ this.existingAddon = options.existingAddon || null;
+ this.promptHandler = options.promptHandler || (() => Promise.resolve());
+ this.releaseNotesURI = options.releaseNotesURI || null;
+
+ this._startupPromise = null;
+
+ this._installPromise = new Promise(resolve => {
+ this._resolveInstallPromise = resolve;
+ });
+ // Ignore uncaught rejections for this promise, since they're
+ // handled by install listeners.
+ this._installPromise.catch(() => {});
+
+ this.listeners = [];
+ this.icons = options.icons || {};
+ this.error = 0;
+
+ this.progress = 0;
+ this.maxProgress = -1;
+
+ // Giving each instance of AddonInstall a reference to the logger.
+ this.logger = logger;
+
+ this.name = options.name || null;
+ this.type = options.type || null;
+ this.version = options.version || null;
+ this.isUserRequestedUpdate = options.isUserRequestedUpdate;
+ this.installTelemetryInfo = null;
+
+ if (options.telemetryInfo) {
+ this.installTelemetryInfo = options.telemetryInfo;
+ } else if (this.existingAddon) {
+ // Inherits the installTelemetryInfo on updates (so that the source of the original
+ // installation telemetry data is being preserved across the extension updates).
+ this.installTelemetryInfo = this.existingAddon.installTelemetryInfo;
+ this.existingAddon._updateInstall = this;
+ }
+
+ this.file = null;
+ this.ownsTempFile = null;
+
+ this.addon = null;
+ this.state = null;
+
+ XPIInstall.installs.add(this);
+ }
+
+ /**
+ * Called when we are finished with this install and are ready to remove
+ * any external references to it.
+ */
+ _cleanup() {
+ XPIInstall.installs.delete(this);
+ if (this.addon && this.addon._install) {
+ if (this.addon._install === this) {
+ this.addon._install = null;
+ } else {
+ Cu.reportError(new Error("AddonInstall mismatch"));
+ }
+ }
+ if (this.existingAddon && this.existingAddon._updateInstall) {
+ if (this.existingAddon._updateInstall === this) {
+ this.existingAddon._updateInstall = null;
+ } else {
+ Cu.reportError(new Error("AddonInstall existingAddon mismatch"));
+ }
+ }
+ }
+
+ /**
+ * Starts installation of this add-on from whatever state it is currently at
+ * if possible.
+ *
+ * Note this method is overridden to handle additional state in
+ * the subclassses below.
+ *
+ * @returns {Promise<Addon>}
+ * @throws if installation cannot proceed from the current state
+ */
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_DOWNLOADED:
+ this.checkPrompt();
+ break;
+ case AddonManager.STATE_PROMPTS_DONE:
+ this.checkForBlockers();
+ break;
+ case AddonManager.STATE_READY:
+ this.startInstall();
+ break;
+ case AddonManager.STATE_POSTPONED:
+ logger.debug(`Postponing install of ${this.addon.id}`);
+ break;
+ case AddonManager.STATE_DOWNLOADING:
+ case AddonManager.STATE_CHECKING_UPDATE:
+ case AddonManager.STATE_INSTALLING:
+ // Installation is already running
+ break;
+ default:
+ throw new Error("Cannot start installing from this state");
+ }
+ return this._installPromise;
+ }
+
+ continuePostponedInstall() {
+ if (this.state !== AddonManager.STATE_POSTPONED) {
+ throw new Error("AddonInstall not in postponed state");
+ }
+
+ // Force the postponed install to continue.
+ logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
+ /**
+ * Called during XPIProvider shutdown so that we can do any necessary
+ * pre-shutdown cleanup.
+ */
+ onShutdown() {
+ switch (this.state) {
+ case AddonManager.STATE_POSTPONED:
+ this.removeTemporaryFile();
+ break;
+ }
+ }
+
+ /**
+ * Cancels installation of this add-on.
+ *
+ * Note this method is overridden to handle additional state in
+ * the subclass DownloadAddonInstall.
+ *
+ * @throws if installation cannot be cancelled from the current state
+ */
+ cancel() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ case AddonManager.STATE_DOWNLOADED:
+ logger.debug("Cancelling download of " + this.sourceURI.spec);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ this.removeTemporaryFile();
+ break;
+ case AddonManager.STATE_POSTPONED:
+ logger.debug(`Cancelling postponed install of ${this.addon.id}`);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners(
+ "onInstallCancelled",
+ /* aCancelledByUser */ false
+ );
+ this.removeTemporaryFile();
+
+ let stagingDir = this.location.installer.getStagingDir();
+ let stagedAddon = stagingDir.clone();
+
+ this.unstageInstall(stagedAddon);
+ break;
+ default:
+ throw new Error(
+ "Cannot cancel install of " +
+ this.sourceURI.spec +
+ " from this state (" +
+ this.state +
+ ")"
+ );
+ }
+ }
+
+ /**
+ * Adds an InstallListener for this instance if the listener is not already
+ * registered.
+ *
+ * @param {InstallListener} aListener
+ * The InstallListener to add
+ */
+ addListener(aListener) {
+ if (
+ !this.listeners.some(function (i) {
+ return i == aListener;
+ })
+ ) {
+ this.listeners.push(aListener);
+ }
+ }
+
+ /**
+ * Removes an InstallListener for this instance if it is registered.
+ *
+ * @param {InstallListener} aListener
+ * The InstallListener to remove
+ */
+ removeListener(aListener) {
+ this.listeners = this.listeners.filter(function (i) {
+ return i != aListener;
+ });
+ }
+
+ /**
+ * Removes the temporary file owned by this AddonInstall if there is one.
+ */
+ removeTemporaryFile() {
+ // Only proceed if this AddonInstall owns its XPI file
+ if (!this.ownsTempFile) {
+ this.logger.debug(
+ `removeTemporaryFile: ${this.sourceURI.spec} does not own temp file`
+ );
+ return;
+ }
+
+ try {
+ this.logger.debug(
+ `removeTemporaryFile: ${this.sourceURI.spec} removing temp file ` +
+ this.file.path
+ );
+ flushJarCache(this.file);
+ this.file.remove(true);
+ this.ownsTempFile = false;
+ } catch (e) {
+ this.logger.warn(
+ `Failed to remove temporary file ${this.file.path} for addon ` +
+ this.sourceURI.spec,
+ e
+ );
+ }
+ }
+
+ _setFileHash(calculatedHash) {
+ this.fileHash = {
+ algorithm: this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO,
+ data: calculatedHash,
+ };
+
+ if (this.hash && calculatedHash != this.hash.data) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Updates the addon metadata that has to be propagated across restarts.
+ */
+ updatePersistedMetadata() {
+ this.addon.sourceURI = this.sourceURI.spec;
+
+ if (this.releaseNotesURI) {
+ this.addon.releaseNotesURI = this.releaseNotesURI.spec;
+ }
+
+ if (this.installTelemetryInfo) {
+ this.addon.installTelemetryInfo = this.installTelemetryInfo;
+ }
+ }
+
+ /**
+ * Called after the add-on is a local file and the signature and install
+ * manifest can be read.
+ *
+ * @param {nsIFile} file
+ * The file from which to load the manifest.
+ * @returns {Promise<void>}
+ */
+ async loadManifest(file) {
+ let pkg;
+ try {
+ pkg = Package.get(file);
+ } catch (e) {
+ return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
+ }
+
+ try {
+ try {
+ this.addon = await loadManifest(pkg, this.location, this.existingAddon);
+ } catch (e) {
+ return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
+ }
+
+ if (!this.addon.id) {
+ let msg = `Cannot find id for addon ${file.path}.`;
+ if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
+ msg += ` Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`;
+ }
+
+ return Promise.reject([
+ AddonManager.ERROR_CORRUPT_FILE,
+ new Error(msg),
+ ]);
+ }
+
+ if (
+ AppConstants.platform == "android" &&
+ this.addon.type !== "extension"
+ ) {
+ return Promise.reject([
+ AddonManager.ERROR_UNSUPPORTED_ADDON_TYPE,
+ `Unsupported add-on type: ${this.addon.type}`,
+ ]);
+ }
+
+ if (this.existingAddon) {
+ // Check various conditions related to upgrades
+ if (this.addon.id != this.existingAddon.id) {
+ return Promise.reject([
+ AddonManager.ERROR_INCORRECT_ID,
+ `Refusing to upgrade addon ${this.existingAddon.id} to different ID ${this.addon.id}`,
+ ]);
+ }
+
+ if (this.existingAddon.isWebExtension && !this.addon.isWebExtension) {
+ // This condition is never met on regular Firefox builds.
+ // Remove it along with externalExtensionLoaders (bug 1674799).
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ "WebExtensions may not be updated to other extension types",
+ ]);
+ }
+ if (this.existingAddon.type != this.addon.type) {
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ `Refusing to change addon type from ${this.existingAddon.type} to ${this.addon.type}`,
+ ]);
+ }
+
+ if (this.version !== this.addon.version) {
+ return Promise.reject([
+ AddonManager.ERROR_UNEXPECTED_ADDON_VERSION,
+ `Expected addon version ${this.version} instead of ${this.addon.version}`,
+ ]);
+ }
+ }
+
+ if (XPIExports.XPIDatabase.mustSign(this.addon.type)) {
+ if (this.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING) {
+ // This add-on isn't properly signed by a signature that chains to the
+ // trusted root.
+ let state = this.addon.signedState;
+ this.addon = null;
+
+ if (state == AddonManager.SIGNEDSTATE_MISSING) {
+ return Promise.reject([
+ AddonManager.ERROR_SIGNEDSTATE_REQUIRED,
+ "signature is required but missing",
+ ]);
+ }
+
+ return Promise.reject([
+ AddonManager.ERROR_CORRUPT_FILE,
+ "signature verification failed",
+ ]);
+ }
+ }
+ } finally {
+ pkg.close();
+ }
+
+ this.updatePersistedMetadata();
+
+ this.addon._install = this;
+ this.name = this.addon.selectedLocale.name;
+ this.type = this.addon.type;
+ this.version = this.addon.version;
+
+ // Setting the iconURL to something inside the XPI locks the XPI and
+ // makes it impossible to delete on Windows.
+
+ // Try to load from the existing cache first
+ let repoAddon = await lazy.AddonRepository.getCachedAddonByID(
+ this.addon.id
+ );
+
+ // It wasn't there so try to re-download it
+ if (!repoAddon) {
+ try {
+ [repoAddon] = await lazy.AddonRepository.cacheAddons([this.addon.id]);
+ } catch (err) {
+ logger.debug(
+ `Error getting metadata for ${this.addon.id}: ${err.message}`
+ );
+ }
+ }
+
+ this.addon._repositoryAddon = repoAddon;
+ this.name = this.name || this.addon._repositoryAddon.name;
+ this.addon.appDisabled = !XPIExports.XPIDatabase.isUsableAddon(this.addon);
+ return undefined;
+ }
+
+ getIcon(desiredSize = 64) {
+ if (!this.addon.icons || !this.file) {
+ return null;
+ }
+
+ let { icon } = lazy.IconDetails.getPreferredIcon(
+ this.addon.icons,
+ null,
+ desiredSize
+ );
+ if (icon.startsWith("chrome://")) {
+ return icon;
+ }
+ return getJarURI(this.file, icon).spec;
+ }
+
+ /**
+ * This method should be called when the XPI is ready to be installed,
+ * i.e., when a download finishes or when a local file has been verified.
+ * It should only be called from install() when the install is in
+ * STATE_DOWNLOADED (which actually means that the file is available
+ * and has been verified).
+ */
+ checkPrompt() {
+ (async () => {
+ if (this.promptHandler) {
+ let info = {
+ existingAddon: this.existingAddon ? this.existingAddon.wrapper : null,
+ addon: this.addon.wrapper,
+ icon: this.getIcon(),
+ // Used in AMTelemetry to detect the install flow related to this prompt.
+ install: this.wrapper,
+ };
+
+ try {
+ await this.promptHandler(info);
+ } catch (err) {
+ if (this.error < 0) {
+ logger.info(`Install of ${this.addon.id} failed ${this.error}`);
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this._cleanup();
+ // In some cases onOperationCancelled is called during failures
+ // to install/uninstall/enable/disable addons. We may need to
+ // do that here in the future.
+ this._callInstallListeners("onInstallFailed");
+ this.removeTemporaryFile();
+ } else {
+ logger.info(`Install of ${this.addon.id} cancelled by user`);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners(
+ "onInstallCancelled",
+ /* aCancelledByUser */ true
+ );
+ }
+ return;
+ }
+ }
+ this.state = AddonManager.STATE_PROMPTS_DONE;
+ this.install();
+ })();
+ }
+
+ /**
+ * This method should be called when we have the XPI and any needed
+ * permissions prompts have been completed. If there are any upgrade
+ * listeners, they are invoked and the install moves into STATE_POSTPONED.
+ * Otherwise, the install moves into STATE_INSTALLING
+ */
+ checkForBlockers() {
+ // If an upgrade listener is registered for this add-on, pass control
+ // over the upgrade to the add-on.
+ if (AddonManagerPrivate.hasUpgradeListener(this.addon.id)) {
+ logger.info(
+ `add-on ${this.addon.id} has an upgrade listener, postponing upgrade until restart`
+ );
+ let resumeFn = () => {
+ this.continuePostponedInstall();
+ };
+ this.postpone(resumeFn);
+ return;
+ }
+
+ this.state = AddonManager.STATE_READY;
+ this.install();
+ }
+
+ /**
+ * Installs the add-on into the install location.
+ */
+ async startInstall() {
+ this.state = AddonManager.STATE_INSTALLING;
+ if (!this._callInstallListeners("onInstallStarted")) {
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.removeTemporaryFile();
+ this._cleanup();
+ this._callInstallListeners(
+ "onInstallCancelled",
+ /* aCancelledByUser */ false
+ );
+ return;
+ }
+
+ // Reinstall existing user-disabled addon (of the same installed version).
+ // If addon is marked to be uninstalled - don't reinstall it.
+ if (
+ this.existingAddon &&
+ this.existingAddon.location === this.location &&
+ this.existingAddon.version === this.addon.version &&
+ this.existingAddon.userDisabled &&
+ !this.existingAddon.pendingUninstall
+ ) {
+ await XPIExports.XPIDatabase.updateAddonDisabledState(
+ this.existingAddon,
+ {
+ userDisabled: false,
+ }
+ );
+ this.state = AddonManager.STATE_INSTALLED;
+ this._callInstallListeners("onInstallEnded", this.existingAddon.wrapper);
+ this._cleanup();
+ return;
+ }
+
+ let isSameLocation = this.existingAddon?.location == this.location;
+ let willActivate =
+ isSameLocation ||
+ !this.existingAddon ||
+ this.location.hasPrecedence(this.existingAddon.location);
+
+ logger.debug(
+ "Starting install of " + this.addon.id + " from " + this.sourceURI.spec
+ );
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ this.addon.wrapper,
+ false
+ );
+
+ let stagedAddon = this.location.installer.getStagingDir();
+
+ try {
+ await this.location.installer.requestStagingDir();
+
+ // remove any previously staged files
+ await this.unstageInstall(stagedAddon);
+
+ stagedAddon.append(`${this.addon.id}.xpi`);
+
+ await this.stageInstall(false, stagedAddon, isSameLocation);
+
+ this._cleanup();
+
+ let install = async () => {
+ // Mark this instance of the addon as inactive if it is being
+ // superseded by an addon in a different location.
+ if (
+ willActivate &&
+ this.existingAddon &&
+ this.existingAddon.active &&
+ !isSameLocation
+ ) {
+ XPIExports.XPIDatabase.updateAddonActive(this.existingAddon, false);
+ }
+
+ // Install the new add-on into its final location
+ let file = await this.location.installer.installAddon({
+ id: this.addon.id,
+ source: stagedAddon,
+ });
+
+ // Update the metadata in the database
+ this.addon.sourceBundle = file;
+ // If this addon will be the active addon, make it visible.
+ this.addon.visible = willActivate;
+
+ if (isSameLocation) {
+ this.addon = XPIExports.XPIDatabase.updateAddonMetadata(
+ this.existingAddon,
+ this.addon,
+ file.path
+ );
+ let state = this.location.get(this.addon.id);
+ if (state) {
+ state.syncWithDB(this.addon, true);
+ } else {
+ logger.warn(
+ "Unexpected missing XPI state for add-on ${id}",
+ this.addon
+ );
+ }
+ } else {
+ this.addon.active = this.addon.visible && !this.addon.disabled;
+ this.addon = XPIExports.XPIDatabase.addToDatabase(
+ this.addon,
+ file.path
+ );
+ XPIExports.XPIInternal.XPIStates.addAddon(this.addon);
+ this.addon.installDate = this.addon.updateDate;
+ XPIExports.XPIDatabase.saveChanges();
+ }
+ XPIExports.XPIInternal.XPIStates.save();
+
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalled",
+ this.addon.wrapper
+ );
+
+ logger.debug(`Install of ${this.sourceURI.spec} completed.`);
+ this.state = AddonManager.STATE_INSTALLED;
+ this._callInstallListeners("onInstallEnded", this.addon.wrapper);
+
+ XPIExports.XPIDatabase.recordAddonTelemetry(this.addon);
+
+ // Notify providers that a new theme has been enabled.
+ if (this.addon.type === "theme" && this.addon.active) {
+ AddonManagerPrivate.notifyAddonChanged(
+ this.addon.id,
+ this.addon.type
+ );
+ }
+
+ // Clear the colorways builtins migrated to a non-builtin themes
+ // form the list of the retained themes.
+ if (
+ this.existingAddon?.isBuiltinColorwayTheme &&
+ !this.addon.isBuiltin &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ XPIExports.BuiltInThemesHelpers.unretainMigratedColorwayTheme(
+ this.addon.id
+ );
+ }
+ };
+
+ this._startupPromise = (async () => {
+ if (!willActivate) {
+ await install();
+ } else if (this.existingAddon) {
+ await XPIExports.XPIInternal.BootstrapScope.get(
+ this.existingAddon
+ ).update(this.addon, !this.addon.disabled, install);
+
+ if (this.addon.disabled) {
+ flushJarCache(this.file);
+ }
+ } else {
+ await install();
+ await XPIExports.XPIInternal.BootstrapScope.get(this.addon).install(
+ undefined,
+ true
+ );
+ }
+ })();
+
+ await this._startupPromise;
+ } catch (e) {
+ logger.warn(
+ `Failed to install ${this.file.path} from ${this.sourceURI.spec} to ${stagedAddon.path}`,
+ e
+ );
+
+ if (stagedAddon.exists()) {
+ recursiveRemove(stagedAddon);
+ }
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ AddonManagerPrivate.callAddonListeners(
+ "onOperationCancelled",
+ this.addon.wrapper
+ );
+ this._callInstallListeners("onInstallFailed");
+ } finally {
+ this.removeTemporaryFile();
+ this.location.installer.releaseStagingDir();
+ }
+ }
+
+ /**
+ * Stages an add-on for install.
+ *
+ * @param {boolean} restartRequired
+ * If true, the final installation will be deferred until the
+ * next app startup.
+ * @param {nsIFile} stagedAddon
+ * The file where the add-on should be staged.
+ * @param {boolean} isSameLocation
+ * True if this installation is an upgrade for an existing
+ * add-on in the same location.
+ * @throws if the file cannot be staged.
+ */
+ async stageInstall(restartRequired, stagedAddon, isSameLocation) {
+ logger.debug(`Addon ${this.addon.id} will be installed as a packed xpi`);
+ stagedAddon.leafName = `${this.addon.id}.xpi`;
+
+ try {
+ await IOUtils.copy(this.file.path, stagedAddon.path);
+
+ let calculatedHash = getHashForFile(stagedAddon, this.fileHash.algorithm);
+ if (calculatedHash != this.fileHash.data) {
+ logger.warn(
+ `Staged file hash (${calculatedHash}) did not match initial hash (${this.fileHash.data})`
+ );
+ throw new Error("Refusing to stage add-on because it has been damaged");
+ }
+ } catch (e) {
+ await IOUtils.remove(stagedAddon.path, { ignoreAbsent: true });
+ throw e;
+ }
+
+ if (restartRequired) {
+ // Point the add-on to its extracted files as the xpi may get deleted
+ this.addon.sourceBundle = stagedAddon;
+
+ logger.debug(
+ `Staged install of ${this.addon.id} from ${this.sourceURI.spec} ready; waiting for restart.`
+ );
+ if (isSameLocation) {
+ delete this.existingAddon.pendingUpgrade;
+ this.existingAddon.pendingUpgrade = this.addon;
+ }
+ }
+
+ if (this.state === AddonManager.STATE_POSTPONED) {
+ // Cache the AddonInternal as it may have updated compatibility info. We
+ // do that unconditionally in case the staged install isn't finalized in
+ // the same session. That way, on the next app startup, the add-on will
+ // be installed.
+ this.location.stageAddon(this.addon.id, this.addon.toJSON());
+ }
+ }
+
+ /**
+ * Removes any previously staged upgrade.
+ *
+ * @param {nsIFile} stagingDir
+ * The staging directory from which to unstage the install.
+ */
+ async unstageInstall(stagingDir) {
+ this.location.unstageAddon(this.addon.id);
+
+ await removeAsync(getFile(this.addon.id, stagingDir));
+
+ await removeAsync(getFile(`${this.addon.id}.xpi`, stagingDir));
+ }
+
+ /**
+ * Postone a pending update, until restart or until the add-on resumes.
+ *
+ * @param {function} resumeFn
+ * A function for the add-on to run when resuming.
+ * @param {boolean} requiresRestart
+ * Whether this add-on requires restart.
+ */
+ async postpone(resumeFn, requiresRestart = true) {
+ this.state = AddonManager.STATE_POSTPONED;
+
+ let stagingDir = this.location.installer.getStagingDir();
+
+ try {
+ await this.location.installer.requestStagingDir();
+ await this.unstageInstall(stagingDir);
+
+ let stagedAddon = getFile(`${this.addon.id}.xpi`, stagingDir);
+
+ await this.stageInstall(requiresRestart, stagedAddon, true);
+ } catch (e) {
+ logger.warn(`Failed to postpone install of ${this.addon.id}`, e);
+ this.state = AddonManager.STATE_INSTALL_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ this.removeTemporaryFile();
+ this.location.installer.releaseStagingDir();
+ this._callInstallListeners("onInstallFailed");
+ return;
+ }
+
+ this._callInstallListeners("onInstallPostponed");
+
+ // upgrade has been staged for restart, provide a way for it to call the
+ // resume function.
+ let callback = AddonManagerPrivate.getUpgradeListener(this.addon.id);
+ if (callback) {
+ callback({
+ version: this.version,
+ install: () => {
+ switch (this.state) {
+ case AddonManager.STATE_POSTPONED:
+ if (resumeFn) {
+ resumeFn();
+ }
+ break;
+ default:
+ logger.warn(
+ `${this.addon.id} cannot resume postponed upgrade from state (${this.state})`
+ );
+ break;
+ }
+ },
+ });
+ }
+ // Release the staging directory lock, but since the staging dir is populated
+ // it will not be removed until resumed or installed by restart.
+ // See also cleanStagingDir()
+ this.location.installer.releaseStagingDir();
+ }
+
+ _callInstallListeners(event, ...args) {
+ switch (event) {
+ case "onDownloadCancelled":
+ case "onDownloadFailed":
+ case "onInstallCancelled":
+ case "onInstallFailed":
+ let rej = Promise.reject(new Error(`Install failed: ${event}`));
+ rej.catch(() => {});
+ this._resolveInstallPromise(rej);
+ break;
+ case "onInstallEnded":
+ this._resolveInstallPromise(
+ Promise.resolve(this._startupPromise).then(() => args[0])
+ );
+ break;
+ }
+ return AddonManagerPrivate.callInstallListeners(
+ event,
+ this.listeners,
+ this.wrapper,
+ ...args
+ );
+ }
+}
+
+var LocalAddonInstall = class extends AddonInstall {
+ /**
+ * Initialises this install to be an install from a local file.
+ */
+ async init() {
+ this.file = this.sourceURI.QueryInterface(Ci.nsIFileURL).file;
+
+ if (!this.file.exists()) {
+ logger.warn("XPI file " + this.file.path + " does not exist");
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_NETWORK_FAILURE;
+ this._cleanup();
+ return;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.progress = this.file.fileSize;
+ this.maxProgress = this.file.fileSize;
+
+ let algorithm = this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO;
+ if (this.hash) {
+ try {
+ CryptoHash(this.hash.algorithm);
+ } catch (e) {
+ logger.warn(
+ "Unknown hash algorithm '" +
+ this.hash.algorithm +
+ "' for addon " +
+ this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ return;
+ }
+ }
+
+ if (!this._setFileHash(getHashForFile(this.file, algorithm))) {
+ logger.warn(
+ `File hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})`
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ return;
+ }
+
+ try {
+ await this.loadManifest(this.file);
+ } catch ([error, message]) {
+ logger.warn("Invalid XPI", message);
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = error;
+ this._cleanup();
+ this._callInstallListeners("onNewInstall");
+ flushJarCache(this.file);
+ return;
+ }
+
+ let addon = await XPIExports.XPIDatabase.getVisibleAddonForID(
+ this.addon.id
+ );
+
+ this.existingAddon = addon;
+ this.addon.propagateDisabledState(this.existingAddon);
+ await this.addon.updateBlocklistState();
+ this.addon.updateDate = Date.now();
+ this.addon.installDate = addon ? addon.installDate : this.addon.updateDate;
+
+ // Report if blocked add-on becomes unblocked through this install.
+ if (
+ addon?.blocklistState === nsIBlocklistService.STATE_BLOCKED &&
+ this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED
+ ) {
+ this.addon.recordAddonBlockChangeTelemetry("addon_install");
+ }
+
+ if (this.addon.blocklistState === nsIBlocklistService.STATE_BLOCKED) {
+ this.error = AddonManager.ERROR_BLOCKLISTED;
+ }
+
+ if (!this.addon.isCompatible) {
+ this.state = AddonManager.STATE_CHECKING_UPDATE;
+
+ await new Promise(resolve => {
+ new UpdateChecker(
+ this.addon,
+ {
+ onUpdateFinished: (aAddon, aError) => {
+ this.state = AddonManager.STATE_DOWNLOADED;
+ // If checking for an updated compatibility range fails or the
+ // add-on is still incompatible, then set the expected
+ // `install.error` to `ERROR_INCOMPATIBLE`.
+ if (!this.addon.isCompatible) {
+ this.error = AddonManager.ERROR_INCOMPATIBLE;
+ }
+ if (aError < 0) {
+ logger.warn(
+ `UpdateChecker failed to download updates for ${this.addon.id}, error code: ${aError}`
+ );
+ } else {
+ this._callInstallListeners("onNewInstall");
+ }
+ resolve();
+ },
+ },
+ AddonManager.UPDATE_WHEN_ADDON_INSTALLED
+ );
+ });
+ } else {
+ this._callInstallListeners("onNewInstall");
+ }
+ }
+
+ install() {
+ if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) {
+ // For a local install, this state means that verification of the
+ // file failed (e.g., the hash or signature or manifest contents
+ // were invalid). It doesn't make sense to retry anything in this
+ // case but we have callers who don't know if their AddonInstall
+ // object is a local file or a download so accommodate them here.
+ this._callInstallListeners("onDownloadFailed");
+ return this._installPromise;
+ }
+ return super.install();
+ }
+};
+
+var DownloadAddonInstall = class extends AddonInstall {
+ /**
+ * Instantiates a DownloadAddonInstall
+ *
+ * @param {XPIStateLocation} installLocation
+ * The XPIStateLocation the add-on will be installed into
+ * @param {nsIURL} url
+ * The nsIURL to get the add-on from
+ * @param {Object} [options = {}]
+ * Additional options for the install
+ * @param {string} [options.hash]
+ * An optional hash for the add-on
+ * @param {AddonInternal} [options.existingAddon]
+ * The add-on this install will update if known
+ * @param {XULElement} [options.browser]
+ * The browser performing the install, used to display
+ * authentication prompts.
+ * @param {nsIPrincipal} [options.principal]
+ * The principal to use. If not present, will default to browser.contentPrincipal.
+ * @param {string} [options.name]
+ * An optional name for the add-on
+ * @param {string} [options.type]
+ * An optional type for the add-on
+ * @param {Object} [options.icons]
+ * Optional icons for the add-on
+ * @param {string} [options.version]
+ * The expected version for the add-on.
+ * Required for updates, i.e. when existingAddon is set.
+ * @param {function(string) : Promise<void>} [options.promptHandler]
+ * A callback to prompt the user before installing.
+ * @param {boolean} [options.sendCookies]
+ * Whether cookies should be sent when downloading the add-on.
+ */
+ constructor(installLocation, url, options = {}) {
+ super(installLocation, url, options);
+
+ this.browser = options.browser;
+ this.loadingPrincipal =
+ options.triggeringPrincipal ||
+ (this.browser && this.browser.contentPrincipal) ||
+ Services.scriptSecurityManager.getSystemPrincipal();
+ this.sendCookies = Boolean(options.sendCookies);
+
+ this.state = AddonManager.STATE_AVAILABLE;
+
+ this.stream = null;
+ this.crypto = null;
+ this.badCertHandler = null;
+ this.restartDownload = false;
+ this.downloadStartedAt = null;
+
+ this._callInstallListeners("onNewInstall", this.listeners, this.wrapper);
+ }
+
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.startDownload();
+ break;
+ case AddonManager.STATE_DOWNLOAD_FAILED:
+ case AddonManager.STATE_INSTALL_FAILED:
+ case AddonManager.STATE_CANCELLED:
+ this.removeTemporaryFile();
+ this.state = AddonManager.STATE_AVAILABLE;
+ this.error = 0;
+ this.progress = 0;
+ this.maxProgress = -1;
+ this.hash = this.originalHash;
+ this.fileHash = null;
+ this.startDownload();
+ break;
+ default:
+ return super.install();
+ }
+ return this._installPromise;
+ }
+
+ cancel() {
+ // If we're done downloading the file but still processing it we cannot
+ // cancel the installation. We just call the base class which will handle
+ // the request by throwing an error.
+ if (this.channel && this.state == AddonManager.STATE_DOWNLOADING) {
+ logger.debug("Cancelling download of " + this.sourceURI.spec);
+ this.channel.cancel(Cr.NS_BINDING_ABORTED);
+ } else {
+ super.cancel();
+ }
+ }
+
+ observe(aSubject, aTopic, aData) {
+ // Network is going offline
+ this.cancel();
+ }
+
+ /**
+ * Starts downloading the add-on's XPI file.
+ */
+ startDownload() {
+ this.downloadStartedAt = Cu.now();
+
+ this.state = AddonManager.STATE_DOWNLOADING;
+ if (!this._callInstallListeners("onDownloadStarted")) {
+ logger.debug(
+ "onDownloadStarted listeners cancelled installation of addon " +
+ this.sourceURI.spec
+ );
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ return;
+ }
+
+ // If a listener changed our state then do not proceed with the download
+ if (this.state != AddonManager.STATE_DOWNLOADING) {
+ return;
+ }
+
+ if (this.channel) {
+ // A previous download attempt hasn't finished cleaning up yet, signal
+ // that it should restart when complete
+ logger.debug("Waiting for previous download to complete");
+ this.restartDownload = true;
+ return;
+ }
+
+ this.openChannel();
+ }
+
+ openChannel() {
+ this.restartDownload = false;
+
+ try {
+ this.file = getTemporaryFile();
+ this.ownsTempFile = true;
+ this.stream = new FileOutputStream(
+ this.file,
+ lazy.FileUtils.MODE_WRONLY |
+ lazy.FileUtils.MODE_CREATE |
+ lazy.FileUtils.MODE_TRUNCATE,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+ } catch (e) {
+ logger.warn(
+ "Failed to start download for addon " + this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_FILE_ACCESS;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ return;
+ }
+
+ let listener = Cc[
+ "@mozilla.org/network/stream-listener-tee;1"
+ ].createInstance(Ci.nsIStreamListenerTee);
+ listener.init(this, this.stream);
+ try {
+ this.badCertHandler = new lazy.CertUtils.BadCertHandler(
+ !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS
+ );
+
+ this.channel = lazy.NetUtil.newChannel({
+ uri: this.sourceURI,
+ securityFlags:
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ loadingPrincipal: this.loadingPrincipal,
+ });
+ this.channel.notificationCallbacks = this;
+ if (this.sendCookies) {
+ if (this.channel instanceof Ci.nsIHttpChannelInternal) {
+ this.channel.forceAllowThirdPartyCookie = true;
+ }
+ } else {
+ this.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ }
+ this.channel.asyncOpen(listener);
+
+ Services.obs.addObserver(this, "network:offline-about-to-go-offline");
+ } catch (e) {
+ logger.warn(
+ "Failed to start download for addon " + this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_NETWORK_FAILURE;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ }
+ }
+
+ /*
+ * Update the crypto hasher with the new data and call the progress listeners.
+ *
+ * @see nsIStreamListener
+ */
+ onDataAvailable(aRequest, aInputstream, aOffset, aCount) {
+ this.crypto.updateFromStream(aInputstream, aCount);
+ this.progress += aCount;
+ if (!this._callInstallListeners("onDownloadProgress")) {
+ // TODO cancel the download and make it available again (bug 553024)
+ }
+ }
+
+ /*
+ * Check the redirect response for a hash of the target XPI and verify that
+ * we don't end up on an insecure channel.
+ *
+ * @see nsIChannelEventSink
+ */
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ if (
+ !this.hash &&
+ aOldChannel.originalURI.schemeIs("https") &&
+ aOldChannel instanceof Ci.nsIHttpChannel
+ ) {
+ try {
+ let hashStr = aOldChannel.getResponseHeader("X-Target-Digest");
+ let hashSplit = hashStr.toLowerCase().split(":");
+ this.hash = {
+ algorithm: hashSplit[0],
+ data: hashSplit[1],
+ };
+ } catch (e) {}
+ }
+
+ // Verify that we don't end up on an insecure channel if we haven't got a
+ // hash to verify with (see bug 537761 for discussion)
+ if (!this.hash) {
+ this.badCertHandler.asyncOnChannelRedirect(
+ aOldChannel,
+ aNewChannel,
+ aFlags,
+ aCallback
+ );
+ } else {
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ this.channel = aNewChannel;
+ }
+
+ /*
+ * This is the first chance to get at real headers on the channel.
+ *
+ * @see nsIStreamListener
+ */
+ onStartRequest(aRequest) {
+ if (this.hash) {
+ try {
+ this.crypto = CryptoHash(this.hash.algorithm);
+ } catch (e) {
+ logger.warn(
+ "Unknown hash algorithm '" +
+ this.hash.algorithm +
+ "' for addon " +
+ this.sourceURI.spec,
+ e
+ );
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = AddonManager.ERROR_INCORRECT_HASH;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+ aRequest.cancel(Cr.NS_BINDING_ABORTED);
+ return;
+ }
+ } else {
+ // We always need something to consume data from the inputstream passed
+ // to onDataAvailable so just create a dummy cryptohasher to do that.
+ this.crypto = CryptoHash(DEFAULT_HASH_ALGO);
+ }
+
+ this.progress = 0;
+ if (aRequest instanceof Ci.nsIChannel) {
+ try {
+ this.maxProgress = aRequest.contentLength;
+ } catch (e) {}
+ logger.debug(
+ "Download started for " +
+ this.sourceURI.spec +
+ " to file " +
+ this.file.path
+ );
+ }
+ }
+
+ /*
+ * The download is complete.
+ *
+ * @see nsIStreamListener
+ */
+ onStopRequest(aRequest, aStatus) {
+ this.stream.close();
+ this.channel = null;
+ this.badCerthandler = null;
+ Services.obs.removeObserver(this, "network:offline-about-to-go-offline");
+
+ let crypto = this.crypto;
+ this.crypto = null;
+
+ // If the download was cancelled then update the state and send events
+ if (aStatus == Cr.NS_BINDING_ABORTED) {
+ if (this.state == AddonManager.STATE_DOWNLOADING) {
+ logger.debug("Cancelled download of " + this.sourceURI.spec);
+ this.state = AddonManager.STATE_CANCELLED;
+ this._cleanup();
+ this._callInstallListeners("onDownloadCancelled");
+ // If a listener restarted the download then there is no need to
+ // remove the temporary file
+ if (this.state != AddonManager.STATE_CANCELLED) {
+ return;
+ }
+ }
+
+ this.removeTemporaryFile();
+ if (this.restartDownload) {
+ this.openChannel();
+ }
+ return;
+ }
+
+ logger.debug("Download of " + this.sourceURI.spec + " completed.");
+
+ if (Components.isSuccessCode(aStatus)) {
+ if (
+ !(aRequest instanceof Ci.nsIHttpChannel) ||
+ aRequest.requestSucceeded
+ ) {
+ if (!this.hash && aRequest instanceof Ci.nsIChannel) {
+ try {
+ lazy.CertUtils.checkCert(
+ aRequest,
+ !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS
+ );
+ } catch (e) {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, e);
+ return;
+ }
+ }
+
+ if (!this._setFileHash(getHashStringForCrypto(crypto))) {
+ this.downloadFailed(
+ AddonManager.ERROR_INCORRECT_HASH,
+ `Downloaded file hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})`
+ );
+ return;
+ }
+
+ this.loadManifest(this.file).then(
+ () => {
+ if (this.addon.isCompatible) {
+ this.downloadCompleted();
+ } else {
+ // TODO Should we send some event here (bug 557716)?
+ this.state = AddonManager.STATE_CHECKING_UPDATE;
+ new UpdateChecker(
+ this.addon,
+ {
+ onUpdateFinished: aAddon => this.downloadCompleted(),
+ },
+ AddonManager.UPDATE_WHEN_ADDON_INSTALLED
+ );
+ }
+ },
+ ([error, message]) => {
+ this.removeTemporaryFile();
+ this.downloadFailed(error, message);
+ }
+ );
+ } else if (aRequest instanceof Ci.nsIHttpChannel) {
+ this.downloadFailed(
+ AddonManager.ERROR_NETWORK_FAILURE,
+ aRequest.responseStatus + " " + aRequest.responseStatusText
+ );
+ } else {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus);
+ }
+ } else {
+ this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus);
+ }
+ }
+
+ /**
+ * Notify listeners that the download failed.
+ *
+ * @param {string} aReason
+ * Something to log about the failure
+ * @param {integer} aError
+ * The error code to pass to the listeners
+ */
+ downloadFailed(aReason, aError) {
+ logger.warn("Download of " + this.sourceURI.spec + " failed", aError);
+ this.state = AddonManager.STATE_DOWNLOAD_FAILED;
+ this.error = aReason;
+ this._cleanup();
+ this._callInstallListeners("onDownloadFailed");
+
+ // If the listener hasn't restarted the download then remove any temporary
+ // file
+ if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) {
+ logger.debug(
+ "downloadFailed: removing temp file for " + this.sourceURI.spec
+ );
+ this.removeTemporaryFile();
+ } else {
+ logger.debug(
+ "downloadFailed: listener changed AddonInstall state for " +
+ this.sourceURI.spec +
+ " to " +
+ this.state
+ );
+ }
+ }
+
+ /**
+ * Notify listeners that the download completed.
+ */
+ async downloadCompleted() {
+ let wasUpdate = !!this.existingAddon;
+ let aAddon = await XPIExports.XPIDatabase.getVisibleAddonForID(
+ this.addon.id
+ );
+ if (aAddon) {
+ this.existingAddon = aAddon;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.addon.updateDate = Date.now();
+
+ if (this.existingAddon) {
+ this.addon.installDate = this.existingAddon.installDate;
+ } else {
+ this.addon.installDate = this.addon.updateDate;
+ }
+ this.addon.propagateDisabledState(this.existingAddon);
+ await this.addon.updateBlocklistState();
+
+ // Report if blocked add-on becomes unblocked through this install/update.
+ if (
+ aAddon?.blocklistState === nsIBlocklistService.STATE_BLOCKED &&
+ this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED
+ ) {
+ this.addon.recordAddonBlockChangeTelemetry(
+ wasUpdate ? "addon_update" : "addon_install"
+ );
+ }
+
+ if (this.addon.blocklistState === nsIBlocklistService.STATE_BLOCKED) {
+ this.error = AddonManager.ERROR_BLOCKLISTED;
+ } else if (!this.addon.isCompatible) {
+ this.error = AddonManager.ERROR_INCOMPATIBLE;
+ }
+
+ if (this._callInstallListeners("onDownloadEnded")) {
+ // If a listener changed our state then do not proceed with the install
+ if (this.state != AddonManager.STATE_DOWNLOADED) {
+ return;
+ }
+
+ // proceed with the install state machine.
+ this.install();
+ }
+ }
+
+ getInterface(iid) {
+ if (iid.equals(Ci.nsIAuthPrompt2)) {
+ let win = null;
+ if (this.browser) {
+ win = this.browser.contentWindow || this.browser.ownerGlobal;
+ }
+
+ let factory = Cc["@mozilla.org/prompter;1"].getService(
+ Ci.nsIPromptFactory
+ );
+ let prompt = factory.getPrompt(win, Ci.nsIAuthPrompt2);
+
+ if (this.browser && prompt instanceof Ci.nsILoginManagerAuthPrompter) {
+ prompt.browser = this.browser;
+ }
+
+ return prompt;
+ } else if (iid.equals(Ci.nsIChannelEventSink)) {
+ return this;
+ }
+
+ return this.badCertHandler.getInterface(iid);
+ }
+};
+
+/**
+ * Creates a new AddonInstall for an update.
+ *
+ * @param {function} aCallback
+ * The callback to pass the new AddonInstall to
+ * @param {AddonInternal} aAddon
+ * The add-on being updated
+ * @param {Object} aUpdate
+ * The metadata about the new version from the update manifest
+ * @param {boolean} isUserRequested
+ * An optional boolean, true if the install object is related to a user triggered update.
+ */
+function createUpdate(aCallback, aAddon, aUpdate, isUserRequested) {
+ let url = Services.io.newURI(aUpdate.updateURL);
+
+ (async function () {
+ let opts = {
+ hash: aUpdate.updateHash,
+ existingAddon: aAddon,
+ name: aAddon.selectedLocale.name,
+ type: aAddon.type,
+ icons: aAddon.icons,
+ version: aUpdate.version,
+ isUserRequestedUpdate: isUserRequested,
+ };
+
+ try {
+ if (aUpdate.updateInfoURL) {
+ opts.releaseNotesURI = Services.io.newURI(
+ escapeAddonURI(aAddon, aUpdate.updateInfoURL)
+ );
+ }
+ } catch (e) {
+ // If the releaseNotesURI cannot be parsed then just ignore it.
+ }
+
+ let install;
+ if (url instanceof Ci.nsIFileURL) {
+ install = new LocalAddonInstall(aAddon.location, url, opts);
+ await install.init();
+ } else {
+ let loc = aAddon.location;
+ if (
+ aAddon.isBuiltinColorwayTheme &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ // Builtin colorways theme needs to be updated by installing the version
+ // got from AMO into the profile location and not using the location
+ // where the builtin addon is currently installed.
+ logger.info(
+ `Overriding location to APP_PROFILE on builtin colorway theme update for "${aAddon.id}"`
+ );
+ loc = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_PROFILE
+ );
+ }
+ install = new DownloadAddonInstall(loc, url, opts);
+ }
+
+ aCallback(install);
+ })();
+}
+
+// Maps instances of AddonInstall to AddonInstallWrapper
+const wrapperMap = new WeakMap();
+let installFor = wrapper => wrapperMap.get(wrapper);
+
+// Numeric id included in the install telemetry events to correlate multiple events related
+// to the same install or update flow.
+let nextInstallId = 0;
+
+/**
+ * Creates a wrapper for an AddonInstall that only exposes the public API
+ *
+ * @param {AddonInstall} aInstall
+ * The AddonInstall to create a wrapper for
+ */
+function AddonInstallWrapper(aInstall) {
+ wrapperMap.set(this, aInstall);
+ this.installId = ++nextInstallId;
+}
+
+AddonInstallWrapper.prototype = {
+ get __AddonInstallInternal__() {
+ return AppConstants.DEBUG ? installFor(this) : undefined;
+ },
+
+ get error() {
+ return installFor(this).error;
+ },
+
+ set error(err) {
+ installFor(this).error = err;
+ },
+
+ get type() {
+ return installFor(this).type;
+ },
+
+ get iconURL() {
+ return installFor(this).icons[32];
+ },
+
+ get existingAddon() {
+ let install = installFor(this);
+ return install.existingAddon ? install.existingAddon.wrapper : null;
+ },
+
+ get addon() {
+ let install = installFor(this);
+ return install.addon ? install.addon.wrapper : null;
+ },
+
+ get sourceURI() {
+ return installFor(this).sourceURI;
+ },
+
+ set promptHandler(handler) {
+ installFor(this).promptHandler = handler;
+ },
+
+ get promptHandler() {
+ return installFor(this).promptHandler;
+ },
+
+ get installTelemetryInfo() {
+ return installFor(this).installTelemetryInfo;
+ },
+
+ get isUserRequestedUpdate() {
+ return Boolean(installFor(this).isUserRequestedUpdate);
+ },
+
+ get downloadStartedAt() {
+ return installFor(this).downloadStartedAt;
+ },
+
+ get hashedAddonId() {
+ const addon = this.addon;
+
+ if (!addon) {
+ return null;
+ }
+
+ return computeSha256HashAsString(addon.id);
+ },
+
+ install() {
+ return installFor(this).install();
+ },
+
+ postpone(returnFn, requiresRestart) {
+ return installFor(this).postpone(returnFn, requiresRestart);
+ },
+
+ cancel() {
+ installFor(this).cancel();
+ },
+
+ continuePostponedInstall() {
+ return installFor(this).continuePostponedInstall();
+ },
+
+ addListener(listener) {
+ installFor(this).addListener(listener);
+ },
+
+ removeListener(listener) {
+ installFor(this).removeListener(listener);
+ },
+};
+
+[
+ "name",
+ "version",
+ "icons",
+ "releaseNotesURI",
+ "file",
+ "state",
+ "progress",
+ "maxProgress",
+].forEach(function (aProp) {
+ Object.defineProperty(AddonInstallWrapper.prototype, aProp, {
+ get() {
+ return installFor(this)[aProp];
+ },
+ enumerable: true,
+ });
+});
+
+/**
+ * Creates a new update checker.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check for updates
+ * @param {UpdateListener} aListener
+ * An UpdateListener to notify of updates
+ * @param {integer} aReason
+ * The reason for the update check
+ * @param {string} [aAppVersion]
+ * An optional application version to check for updates for
+ * @param {string} [aPlatformVersion]
+ * An optional platform version to check for updates for
+ * @throws if the aListener or aReason arguments are not valid
+ */
+var AddonUpdateChecker;
+
+export var UpdateChecker = function (
+ aAddon,
+ aListener,
+ aReason,
+ aAppVersion,
+ aPlatformVersion
+) {
+ if (!aListener || !aReason) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ ({ AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+ ));
+
+ this.addon = aAddon;
+ aAddon._updateCheck = this;
+ XPIInstall.doing(this);
+ this.listener = aListener;
+ this.appVersion = aAppVersion;
+ this.platformVersion = aPlatformVersion;
+ this.syncCompatibility =
+ aReason == AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED;
+ this.isUserRequested = aReason == AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ let updateURL = aAddon.updateURL;
+ if (!updateURL) {
+ if (
+ aReason == AddonManager.UPDATE_WHEN_PERIODIC_UPDATE &&
+ Services.prefs.getPrefType(PREF_EM_UPDATE_BACKGROUND_URL) ==
+ Services.prefs.PREF_STRING
+ ) {
+ updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_BACKGROUND_URL);
+ } else {
+ updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_URL);
+ }
+ }
+
+ const UPDATE_TYPE_COMPATIBILITY = 32;
+ const UPDATE_TYPE_NEWVERSION = 64;
+
+ aReason |= UPDATE_TYPE_COMPATIBILITY;
+ if ("onUpdateAvailable" in this.listener) {
+ aReason |= UPDATE_TYPE_NEWVERSION;
+ }
+
+ let url = escapeAddonURI(aAddon, updateURL, aReason, aAppVersion);
+ this._parser = AddonUpdateChecker.checkForUpdates(aAddon.id, url, this);
+};
+
+UpdateChecker.prototype = {
+ addon: null,
+ listener: null,
+ appVersion: null,
+ platformVersion: null,
+ syncCompatibility: null,
+
+ /**
+ * Calls a method on the listener passing any number of arguments and
+ * consuming any exceptions.
+ *
+ * @param {string} aMethod
+ * The method to call on the listener
+ * @param {any[]} aArgs
+ * Additional arguments to pass to the listener.
+ */
+ callListener(aMethod, ...aArgs) {
+ if (!(aMethod in this.listener)) {
+ return;
+ }
+
+ try {
+ this.listener[aMethod].apply(this.listener, aArgs);
+ } catch (e) {
+ logger.warn("Exception calling UpdateListener method " + aMethod, e);
+ }
+ },
+
+ /**
+ * Called when AddonUpdateChecker completes the update check
+ *
+ * @param {object[]} aUpdates
+ * The list of update details for the add-on
+ */
+ async onUpdateCheckComplete(aUpdates) {
+ XPIInstall.done(this.addon._updateCheck);
+ this.addon._updateCheck = null;
+ let AUC = AddonUpdateChecker;
+ let ignoreMaxVersion = false;
+ // Ignore strict compatibility for dictionaries by default.
+ let ignoreStrictCompat = this.addon.type == "dictionary";
+ if (!AddonManager.checkCompatibility) {
+ ignoreMaxVersion = true;
+ ignoreStrictCompat = true;
+ } else if (
+ !AddonManager.strictCompatibility &&
+ !this.addon.strictCompatibility
+ ) {
+ ignoreMaxVersion = true;
+ }
+
+ // Always apply any compatibility update for the current version
+ let compatUpdate = AUC.getCompatibilityUpdate(
+ aUpdates,
+ this.addon.version,
+ this.syncCompatibility,
+ null,
+ null,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+ // Apply the compatibility update to the database
+ if (compatUpdate) {
+ this.addon.applyCompatibilityUpdate(compatUpdate, this.syncCompatibility);
+ }
+
+ // If the request is for an application or platform version that is
+ // different to the current application or platform version then look for a
+ // compatibility update for those versions.
+ if (
+ (this.appVersion &&
+ Services.vc.compare(this.appVersion, Services.appinfo.version) != 0) ||
+ (this.platformVersion &&
+ Services.vc.compare(
+ this.platformVersion,
+ Services.appinfo.platformVersion
+ ) != 0)
+ ) {
+ compatUpdate = AUC.getCompatibilityUpdate(
+ aUpdates,
+ this.addon.version,
+ false,
+ this.appVersion,
+ this.platformVersion,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+ }
+
+ if (compatUpdate) {
+ this.callListener("onCompatibilityUpdateAvailable", this.addon.wrapper);
+ } else {
+ this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper);
+ }
+
+ function sendUpdateAvailableMessages(aSelf, aInstall) {
+ if (aInstall) {
+ aSelf.callListener(
+ "onUpdateAvailable",
+ aSelf.addon.wrapper,
+ aInstall.wrapper
+ );
+ } else {
+ aSelf.callListener("onNoUpdateAvailable", aSelf.addon.wrapper);
+ }
+ aSelf.callListener(
+ "onUpdateFinished",
+ aSelf.addon.wrapper,
+ AddonManager.UPDATE_STATUS_NO_ERROR
+ );
+ }
+
+ let update = await AUC.getNewestCompatibleUpdate(
+ aUpdates,
+ this.addon,
+ this.appVersion,
+ this.platformVersion,
+ ignoreMaxVersion,
+ ignoreStrictCompat
+ );
+
+ if (update && !this.addon.location.locked) {
+ for (let currentInstall of XPIInstall.installs) {
+ // Skip installs that don't match the available update
+ if (
+ currentInstall.existingAddon != this.addon ||
+ currentInstall.version != update.version
+ ) {
+ continue;
+ }
+
+ // If the existing install has not yet started downloading then send an
+ // available update notification. If it is already downloading then
+ // don't send any available update notification
+ if (currentInstall.state == AddonManager.STATE_AVAILABLE) {
+ logger.debug("Found an existing AddonInstall for " + this.addon.id);
+ sendUpdateAvailableMessages(this, currentInstall);
+ } else {
+ sendUpdateAvailableMessages(this, null);
+ }
+ return;
+ }
+
+ createUpdate(
+ aInstall => {
+ sendUpdateAvailableMessages(this, aInstall);
+ },
+ this.addon,
+ update,
+ this.isUserRequested
+ );
+ } else {
+ sendUpdateAvailableMessages(this, null);
+ }
+ },
+
+ /**
+ * Called when AddonUpdateChecker fails the update check
+ *
+ * @param {any} aError
+ * An error status
+ */
+ onUpdateCheckError(aError) {
+ XPIInstall.done(this.addon._updateCheck);
+ this.addon._updateCheck = null;
+ this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper);
+ this.callListener("onNoUpdateAvailable", this.addon.wrapper);
+ this.callListener("onUpdateFinished", this.addon.wrapper, aError);
+ },
+
+ /**
+ * Called to cancel an in-progress update check
+ */
+ cancel() {
+ let parser = this._parser;
+ if (parser) {
+ this._parser = null;
+ // This will call back to onUpdateCheckError with a CANCELLED error
+ parser.cancel();
+ }
+ },
+};
+
+/**
+ * Creates a new AddonInstall to install an add-on from a local file.
+ *
+ * @param {nsIFile} file
+ * The file to install
+ * @param {XPIStateLocation} location
+ * The location to install to
+ * @param {Object?} [telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @returns {Promise<AddonInstall>}
+ * A Promise that resolves with the new install object.
+ */
+function createLocalInstall(file, location, telemetryInfo) {
+ if (!location) {
+ location = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_PROFILE
+ );
+ }
+ let url = Services.io.newFileURI(file);
+
+ try {
+ let install = new LocalAddonInstall(location, url, { telemetryInfo });
+ return install.init().then(() => install);
+ } catch (e) {
+ logger.error("Error creating install", e);
+ return Promise.resolve(null);
+ }
+}
+
+/**
+ * Uninstall an addon from a location. This allows removing non-visible
+ * addons, such as system addon upgrades, when a higher precedence addon
+ * is installed.
+ *
+ * @param {string} addonID
+ * ID of the addon being removed.
+ * @param {XPIStateLocation} location
+ * The location to remove the addon from.
+ */
+async function uninstallAddonFromLocation(addonID, location) {
+ let existing = await XPIExports.XPIDatabase.getAddonInLocation(
+ addonID,
+ location.name
+ );
+ if (!existing) {
+ return;
+ }
+ if (existing.active) {
+ let a = await AddonManager.getAddonByID(addonID);
+ if (a) {
+ await a.uninstall();
+ }
+ } else {
+ XPIExports.XPIDatabase.removeAddonMetadata(existing);
+ location.removeAddon(addonID);
+ XPIExports.XPIInternal.XPIStates.save();
+ AddonManagerPrivate.callAddonListeners("onUninstalled", existing);
+ }
+}
+
+class DirectoryInstaller {
+ constructor(location) {
+ this.location = location;
+
+ this._stagingDirLock = 0;
+ this._stagingDirPromise = null;
+ }
+
+ get name() {
+ return this.location.name;
+ }
+
+ get dir() {
+ return this.location.dir;
+ }
+ set dir(val) {
+ this.location.dir = val;
+ this.location.path = val.path;
+ }
+
+ /**
+ * Gets the staging directory to put add-ons that are pending install and
+ * uninstall into.
+ *
+ * @returns {nsIFile}
+ */
+ getStagingDir() {
+ return getFile(XPIExports.XPIInternal.DIR_STAGE, this.dir);
+ }
+
+ requestStagingDir() {
+ this._stagingDirLock++;
+
+ if (this._stagingDirPromise) {
+ return this._stagingDirPromise;
+ }
+
+ let stagepath = PathUtils.join(
+ this.dir.path,
+ XPIExports.XPIInternal.DIR_STAGE
+ );
+ return (this._stagingDirPromise = IOUtils.makeDirectory(stagepath, {
+ createAncestors: true,
+ ignoreExisting: true,
+ }).catch(e => {
+ logger.error("Failed to create staging directory", e);
+ throw e;
+ }));
+ }
+
+ releaseStagingDir() {
+ this._stagingDirLock--;
+
+ if (this._stagingDirLock == 0) {
+ this._stagingDirPromise = null;
+ this.cleanStagingDir();
+ }
+
+ return Promise.resolve();
+ }
+
+ /**
+ * Removes the specified files or directories in the staging directory and
+ * then if the staging directory is empty attempts to remove it.
+ *
+ * @param {string[]} [aLeafNames = []]
+ * An array of file or directory to remove from the directory, the
+ * array may be empty
+ */
+ cleanStagingDir(aLeafNames = []) {
+ let dir = this.getStagingDir();
+
+ // SystemAddonInstaller getStatingDir may return null if there isn't
+ // any addon set directory returned by SystemAddonInstaller._loadAddonSet.
+ if (!dir) {
+ return;
+ }
+
+ for (let name of aLeafNames) {
+ let file = getFile(name, dir);
+ recursiveRemove(file);
+ }
+
+ if (this._stagingDirLock > 0) {
+ return;
+ }
+
+ // eslint-disable-next-line no-unused-vars
+ for (let file of XPIExports.XPIInternal.iterDirectory(dir)) {
+ return;
+ }
+
+ try {
+ setFilePermissions(dir, lazy.FileUtils.PERMS_DIRECTORY);
+ dir.remove(false);
+ } catch (e) {
+ logger.warn("Failed to remove staging dir", e);
+ // Failing to remove the staging directory is ignorable
+ }
+ }
+
+ /**
+ * Returns a directory that is normally on the same filesystem as the rest of
+ * the install location and can be used for temporarily storing files during
+ * safe move operations. Calling this method will delete the existing trash
+ * directory and its contents.
+ *
+ * @returns {nsIFile}
+ */
+ getTrashDir() {
+ let trashDir = getFile(XPIExports.XPIInternal.DIR_TRASH, this.dir);
+ let trashDirExists = trashDir.exists();
+ try {
+ if (trashDirExists) {
+ recursiveRemove(trashDir);
+ }
+ trashDirExists = false;
+ } catch (e) {
+ logger.warn("Failed to remove trash directory", e);
+ }
+ if (!trashDirExists) {
+ trashDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+
+ return trashDir;
+ }
+
+ /**
+ * Installs an add-on into the install location.
+ *
+ * @param {Object} options
+ * Installation options.
+ * @param {string} options.id
+ * The ID of the add-on to install
+ * @param {nsIFile} options.source
+ * The source nsIFile to install from
+ * @param {string} options.action
+ * What to we do with the given source file:
+ * "move"
+ * Default action, the source files will be moved to the new
+ * location,
+ * "copy"
+ * The source files will be copied,
+ * "proxy"
+ * A "proxy file" is going to refer to the source file path
+ * @returns {nsIFile}
+ * An nsIFile indicating where the add-on was installed to
+ */
+ installAddon({ id, source, action = "move" }) {
+ let trashDir = this.getTrashDir();
+
+ let transaction = new SafeInstallOperation();
+
+ let moveOldAddon = aId => {
+ let file = getFile(aId, this.dir);
+ if (file.exists()) {
+ transaction.moveUnder(file, trashDir);
+ }
+
+ file = getFile(`${aId}.xpi`, this.dir);
+ if (file.exists()) {
+ flushJarCache(file);
+ transaction.moveUnder(file, trashDir);
+ }
+ };
+
+ // If any of these operations fails the finally block will clean up the
+ // temporary directory
+ try {
+ moveOldAddon(id);
+ if (action == "copy") {
+ transaction.copy(source, this.dir);
+ } else if (action == "move") {
+ flushJarCache(source);
+ transaction.moveUnder(source, this.dir);
+ }
+ // Do nothing for the proxy file as we sideload an addon permanently
+ } finally {
+ // It isn't ideal if this cleanup fails but it isn't worth rolling back
+ // the install because of it.
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when installing ${id}`,
+ e
+ );
+ }
+ }
+
+ let newFile = this.dir.clone();
+
+ if (action == "proxy") {
+ // When permanently installing sideloaded addon, we just put a proxy file
+ // referring to the addon sources
+ newFile.append(id);
+
+ writeStringToFile(newFile, source.path);
+ } else {
+ newFile.append(source.leafName);
+ }
+
+ try {
+ newFile.lastModifiedTime = Date.now();
+ } catch (e) {
+ logger.warn(`failed to set lastModifiedTime on ${newFile.path}`, e);
+ }
+
+ return newFile;
+ }
+
+ /**
+ * Uninstalls an add-on from this location.
+ *
+ * @param {string} aId
+ * The ID of the add-on to uninstall
+ * @throws if the ID does not match any of the add-ons installed
+ */
+ uninstallAddon(aId) {
+ let file = getFile(aId, this.dir);
+ if (!file.exists()) {
+ file.leafName += ".xpi";
+ }
+
+ if (!file.exists()) {
+ logger.warn(
+ `Attempted to remove ${aId} from ${this.name} but it was already gone`
+ );
+ this.location.delete(aId);
+ return;
+ }
+
+ if (file.leafName != aId) {
+ logger.debug(
+ `uninstallAddon: flushing jar cache ${file.path} for addon ${aId}`
+ );
+ flushJarCache(file);
+ }
+
+ // In case this is a foreignInstall we do not want to remove the file if
+ // the location is locked.
+ if (!this.location.locked) {
+ let trashDir = this.getTrashDir();
+ let transaction = new SafeInstallOperation();
+
+ try {
+ transaction.moveUnder(file, trashDir);
+ } finally {
+ // It isn't ideal if this cleanup fails, but it is probably better than
+ // rolling back the uninstall at this point
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when uninstalling ${aId}`,
+ e
+ );
+ }
+ }
+ }
+
+ this.location.removeAddon(aId);
+ }
+}
+
+class SystemAddonInstaller extends DirectoryInstaller {
+ constructor(location) {
+ super(location);
+
+ this._baseDir = location._baseDir;
+ this._nextDir = null;
+ }
+
+ get _addonSet() {
+ return this.location._addonSet;
+ }
+ set _addonSet(val) {
+ this.location._addonSet = val;
+ }
+
+ /**
+ * Saves the current set of system add-ons
+ *
+ * @param {Object} aAddonSet - object containing schema, directory and set
+ * of system add-on IDs and versions.
+ */
+ static _saveAddonSet(aAddonSet) {
+ Services.prefs.setStringPref(
+ XPIExports.XPIInternal.PREF_SYSTEM_ADDON_SET,
+ JSON.stringify(aAddonSet)
+ );
+ }
+
+ static _loadAddonSet() {
+ return XPIExports.XPIInternal.SystemAddonLocation._loadAddonSet();
+ }
+
+ /**
+ * Gets the staging directory to put add-ons that are pending install and
+ * uninstall into.
+ *
+ * @returns {nsIFile}
+ * Staging directory for system add-on upgrades.
+ */
+ getStagingDir() {
+ this._addonSet = SystemAddonInstaller._loadAddonSet();
+ let dir = null;
+ if (this._addonSet.directory) {
+ this.dir = getFile(this._addonSet.directory, this._baseDir);
+ dir = getFile(XPIExports.XPIInternal.DIR_STAGE, this.dir);
+ } else {
+ logger.info("SystemAddonInstaller directory is missing");
+ }
+
+ return dir;
+ }
+
+ requestStagingDir() {
+ this._addonSet = SystemAddonInstaller._loadAddonSet();
+ if (this._addonSet.directory) {
+ this.dir = getFile(this._addonSet.directory, this._baseDir);
+ }
+ return super.requestStagingDir();
+ }
+
+ isValidAddon(aAddon) {
+ if (aAddon.appDisabled) {
+ logger.warn(
+ `System add-on ${aAddon.id} isn't compatible with the application.`
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Tests whether the loaded add-on information matches what is expected.
+ *
+ * @param {Map<string, AddonInternal>} aAddons
+ * The set of add-ons to check.
+ * @returns {boolean}
+ * True if all of the given add-ons are valid.
+ */
+ isValid(aAddons) {
+ for (let id of Object.keys(this._addonSet.addons)) {
+ if (!aAddons.has(id)) {
+ logger.warn(
+ `Expected add-on ${id} is missing from the system add-on location.`
+ );
+ return false;
+ }
+
+ let addon = aAddons.get(id);
+ if (addon.version != this._addonSet.addons[id].version) {
+ logger.warn(
+ `Expected system add-on ${id} to be version ${this._addonSet.addons[id].version} but was ${addon.version}.`
+ );
+ return false;
+ }
+
+ if (!this.isValidAddon(addon)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Resets the add-on set so on the next startup the default set will be used.
+ */
+ async resetAddonSet() {
+ logger.info("Removing all system add-on upgrades.");
+
+ // remove everything from the pref first, if uninstall
+ // fails then at least they will not be re-activated on
+ // next restart.
+ let addonSet = this._addonSet;
+ this._addonSet = { schema: 1, addons: {} };
+ SystemAddonInstaller._saveAddonSet(this._addonSet);
+
+ // If this is running at app startup, the pref being cleared
+ // will cause later stages of startup to notice that the
+ // old updates are now gone.
+ //
+ // Updates will only be explicitly uninstalled if they are
+ // removed restartlessly, for instance if they are no longer
+ // part of the latest update set.
+ if (addonSet) {
+ for (let addonID of Object.keys(addonSet.addons)) {
+ await uninstallAddonFromLocation(addonID, this.location);
+ }
+ }
+ }
+
+ /**
+ * Removes any directories not currently in use or pending use after a
+ * restart. Any errors that happen here don't really matter as we'll attempt
+ * to cleanup again next time.
+ */
+ async cleanDirectories() {
+ try {
+ let children = await IOUtils.getChildren(this._baseDir.path, {
+ ignoreAbsent: true,
+ });
+ for (let path of children) {
+ // Skip the directory currently in use
+ if (this.dir && this.dir.path == path) {
+ continue;
+ }
+
+ // Skip the next directory
+ if (this._nextDir && this._nextDir.path == path) {
+ continue;
+ }
+
+ await IOUtils.remove(path, {
+ ignoreAbsent: true,
+ recursive: true,
+ });
+ }
+ } catch (e) {
+ logger.error("Failed to clean updated system add-ons directories.", e);
+ }
+ }
+
+ /**
+ * Installs a new set of system add-ons into the location and updates the
+ * add-on set in prefs.
+ *
+ * @param {Array} aAddons - An array of addons to install.
+ */
+ async installAddonSet(aAddons) {
+ // Make sure the base dir exists
+ await IOUtils.makeDirectory(this._baseDir.path, { ignoreExisting: true });
+
+ let addonSet = SystemAddonInstaller._loadAddonSet();
+
+ // Remove any add-ons that are no longer part of the set.
+ const ids = aAddons.map(a => a.id);
+ for (let addonID of Object.keys(addonSet.addons)) {
+ if (!ids.includes(addonID)) {
+ await uninstallAddonFromLocation(addonID, this.location);
+ }
+ }
+
+ let newDir = this._baseDir.clone();
+ newDir.append("blank");
+
+ while (true) {
+ newDir.leafName = Services.uuid.generateUUID().toString();
+ try {
+ await IOUtils.makeDirectory(newDir.path, { ignoreExisting: false });
+ break;
+ } catch (e) {
+ logger.debug(
+ "Could not create new system add-on updates dir, retrying",
+ e
+ );
+ }
+ }
+
+ // Record the new upgrade directory.
+ let state = { schema: 1, directory: newDir.leafName, addons: {} };
+ SystemAddonInstaller._saveAddonSet(state);
+
+ this._nextDir = newDir;
+
+ let installs = [];
+ for (let addon of aAddons) {
+ let install = await createLocalInstall(
+ addon._sourceBundle,
+ this.location,
+ // Make sure that system addons being installed for the first time through
+ // Balrog have telemetryInfo associated with them (on the contrary the ones
+ // updated through Balrog but part of the build will already have the same
+ // `source`, but we expect no `method` to be set for them).
+ {
+ source: "system-addon",
+ method: "product-updates",
+ }
+ );
+ installs.push(install);
+ }
+
+ async function installAddon(install) {
+ // Make the new install own its temporary file.
+ install.ownsTempFile = true;
+ install.install();
+ }
+
+ async function postponeAddon(install) {
+ install.ownsTempFile = true;
+ let resumeFn;
+ if (AddonManagerPrivate.hasUpgradeListener(install.addon.id)) {
+ logger.info(
+ `system add-on ${install.addon.id} has an upgrade listener, postponing upgrade set until restart`
+ );
+ resumeFn = () => {
+ logger.info(
+ `${install.addon.id} has resumed a previously postponed addon set`
+ );
+ install.location.installer.resumeAddonSet(installs);
+ };
+ }
+ await install.postpone(resumeFn);
+ }
+
+ let previousState;
+
+ try {
+ // All add-ons in position, create the new state and store it in prefs
+ state = { schema: 1, directory: newDir.leafName, addons: {} };
+ for (let addon of aAddons) {
+ state.addons[addon.id] = {
+ version: addon.version,
+ };
+ }
+
+ previousState = SystemAddonInstaller._loadAddonSet();
+ SystemAddonInstaller._saveAddonSet(state);
+
+ let blockers = aAddons.filter(addon =>
+ AddonManagerPrivate.hasUpgradeListener(addon.id)
+ );
+
+ if (blockers.length) {
+ await waitForAllPromises(installs.map(postponeAddon));
+ } else {
+ await waitForAllPromises(installs.map(installAddon));
+ }
+ } catch (e) {
+ // Roll back to previous upgrade set (if present) on restart.
+ if (previousState) {
+ SystemAddonInstaller._saveAddonSet(previousState);
+ }
+ // Otherwise, roll back to built-in set on restart.
+ // TODO try to do these restartlessly
+ await this.resetAddonSet();
+
+ try {
+ await IOUtils.remove(newDir.path, { recursive: true });
+ } catch (e) {
+ logger.warn(
+ `Failed to remove failed system add-on directory ${newDir.path}.`,
+ e
+ );
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Resumes upgrade of a previously-delayed add-on set.
+ *
+ * @param {AddonInstall[]} installs
+ * The set of installs to resume.
+ */
+ async resumeAddonSet(installs) {
+ async function resumeAddon(install) {
+ install.state = AddonManager.STATE_DOWNLOADED;
+ install.location.installer.releaseStagingDir();
+ install.install();
+ }
+
+ let blockers = installs.filter(install =>
+ AddonManagerPrivate.hasUpgradeListener(install.addon.id)
+ );
+
+ if (blockers.length > 1) {
+ logger.warn(
+ "Attempted to resume system add-on install but upgrade blockers are still present"
+ );
+ } else {
+ await waitForAllPromises(installs.map(resumeAddon));
+ }
+ }
+
+ /**
+ * Returns a directory that is normally on the same filesystem as the rest of
+ * the install location and can be used for temporarily storing files during
+ * safe move operations. Calling this method will delete the existing trash
+ * directory and its contents.
+ *
+ * @returns {nsIFile}
+ */
+ getTrashDir() {
+ let trashDir = getFile(XPIExports.XPIInternal.DIR_TRASH, this.dir);
+ let trashDirExists = trashDir.exists();
+ try {
+ if (trashDirExists) {
+ recursiveRemove(trashDir);
+ }
+ trashDirExists = false;
+ } catch (e) {
+ logger.warn("Failed to remove trash directory", e);
+ }
+ if (!trashDirExists) {
+ trashDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+
+ return trashDir;
+ }
+
+ /**
+ * Installs an add-on into the install location.
+ *
+ * @param {string} id
+ * The ID of the add-on to install
+ * @param {nsIFile} source
+ * The source nsIFile to install from
+ * @returns {nsIFile}
+ * An nsIFile indicating where the add-on was installed to
+ */
+ installAddon({ id, source }) {
+ let trashDir = this.getTrashDir();
+ let transaction = new SafeInstallOperation();
+
+ // If any of these operations fails the finally block will clean up the
+ // temporary directory
+ try {
+ flushJarCache(source);
+
+ transaction.moveUnder(source, this.dir);
+ } finally {
+ // It isn't ideal if this cleanup fails but it isn't worth rolling back
+ // the install because of it.
+ try {
+ recursiveRemove(trashDir);
+ } catch (e) {
+ logger.warn(
+ `Failed to remove trash directory when installing ${id}`,
+ e
+ );
+ }
+ }
+
+ let newFile = getFile(source.leafName, this.dir);
+
+ try {
+ newFile.lastModifiedTime = Date.now();
+ } catch (e) {
+ logger.warn("failed to set lastModifiedTime on " + newFile.path, e);
+ }
+
+ return newFile;
+ }
+
+ // old system add-on upgrade dirs get automatically removed
+ uninstallAddon(aAddon) {}
+}
+
+var AppUpdate = {
+ findAddonUpdates(addon, reason, appVersion, platformVersion) {
+ return new Promise((resolve, reject) => {
+ let update = null;
+ addon.findUpdates(
+ {
+ onUpdateAvailable(addon2, install) {
+ update = install;
+ },
+
+ onUpdateFinished(addon2, error) {
+ if (error == AddonManager.UPDATE_STATUS_NO_ERROR) {
+ resolve(update);
+ } else {
+ reject(error);
+ }
+ },
+ },
+ reason,
+ appVersion,
+ platformVersion || appVersion
+ );
+ });
+ },
+
+ stageInstall(installer) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onDownloadEnded: install => {
+ install.postpone();
+ },
+ onInstallFailed: install => {
+ install.removeListener(listener);
+ reject();
+ },
+ onInstallEnded: install => {
+ // We shouldn't end up here, but if we do, resolve
+ // since we've installed.
+ install.removeListener(listener);
+ resolve();
+ },
+ onInstallPostponed: install => {
+ // At this point the addon is staged for restart.
+ install.removeListener(listener);
+ resolve();
+ },
+ };
+
+ installer.addListener(listener);
+ installer.install();
+ });
+ },
+
+ async stageLangpackUpdates(nextVersion, nextPlatformVersion) {
+ let updates = [];
+ let addons = await AddonManager.getAddonsByTypes(["locale"]);
+ for (let addon of addons) {
+ updates.push(
+ this.findAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_NEW_APP_DETECTED,
+ nextVersion,
+ nextPlatformVersion
+ )
+ .then(update => update && this.stageInstall(update))
+ .catch(e => {
+ logger.debug(`addon.findUpdate error: ${e}`);
+ })
+ );
+ }
+ return Promise.all(updates);
+ },
+};
+
+export var XPIInstall = {
+ // An array of currently active AddonInstalls
+ installs: new Set(),
+
+ createLocalInstall,
+ flushJarCache,
+ newVersionReason,
+ recursiveRemove,
+ syncLoadManifest,
+ loadManifestFromFile,
+ uninstallAddonFromLocation,
+
+ stageLangpacksForAppUpdate(nextVersion, nextPlatformVersion) {
+ return AppUpdate.stageLangpackUpdates(nextVersion, nextPlatformVersion);
+ },
+
+ // Keep track of in-progress operations that support cancel()
+ _inProgress: [],
+
+ doing(aCancellable) {
+ this._inProgress.push(aCancellable);
+ },
+
+ done(aCancellable) {
+ let i = this._inProgress.indexOf(aCancellable);
+ if (i != -1) {
+ this._inProgress.splice(i, 1);
+ return true;
+ }
+ return false;
+ },
+
+ cancelAll() {
+ // Cancelling one may alter _inProgress, so don't use a simple iterator
+ while (this._inProgress.length) {
+ let c = this._inProgress.shift();
+ try {
+ c.cancel();
+ } catch (e) {
+ logger.warn("Cancel failed", e);
+ }
+ }
+ },
+
+ /**
+ * @param {string} id
+ * The expected ID of the add-on.
+ * @param {nsIFile} file
+ * The XPI file to install the add-on from.
+ * @param {XPIStateLocation} location
+ * The install location to install the add-on to.
+ * @param {string?} [oldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {AddonInternal}
+ * The installed Addon object, upon success.
+ */
+ async installDistributionAddon(id, file, location, oldAppVersion) {
+ let addon = await loadManifestFromFile(file, location);
+ addon.installTelemetryInfo = { source: "distribution" };
+
+ if (addon.id != id) {
+ throw new Error(
+ `File file ${file.path} contains an add-on with an incorrect ID`
+ );
+ }
+
+ let state = location.get(id);
+
+ if (state) {
+ try {
+ let existingAddon = await loadManifestFromFile(state.file, location);
+
+ if (Services.vc.compare(addon.version, existingAddon.version) <= 0) {
+ return null;
+ }
+ } catch (e) {
+ // Bad add-on in the profile so just proceed and install over the top
+ logger.warn(
+ "Profile contains an add-on with a bad or missing install " +
+ `manifest at ${state.path}, overwriting`,
+ e
+ );
+ }
+ } else if (
+ addon.type === "locale" &&
+ oldAppVersion &&
+ Services.vc.compare(oldAppVersion, "67") < 0
+ ) {
+ /* Distribution language packs didn't get installed due to the signing
+ issues so we need to force them to be reinstalled. */
+ Services.prefs.clearUserPref(
+ XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id
+ );
+ } else if (
+ Services.prefs.getBoolPref(
+ XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id,
+ false
+ )
+ ) {
+ return null;
+ }
+
+ // Install the add-on
+ addon.sourceBundle = location.installer.installAddon({
+ id,
+ source: file,
+ action: "copy",
+ });
+
+ XPIExports.XPIInternal.XPIStates.addAddon(addon);
+ logger.debug(`Installed distribution add-on ${id}`);
+
+ Services.prefs.setBoolPref(
+ XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id,
+ true
+ );
+
+ return addon;
+ },
+
+ /**
+ * Completes the install of an add-on which was staged during the last
+ * session.
+ *
+ * @param {string} id
+ * The expected ID of the add-on.
+ * @param {object} metadata
+ * The parsed metadata for the staged install.
+ * @param {XPIStateLocation} location
+ * The install location to install the add-on to.
+ * @returns {AddonInternal}
+ * The installed Addon object, upon success.
+ */
+ async installStagedAddon(id, metadata, location) {
+ let source = getFile(`${id}.xpi`, location.installer.getStagingDir());
+
+ // Check that the directory's name is a valid ID.
+ if (!gIDTest.test(id) || !source.exists() || !source.isFile()) {
+ throw new Error(`Ignoring invalid staging directory entry: ${id}`);
+ }
+
+ let addon = await loadManifestFromFile(source, location);
+
+ if (
+ XPIExports.XPIDatabase.mustSign(addon.type) &&
+ addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
+ ) {
+ throw new Error(
+ `Refusing to install staged add-on ${id} with signed state ${addon.signedState}`
+ );
+ }
+
+ // Import saved metadata before checking for compatibility.
+ addon.importMetadata(metadata);
+
+ // Ensure a staged addon is compatible with the current running version of
+ // Firefox. If a prior version of the addon is installed, it will remain.
+ if (!addon.isCompatible) {
+ throw new Error(
+ `Add-on ${addon.id} is not compatible with application version.`
+ );
+ }
+
+ logger.debug(`Processing install of ${id} in ${location.name}`);
+ let existingAddon = XPIExports.XPIInternal.XPIStates.findAddon(id);
+ // This part of the startup file changes is called from
+ // processPendingFileChanges, no addons are started yet.
+ // Here we handle copying the xpi into its proper place, later
+ // processFileChanges will call update.
+ try {
+ addon.sourceBundle = location.installer.installAddon({
+ id,
+ source,
+ });
+ XPIExports.XPIInternal.XPIStates.addAddon(addon);
+ } catch (e) {
+ if (existingAddon) {
+ // Re-install the old add-on
+ XPIExports.XPIInternal.get(existingAddon).install();
+ }
+ throw e;
+ }
+
+ return addon;
+ },
+
+ async updateSystemAddons() {
+ let systemAddonLocation = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ );
+ if (!systemAddonLocation) {
+ return;
+ }
+
+ let installer = systemAddonLocation.installer;
+
+ // Don't do anything in safe mode
+ if (Services.appinfo.inSafeMode) {
+ return;
+ }
+
+ // Download the list of system add-ons
+ let url = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_UPDATE_URL, null);
+ if (!url) {
+ await installer.cleanDirectories();
+ return;
+ }
+
+ url = await lazy.UpdateUtils.formatUpdateURL(url);
+
+ logger.info(`Starting system add-on update check from ${url}.`);
+ let res = await lazy.ProductAddonChecker.getProductAddonList(
+ url,
+ true
+ ).catch(e => logger.error(`System addon update list error ${e}`));
+
+ // If there was no list then do nothing.
+ if (!res || !res.addons) {
+ logger.info("No system add-ons list was returned.");
+ await installer.cleanDirectories();
+ return;
+ }
+
+ let addonList = new Map(
+ res.addons.map(spec => [spec.id, { spec, path: null, addon: null }])
+ );
+
+ let setMatches = (wanted, existing) => {
+ if (wanted.size != existing.size) {
+ return false;
+ }
+
+ for (let [id, addon] of existing) {
+ let wantedInfo = wanted.get(id);
+
+ if (!wantedInfo) {
+ return false;
+ }
+ if (wantedInfo.spec.version != addon.version) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ // If this matches the current set in the profile location then do nothing.
+ let updatedAddons = addonMap(
+ await XPIExports.XPIDatabase.getAddonsInLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ )
+ );
+ if (setMatches(addonList, updatedAddons)) {
+ logger.info("Retaining existing updated system add-ons.");
+ await installer.cleanDirectories();
+ return;
+ }
+
+ // If this matches the current set in the default location then reset the
+ // updated set.
+ let defaultAddons = addonMap(
+ await XPIExports.XPIDatabase.getAddonsInLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_DEFAULTS
+ )
+ );
+ if (setMatches(addonList, defaultAddons)) {
+ logger.info("Resetting system add-ons.");
+ await installer.resetAddonSet();
+ await installer.cleanDirectories();
+ return;
+ }
+
+ // Download all the add-ons
+ async function downloadAddon(item) {
+ try {
+ let sourceAddon = updatedAddons.get(item.spec.id);
+ if (sourceAddon && sourceAddon.version == item.spec.version) {
+ // Copying the file to a temporary location has some benefits. If the
+ // file is locked and cannot be read then we'll fall back to
+ // downloading a fresh copy. We later mark the install object with
+ // ownsTempFile so that we will cleanup later (see installAddonSet).
+ try {
+ let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile).path;
+ let uniquePath = await IOUtils.createUniqueFile(tmpDir, "tmpaddon");
+ await IOUtils.copy(sourceAddon._sourceBundle.path, uniquePath);
+ // Make sure to update file modification times so this is detected
+ // as a new add-on.
+ await IOUtils.setModificationTime(uniquePath);
+ item.path = uniquePath;
+ } catch (e) {
+ logger.warn(
+ `Failed make temporary copy of ${sourceAddon._sourceBundle.path}.`,
+ e
+ );
+ }
+ }
+ if (!item.path) {
+ item.path = await lazy.ProductAddonChecker.downloadAddon(item.spec);
+ }
+ item.addon = await loadManifestFromFile(
+ nsIFile(item.path),
+ systemAddonLocation
+ );
+ } catch (e) {
+ logger.error(`Failed to download system add-on ${item.spec.id}`, e);
+ }
+ }
+ await Promise.all(Array.from(addonList.values()).map(downloadAddon));
+
+ // The download promises all resolve regardless, now check if they all
+ // succeeded
+ let validateAddon = item => {
+ if (item.spec.id != item.addon.id) {
+ logger.warn(
+ `Downloaded system add-on expected to be ${item.spec.id} but was ${item.addon.id}.`
+ );
+ return false;
+ }
+
+ if (item.spec.version != item.addon.version) {
+ logger.warn(
+ `Expected system add-on ${item.spec.id} to be version ${item.spec.version} but was ${item.addon.version}.`
+ );
+ return false;
+ }
+
+ if (!installer.isValidAddon(item.addon)) {
+ return false;
+ }
+
+ return true;
+ };
+
+ if (
+ !Array.from(addonList.values()).every(
+ item => item.path && item.addon && validateAddon(item)
+ )
+ ) {
+ throw new Error(
+ "Rejecting updated system add-on set that either could not " +
+ "be downloaded or contained unusable add-ons."
+ );
+ }
+
+ // Install into the install location
+ logger.info("Installing new system add-on set");
+ await installer.installAddonSet(
+ Array.from(addonList.values()).map(a => a.addon)
+ );
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons is enabled.
+ *
+ * @returns {boolean}
+ * True if installing is enabled.
+ */
+ isInstallEnabled() {
+ // Default to enabled if the preference does not exist
+ return Services.prefs.getBoolPref(PREF_XPI_ENABLED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons by direct URL requests is
+ * whitelisted.
+ *
+ * @returns {boolean}
+ * True if installing by direct requests is whitelisted
+ */
+ isDirectRequestWhitelisted() {
+ // Default to whitelisted if the preference does not exist.
+ return Services.prefs.getBoolPref(PREF_XPI_DIRECT_WHITELISTED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons from file referrers is
+ * whitelisted.
+ *
+ * @returns {boolean}
+ * True if installing from file referrers is whitelisted
+ */
+ isFileRequestWhitelisted() {
+ // Default to whitelisted if the preference does not exist.
+ return Services.prefs.getBoolPref(PREF_XPI_FILE_WHITELISTED, true);
+ },
+
+ /**
+ * Called to test whether installing XPI add-ons from a URI is allowed.
+ *
+ * @param {nsIPrincipal} aInstallingPrincipal
+ * The nsIPrincipal that initiated the install
+ * @returns {boolean}
+ * True if installing is allowed
+ */
+ isInstallAllowed(aInstallingPrincipal) {
+ if (!this.isInstallEnabled()) {
+ return false;
+ }
+
+ let uri = aInstallingPrincipal.URI;
+
+ // Direct requests without a referrer are either whitelisted or blocked.
+ if (!uri) {
+ return this.isDirectRequestWhitelisted();
+ }
+
+ // Local referrers can be whitelisted.
+ if (
+ this.isFileRequestWhitelisted() &&
+ (uri.schemeIs("chrome") || uri.schemeIs("file"))
+ ) {
+ return true;
+ }
+
+ XPIExports.XPIDatabase.importPermissions();
+
+ let permission = Services.perms.testPermissionFromPrincipal(
+ aInstallingPrincipal,
+ XPIExports.XPIInternal.XPI_PERMISSION
+ );
+ if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
+ return false;
+ }
+
+ let requireWhitelist = Services.prefs.getBoolPref(
+ PREF_XPI_WHITELIST_REQUIRED,
+ true
+ );
+ if (
+ requireWhitelist &&
+ permission != Ci.nsIPermissionManager.ALLOW_ACTION
+ ) {
+ return false;
+ }
+
+ let requireSecureOrigin = Services.prefs.getBoolPref(
+ PREF_INSTALL_REQUIRESECUREORIGIN,
+ true
+ );
+ let safeSchemes = ["https", "chrome", "file"];
+ if (requireSecureOrigin && !safeSchemes.includes(uri.scheme)) {
+ return false;
+ }
+
+ return true;
+ },
+
+ /**
+ * Called to get an AddonInstall to download and install an add-on from a URL.
+ *
+ * @param {nsIURI} aUrl
+ * The URL to be installed
+ * @param {object} [aOptions]
+ * Additional options for this install.
+ * @param {string?} [aOptions.hash]
+ * A hash for the install
+ * @param {string} [aOptions.name]
+ * A name for the install
+ * @param {Object} [aOptions.icons]
+ * Icon URLs for the install
+ * @param {string} [aOptions.version]
+ * A version for the install
+ * @param {XULElement} [aOptions.browser]
+ * The browser performing the install
+ * @param {Object} [aOptions.telemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [aOptions.sendCookies = false]
+ * Whether cookies should be sent when downloading the add-on.
+ * @param {string} [aOptions.useSystemLocation = false]
+ * If true installs to the system profile location.
+ * @returns {AddonInstall}
+ */
+ async getInstallForURL(aUrl, aOptions) {
+ let locationName = aOptions.useSystemLocation
+ ? XPIExports.XPIInternal.KEY_APP_SYSTEM_PROFILE
+ : XPIExports.XPIInternal.KEY_APP_PROFILE;
+ let location = XPIExports.XPIInternal.XPIStates.getLocation(locationName);
+ if (!location) {
+ throw Components.Exception(
+ "Invalid location name",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ let url = Services.io.newURI(aUrl);
+
+ if (url instanceof Ci.nsIFileURL) {
+ let install = new LocalAddonInstall(location, url, aOptions);
+ await install.init();
+ return install.wrapper;
+ }
+
+ let install = new DownloadAddonInstall(location, url, aOptions);
+ return install.wrapper;
+ },
+
+ /**
+ * Called to get an AddonInstall to install an add-on from a local file.
+ *
+ * @param {nsIFile} aFile
+ * The file to be installed
+ * @param {Object?} [aInstallTelemetryInfo]
+ * An optional object which provides details about the installation source
+ * included in the addon manager telemetry events.
+ * @param {boolean} [aUseSystemLocation = false]
+ * If true install to the system profile location.
+ * @returns {AddonInstall?}
+ */
+ async getInstallForFile(
+ aFile,
+ aInstallTelemetryInfo,
+ aUseSystemLocation = false
+ ) {
+ let location = XPIExports.XPIInternal.XPIStates.getLocation(
+ aUseSystemLocation
+ ? XPIExports.XPIInternal.KEY_APP_SYSTEM_PROFILE
+ : XPIExports.XPIInternal.KEY_APP_PROFILE
+ );
+ let install = await createLocalInstall(
+ aFile,
+ location,
+ aInstallTelemetryInfo
+ );
+ return install ? install.wrapper : null;
+ },
+
+ /**
+ * Called to get the current AddonInstalls, optionally limiting to a list of
+ * types.
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types or null to get all types
+ * @returns {AddonInstall[]}
+ */
+ getInstallsByTypes(aTypes) {
+ let results = [...this.installs];
+ if (aTypes) {
+ results = results.filter(install => {
+ return aTypes.includes(install.type);
+ });
+ }
+
+ return results.map(install => install.wrapper);
+ },
+
+ /**
+ * Temporarily installs add-on from a local XPI file or directory.
+ * As this is intended for development, the signature is not checked and
+ * the add-on does not persist on application restart.
+ *
+ * @param {nsIFile} aFile
+ * An nsIFile for the unpacked add-on directory or XPI file.
+ *
+ * @returns {Promise<Addon>}
+ * A Promise that resolves to an Addon object on success, or rejects
+ * if the add-on is not a valid restartless add-on or if the
+ * same ID is already installed.
+ */
+ async installTemporaryAddon(aFile) {
+ let installLocation = XPIExports.XPIInternal.TemporaryInstallLocation;
+
+ if (XPIExports.XPIInternal.isXPI(aFile.leafName)) {
+ flushJarCache(aFile);
+ }
+ let addon = await loadManifestFromFile(aFile, installLocation);
+ addon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile(
+ aFile,
+ ""
+ ).spec;
+
+ await this._activateAddon(addon, { temporarilyInstalled: true });
+
+ logger.debug(`Install of temporary addon in ${aFile.path} completed.`);
+ return addon.wrapper;
+ },
+
+ /**
+ * Installs an add-on from a built-in location
+ * (ie a resource: url referencing assets shipped with the application)
+ *
+ * @param {string} base
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns {Promise<Addon>}
+ * A Promise that resolves to an Addon object when the addon is
+ * installed.
+ */
+ async installBuiltinAddon(base) {
+ // We have to get this before the install, as the install will overwrite
+ // the pref. We then keep the value for this run, so we can restore
+ // the selected theme once it becomes available.
+ if (lastSelectedTheme === null) {
+ lastSelectedTheme = Services.prefs.getCharPref(PREF_SELECTED_THEME, "");
+ }
+
+ let baseURL = Services.io.newURI(base);
+
+ // WebExtensions need to be able to iterate through the contents of
+ // an extension (for localization). It knows how to do this with
+ // jar: and file: URLs, so translate the provided base URL to
+ // something it can use.
+ if (baseURL.scheme !== "resource") {
+ throw new Error("Built-in addons must use resource: URLS");
+ }
+
+ let pkg = builtinPackage(baseURL);
+ let addon = await loadManifest(pkg, XPIExports.XPIInternal.BuiltInLocation);
+ addon.rootURI = base;
+
+ // If this is a theme, decide whether to enable it. Themes are
+ // disabled by default. However:
+ //
+ // We always want one theme to be active, falling back to the
+ // default theme when the active theme is disabled.
+ // During a theme migration, such as a change in the path to the addon, we
+ // will need to ensure a correct theme is enabled.
+ if (addon.type === "theme") {
+ if (
+ addon.id === lastSelectedTheme ||
+ (!lastSelectedTheme.endsWith("@mozilla.org") &&
+ addon.id === lazy.AddonSettings.DEFAULT_THEME_ID &&
+ !XPIExports.XPIDatabase.getAddonsByType("theme").some(
+ theme => !theme.disabled
+ ))
+ ) {
+ addon.userDisabled = false;
+ }
+ }
+ await this._activateAddon(addon);
+ return addon.wrapper;
+ },
+
+ /**
+ * Activate a newly installed addon.
+ * This function handles all the bookkeeping related to a new addon
+ * and invokes whatever bootstrap methods are necessary.
+ * Note that this function is only used for temporary and built-in
+ * installs, it is very similar to AddonInstall::startInstall().
+ * It would be great to merge this function with that one some day.
+ *
+ * @param {AddonInternal} addon The addon to activate
+ * @param {object} [extraParams] Any extra parameters to pass to the
+ * bootstrap install() method
+ *
+ * @returns {Promise<void>}
+ */
+ async _activateAddon(addon, extraParams = {}) {
+ if (addon.appDisabled) {
+ let message = `Add-on ${addon.id} is not compatible with application version.`;
+
+ let app = addon.matchingTargetApplication;
+ if (app) {
+ if (app.minVersion) {
+ message += ` add-on minVersion: ${app.minVersion}.`;
+ }
+ if (app.maxVersion) {
+ message += ` add-on maxVersion: ${app.maxVersion}.`;
+ }
+ }
+ throw new Error(message);
+ }
+
+ let oldAddon = await XPIExports.XPIDatabase.getVisibleAddonForID(addon.id);
+
+ let willActivate =
+ !oldAddon ||
+ oldAddon.location == addon.location ||
+ addon.location.hasPrecedence(oldAddon.location);
+
+ let install = () => {
+ addon.visible = willActivate;
+ // Themes are generally not enabled by default at install time,
+ // unless enabled by the front-end code. If they are meant to be
+ // enabled, they will already have been enabled by this point.
+ if (addon.type !== "theme" || addon.location.isTemporary) {
+ addon.userDisabled = false;
+ }
+ addon.active = addon.visible && !addon.disabled;
+
+ addon = XPIExports.XPIDatabase.addToDatabase(
+ addon,
+ addon._sourceBundle ? addon._sourceBundle.path : null
+ );
+
+ XPIExports.XPIInternal.XPIStates.addAddon(addon);
+ XPIExports.XPIInternal.XPIStates.save();
+ };
+
+ AddonManagerPrivate.callAddonListeners("onInstalling", addon.wrapper);
+
+ if (!willActivate) {
+ addon.installDate = Date.now();
+
+ install();
+ } else if (oldAddon) {
+ logger.warn(
+ `Addon with ID ${oldAddon.id} already installed, ` +
+ "older version will be disabled"
+ );
+
+ addon.installDate = oldAddon.installDate;
+
+ await XPIExports.XPIInternal.BootstrapScope.get(oldAddon).update(
+ addon,
+ true,
+ install
+ );
+ } else {
+ addon.installDate = Date.now();
+
+ install();
+ let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(addon);
+ await bootstrap.install(undefined, true, extraParams);
+ }
+
+ AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ addon.wrapper,
+ oldAddon ? oldAddon.wrapper : null,
+ false
+ );
+ AddonManagerPrivate.callAddonListeners("onInstalled", addon.wrapper);
+
+ // Notify providers that a new theme has been enabled.
+ if (addon.type === "theme" && !addon.userDisabled) {
+ AddonManagerPrivate.notifyAddonChanged(addon.id, addon.type, false);
+ }
+ },
+
+ /**
+ * Uninstalls an add-on, immediately if possible or marks it as pending
+ * uninstall if not.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The DBAddonInternal to uninstall
+ * @param {boolean} aForcePending
+ * Force this addon into the pending uninstall state (used
+ * e.g. while the add-on manager is open and offering an
+ * "undo" button)
+ * @throws if the addon cannot be uninstalled because it is in an install
+ * location that does not allow it
+ */
+ async uninstallAddon(aAddon, aForcePending) {
+ if (!aAddon.inDatabase) {
+ throw new Error(
+ `Cannot uninstall addon ${aAddon.id} because it is not installed`
+ );
+ }
+ let { location } = aAddon;
+
+ // If the addon is sideloaded into a location that does not allow
+ // sideloads, it is a legacy sideload. We allow those to be uninstalled.
+ let isLegacySideload =
+ aAddon.foreignInstall &&
+ !(location.scope & lazy.AddonSettings.SCOPES_SIDELOAD);
+
+ if (location.locked && !isLegacySideload) {
+ throw new Error(
+ `Cannot uninstall addon ${aAddon.id} ` +
+ `from locked install location ${location.name}`
+ );
+ }
+
+ if (aForcePending && aAddon.pendingUninstall) {
+ throw new Error("Add-on is already marked to be uninstalled");
+ }
+
+ if (aAddon._updateCheck) {
+ logger.debug(`Cancel in-progress update check for ${aAddon.id}`);
+ aAddon._updateCheck.cancel();
+ }
+
+ let wasActive = aAddon.active;
+ let wasPending = aAddon.pendingUninstall;
+
+ if (aForcePending) {
+ // We create an empty directory in the staging directory to indicate
+ // that an uninstall is necessary on next startup. Temporary add-ons are
+ // automatically uninstalled on shutdown anyway so there is no need to
+ // do this for them.
+ if (!aAddon.location.isTemporary && aAddon.location.installer) {
+ let stage = getFile(
+ aAddon.id,
+ aAddon.location.installer.getStagingDir()
+ );
+ if (!stage.exists()) {
+ stage.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ lazy.FileUtils.PERMS_DIRECTORY
+ );
+ }
+ }
+
+ XPIExports.XPIDatabase.setAddonProperties(aAddon, {
+ pendingUninstall: true,
+ });
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+ let xpiState = aAddon.location.get(aAddon.id);
+ if (xpiState) {
+ xpiState.enabled = false;
+ XPIExports.XPIInternal.XPIStates.save();
+ } else {
+ logger.warn(
+ "Can't find XPI state while uninstalling ${id} from ${location}",
+ aAddon
+ );
+ }
+ }
+
+ // If the add-on is not visible then there is no need to notify listeners.
+ if (!aAddon.visible) {
+ return;
+ }
+
+ let wrapper = aAddon.wrapper;
+
+ // If the add-on wasn't already pending uninstall then notify listeners.
+ if (!wasPending) {
+ AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ wrapper,
+ !!aForcePending
+ );
+ }
+
+ let existingAddon = XPIExports.XPIInternal.XPIStates.findAddon(
+ aAddon.id,
+ loc => loc != aAddon.location
+ );
+
+ let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(aAddon);
+ if (!aForcePending) {
+ let existing;
+ if (existingAddon) {
+ existing = await XPIExports.XPIDatabase.getAddonInLocation(
+ aAddon.id,
+ existingAddon.location.name
+ );
+ }
+
+ let uninstall = () => {
+ XPIExports.XPIInternal.XPIStates.disableAddon(aAddon.id);
+ if (aAddon.location.installer) {
+ aAddon.location.installer.uninstallAddon(aAddon.id);
+ }
+ XPIExports.XPIDatabase.removeAddonMetadata(aAddon);
+ aAddon.location.removeAddon(aAddon.id);
+ AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
+
+ if (existing) {
+ // Migrate back to the existing addon, unless it was a builtin colorway theme,
+ // in that case we also make sure to remove the addon from the builtin location.
+ if (
+ existing.isBuiltinColorwayTheme &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ ) {
+ existing.location.removeAddon(existing.id);
+ } else {
+ XPIExports.XPIDatabase.makeAddonVisible(existing);
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ existing.wrapper,
+ false
+ );
+
+ if (!existing.disabled) {
+ XPIExports.XPIDatabase.updateAddonActive(existing, true);
+ }
+ }
+ }
+ };
+
+ // Migrate back to the existing addon, unless it was a builtin colorway theme.
+ if (
+ existing &&
+ !(
+ existing.isBuiltinColorwayTheme &&
+ XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled
+ )
+ ) {
+ await bootstrap.update(existing, !existing.disabled, uninstall);
+
+ AddonManagerPrivate.callAddonListeners("onInstalled", existing.wrapper);
+ } else {
+ aAddon.location.removeAddon(aAddon.id);
+ await bootstrap.uninstall();
+ uninstall();
+ }
+ } else if (aAddon.active) {
+ XPIExports.XPIInternal.XPIStates.disableAddon(aAddon.id);
+ bootstrap.shutdown(
+ XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_UNINSTALL
+ );
+ XPIExports.XPIDatabase.updateAddonActive(aAddon, false);
+ }
+
+ // Notify any other providers that a new theme has been enabled
+ // (when the active theme is uninstalled, the default theme is enabled).
+ if (aAddon.type === "theme" && wasActive) {
+ AddonManagerPrivate.notifyAddonChanged(null, aAddon.type);
+ }
+ },
+
+ /**
+ * Cancels the pending uninstall of an add-on.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The DBAddonInternal to cancel uninstall for
+ */
+ cancelUninstallAddon(aAddon) {
+ if (!aAddon.inDatabase) {
+ throw new Error("Can only cancel uninstall for installed addons.");
+ }
+ if (!aAddon.pendingUninstall) {
+ throw new Error("Add-on is not marked to be uninstalled");
+ }
+
+ if (!aAddon.location.isTemporary && aAddon.location.installer) {
+ aAddon.location.installer.cleanStagingDir([aAddon.id]);
+ }
+
+ XPIExports.XPIDatabase.setAddonProperties(aAddon, {
+ pendingUninstall: false,
+ });
+
+ if (!aAddon.visible) {
+ return;
+ }
+
+ aAddon.location.get(aAddon.id).syncWithDB(aAddon);
+ XPIExports.XPIInternal.XPIStates.save();
+
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
+
+ if (!aAddon.disabled) {
+ XPIExports.XPIInternal.BootstrapScope.get(aAddon).startup(
+ XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_INSTALL
+ );
+ XPIExports.XPIDatabase.updateAddonActive(aAddon, true);
+ }
+
+ let wrapper = aAddon.wrapper;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper);
+
+ // Notify any other providers that this theme is now enabled again.
+ if (aAddon.type === "theme" && aAddon.active) {
+ AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type, false);
+ }
+ },
+
+ DirectoryInstaller,
+ SystemAddonInstaller,
+};
diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs b/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs
new file mode 100644
index 0000000000..d5ffd06d11
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs
@@ -0,0 +1,3377 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 contains most of the logic required to load and run
+ * extensions at startup. Anything which is not required immediately at
+ * startup should go in XPIInstall.sys.mjs or XPIDatabase.sys.mjs if at all
+ * possible, in order to minimize the impact on startup performance.
+ */
+
+/**
+ * @typedef {number} integer
+ */
+
+/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs";
+import {
+ AddonManager,
+ AddonManagerPrivate,
+} from "resource://gre/modules/AddonManager.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ Dictionary: "resource://gre/modules/Extension.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionData: "resource://gre/modules/Extension.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ Langpack: "resource://gre/modules/Extension.sys.mjs",
+ SitePermission: "resource://gre/modules/Extension.sys.mjs",
+ TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ aomStartup: [
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup",
+ ],
+ resProto: [
+ "@mozilla.org/network/protocol;1?name=resource",
+ "nsISubstitutingProtocolHandler",
+ ],
+ spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
+ timerManager: [
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager",
+ ],
+});
+
+const nsIFile = Components.Constructor(
+ "@mozilla.org/file/local;1",
+ "nsIFile",
+ "initWithPath"
+);
+const FileInputStream = Components.Constructor(
+ "@mozilla.org/network/file-input-stream;1",
+ "nsIFileInputStream",
+ "init"
+);
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
+const PREF_EM_ENABLED_SCOPES = "extensions.enabledScopes";
+const PREF_EM_STARTUP_SCAN_SCOPES = "extensions.startupScanScopes";
+// xpinstall.signatures.required only supported in dev builds
+const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
+const PREF_INSTALL_DISTRO_ADDONS = "extensions.installDistroAddons";
+const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
+const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet";
+
+const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId";
+
+// Specify a list of valid built-in add-ons to load.
+const BUILT_IN_ADDONS_URI = "chrome://browser/content/built_in_addons.json";
+
+const DIR_EXTENSIONS = "extensions";
+const DIR_SYSTEM_ADDONS = "features";
+const DIR_APP_SYSTEM_PROFILE = "system-extensions";
+const DIR_STAGE = "staged";
+const DIR_TRASH = "trash";
+
+const FILE_XPI_STATES = "addonStartup.json.lz4";
+
+const KEY_PROFILEDIR = "ProfD";
+const KEY_ADDON_APP_DIR = "XREAddonAppDir";
+const KEY_APP_DISTRIBUTION = "XREAppDist";
+const KEY_APP_FEATURES = "XREAppFeat";
+
+const KEY_APP_PROFILE = "app-profile";
+const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
+const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
+const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
+const KEY_APP_BUILTINS = "app-builtin";
+const KEY_APP_GLOBAL = "app-global";
+const KEY_APP_SYSTEM_LOCAL = "app-system-local";
+const KEY_APP_SYSTEM_SHARE = "app-system-share";
+const KEY_APP_SYSTEM_USER = "app-system-user";
+const KEY_APP_TEMPORARY = "app-temporary";
+
+const TEMPORARY_ADDON_SUFFIX = "@temporary-addon";
+
+const STARTUP_MTIME_SCOPES = [
+ KEY_APP_GLOBAL,
+ KEY_APP_SYSTEM_LOCAL,
+ KEY_APP_SYSTEM_SHARE,
+ KEY_APP_SYSTEM_USER,
+];
+
+const NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions";
+const XPI_PERMISSION = "install";
+
+const XPI_SIGNATURE_CHECK_PERIOD = 24 * 60 * 60;
+
+const DB_SCHEMA = 35;
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "enabledScopesPref",
+ PREF_EM_ENABLED_SCOPES,
+ AddonManager.SCOPE_ALL
+);
+
+Object.defineProperty(lazy, "enabledScopes", {
+ get() {
+ // The profile location is always enabled
+ return lazy.enabledScopesPref | AddonManager.SCOPE_PROFILE;
+ },
+});
+
+function encoded(strings, ...values) {
+ let result = [];
+
+ for (let [i, string] of strings.entries()) {
+ result.push(string);
+ if (i < values.length) {
+ result.push(encodeURIComponent(values[i]));
+ }
+ }
+
+ return result.join("");
+}
+
+const BOOTSTRAP_REASONS = {
+ APP_STARTUP: 1,
+ APP_SHUTDOWN: 2,
+ ADDON_ENABLE: 3,
+ ADDON_DISABLE: 4,
+ ADDON_INSTALL: 5,
+ ADDON_UNINSTALL: 6,
+ ADDON_UPGRADE: 7,
+ ADDON_DOWNGRADE: 8,
+};
+
+// All addonTypes supported by the XPIProvider. These values can be passed to
+// AddonManager.getAddonsByTypes in order to get XPIProvider.getAddonsByTypes
+// to return only supported add-ons. Without these, it is possible for
+// AddonManager.getAddonsByTypes to return addons from other providers, or even
+// add-on types that are no longer supported by XPIProvider.
+const ALL_XPI_TYPES = new Set([
+ "dictionary",
+ "extension",
+ "locale",
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ "sitepermission-deprecated",
+ "theme",
+]);
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest =
+ /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const LOGGER_ID = "addons.xpi";
+
+// Create a new logger for use by all objects in this Addons XPI Provider module
+// (Requires AddonManager.jsm)
+var logger = Log.repository.getLogger(LOGGER_ID);
+
+/**
+ * Spins the event loop until the given promise resolves, and then eiter returns
+ * its success value or throws its rejection value.
+ *
+ * @param {Promise} promise
+ * The promise to await.
+ * @returns {any}
+ * The promise's resolution value, if any.
+ */
+function awaitPromise(promise) {
+ let success = undefined;
+ let result = null;
+
+ promise.then(
+ val => {
+ success = true;
+ result = val;
+ },
+ val => {
+ success = false;
+ result = val;
+ }
+ );
+
+ Services.tm.spinEventLoopUntil(
+ "XPIProvider.sys.mjs:awaitPromise",
+ () => success !== undefined
+ );
+
+ if (!success) {
+ throw result;
+ }
+ return result;
+}
+
+/**
+ * Returns a nsIFile instance for the given path, relative to the given
+ * base file, if provided.
+ *
+ * @param {string} path
+ * The (possibly relative) path of the file.
+ * @param {nsIFile} [base]
+ * An optional file to use as a base path if `path` is relative.
+ * @returns {nsIFile}
+ */
+function getFile(path, base = null) {
+ // First try for an absolute path, as we get in the case of proxy
+ // files. Ideally we would try a relative path first, but on Windows,
+ // paths which begin with a drive letter are valid as relative paths,
+ // and treated as such.
+ try {
+ return new nsIFile(path);
+ } catch (e) {
+ // Ignore invalid relative paths. The only other error we should see
+ // here is EOM, and either way, any errors that we care about should
+ // be re-thrown below.
+ }
+
+ // If the path isn't absolute, we must have a base path.
+ let file = base.clone();
+ file.appendRelativePath(path);
+ return file;
+}
+
+/**
+ * Returns true if the given file, based on its name, should be treated
+ * as an XPI. If the file does not have an appropriate extension, it is
+ * assumed to be an unpacked add-on.
+ *
+ * @param {string} filename
+ * The filename to check.
+ * @param {boolean} [strict = false]
+ * If true, this file is in a location maintained by the browser, and
+ * must have a strict, lower-case ".xpi" extension.
+ * @returns {boolean}
+ * True if the file is an XPI.
+ */
+function isXPI(filename, strict) {
+ if (strict) {
+ return filename.endsWith(".xpi");
+ }
+ let ext = filename.slice(-4).toLowerCase();
+ return ext === ".xpi" || ext === ".zip";
+}
+
+/**
+ * Returns the extension expected ID for a given file in an extension install
+ * directory.
+ *
+ * @param {nsIFile} file
+ * The extension XPI file or unpacked directory.
+ * @returns {AddonId?}
+ * The add-on ID, if valid, or null otherwise.
+ */
+function getExpectedID(file) {
+ let { leafName } = file;
+ let id = isXPI(leafName, true) ? leafName.slice(0, -4) : leafName;
+ if (gIDTest.test(id)) {
+ return id;
+ }
+ return null;
+}
+
+/**
+ * Evaluates whether an add-on is allowed to run in safe mode.
+ *
+ * @param {AddonInternal} aAddon
+ * The add-on to check
+ * @returns {boolean}
+ * True if the add-on should run in safe mode
+ */
+function canRunInSafeMode(aAddon) {
+ let location = aAddon.location || null;
+ if (!location) {
+ return false;
+ }
+
+ // Even though the updated system add-ons aren't generally run in safe mode we
+ // include them here so their uninstall functions get called when switching
+ // back to the default set.
+
+ // TODO product should make the call about temporary add-ons running
+ // in safe mode. assuming for now that they are.
+ return location.isTemporary || location.isSystem || location.isBuiltin;
+}
+
+/**
+ * Gets an nsIURI for a file within another file, either a directory or an XPI
+ * file. If aFile is a directory then this will return a file: URI, if it is an
+ * XPI file then it will return a jar: URI.
+ *
+ * @param {nsIFile} aFile
+ * The file containing the resources, must be either a directory or an
+ * XPI file
+ * @param {string} aPath
+ * The path to find the resource at, "/" separated. If aPath is empty
+ * then the uri to the root of the contained files will be returned
+ * @returns {nsIURI}
+ * An nsIURI pointing at the resource
+ */
+function getURIForResourceInFile(aFile, aPath) {
+ if (!isXPI(aFile.leafName)) {
+ let resource = aFile.clone();
+ if (aPath) {
+ aPath.split("/").forEach(part => resource.append(part));
+ }
+
+ return Services.io.newFileURI(resource);
+ }
+
+ return buildJarURI(aFile, aPath);
+}
+
+/**
+ * Creates a jar: URI for a file inside a ZIP file.
+ *
+ * @param {nsIFile} aJarfile
+ * The ZIP file as an nsIFile
+ * @param {string} aPath
+ * The path inside the ZIP file
+ * @returns {nsIURI}
+ * An nsIURI for the file
+ */
+function buildJarURI(aJarfile, aPath) {
+ let uri = Services.io.newFileURI(aJarfile);
+ uri = "jar:" + uri.spec + "!/" + aPath;
+ return Services.io.newURI(uri);
+}
+
+function maybeResolveURI(uri) {
+ if (uri.schemeIs("resource")) {
+ return Services.io.newURI(lazy.resProto.resolveURI(uri));
+ }
+ return uri;
+}
+
+/**
+ * Iterates over the entries in a given directory.
+ *
+ * Fails silently if the given directory does not exist.
+ *
+ * @param {nsIFile} aDir
+ * Directory to iterate.
+ */
+function* iterDirectory(aDir) {
+ let dirEnum;
+ try {
+ dirEnum = aDir.directoryEntries;
+ let file;
+ while ((file = dirEnum.nextFile)) {
+ yield file;
+ }
+ } catch (e) {
+ if (aDir.exists()) {
+ logger.warn(`Can't iterate directory ${aDir.path}`, e);
+ }
+ } finally {
+ if (dirEnum) {
+ dirEnum.close();
+ }
+ }
+}
+
+/**
+ * Migrate data about an addon to match the change made in bug 857456
+ * in which "webextension-foo" types were converted to "foo" and the
+ * "loader" property was added to distinguish different addon types.
+ *
+ * @param {Object} addon The addon info to migrate.
+ * @returns {boolean} True if the addon data was converted, false if not.
+ */
+function migrateAddonLoader(addon) {
+ if (addon.hasOwnProperty("loader")) {
+ return false;
+ }
+
+ switch (addon.type) {
+ case "extension":
+ case "dictionary":
+ case "locale":
+ case "theme":
+ addon.loader = "bootstrap";
+ break;
+
+ case "webextension":
+ addon.type = "extension";
+ addon.loader = null;
+ break;
+
+ case "webextension-dictionary":
+ addon.type = "dictionary";
+ addon.loader = null;
+ break;
+
+ case "webextension-langpack":
+ addon.type = "locale";
+ addon.loader = null;
+ break;
+
+ case "webextension-theme":
+ addon.type = "theme";
+ addon.loader = null;
+ break;
+
+ default:
+ logger.warn(`Not converting unknown addon type ${addon.type}`);
+ }
+ return true;
+}
+
+/**
+ * The on-disk state of an individual XPI, created from an Object
+ * as stored in the addonStartup.json file.
+ */
+const JSON_FIELDS = Object.freeze([
+ "dependencies",
+ "enabled",
+ "file",
+ "loader",
+ "lastModifiedTime",
+ "path",
+ "recommendationState",
+ "rootURI",
+ "runInSafeMode",
+ "signedState",
+ "signedDate",
+ "startupData",
+ "telemetryKey",
+ "type",
+ "version",
+]);
+
+class XPIState {
+ constructor(location, id, saved = {}) {
+ this.location = location;
+ this.id = id;
+
+ // Set default values.
+ this.type = "extension";
+
+ for (let prop of JSON_FIELDS) {
+ if (prop in saved) {
+ this[prop] = saved[prop];
+ }
+ }
+
+ // Builds prior to be 1512436 did not include the rootURI property.
+ // If we're updating from such a build, add that property now.
+ if (!("rootURI" in this) && this.file) {
+ this.rootURI = getURIForResourceInFile(this.file, "").spec;
+ }
+
+ if (!this.telemetryKey) {
+ this.telemetryKey = this.getTelemetryKey();
+ }
+
+ if (
+ saved.currentModifiedTime &&
+ saved.currentModifiedTime != this.lastModifiedTime
+ ) {
+ this.lastModifiedTime = saved.currentModifiedTime;
+ } else if (saved.currentModifiedTime === null) {
+ this.missing = true;
+ }
+ }
+
+ // Compatibility shim getters for legacy callers in XPIDatabase.sys.mjs.
+ get mtime() {
+ return this.lastModifiedTime;
+ }
+ get active() {
+ return this.enabled;
+ }
+
+ /**
+ * @property {string} path
+ * The full on-disk path of the add-on.
+ */
+ get path() {
+ return this.file && this.file.path;
+ }
+ set path(path) {
+ this.file = path ? getFile(path, this.location.dir) : null;
+ }
+
+ /**
+ * @property {string} relativePath
+ * The path to the add-on relative to its parent location, or
+ * the full path if its parent location has no on-disk path.
+ */
+ get relativePath() {
+ if (this.location.dir && this.location.dir.contains(this.file)) {
+ let path = this.file.getRelativePath(this.location.dir);
+ if (AppConstants.platform == "win") {
+ path = path.replace(/\//g, "\\");
+ }
+ return path;
+ }
+ return this.path;
+ }
+
+ /**
+ * Returns a JSON-compatible representation of this add-on's state
+ * data, to be saved to addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ let json = {
+ dependencies: this.dependencies,
+ enabled: this.enabled,
+ lastModifiedTime: this.lastModifiedTime,
+ loader: this.loader,
+ path: this.relativePath,
+ recommendationState: this.recommendationState,
+ rootURI: this.rootURI,
+ runInSafeMode: this.runInSafeMode,
+ signedState: this.signedState,
+ signedDate: this.signedDate,
+ telemetryKey: this.telemetryKey,
+ version: this.version,
+ };
+ if (this.type != "extension") {
+ json.type = this.type;
+ }
+ if (this.startupData) {
+ json.startupData = this.startupData;
+ }
+ return json;
+ }
+
+ get isWebExtension() {
+ return this.loader == null;
+ }
+
+ get isPrivileged() {
+ return lazy.ExtensionData.getIsPrivileged({
+ signedState: this.signedState,
+ builtIn: this.location.isBuiltin,
+ temporarilyInstalled: this.location.isTemporary,
+ });
+ }
+
+ /**
+ * Update the last modified time for an add-on on disk.
+ *
+ * @param {nsIFile} aFile
+ * The location of the add-on.
+ * @returns {boolean}
+ * True if the time stamp has changed.
+ */
+ getModTime(aFile) {
+ let mtime = 0;
+ try {
+ // Clone the file object so we always get the actual mtime, rather
+ // than whatever value it may have cached.
+ mtime = aFile.clone().lastModifiedTime;
+ } catch (e) {
+ logger.warn("Can't get modified time of ${path}", aFile, e);
+ }
+
+ let changed = mtime != this.lastModifiedTime;
+ this.lastModifiedTime = mtime;
+ return changed;
+ }
+
+ /**
+ * Returns a string key by which to identify this add-on in telemetry
+ * and crash reports.
+ *
+ * @returns {string}
+ */
+ getTelemetryKey() {
+ return encoded`${this.id}:${this.version}`;
+ }
+
+ get resolvedRootURI() {
+ return maybeResolveURI(Services.io.newURI(this.rootURI));
+ }
+
+ /**
+ * Update the XPIState to match an XPIDatabase entry; if 'enabled' is changed to true,
+ * update the last-modified time. This should probably be made async, but for now we
+ * don't want to maintain parallel sync and async versions of the scan.
+ *
+ * Caller is responsible for doing XPIStates.save() if necessary.
+ *
+ * @param {DBAddonInternal} aDBAddon
+ * The DBAddonInternal for this add-on.
+ * @param {boolean} [aUpdated = false]
+ * The add-on was updated, so we must record new modified time.
+ */
+ syncWithDB(aDBAddon, aUpdated = false) {
+ logger.debug("Updating XPIState for " + JSON.stringify(aDBAddon));
+ // If the add-on changes from disabled to enabled, we should re-check the modified time.
+ // If this is a newly found add-on, it won't have an 'enabled' field but we
+ // did a full recursive scan in that case, so we don't need to do it again.
+ // We don't use aDBAddon.active here because it's not updated until after restart.
+ let mustGetMod = aDBAddon.visible && !aDBAddon.disabled && !this.enabled;
+
+ this.enabled = aDBAddon.visible && !aDBAddon.disabled;
+
+ this.version = aDBAddon.version;
+ this.type = aDBAddon.type;
+ this.loader = aDBAddon.loader;
+
+ if (aDBAddon.startupData) {
+ this.startupData = aDBAddon.startupData;
+ }
+
+ this.telemetryKey = this.getTelemetryKey();
+
+ this.dependencies = aDBAddon.dependencies;
+ this.runInSafeMode = canRunInSafeMode(aDBAddon);
+ this.signedState = aDBAddon.signedState;
+ this.signedDate = aDBAddon.signedDate;
+ this.file = aDBAddon._sourceBundle;
+ this.rootURI = aDBAddon.rootURI;
+ this.recommendationState = aDBAddon.recommendationState;
+
+ if ((aUpdated || mustGetMod) && this.file) {
+ this.getModTime(this.file);
+ if (this.lastModifiedTime != aDBAddon.updateDate) {
+ aDBAddon.updateDate = this.lastModifiedTime;
+ if (XPIExports.XPIDatabase.initialized) {
+ XPIExports.XPIDatabase.saveChanges();
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Manages the state data for add-ons in a given install location.
+ *
+ * @param {string} name
+ * The name of the install location (e.g., "app-profile").
+ * @param {string | nsIFile | null} path
+ * The on-disk path of the install location. May be null for some
+ * locations which do not map to a specific on-disk path.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {object} [saved]
+ * The persisted JSON state data to restore.
+ */
+class XPIStateLocation extends Map {
+ constructor(name, path, scope, saved) {
+ super();
+
+ this.name = name;
+ this.scope = scope;
+ if (path instanceof Ci.nsIFile) {
+ this.dir = path;
+ this.path = path.path;
+ } else {
+ this.path = path;
+ this.dir = this.path && new nsIFile(this.path);
+ }
+ this.staged = {};
+ this.changed = false;
+
+ if (saved) {
+ this.restore(saved);
+ }
+
+ this._installer = undefined;
+ }
+
+ hasPrecedence(otherLocation) {
+ let locations = Array.from(XPIStates.locations());
+ return locations.indexOf(this) <= locations.indexOf(otherLocation);
+ }
+
+ get installer() {
+ if (this._installer === undefined) {
+ this._installer = this.makeInstaller();
+ }
+ return this._installer;
+ }
+
+ makeInstaller() {
+ return null;
+ }
+
+ restore(saved) {
+ if (!this.path && saved.path) {
+ this.path = saved.path;
+ this.dir = new nsIFile(this.path);
+ }
+ this.staged = saved.staged || {};
+ this.changed = saved.changed || false;
+
+ for (let [id, data] of Object.entries(saved.addons || {})) {
+ let xpiState = this._addState(id, data);
+
+ // Make a note that this state was restored from saved data. But
+ // only if this location hasn't moved since the last startup,
+ // since that causes problems for new system add-on bundles.
+ if (!this.path || this.path == saved.path) {
+ xpiState.wasRestored = true;
+ }
+ }
+ }
+
+ /**
+ * Returns a JSON-compatible representation of this location's state
+ * data, to be saved to addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ toJSON() {
+ let json = {
+ addons: {},
+ staged: this.staged,
+ };
+
+ if (this.path) {
+ json.path = this.path;
+ }
+
+ if (STARTUP_MTIME_SCOPES.includes(this.name)) {
+ json.checkStartupModifications = true;
+ }
+
+ for (let [id, addon] of this.entries()) {
+ json.addons[id] = addon;
+ }
+ return json;
+ }
+
+ get hasStaged() {
+ for (let key in this.staged) {
+ return true;
+ }
+ return false;
+ }
+
+ _addState(addonId, saved) {
+ let xpiState = new XPIState(this, addonId, saved);
+ this.set(addonId, xpiState);
+ return xpiState;
+ }
+
+ /**
+ * Adds state data for the given DB add-on to the DB.
+ *
+ * @param {DBAddon} addon
+ * The DBAddon to add.
+ */
+ addAddon(addon) {
+ logger.debug(
+ "XPIStates adding add-on ${id} in ${location}: ${path}",
+ addon
+ );
+
+ XPIProvider.persistStartupData(addon);
+
+ let xpiState = this._addState(addon.id, { file: addon._sourceBundle });
+ xpiState.syncWithDB(addon, true);
+
+ XPIProvider.addTelemetry(addon.id, { location: this.name });
+ }
+
+ /**
+ * Remove the XPIState for an add-on and save the new state.
+ *
+ * @param {string} aId
+ * The ID of the add-on.
+ */
+ removeAddon(aId) {
+ if (this.has(aId)) {
+ this.delete(aId);
+ XPIStates.save();
+ }
+ }
+
+ /**
+ * Adds stub state data for the local file to the DB.
+ *
+ * @param {string} addonId
+ * The ID of the add-on represented by the given file.
+ * @param {nsIFile} file
+ * The local file or directory containing the add-on.
+ * @returns {XPIState}
+ */
+ addFile(addonId, file) {
+ let xpiState = this._addState(addonId, {
+ enabled: false,
+ file: file.clone(),
+ });
+ xpiState.getModTime(xpiState.file);
+ return xpiState;
+ }
+
+ /**
+ * Adds metadata for a staged install which should be performed after
+ * the next restart.
+ *
+ * @param {string} addonId
+ * The ID of the staged install. The leaf name of the XPI
+ * within the location's staging directory must correspond to
+ * this ID.
+ * @param {object} metadata
+ * The JSON metadata of the parsed install, to be used during
+ * the next startup.
+ */
+ stageAddon(addonId, metadata) {
+ this.staged[addonId] = metadata;
+ XPIStates.save();
+ }
+
+ /**
+ * Removes staged install metadata for the given add-on ID.
+ *
+ * @param {string} addonId
+ * The ID of the staged install.
+ */
+ unstageAddon(addonId) {
+ if (addonId in this.staged) {
+ delete this.staged[addonId];
+ XPIStates.save();
+ }
+ }
+
+ *getStagedAddons() {
+ for (let [id, metadata] of Object.entries(this.staged)) {
+ yield [id, metadata];
+ }
+ }
+
+ /**
+ * Returns true if the given addon was installed in this location by a text
+ * file pointing to its real path.
+ *
+ * @param {string} aId
+ * The ID of the addon
+ * @returns {boolean}
+ */
+ isLinkedAddon(aId) {
+ if (!this.dir) {
+ return true;
+ }
+ return this.has(aId) && !this.dir.contains(this.get(aId).file);
+ }
+
+ get isTemporary() {
+ return false;
+ }
+
+ get isSystem() {
+ return false;
+ }
+
+ get isBuiltin() {
+ return false;
+ }
+
+ get hidden() {
+ return this.isBuiltin;
+ }
+
+ // If this property is false, it does not implement readAddons()
+ // interface. This is used for the temporary and built-in locations
+ // that do not correspond to a physical location that can be scanned.
+ get enumerable() {
+ return true;
+ }
+}
+
+class TemporaryLocation extends XPIStateLocation {
+ /**
+ * @param {string} name
+ * The string identifier for the install location.
+ */
+ constructor(name) {
+ super(name, null, AddonManager.SCOPE_TEMPORARY);
+ this.locked = false;
+ }
+
+ makeInstaller() {
+ // Installs are a no-op. We only register that add-ons exist, and
+ // run them from their current location.
+ return {
+ installAddon() {},
+ uninstallAddon() {},
+ };
+ }
+
+ toJSON() {
+ return {};
+ }
+
+ get isTemporary() {
+ return true;
+ }
+
+ get enumerable() {
+ return false;
+ }
+}
+
+var TemporaryInstallLocation = new TemporaryLocation(KEY_APP_TEMPORARY);
+
+/**
+ * A "location" for addons installed from assets packged into the app.
+ */
+var BuiltInLocation = new (class _BuiltInLocation extends XPIStateLocation {
+ constructor() {
+ super(KEY_APP_BUILTINS, null, AddonManager.SCOPE_APPLICATION);
+ this.locked = false;
+ }
+
+ // The installer object is responsible for moving files around on disk
+ // when (un)installing an addon. Since this location handles only addons
+ // that are embedded within the browser, these are no-ops.
+ makeInstaller() {
+ return {
+ installAddon() {},
+ uninstallAddon() {},
+ };
+ }
+
+ get hidden() {
+ return false;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+
+ get enumerable() {
+ return false;
+ }
+
+ // Builtin addons are never linked, return false
+ // here for correct behavior elsewhere.
+ isLinkedAddon(/* aId */) {
+ return false;
+ }
+})();
+
+/**
+ * An object which identifies a directory install location for add-ons. The
+ * location consists of a directory which contains the add-ons installed in the
+ * location.
+ *
+ */
+class DirectoryLocation extends XPIStateLocation {
+ /**
+ * Each add-on installed in the location is either a directory containing the
+ * add-on's files or a text file containing an absolute path to the directory
+ * containing the add-ons files. The directory or text file must have the same
+ * name as the add-on's ID.
+ *
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {nsIFile} dir
+ * The directory for the install location.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {boolean} [locked = true]
+ * If false, the location accepts new add-on installs.
+ * @param {boolean} [system = false]
+ * If true, the location is a system addon location.
+ */
+ constructor(name, dir, scope, locked = true, system = false) {
+ super(name, dir, scope);
+ this.locked = locked;
+ this._isSystem = system;
+ }
+
+ makeInstaller() {
+ if (this.locked) {
+ return null;
+ }
+ return new XPIExports.XPIInstall.DirectoryInstaller(this);
+ }
+
+ /**
+ * Reads a single-line file containing the path to a directory, and
+ * returns an nsIFile pointing to that directory, if successful.
+ *
+ * @param {nsIFile} aFile
+ * The file containing the directory path
+ * @returns {nsIFile?}
+ * An nsIFile object representing the linked directory, or null
+ * on error.
+ */
+ _readLinkFile(aFile) {
+ let linkedDirectory;
+ if (aFile.isSymlink()) {
+ linkedDirectory = aFile.clone();
+ try {
+ linkedDirectory.normalize();
+ } catch (e) {
+ logger.warn(
+ `Symbolic link ${aFile.path} points to a path ` +
+ `which does not exist`
+ );
+ return null;
+ }
+ } else {
+ let fis = new FileInputStream(aFile, -1, -1, false);
+ let line = {};
+ fis.QueryInterface(Ci.nsILineInputStream).readLine(line);
+ fis.close();
+
+ if (line.value) {
+ linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ try {
+ linkedDirectory.initWithPath(line.value);
+ } catch (e) {
+ linkedDirectory.setRelativeDescriptor(aFile.parent, line.value);
+ }
+ }
+ }
+
+ if (linkedDirectory) {
+ if (!linkedDirectory.exists()) {
+ logger.warn(
+ `File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+ "which does not exist"
+ );
+ return null;
+ }
+
+ if (!linkedDirectory.isDirectory()) {
+ logger.warn(
+ `File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+ "which is not a directory"
+ );
+ return null;
+ }
+
+ return linkedDirectory;
+ }
+
+ logger.warn(`File pointer ${aFile.path} does not contain a path`);
+ return null;
+ }
+
+ /**
+ * Finds all the add-ons installed in this location.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons present in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ if (!this.dir) {
+ return addons;
+ }
+
+ // Use a snapshot of the directory contents to avoid possible issues with
+ // iterating over a directory while removing files from it (the YAFFS2
+ // embedded filesystem has this issue, see bug 772238).
+ for (let entry of Array.from(iterDirectory(this.dir))) {
+ let id = getExpectedID(entry);
+ if (!id) {
+ if (![DIR_STAGE, DIR_TRASH].includes(entry.leafName)) {
+ logger.debug(
+ "Ignoring file: name is not a valid add-on ID: ${}",
+ entry.path
+ );
+ }
+ continue;
+ }
+
+ if (id == entry.leafName && (entry.isFile() || entry.isSymlink())) {
+ let newEntry = this._readLinkFile(entry);
+ if (!newEntry) {
+ logger.debug(`Deleting stale pointer file ${entry.path}`);
+ try {
+ entry.remove(true);
+ } catch (e) {
+ logger.warn(`Failed to remove stale pointer file ${entry.path}`, e);
+ // Failing to remove the stale pointer file is ignorable
+ }
+ continue;
+ }
+
+ entry = newEntry;
+ }
+
+ addons.set(id, entry);
+ }
+ return addons;
+ }
+
+ get isSystem() {
+ return this._isSystem;
+ }
+}
+
+/**
+ * An object which identifies a built-in install location for add-ons, such
+ * as default system add-ons.
+ *
+ * This location should point either to a XPI, or a directory in a local build.
+ */
+class SystemAddonDefaults extends DirectoryLocation {
+ /**
+ * Read the manifest of allowed add-ons and build a mapping between ID and URI
+ * for each.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons present in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ let manifest = XPIProvider.builtInAddons;
+
+ if (!("system" in manifest)) {
+ logger.debug("No list of valid system add-ons found.");
+ return addons;
+ }
+
+ for (let id of manifest.system) {
+ let file = this.dir.clone();
+ file.append(`${id}.xpi`);
+
+ // Only attempt to load unpacked directory if unofficial build.
+ if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) {
+ file = this.dir.clone();
+ file.append(`${id}`);
+ }
+
+ addons.set(id, file);
+ }
+
+ return addons;
+ }
+
+ get isSystem() {
+ return true;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+}
+
+/**
+ * An object which identifies a directory install location for system add-ons
+ * updates.
+ */
+class SystemAddonLocation extends DirectoryLocation {
+ /**
+ * The location consists of a directory which contains the add-ons installed.
+ *
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {nsIFile} dir
+ * The directory for the install location.
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ * @param {boolean} resetSet
+ * True to throw away the current add-on set
+ */
+ constructor(name, dir, scope, resetSet) {
+ let addonSet = SystemAddonLocation._loadAddonSet();
+ let directory = null;
+
+ // The system add-on update directory is stored in a pref.
+ // Therefore, this is looked up before calling the
+ // constructor on the superclass.
+ if (addonSet.directory) {
+ directory = getFile(addonSet.directory, dir);
+ logger.info(`SystemAddonLocation scanning directory ${directory.path}`);
+ } else {
+ logger.info("SystemAddonLocation directory is missing");
+ }
+
+ super(name, directory, scope, false);
+
+ this._addonSet = addonSet;
+ this._baseDir = dir;
+
+ if (resetSet) {
+ this.installer.resetAddonSet();
+ }
+ }
+
+ makeInstaller() {
+ if (this.locked) {
+ return null;
+ }
+ return new XPIExports.XPIInstall.SystemAddonInstaller(this);
+ }
+
+ /**
+ * Reads the current set of system add-ons
+ *
+ * @returns {Object}
+ */
+ static _loadAddonSet() {
+ try {
+ let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null);
+ if (setStr) {
+ let addonSet = JSON.parse(setStr);
+ if (typeof addonSet == "object" && addonSet.schema == 1) {
+ return addonSet;
+ }
+ }
+ } catch (e) {
+ logger.error("Malformed system add-on set, resetting.");
+ }
+
+ return { schema: 1, addons: {} };
+ }
+
+ readAddons() {
+ // Updated system add-ons are ignored in safe mode
+ if (Services.appinfo.inSafeMode) {
+ return new Map();
+ }
+
+ let addons = super.readAddons();
+
+ // Strip out any unexpected add-ons from the list
+ for (let id of addons.keys()) {
+ if (!(id in this._addonSet.addons)) {
+ addons.delete(id);
+ }
+ }
+
+ return addons;
+ }
+
+ /**
+ * Tests whether updated system add-ons are expected.
+ *
+ * @returns {boolean}
+ */
+ isActive() {
+ return this.dir != null;
+ }
+
+ get isSystem() {
+ return true;
+ }
+
+ get isBuiltin() {
+ return true;
+ }
+}
+
+/**
+ * An object that identifies a registry install location for add-ons. The location
+ * consists of a registry key which contains string values mapping ID to the
+ * path where an add-on is installed
+ *
+ */
+class WinRegLocation extends XPIStateLocation {
+ /**
+ * @param {string} name
+ * The string identifier for the install location.
+ * @param {integer} rootKey
+ * The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey).
+ * @param {integer} scope
+ * The scope of add-ons installed in this location.
+ */
+ constructor(name, rootKey, scope) {
+ super(name, undefined, scope);
+
+ this.locked = true;
+ this._rootKey = rootKey;
+ }
+
+ /**
+ * Retrieves the path of this Application's data key in the registry.
+ */
+ get _appKeyPath() {
+ let appVendor = Services.appinfo.vendor;
+ let appName = Services.appinfo.name;
+
+ // XXX Thunderbird doesn't specify a vendor string
+ if (appVendor == "" && AppConstants.MOZ_APP_NAME == "thunderbird") {
+ appVendor = "Mozilla";
+ }
+
+ return `SOFTWARE\\${appVendor}\\${appName}`;
+ }
+
+ /**
+ * Read the registry and build a mapping between ID and path for each
+ * installed add-on.
+ *
+ * @returns {Map<AddonID, nsIFile>}
+ * A map of add-ons in this location.
+ */
+ readAddons() {
+ let addons = new Map();
+
+ let path = `${this._appKeyPath}\\Extensions`;
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+
+ // Reading the registry may throw an exception, and that's ok. In error
+ // cases, we just leave ourselves in the empty state.
+ try {
+ key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ);
+ } catch (e) {
+ return addons;
+ }
+
+ try {
+ let count = key.valueCount;
+ for (let i = 0; i < count; ++i) {
+ let id = key.getValueName(i);
+ let file = new nsIFile(key.readStringValue(id));
+ if (!file.exists()) {
+ logger.warn(`Ignoring missing add-on in ${file.path}`);
+ continue;
+ }
+
+ addons.set(id, file);
+ }
+ } finally {
+ key.close();
+ }
+
+ return addons;
+ }
+}
+
+/**
+ * Keeps track of the state of XPI add-ons on the file system.
+ */
+var XPIStates = {
+ // Map(location-name -> XPIStateLocation)
+ db: new Map(),
+
+ _jsonFile: null,
+
+ /**
+ * @property {Map<string, XPIState>} sideLoadedAddons
+ * A map of new add-ons detected during install location
+ * directory scans. Keys are add-on IDs, values are XPIState
+ * objects corresponding to those add-ons.
+ */
+ sideLoadedAddons: new Map(),
+
+ get size() {
+ let count = 0;
+ for (let location of this.locations()) {
+ count += location.size;
+ }
+ return count;
+ },
+
+ /**
+ * Load extension state data from addonStartup.json.
+ *
+ * @returns {Object}
+ */
+ loadExtensionState() {
+ let state;
+ try {
+ state = lazy.aomStartup.readStartupData();
+ } catch (e) {
+ logger.warn("Error parsing extensions state: ${error}", { error: e });
+ }
+
+ // When upgrading from a build prior to bug 857456, convert startup
+ // metadata.
+ let done = false;
+ for (let location of Object.values(state || {})) {
+ for (let data of Object.values(location.addons || {})) {
+ if (!migrateAddonLoader(data)) {
+ done = true;
+ break;
+ }
+ }
+ if (done) {
+ break;
+ }
+ }
+
+ logger.debug("Loaded add-on state: ${}", state);
+ return state || {};
+ },
+
+ /**
+ * Walk through all install locations, highest priority first,
+ * comparing the on-disk state of extensions to what is stored in prefs.
+ *
+ * @param {boolean} [ignoreSideloads = true]
+ * If true, ignore changes in scopes where we don't accept
+ * side-loads.
+ *
+ * @returns {boolean}
+ * True if anything has changed.
+ */
+ scanForChanges(ignoreSideloads = true) {
+ let oldState = this.initialStateData || this.loadExtensionState();
+ // We're called twice, do not restore the second time as new data
+ // may have been inserted since the first call.
+ let shouldRestoreLocationData = !this.initialStateData;
+ this.initialStateData = oldState;
+
+ let changed = false;
+ let oldLocations = new Set(Object.keys(oldState));
+
+ let startupScanScopes;
+ if (
+ Services.appinfo.appBuildID ==
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, "")
+ ) {
+ startupScanScopes = Services.prefs.getIntPref(
+ PREF_EM_STARTUP_SCAN_SCOPES,
+ 0
+ );
+ } else {
+ // If the build id has changed, we need to do a full scan on first startup.
+ Services.prefs.setCharPref(
+ PREF_EM_LAST_APP_BUILD_ID,
+ Services.appinfo.appBuildID
+ );
+ startupScanScopes = AddonManager.SCOPE_ALL;
+ }
+
+ for (let loc of XPIStates.locations()) {
+ oldLocations.delete(loc.name);
+
+ if (shouldRestoreLocationData && oldState[loc.name]) {
+ loc.restore(oldState[loc.name]);
+ }
+ changed = changed || loc.changed;
+
+ // Don't bother checking scopes where we don't accept side-loads.
+ if (ignoreSideloads && !(loc.scope & startupScanScopes)) {
+ continue;
+ }
+
+ if (!loc.enumerable) {
+ continue;
+ }
+
+ // Don't bother scanning scopes where we don't have addons installed if they
+ // do not allow sideloading new addons. Once we have an addon in one of those
+ // locations, we need to check the location for changes (updates/deletions).
+ if (!loc.size && !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)) {
+ continue;
+ }
+
+ let knownIds = new Set(loc.keys());
+ for (let [id, file] of loc.readAddons()) {
+ knownIds.delete(id);
+
+ let xpiState = loc.get(id);
+ if (!xpiState) {
+ // If the location is not supported for sideloading, skip new
+ // addons. We handle this here so changes for existing sideloads
+ // will function.
+ if (
+ !loc.isSystem &&
+ !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)
+ ) {
+ continue;
+ }
+ logger.debug("New add-on ${id} in ${loc}", { id, loc: loc.name });
+
+ changed = true;
+ xpiState = loc.addFile(id, file);
+ if (!loc.isSystem) {
+ this.sideLoadedAddons.set(id, xpiState);
+ }
+ } else {
+ let addonChanged =
+ xpiState.getModTime(file) || file.path != xpiState.path;
+ xpiState.file = file.clone();
+
+ if (addonChanged) {
+ changed = true;
+ logger.debug("Changed add-on ${id} in ${loc}", {
+ id,
+ loc: loc.name,
+ });
+ } else {
+ logger.debug("Existing add-on ${id} in ${loc}", {
+ id,
+ loc: loc.name,
+ });
+ }
+ }
+ XPIProvider.addTelemetry(id, { location: loc.name });
+ }
+
+ // Anything left behind in oldState was removed from the file system.
+ for (let id of knownIds) {
+ loc.delete(id);
+ changed = true;
+ }
+ }
+
+ // If there's anything left in oldState, an install location that held add-ons
+ // was removed from the browser configuration.
+ changed = changed || oldLocations.size > 0;
+
+ logger.debug("scanForChanges changed: ${rv}, state: ${state}", {
+ rv: changed,
+ state: this.db,
+ });
+ return changed;
+ },
+
+ locations() {
+ return this.db.values();
+ },
+
+ /**
+ * @param {string} name
+ * The location name.
+ * @param {XPIStateLocation} location
+ * The location object.
+ */
+ addLocation(name, location) {
+ if (this.db.has(name)) {
+ throw new Error(`Trying to add duplicate location: ${name}`);
+ }
+ this.db.set(name, location);
+ },
+
+ /**
+ * Get the Map of XPI states for a particular location.
+ *
+ * @param {string} name
+ * The name of the install location.
+ *
+ * @returns {XPIStateLocation?}
+ * (id -> XPIState) or null if there are no add-ons in the location.
+ */
+ getLocation(name) {
+ return this.db.get(name);
+ },
+
+ /**
+ * Get the XPI state for a specific add-on in a location.
+ * If the state is not in our cache, return null.
+ *
+ * @param {string} aLocation
+ * The name of the location where the add-on is installed.
+ * @param {string} aId
+ * The add-on ID
+ *
+ * @returns {XPIState?}
+ * The XPIState entry for the add-on, or null.
+ */
+ getAddon(aLocation, aId) {
+ let location = this.db.get(aLocation);
+ return location && location.get(aId);
+ },
+
+ /**
+ * Find the highest priority location of an add-on by ID and return the
+ * XPIState.
+ * @param {string} aId
+ * The add-on IDa
+ * @param {function} aFilter
+ * An optional filter to apply to install locations. If provided,
+ * addons in locations that do not match the filter are not considered.
+ *
+ * @returns {XPIState?}
+ */
+ findAddon(aId, aFilter = location => true) {
+ // Fortunately the Map iterator returns in order of insertion, which is
+ // also our highest -> lowest priority order.
+ for (let location of this.locations()) {
+ if (!aFilter(location)) {
+ continue;
+ }
+ if (location.has(aId)) {
+ return location.get(aId);
+ }
+ }
+ return undefined;
+ },
+
+ /**
+ * Iterates over the list of all enabled add-ons in any location.
+ */
+ *enabledAddons() {
+ for (let location of this.locations()) {
+ for (let entry of location.values()) {
+ if (entry.enabled) {
+ yield entry;
+ }
+ }
+ }
+ },
+
+ /**
+ * Add a new XPIState for an add-on and synchronize it with the DBAddonInternal.
+ *
+ * @param {DBAddonInternal} aAddon
+ * The add-on to add.
+ */
+ addAddon(aAddon) {
+ aAddon.location.addAddon(aAddon);
+ },
+
+ /**
+ * Save the current state of installed add-ons.
+ */
+ save() {
+ if (!this._jsonFile) {
+ this._jsonFile = new lazy.JSONFile({
+ path: PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ FILE_XPI_STATES
+ ),
+ finalizeAt: AddonManagerPrivate.finalShutdown,
+ compression: "lz4",
+ });
+ this._jsonFile.data = this;
+ }
+
+ this._jsonFile.saveSoon();
+ },
+
+ toJSON() {
+ let data = {};
+ for (let [key, loc] of this.db.entries()) {
+ if (!loc.isTemporary && (loc.size || loc.hasStaged)) {
+ data[key] = loc;
+ }
+ }
+ return data;
+ },
+
+ /**
+ * Remove the XPIState for an add-on and save the new state.
+ *
+ * @param {string} aLocation
+ * The name of the add-on location.
+ * @param {string} aId
+ * The ID of the add-on.
+ *
+ */
+ removeAddon(aLocation, aId) {
+ logger.debug(`Removing XPIState for ${aLocation}: ${aId}`);
+ let location = this.db.get(aLocation);
+ if (location) {
+ location.removeAddon(aId);
+ this.save();
+ }
+ },
+
+ /**
+ * Disable the XPIState for an add-on.
+ *
+ * @param {string} aId
+ * The ID of the add-on.
+ */
+ disableAddon(aId) {
+ logger.debug(`Disabling XPIState for ${aId}`);
+ let state = this.findAddon(aId);
+ if (state) {
+ state.enabled = false;
+ }
+ },
+};
+
+/**
+ * A helper class to manage the lifetime of and interaction with
+ * bootstrap scopes for an add-on.
+ *
+ * @param {Object} addon
+ * The add-on which owns this scope. Should be either an
+ * AddonInternal or XPIState object.
+ */
+class BootstrapScope {
+ constructor(addon) {
+ if (!addon.id || !addon.version || !addon.type) {
+ throw new Error("Addon must include an id, version, and type");
+ }
+
+ this.addon = addon;
+ this.instanceID = null;
+ this.scope = null;
+ this.started = false;
+ }
+
+ /**
+ * Returns a BootstrapScope object for the given add-on. If an active
+ * scope exists, it is returned. Otherwise a new one is created.
+ *
+ * @param {Object} addon
+ * The add-on which owns this scope, as accepted by the
+ * constructor.
+ * @returns {BootstrapScope}
+ */
+ static get(addon) {
+ let scope = XPIProvider.activeAddons.get(addon.id);
+ if (!scope) {
+ scope = new this(addon);
+ }
+ return scope;
+ }
+
+ get file() {
+ return this.addon.file || this.addon._sourceBundle;
+ }
+
+ get runInSafeMode() {
+ return "runInSafeMode" in this.addon
+ ? this.addon.runInSafeMode
+ : canRunInSafeMode(this.addon);
+ }
+
+ /**
+ * Returns state information for use by an AsyncShutdown blocker. If
+ * the wrapped bootstrap scope has a fetchState method, it is called,
+ * and its result returned. If not, returns null.
+ *
+ * @returns {Object|null}
+ */
+ fetchState() {
+ if (this.scope && this.scope.fetchState) {
+ return this.scope.fetchState();
+ }
+ return null;
+ }
+
+ /**
+ * Calls a bootstrap method for an add-on.
+ *
+ * @param {string} aMethod
+ * The name of the bootstrap method to call
+ * @param {integer} aReason
+ * The reason flag to pass to the bootstrap's startup method
+ * @param {Object} [aExtraParams = {}]
+ * An object of additional key/value pairs to pass to the method in
+ * the params argument
+ * @returns {any}
+ * The return value of the bootstrap method.
+ */
+ async callBootstrapMethod(aMethod, aReason, aExtraParams = {}) {
+ let { addon, runInSafeMode } = this;
+ if (
+ Services.appinfo.inSafeMode &&
+ !runInSafeMode &&
+ aMethod !== "uninstall"
+ ) {
+ return null;
+ }
+
+ try {
+ if (!this.scope) {
+ this.loadBootstrapScope(aReason);
+ }
+
+ if (aMethod == "startup" || aMethod == "shutdown") {
+ aExtraParams.instanceID = this.instanceID;
+ }
+
+ let method = undefined;
+ let { scope } = this;
+ try {
+ method = scope[aMethod];
+ } catch (e) {
+ // An exception will be caught if the expected method is not defined.
+ // That will be logged below.
+ }
+
+ if (aMethod == "startup") {
+ this.started = true;
+ } else if (aMethod == "shutdown") {
+ this.started = false;
+
+ // Extensions are automatically deinitialized in the correct order at shutdown.
+ if (aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+ this._pendingDisable = true;
+ for (let addon of XPIProvider.getDependentAddons(this.addon)) {
+ if (addon.active) {
+ await XPIExports.XPIDatabase.updateAddonDisabledState(addon);
+ }
+ }
+ }
+ }
+
+ let params = {
+ id: addon.id,
+ version: addon.version,
+ resourceURI: addon.resolvedRootURI,
+ signedState: addon.signedState,
+ temporarilyInstalled: addon.location.isTemporary,
+ builtIn: addon.location.isBuiltin,
+ isSystem: addon.location.isSystem,
+ isPrivileged: addon.isPrivileged,
+ locationHidden: addon.location.hidden,
+ recommendationState: addon.recommendationState,
+ };
+
+ if (aMethod == "startup" && addon.startupData) {
+ params.startupData = addon.startupData;
+ }
+
+ Object.assign(params, aExtraParams);
+
+ let result;
+ if (!method) {
+ logger.warn(
+ `Add-on ${addon.id} is missing bootstrap method ${aMethod}`
+ );
+ } else {
+ logger.debug(
+ `Calling bootstrap method ${aMethod} on ${addon.id} version ${addon.version}`
+ );
+
+ this._beforeCallBootstrapMethod(aMethod, params, aReason);
+
+ try {
+ result = await method.call(scope, params, aReason);
+ } catch (e) {
+ logger.warn(
+ `Exception running bootstrap method ${aMethod} on ${addon.id}`,
+ e
+ );
+ }
+ }
+ return result;
+ } finally {
+ // Extensions are automatically initialized in the correct order at startup.
+ if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) {
+ for (let addon of XPIProvider.getDependentAddons(this.addon)) {
+ XPIExports.XPIDatabase.updateAddonDisabledState(addon);
+ }
+ }
+ }
+ }
+
+ // No-op method to be overridden by tests.
+ _beforeCallBootstrapMethod() {}
+
+ /**
+ * Loads a bootstrapped add-on's bootstrap.js into a sandbox and the reason
+ * values as constants in the scope.
+ *
+ * @param {integer?} [aReason]
+ * The reason this bootstrap is being loaded, as passed to a
+ * bootstrap method.
+ */
+ loadBootstrapScope(aReason) {
+ this.instanceID = Symbol(this.addon.id);
+ this._pendingDisable = false;
+
+ XPIProvider.activeAddons.set(this.addon.id, this);
+
+ // Mark the add-on as active for the crash reporter before loading.
+ // But not at app startup, since we'll already have added all of our
+ // annotations before starting any loads.
+ if (aReason !== BOOTSTRAP_REASONS.APP_STARTUP) {
+ XPIProvider.addAddonsToCrashReporter();
+ }
+
+ logger.debug(`Loading bootstrap scope from ${this.addon.rootURI}`);
+
+ if (this.addon.isWebExtension) {
+ switch (this.addon.type) {
+ case "extension":
+ case "theme":
+ this.scope = lazy.Extension.getBootstrapScope();
+ break;
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed.
+ case "sitepermission-deprecated":
+ this.scope = lazy.SitePermission.getBootstrapScope();
+ break;
+
+ case "locale":
+ this.scope = lazy.Langpack.getBootstrapScope();
+ break;
+
+ case "dictionary":
+ this.scope = lazy.Dictionary.getBootstrapScope();
+ break;
+
+ default:
+ throw new Error(`Unknown webextension type ${this.addon.type}`);
+ }
+ } else {
+ let loader = AddonManagerPrivate.externalExtensionLoaders.get(
+ this.addon.loader
+ );
+ if (!loader) {
+ throw new Error(`Cannot find loader for ${this.addon.loader}`);
+ }
+
+ this.scope = loader.loadScope(this.addon);
+ }
+ }
+
+ /**
+ * Unloads a bootstrap scope by dropping all references to it and then
+ * updating the list of active add-ons with the crash reporter.
+ */
+ unloadBootstrapScope() {
+ XPIProvider.activeAddons.delete(this.addon.id);
+ XPIProvider.addAddonsToCrashReporter();
+
+ this.scope = null;
+ this.startupPromise = null;
+ this.instanceID = null;
+ }
+
+ /**
+ * Calls the bootstrap scope's startup method, with the given reason
+ * and extra parameters.
+ *
+ * @param {integer} reason
+ * The reason code for the startup call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the startup method has run to completion, rejects
+ * if called late during shutdown.
+ */
+ async startup(reason, aExtraParams) {
+ if (this.shutdownPromise) {
+ await this.shutdownPromise;
+ }
+
+ if (
+ Services.startup.isInOrBeyondShutdownPhase(
+ Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ )
+ ) {
+ let err = new Error(
+ `XPIProvider can't start bootstrap scope for ${this.addon.id} after shutdown was already granted`
+ );
+ logger.warn("BoostrapScope startup failure: ${error}", { error: err });
+ this.startupPromise = Promise.reject(err);
+ } else {
+ this.startupPromise = this.callBootstrapMethod(
+ "startup",
+ reason,
+ aExtraParams
+ );
+ }
+
+ return this.startupPromise;
+ }
+
+ /**
+ * Calls the bootstrap scope's shutdown method, with the given reason
+ * and extra parameters.
+ *
+ * @param {integer} reason
+ * The reason code for the shutdown call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ */
+ async shutdown(reason, aExtraParams) {
+ this.shutdownPromise = this._shutdown(reason, aExtraParams);
+ await this.shutdownPromise;
+ this.shutdownPromise = null;
+ }
+
+ async _shutdown(reason, aExtraParams) {
+ await this.startupPromise;
+ return this.callBootstrapMethod("shutdown", reason, aExtraParams);
+ }
+
+ /**
+ * If the add-on is already running, calls its "shutdown" method, and
+ * unloads its bootstrap scope.
+ *
+ * @param {integer} reason
+ * The reason code for the shutdown call.
+ * @param {Object} [aExtraParams]
+ * Optional extra parameters to pass to the bootstrap method.
+ */
+ async disable() {
+ if (this.started) {
+ await this.shutdown(BOOTSTRAP_REASONS.ADDON_DISABLE);
+ // If we disable and re-enable very quickly, it's possible that
+ // the next startup() method will be called immediately after this
+ // shutdown method finishes. This almost never happens outside of
+ // tests. In tests, alas...
+ if (!this.started) {
+ this.unloadBootstrapScope();
+ }
+ }
+ }
+
+ /**
+ * Calls the bootstrap scope's install method, and optionally its
+ * startup method.
+ *
+ * @param {integer} reason
+ * The reason code for the calls.
+ * @param {boolean} [startup = false]
+ * If true, and the add-on is active, calls its startup method
+ * after its install method.
+ * @param {Object} [extraArgs]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the startup method has run to completion, if
+ * startup is required.
+ */
+ install(reason = BOOTSTRAP_REASONS.ADDON_INSTALL, startup, extraArgs) {
+ return this._install(reason, false, startup, extraArgs);
+ }
+
+ async _install(reason, callUpdate, startup, extraArgs) {
+ if (callUpdate) {
+ await this.callBootstrapMethod("update", reason, extraArgs);
+ } else {
+ this.callBootstrapMethod("install", reason, extraArgs);
+ }
+
+ if (startup && this.addon.active) {
+ await this.startup(reason, extraArgs);
+ } else if (this.addon.disabled) {
+ this.unloadBootstrapScope();
+ }
+ }
+
+ /**
+ * Calls the bootstrap scope's uninstall method, and unloads its
+ * bootstrap scope. If the extension is already running, its shutdown
+ * method is called before its uninstall method.
+ *
+ * @param {integer} reason
+ * The reason code for the calls.
+ * @param {Object} [extraArgs]
+ * Optional extra parameters to pass to the bootstrap method.
+ * @returns {Promise}
+ * Resolves when the shutdown method has run to completion, if
+ * shutdown is required, and the uninstall method has been
+ * called.
+ */
+ uninstall(reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL, extraArgs) {
+ return this._uninstall(reason, false, extraArgs);
+ }
+
+ async _uninstall(reason, callUpdate, extraArgs) {
+ if (this.started) {
+ await this.shutdown(reason, extraArgs);
+ }
+ if (!callUpdate) {
+ this.callBootstrapMethod("uninstall", reason, extraArgs);
+ }
+ this.unloadBootstrapScope();
+
+ if (this.file) {
+ XPIExports.XPIInstall.flushJarCache(this.file);
+ }
+ }
+
+ /**
+ * Calls the appropriate sequence of shutdown, uninstall, update,
+ * startup, and install methods for updating the current scope's
+ * add-on to the given new add-on, depending on the current state of
+ * the scope.
+ *
+ * @param {XPIState} newAddon
+ * The new add-on which is being installed, as expected by the
+ * constructor.
+ * @param {boolean} [startup = false]
+ * If true, and the new add-on is enabled, calls its startup
+ * method as its final operation.
+ * @param {function} [updateCallback]
+ * An optional callback function to call between uninstalling
+ * the old add-on and installing the new one. This callback
+ * should update any database state which is necessary for the
+ * startup of the new add-on.
+ * @returns {Promise}
+ * Resolves when all required bootstrap callbacks have
+ * completed.
+ */
+ async update(newAddon, startup = false, updateCallback) {
+ let reason = XPIExports.XPIInstall.newVersionReason(
+ this.addon.version,
+ newAddon.version
+ );
+
+ let callUpdate = this.addon.isWebExtension && newAddon.isWebExtension;
+
+ // BootstrapScope gets either an XPIState instance or an AddonInternal
+ // instance, when we update, we need the latter to access permissions
+ // from the manifest.
+ let existingAddon = this.addon;
+
+ let extraArgs = {
+ oldVersion: existingAddon.version,
+ newVersion: newAddon.version,
+ };
+
+ // If we're updating an extension, we may need to read data to
+ // calculate permission changes.
+ if (callUpdate && existingAddon.type === "extension") {
+ if (this.addon instanceof XPIState) {
+ // The existing addon will be cached in the database.
+ existingAddon = await XPIExports.XPIDatabase.getAddonByID(
+ this.addon.id
+ );
+ }
+
+ if (newAddon instanceof XPIState) {
+ newAddon = await XPIExports.XPIInstall.loadManifestFromFile(
+ newAddon.file,
+ newAddon.location
+ );
+ }
+
+ Object.assign(extraArgs, {
+ userPermissions: newAddon.userPermissions,
+ optionalPermissions: newAddon.optionalPermissions,
+ oldPermissions: existingAddon.userPermissions,
+ oldOptionalPermissions: existingAddon.optionalPermissions,
+ });
+ }
+
+ await this._uninstall(reason, callUpdate, extraArgs);
+
+ if (updateCallback) {
+ await updateCallback();
+ }
+
+ this.addon = newAddon;
+ return this._install(reason, callUpdate, startup, extraArgs);
+ }
+}
+
+let resolveDBReady;
+let dbReadyPromise = new Promise(resolve => {
+ resolveDBReady = resolve;
+});
+let resolveProviderReady;
+let providerReadyPromise = new Promise(resolve => {
+ resolveProviderReady = resolve;
+});
+
+export var XPIProvider = {
+ get name() {
+ return "XPIProvider";
+ },
+
+ BOOTSTRAP_REASONS: Object.freeze(BOOTSTRAP_REASONS),
+
+ // A Map of active addons to their bootstrapScope by ID
+ activeAddons: new Map(),
+ // Per-addon telemetry information
+ _telemetryDetails: {},
+ // Have we started shutting down bootstrap add-ons?
+ _closing: false,
+
+ // Promises awaited by the XPIProvider before resolving providerReadyPromise,
+ // (pushed into the array by XPIProvider maybeInstallBuiltinAddon and startup
+ // methods).
+ startupPromises: [],
+
+ // Array of the bootstrap startup promises for the enabled addons being
+ // initiated during the XPIProvider startup.
+ //
+ // NOTE: XPIProvider will wait for these promises (and the startupPromises one)
+ // to have settled before allowing the application to proceed with shutting down
+ // (see quitApplicationGranted blocker at the end of the XPIProvider.startup).
+ enabledAddonsStartupPromises: [],
+
+ databaseReady: Promise.all([dbReadyPromise, providerReadyPromise]),
+
+ registerProvider() {
+ AddonManagerPrivate.registerProvider(this, Array.from(ALL_XPI_TYPES));
+ },
+
+ // Check if the XPIDatabase has been loaded (without actually
+ // triggering unwanted imports or I/O)
+ get isDBLoaded() {
+ // Make sure we don't touch the XPIDatabase getter before it's
+ // actually loaded, and force an early load.
+ return (
+ (Object.getOwnPropertyDescriptor(XPIExports, "XPIDatabase").value &&
+ XPIExports.XPIDatabase.initialized) ||
+ false
+ );
+ },
+
+ /**
+ * Returns true if the add-on with the given ID is currently active,
+ * without forcing the add-ons database to load.
+ *
+ * @param {string} addonId
+ * The ID of the add-on to check.
+ * @returns {boolean}
+ */
+ addonIsActive(addonId) {
+ let state = XPIStates.findAddon(addonId);
+ return state && state.enabled;
+ },
+
+ /**
+ * Returns an array of the add-on values in `enabledAddons`,
+ * sorted so that all of an add-on's dependencies appear in the array
+ * before itself.
+ *
+ * @returns {Array<object>}
+ * A sorted array of add-on objects. Each value is a copy of the
+ * corresponding value in the `enabledAddons` object, with an
+ * additional `id` property, which corresponds to the key in that
+ * object, which is the same as the add-ons ID.
+ */
+ sortBootstrappedAddons() {
+ function compare(a, b) {
+ if (a === b) {
+ return 0;
+ }
+ return a < b ? -1 : 1;
+ }
+
+ // Sort the list so that ordering is deterministic.
+ let list = Array.from(XPIStates.enabledAddons());
+ list.sort((a, b) => compare(a.id, b.id));
+
+ let addons = {};
+ for (let entry of list) {
+ addons[entry.id] = entry;
+ }
+
+ let res = new Set();
+ let seen = new Set();
+
+ let add = addon => {
+ seen.add(addon.id);
+
+ for (let id of addon.dependencies || []) {
+ if (id in addons && !seen.has(id)) {
+ add(addons[id]);
+ }
+ }
+
+ res.add(addon.id);
+ };
+
+ Object.values(addons).forEach(add);
+
+ return Array.from(res, id => addons[id]);
+ },
+
+ /*
+ * Adds metadata to the telemetry payload for the given add-on.
+ */
+ addTelemetry(aId, aPayload) {
+ if (!this._telemetryDetails[aId]) {
+ this._telemetryDetails[aId] = {};
+ }
+ Object.assign(this._telemetryDetails[aId], aPayload);
+ },
+
+ setupInstallLocations(aAppChanged) {
+ function DirectoryLoc(aName, aScope, aKey, aPaths, aLocked, aIsSystem) {
+ try {
+ var dir = lazy.FileUtils.getDir(aKey, aPaths);
+ } catch (e) {
+ return null;
+ }
+ return new DirectoryLocation(aName, dir, aScope, aLocked, aIsSystem);
+ }
+
+ function SystemDefaultsLoc(name, scope, key, paths) {
+ try {
+ var dir = lazy.FileUtils.getDir(key, paths);
+ } catch (e) {
+ return null;
+ }
+ return new SystemAddonDefaults(name, dir, scope);
+ }
+
+ function SystemLoc(aName, aScope, aKey, aPaths) {
+ try {
+ var dir = lazy.FileUtils.getDir(aKey, aPaths);
+ } catch (e) {
+ return null;
+ }
+ return new SystemAddonLocation(aName, dir, aScope, aAppChanged !== false);
+ }
+
+ function RegistryLoc(aName, aScope, aKey) {
+ if ("nsIWindowsRegKey" in Ci) {
+ return new WinRegLocation(aName, Ci.nsIWindowsRegKey[aKey], aScope);
+ }
+ }
+
+ // These must be in order of priority, highest to lowest,
+ // for processFileChanges etc. to work
+ let locations = [
+ [() => TemporaryInstallLocation, TemporaryInstallLocation.name, null],
+
+ [
+ DirectoryLoc,
+ KEY_APP_PROFILE,
+ AddonManager.SCOPE_PROFILE,
+ KEY_PROFILEDIR,
+ [DIR_EXTENSIONS],
+ false,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_PROFILE,
+ AddonManager.SCOPE_APPLICATION,
+ KEY_PROFILEDIR,
+ [DIR_APP_SYSTEM_PROFILE],
+ false,
+ true,
+ ],
+
+ [
+ SystemLoc,
+ KEY_APP_SYSTEM_ADDONS,
+ AddonManager.SCOPE_PROFILE,
+ KEY_PROFILEDIR,
+ [DIR_SYSTEM_ADDONS],
+ ],
+
+ [
+ SystemDefaultsLoc,
+ KEY_APP_SYSTEM_DEFAULTS,
+ AddonManager.SCOPE_PROFILE,
+ KEY_APP_FEATURES,
+ [],
+ ],
+
+ [() => BuiltInLocation, KEY_APP_BUILTINS, AddonManager.SCOPE_APPLICATION],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_USER,
+ AddonManager.SCOPE_USER,
+ "XREUSysExt",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ RegistryLoc,
+ "winreg-app-user",
+ AddonManager.SCOPE_USER,
+ "ROOT_KEY_CURRENT_USER",
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_GLOBAL,
+ AddonManager.SCOPE_APPLICATION,
+ KEY_ADDON_APP_DIR,
+ [DIR_EXTENSIONS],
+ true,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_SHARE,
+ AddonManager.SCOPE_SYSTEM,
+ "XRESysSExtPD",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ DirectoryLoc,
+ KEY_APP_SYSTEM_LOCAL,
+ AddonManager.SCOPE_SYSTEM,
+ "XRESysLExtPD",
+ [Services.appinfo.ID],
+ true,
+ ],
+
+ [
+ RegistryLoc,
+ "winreg-app-global",
+ AddonManager.SCOPE_SYSTEM,
+ "ROOT_KEY_LOCAL_MACHINE",
+ ],
+ ];
+
+ for (let [constructor, name, scope, ...args] of locations) {
+ if (!scope || lazy.enabledScopes & scope) {
+ try {
+ let loc = constructor(name, scope, ...args);
+ if (loc) {
+ XPIStates.addLocation(name, loc);
+ }
+ } catch (e) {
+ logger.warn(
+ `Failed to add ${constructor.name} install location ${name}`,
+ e
+ );
+ }
+ }
+ }
+ },
+
+ /**
+ * Registers the built-in set of dictionaries with the spell check
+ * service.
+ */
+ registerBuiltinDictionaries() {
+ this.dictionaries = {};
+ for (let [lang, path] of Object.entries(
+ this.builtInAddons.dictionaries || {}
+ )) {
+ path = path.slice(0, -4) + ".aff";
+ let uri = Services.io.newURI(`resource://gre/${path}`);
+
+ this.dictionaries[lang] = uri;
+ lazy.spellCheck.addDictionary(lang, uri);
+ }
+ },
+
+ /**
+ * Unregisters the dictionaries in the given object, and re-registers
+ * any built-in dictionaries in their place, when they exist.
+ *
+ * @param {Object<nsIURI>} aDicts
+ * An object containing a property with a dictionary language
+ * code and a nsIURI value for each dictionary to be
+ * unregistered.
+ */
+ unregisterDictionaries(aDicts) {
+ let origDicts = lazy.spellCheck.dictionaries.slice();
+ let toRemove = [];
+
+ for (let [lang, uri] of Object.entries(aDicts)) {
+ if (
+ lazy.spellCheck.removeDictionary(lang, uri) &&
+ this.dictionaries.hasOwnProperty(lang)
+ ) {
+ lazy.spellCheck.addDictionary(lang, this.dictionaries[lang]);
+ } else {
+ toRemove.push(lang);
+ }
+ }
+
+ lazy.spellCheck.dictionaries = origDicts.filter(
+ lang => !toRemove.includes(lang)
+ );
+ },
+
+ /**
+ * Starts the XPI provider initializes the install locations and prefs.
+ *
+ * @param {boolean?} aAppChanged
+ * A tri-state value. Undefined means the current profile was created
+ * for this session, true means the profile already existed but was
+ * last used with an application with a different version number,
+ * false means that the profile was last used by this version of the
+ * application.
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ */
+ startup(aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ try {
+ AddonManagerPrivate.recordTimestamp("XPI_startup_begin");
+
+ logger.debug("startup");
+
+ this.builtInAddons = {};
+ try {
+ let url = Services.io.newURI(BUILT_IN_ADDONS_URI);
+ let data = Cu.readUTF8URI(url);
+ this.builtInAddons = JSON.parse(data);
+ } catch (e) {
+ if (AppConstants.DEBUG) {
+ logger.debug("List of built-in add-ons is missing or invalid.", e);
+ }
+ }
+
+ this.registerBuiltinDictionaries();
+
+ // Clear this at startup for xpcshell test restarts
+ this._telemetryDetails = {};
+ // Register our details structure with AddonManager
+ AddonManagerPrivate.setTelemetryDetails("XPI", this._telemetryDetails);
+
+ this.setupInstallLocations(aAppChanged);
+
+ if (!AppConstants.MOZ_REQUIRE_SIGNING || Cu.isInAutomation) {
+ Services.prefs.addObserver(PREF_XPI_SIGNATURES_REQUIRED, this);
+ }
+ Services.prefs.addObserver(PREF_LANGPACK_SIGNATURES, this);
+ Services.obs.addObserver(this, NOTIFICATION_FLUSH_PERMISSIONS);
+
+ this.checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion);
+
+ AddonManagerPrivate.markProviderSafe(this);
+
+ const lastTheme = Services.prefs.getCharPref(
+ "extensions.activeThemeID",
+ null
+ );
+
+ if (
+ lastTheme === "recommended-1" ||
+ lastTheme === "recommended-2" ||
+ lastTheme === "recommended-3" ||
+ lastTheme === "recommended-4" ||
+ lastTheme === "recommended-5"
+ ) {
+ // The user is using a theme that was once bundled with Firefox, but no longer
+ // is. Clear their theme so that they will be forced to reset to the default.
+ this.startupPromises.push(
+ AddonManagerPrivate.notifyAddonChanged(null, "theme")
+ );
+ }
+ this.maybeInstallBuiltinAddon(
+ "default-theme@mozilla.org",
+ "1.3",
+ "resource://default-theme/"
+ );
+
+ resolveProviderReady(Promise.all(this.startupPromises));
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ // Annotate the crash report with relevant add-on information.
+ try {
+ // The `EMCheckCompatibility` annotation represents a boolean, but
+ // we've historically set it as a string so keep doing it for the
+ // time being.
+ Services.appinfo.annotateCrashReport(
+ "EMCheckCompatibility",
+ AddonManager.checkCompatibility.toString()
+ );
+ } catch (e) {}
+ this.addAddonsToCrashReporter();
+ }
+
+ try {
+ AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_begin");
+
+ for (let addon of this.sortBootstrappedAddons()) {
+ // The startup update check above may have already started some
+ // extensions, make sure not to try to start them twice.
+ let activeAddon = this.activeAddons.get(addon.id);
+ if (activeAddon && activeAddon.started) {
+ continue;
+ }
+ try {
+ let reason = BOOTSTRAP_REASONS.APP_STARTUP;
+ // Eventually set INSTALLED reason when a bootstrap addon
+ // is dropped in profile folder and automatically installed
+ if (
+ AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_INSTALLED
+ ).includes(addon.id)
+ ) {
+ reason = BOOTSTRAP_REASONS.ADDON_INSTALL;
+ } else if (
+ AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_ENABLED
+ ).includes(addon.id)
+ ) {
+ reason = BOOTSTRAP_REASONS.ADDON_ENABLE;
+ }
+ this.enabledAddonsStartupPromises.push(
+ BootstrapScope.get(addon).startup(reason)
+ );
+ } catch (e) {
+ logger.error(
+ "Failed to load bootstrap addon " +
+ addon.id +
+ " from " +
+ addon.descriptor,
+ e
+ );
+ }
+ }
+ AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_end");
+ } catch (e) {
+ logger.error("bootstrap startup failed", e);
+ AddonManagerPrivate.recordException(
+ "XPI-BOOTSTRAP",
+ "startup failed",
+ e
+ );
+ }
+
+ // Let these shutdown a little earlier when they still have access to most
+ // of XPCOM
+ lazy.AsyncShutdown.quitApplicationGranted.addBlocker(
+ "XPIProvider shutdown",
+ async () => {
+ // Do not enter shutdown before we actually finished starting as this
+ // can lead to hangs as seen in bug 1814104.
+ await Promise.allSettled([
+ ...this.startupPromises,
+ ...this.enabledAddonsStartupPromises,
+ ]);
+
+ XPIProvider._closing = true;
+
+ await XPIProvider.cleanupTemporaryAddons();
+ for (let addon of XPIProvider.sortBootstrappedAddons().reverse()) {
+ // If no scope has been loaded for this add-on then there is no need
+ // to shut it down (should only happen when a bootstrapped add-on is
+ // pending enable)
+ let activeAddon = XPIProvider.activeAddons.get(addon.id);
+ if (!activeAddon || !activeAddon.started) {
+ continue;
+ }
+
+ // If the add-on was pending disable then shut it down and remove it
+ // from the persisted data.
+ let reason = BOOTSTRAP_REASONS.APP_SHUTDOWN;
+ if (addon._pendingDisable) {
+ reason = BOOTSTRAP_REASONS.ADDON_DISABLE;
+ } else if (addon.location.name == KEY_APP_TEMPORARY) {
+ reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL;
+ let existing = XPIStates.findAddon(
+ addon.id,
+ loc => !loc.isTemporary
+ );
+ if (existing) {
+ reason = XPIExports.XPIInstall.newVersionReason(
+ addon.version,
+ existing.version
+ );
+ }
+ }
+
+ let scope = BootstrapScope.get(addon);
+ let promise = scope.shutdown(reason);
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Extension shutdown: ${addon.id}`,
+ promise,
+ {
+ fetchState: scope.fetchState.bind(scope),
+ }
+ );
+ }
+ }
+ );
+
+ // Detect final-ui-startup for telemetry reporting
+ Services.obs.addObserver(function observer() {
+ AddonManagerPrivate.recordTimestamp("XPI_finalUIStartup");
+ Services.obs.removeObserver(observer, "final-ui-startup");
+ }, "final-ui-startup");
+
+ // If we haven't yet loaded the XPI database, schedule loading it
+ // to occur once other important startup work is finished. We want
+ // this to happen relatively quickly after startup so the telemetry
+ // environment has complete addon information.
+ //
+ // Unfortunately we have to use a variety of ways do detect when it
+ // is time to load. In a regular browser process we just wait for
+ // sessionstore-windows-restored. In a browser toolbox process
+ // we wait for the toolbox to show up, based on xul-window-visible
+ // and a visible toolbox window.
+ //
+ // TelemetryEnvironment's EnvironmentAddonBuilder awaits databaseReady
+ // before releasing a blocker on AddonManager.beforeShutdown, which in its
+ // turn is a blocker of a shutdown blocker at "profile-before-change".
+ // To avoid a deadlock, trigger the DB load at "profile-before-change" if
+ // the database hasn't started loading yet.
+ //
+ // Finally, we have a test-only event called test-load-xpi-database
+ // as a temporary workaround for bug 1372845. The latter can be
+ // cleaned up when that bug is resolved.
+ if (!this.isDBLoaded) {
+ const EVENTS = [
+ "sessionstore-windows-restored",
+ "xul-window-visible",
+ "profile-before-change",
+ "test-load-xpi-database",
+ ];
+ let observer = (subject, topic, data) => {
+ if (
+ topic == "xul-window-visible" &&
+ !Services.wm.getMostRecentWindow("devtools:toolbox")
+ ) {
+ return;
+ }
+
+ for (let event of EVENTS) {
+ Services.obs.removeObserver(observer, event);
+ }
+
+ XPIExports.XPIDatabase.asyncLoadDB();
+ };
+ for (let event of EVENTS) {
+ Services.obs.addObserver(observer, event);
+ }
+ }
+
+ AddonManagerPrivate.recordTimestamp("XPI_startup_end");
+
+ lazy.timerManager.registerTimer(
+ "xpi-signature-verification",
+ () => {
+ XPIExports.XPIDatabase.verifySignatures();
+ },
+ XPI_SIGNATURE_CHECK_PERIOD
+ );
+ } catch (e) {
+ logger.error("startup failed", e);
+ AddonManagerPrivate.recordException("XPI", "startup failed", e);
+ }
+ },
+
+ /**
+ * Shuts down the database and releases all references.
+ * Return: Promise{integer} resolves / rejects with the result of
+ * flushing the XPI Database if it was loaded,
+ * 0 otherwise.
+ */
+ async shutdown() {
+ logger.debug("shutdown");
+
+ this.activeAddons.clear();
+ this.allAppGlobal = true;
+
+ // Stop anything we were doing asynchronously
+ XPIExports.XPIInstall.cancelAll();
+
+ for (let install of XPIExports.XPIInstall.installs) {
+ if (install.onShutdown()) {
+ install.onShutdown();
+ }
+ }
+
+ // If there are pending operations then we must update the list of active
+ // add-ons
+ if (Services.prefs.getBoolPref(PREF_PENDING_OPERATIONS, false)) {
+ XPIExports.XPIDatabase.updateActiveAddons();
+ Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false);
+ }
+
+ await XPIExports.XPIDatabase.shutdown();
+ },
+
+ cleanupTemporaryAddons() {
+ let promises = [];
+ let tempLocation = TemporaryInstallLocation;
+ for (let [id, addon] of tempLocation.entries()) {
+ tempLocation.delete(id);
+
+ let bootstrap = BootstrapScope.get(addon);
+ let existing = XPIStates.findAddon(id, loc => !loc.isTemporary);
+
+ let cleanup = () => {
+ tempLocation.installer.uninstallAddon(id);
+ tempLocation.removeAddon(id);
+ };
+
+ let promise;
+ if (existing) {
+ promise = bootstrap.update(existing, false, () => {
+ cleanup();
+ XPIExports.XPIDatabase.makeAddonLocationVisible(
+ id,
+ existing.location
+ );
+ });
+ } else {
+ promise = bootstrap.uninstall().then(cleanup);
+ }
+ lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
+ `Temporary extension shutdown: ${addon.id}`,
+ promise
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+ },
+
+ /**
+ * Adds a list of currently active add-ons to the next crash report.
+ */
+ addAddonsToCrashReporter() {
+ void (Services.appinfo instanceof Ci.nsICrashReporter);
+ if (!Services.appinfo.annotateCrashReport || Services.appinfo.inSafeMode) {
+ return;
+ }
+
+ let data = Array.from(XPIStates.enabledAddons(), a => a.telemetryKey).join(
+ ","
+ );
+
+ try {
+ Services.appinfo.annotateCrashReport("Add-ons", data);
+ } catch (e) {}
+
+ lazy.TelemetrySession.setAddOns(data);
+ },
+
+ /**
+ * Check the staging directories of install locations for any add-ons to be
+ * installed or add-ons to be uninstalled.
+ *
+ * @param {Object} aManifests
+ * A dictionary to add detected install manifests to for the purpose
+ * of passing through updated compatibility information
+ * @returns {boolean}
+ * True if an add-on was installed or uninstalled
+ */
+ processPendingFileChanges(aManifests) {
+ let changed = false;
+ for (let loc of XPIStates.locations()) {
+ aManifests[loc.name] = {};
+ // We can't install or uninstall anything in locked locations
+ if (loc.locked) {
+ continue;
+ }
+
+ // Collect any install errors for specific removal from the staged directory
+ // during cleanStagingDir. Successful installs remove the files.
+ let stagedFailureNames = [];
+ let promises = [];
+ for (let [id, metadata] of loc.getStagedAddons()) {
+ loc.unstageAddon(id);
+
+ aManifests[loc.name][id] = null;
+ promises.push(
+ XPIExports.XPIInstall.installStagedAddon(id, metadata, loc).then(
+ addon => {
+ aManifests[loc.name][id] = addon;
+ },
+ error => {
+ delete aManifests[loc.name][id];
+ stagedFailureNames.push(`${id}.xpi`);
+
+ logger.error(
+ `Failed to install staged add-on ${id} in ${loc.name}`,
+ error
+ );
+ }
+ )
+ );
+ }
+
+ if (promises.length) {
+ changed = true;
+ awaitPromise(Promise.all(promises));
+ }
+
+ try {
+ if (changed || stagedFailureNames.length) {
+ loc.installer.cleanStagingDir(stagedFailureNames);
+ }
+ } catch (e) {
+ // Non-critical, just saves some perf on startup if we clean this up.
+ logger.debug("Error cleaning staging dir", e);
+ }
+ }
+ return changed;
+ },
+
+ /**
+ * Installs any add-ons located in the extensions directory of the
+ * application's distribution specific directory into the profile unless a
+ * newer version already exists or the user has previously uninstalled the
+ * distributed add-on.
+ *
+ * @param {Object} aManifests
+ * A dictionary to add new install manifests to to save having to
+ * reload them later
+ * @param {string} [aAppChanged]
+ * See checkForChanges
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @returns {boolean}
+ * True if any new add-ons were installed
+ */
+ installDistributionAddons(aManifests, aAppChanged, aOldAppVersion) {
+ let distroDirs = [];
+ try {
+ distroDirs.push(
+ lazy.FileUtils.getDir(KEY_APP_DISTRIBUTION, [DIR_EXTENSIONS])
+ );
+ } catch (e) {
+ return false;
+ }
+
+ let availableLocales = [];
+ for (let file of iterDirectory(distroDirs[0])) {
+ if (file.isDirectory() && file.leafName.startsWith("locale-")) {
+ availableLocales.push(file.leafName.replace("locale-", ""));
+ }
+ }
+
+ let locales = Services.locale.negotiateLanguages(
+ Services.locale.requestedLocales,
+ availableLocales,
+ undefined,
+ Services.locale.langNegStrategyMatching
+ );
+
+ // Also install addons from subdirectories that correspond to the requested
+ // locales. This allows for installing language packs and dictionaries.
+ for (let locale of locales) {
+ let langPackDir = distroDirs[0].clone();
+ langPackDir.append(`locale-${locale}`);
+ distroDirs.push(langPackDir);
+ }
+
+ let changed = false;
+ for (let distroDir of distroDirs) {
+ logger.warn(`Checking ${distroDir.path} for addons`);
+ for (let file of iterDirectory(distroDir)) {
+ if (!isXPI(file.leafName, true)) {
+ // Only warn for files, not directories
+ if (!file.isDirectory()) {
+ logger.warn(`Ignoring distribution: not an XPI: ${file.path}`);
+ }
+ continue;
+ }
+
+ let id = getExpectedID(file);
+ if (!id) {
+ logger.warn(
+ `Ignoring distribution: name is not a valid add-on ID: ${file.path}`
+ );
+ continue;
+ }
+
+ /* If this is not an upgrade and we've already handled this extension
+ * just continue */
+ if (
+ !aAppChanged &&
+ Services.prefs.prefHasUserValue(PREF_BRANCH_INSTALLED_ADDON + id)
+ ) {
+ continue;
+ }
+
+ try {
+ let loc = XPIStates.getLocation(KEY_APP_PROFILE);
+ let addon = awaitPromise(
+ XPIExports.XPIInstall.installDistributionAddon(
+ id,
+ file,
+ loc,
+ aOldAppVersion
+ )
+ );
+
+ if (addon) {
+ // aManifests may contain a copy of a newly installed add-on's manifest
+ // and we'll have overwritten that so instead cache our install manifest
+ // which will later be put into the database in processFileChanges
+ if (!(loc.name in aManifests)) {
+ aManifests[loc.name] = {};
+ }
+ aManifests[loc.name][id] = addon;
+ changed = true;
+ }
+ } catch (e) {
+ logger.error(`Failed to install distribution add-on ${file.path}`, e);
+ }
+ }
+ }
+
+ return changed;
+ },
+
+ /**
+ * Like `installBuiltinAddon`, but only installs the addon at `aBase`
+ * if an existing built-in addon with the ID `aID` and version doesn't
+ * already exist.
+ *
+ * @param {string} aID
+ * The ID of the add-on being registered.
+ * @param {string} aVersion
+ * The version of the add-on being registered.
+ * @param {string} aBase
+ * A string containing the base URL. Must be a resource: URL.
+ * @returns {Promise<Addon>} a Promise that resolves when the addon is installed.
+ */
+ async maybeInstallBuiltinAddon(aID, aVersion, aBase) {
+ let installed;
+ if (lazy.enabledScopes & BuiltInLocation.scope) {
+ let existing = BuiltInLocation.get(aID);
+ if (!existing || existing.version != aVersion) {
+ installed = this.installBuiltinAddon(aBase);
+ this.startupPromises.push(installed);
+ }
+ }
+ return installed;
+ },
+
+ getDependentAddons(aAddon) {
+ return Array.from(XPIExports.XPIDatabase.getAddons()).filter(addon =>
+ addon.dependencies.includes(aAddon.id)
+ );
+ },
+
+ /**
+ * Checks for any changes that have occurred since the last time the
+ * application was launched.
+ *
+ * @param {boolean?} [aAppChanged]
+ * A tri-state value. Undefined means the current profile was created
+ * for this session, true means the profile already existed but was
+ * last used with an application with a different version number,
+ * false means that the profile was last used by this version of the
+ * application.
+ * @param {string?} [aOldAppVersion]
+ * The version of the application last run with this profile or null
+ * if it is a new profile or the version is unknown
+ * @param {string?} [aOldPlatformVersion]
+ * The version of the platform last run with this profile or null
+ * if it is a new profile or the version is unknown
+ */
+ checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion) {
+ logger.debug("checkForChanges");
+
+ // Keep track of whether and why we need to open and update the database at
+ // startup time.
+ let updateReasons = [];
+ if (aAppChanged) {
+ updateReasons.push("appChanged");
+ }
+
+ let installChanged = XPIStates.scanForChanges(aAppChanged === false);
+ if (installChanged) {
+ updateReasons.push("directoryState");
+ }
+
+ // First install any new add-ons into the locations, if there are any
+ // changes then we must update the database with the information in the
+ // install locations
+ let manifests = {};
+ let updated = this.processPendingFileChanges(manifests);
+ if (updated) {
+ updateReasons.push("pendingFileChanges");
+ }
+
+ // This will be true if the previous session made changes that affect the
+ // active state of add-ons but didn't commit them properly (normally due
+ // to the application crashing)
+ let hasPendingChanges = Services.prefs.getBoolPref(
+ PREF_PENDING_OPERATIONS,
+ false
+ );
+ if (hasPendingChanges) {
+ updateReasons.push("hasPendingChanges");
+ }
+
+ // If the application has changed then check for new distribution add-ons
+ if (Services.prefs.getBoolPref(PREF_INSTALL_DISTRO_ADDONS, true)) {
+ updated = this.installDistributionAddons(
+ manifests,
+ aAppChanged,
+ aOldAppVersion
+ );
+ if (updated) {
+ updateReasons.push("installDistributionAddons");
+ }
+ }
+
+ // If the schema appears to have changed then we should update the database
+ if (DB_SCHEMA != Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) {
+ // If we don't have any add-ons, just update the pref, since we don't need to
+ // write the database
+ if (!XPIStates.size) {
+ logger.debug(
+ "Empty XPI database, setting schema version preference to " +
+ DB_SCHEMA
+ );
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, DB_SCHEMA);
+ } else {
+ updateReasons.push("schemaChanged");
+ }
+ }
+
+ // Catch and log any errors during the main startup
+ try {
+ let extensionListChanged = false;
+ // If the database needs to be updated then open it and then update it
+ // from the filesystem
+ if (updateReasons.length) {
+ AddonManagerPrivate.recordSimpleMeasure(
+ "XPIDB_startup_load_reasons",
+ updateReasons
+ );
+ XPIExports.XPIDatabase.syncLoadDB(false);
+ try {
+ extensionListChanged =
+ XPIExports.XPIDatabaseReconcile.processFileChanges(
+ manifests,
+ aAppChanged,
+ aOldAppVersion,
+ aOldPlatformVersion,
+ updateReasons.includes("schemaChanged")
+ );
+ } catch (e) {
+ logger.error("Failed to process extension changes at startup", e);
+ }
+ }
+
+ // If the application crashed before completing any pending operations then
+ // we should perform them now.
+ if (extensionListChanged || hasPendingChanges) {
+ XPIExports.XPIDatabase.updateActiveAddons();
+ return;
+ }
+
+ logger.debug("No changes found");
+ } catch (e) {
+ logger.error("Error during startup file checks", e);
+ }
+ },
+
+ /**
+ * Gets an array of add-ons which were placed in a known install location
+ * prior to startup of the current session, were detected by a directory scan
+ * of those locations, and are currently disabled.
+ *
+ * @returns {Promise<Array<Addon>>}
+ */
+ async getNewSideloads() {
+ if (XPIStates.scanForChanges(false)) {
+ // We detected changes. Update the database to account for them.
+ await XPIExports.XPIDatabase.asyncLoadDB(false);
+ XPIExports.XPIDatabaseReconcile.processFileChanges({}, false);
+ XPIExports.XPIDatabase.updateActiveAddons();
+ }
+
+ let addons = await Promise.all(
+ Array.from(XPIStates.sideLoadedAddons.keys(), id => this.getAddonByID(id))
+ );
+
+ return addons.filter(
+ addon =>
+ addon &&
+ addon.seen === false &&
+ addon.permissions & AddonManager.PERM_CAN_ENABLE
+ );
+ },
+
+ /**
+ * Called to test whether this provider supports installing a particular
+ * mimetype.
+ *
+ * @param {string} aMimetype
+ * The mimetype to check for
+ * @returns {boolean}
+ * True if the mimetype is application/x-xpinstall
+ */
+ supportsMimetype(aMimetype) {
+ return aMimetype == "application/x-xpinstall";
+ },
+
+ // Identify temporary install IDs.
+ isTemporaryInstallID(id) {
+ return id.endsWith(TEMPORARY_ADDON_SUFFIX);
+ },
+
+ /**
+ * Sets startupData for the given addon. The provided data will be stored
+ * in addonsStartup.json so it is available early during browser startup.
+ * Note that this file is read synchronously at startup, so startupData
+ * should be used with care.
+ *
+ * @param {string} aID
+ * The id of the addon to save startup data for.
+ * @param {any} aData
+ * The data to store. Must be JSON serializable.
+ */
+ setStartupData(aID, aData) {
+ let state = XPIStates.findAddon(aID);
+ state.startupData = aData;
+ XPIStates.save();
+ },
+
+ /**
+ * Persists some startupData into an addon if it is available in the current
+ * XPIState for the addon id.
+ *
+ * @param {AddonInternal} addon An addon to receive the startup data, typically an update that is occuring.
+ * @param {XPIState} state optional
+ */
+ persistStartupData(addon, state) {
+ if (!addon.startupData) {
+ state = state || XPIStates.findAddon(addon.id);
+ if (state?.enabled) {
+ // Save persistent listener data if available. It will be
+ // removed later if necessary.
+ let persistentListeners = state.startupData?.persistentListeners;
+ addon.startupData = { persistentListeners };
+ }
+ }
+ },
+
+ getAddonIDByInstanceID(aInstanceID) {
+ if (!aInstanceID || typeof aInstanceID != "symbol") {
+ throw Components.Exception(
+ "aInstanceID must be a Symbol()",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ for (let [id, val] of this.activeAddons) {
+ if (aInstanceID == val.instanceID) {
+ return id;
+ }
+ }
+
+ return null;
+ },
+
+ async getAddonsByTypes(aTypes) {
+ if (aTypes && !aTypes.some(type => ALL_XPI_TYPES.has(type))) {
+ return [];
+ }
+ return XPIExports.XPIDatabase.getAddonsByTypes(aTypes);
+ },
+
+ /**
+ * Called to get active Addons of a particular type
+ *
+ * @param {Array<string>?} aTypes
+ * An array of types to fetch. Can be null to get all types.
+ * @returns {Promise<Array<Addon>>}
+ */
+ async getActiveAddons(aTypes) {
+ // If we already have the database loaded, returning full info is fast.
+ if (this.isDBLoaded) {
+ let addons = await this.getAddonsByTypes(aTypes);
+ return {
+ addons: addons.filter(addon => addon.isActive),
+ fullData: true,
+ };
+ }
+
+ let result = [];
+ for (let addon of XPIStates.enabledAddons()) {
+ if (aTypes && !aTypes.includes(addon.type)) {
+ continue;
+ }
+ let { scope, isSystem } = addon.location;
+ result.push({
+ id: addon.id,
+ version: addon.version,
+ type: addon.type,
+ updateDate: addon.lastModifiedTime,
+ scope,
+ isSystem,
+ isWebExtension: addon.isWebExtension,
+ });
+ }
+
+ return { addons: result, fullData: false };
+ },
+
+ /*
+ * Notified when a preference we're interested in has changed.
+ *
+ * @see nsIObserver
+ */
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case NOTIFICATION_FLUSH_PERMISSIONS:
+ if (!aData || aData == XPI_PERMISSION) {
+ XPIExports.XPIDatabase.importPermissions();
+ }
+ break;
+
+ case "nsPref:changed":
+ switch (aData) {
+ case PREF_XPI_SIGNATURES_REQUIRED:
+ case PREF_LANGPACK_SIGNATURES:
+ XPIExports.XPIDatabase.updateAddonAppDisabledStates();
+ break;
+ }
+ }
+ },
+
+ uninstallSystemProfileAddon(aID) {
+ let location = XPIStates.getLocation(KEY_APP_SYSTEM_PROFILE);
+ return XPIExports.XPIInstall.uninstallAddonFromLocation(aID, location);
+ },
+};
+
+for (let meth of [
+ "getInstallForFile",
+ "getInstallForURL",
+ "getInstallsByTypes",
+ "installTemporaryAddon",
+ "installBuiltinAddon",
+ "isInstallAllowed",
+ "isInstallEnabled",
+ "updateSystemAddons",
+ "stageLangpacksForAppUpdate",
+]) {
+ XPIProvider[meth] = function () {
+ return XPIExports.XPIInstall[meth](...arguments);
+ };
+}
+
+for (let meth of [
+ "addonChanged",
+ "getAddonByID",
+ "getAddonBySyncGUID",
+ "updateAddonRepositoryData",
+ "updateAddonAppDisabledStates",
+]) {
+ XPIProvider[meth] = function () {
+ return XPIExports.XPIDatabase[meth](...arguments);
+ };
+}
+
+export var XPIInternal = {
+ BOOTSTRAP_REASONS,
+ BootstrapScope,
+ BuiltInLocation,
+ DB_SCHEMA,
+ DIR_STAGE,
+ DIR_TRASH,
+ KEY_APP_PROFILE,
+ KEY_APP_SYSTEM_PROFILE,
+ KEY_APP_SYSTEM_ADDONS,
+ KEY_APP_SYSTEM_DEFAULTS,
+ PREF_BRANCH_INSTALLED_ADDON,
+ PREF_SYSTEM_ADDON_SET,
+ SystemAddonLocation,
+ TEMPORARY_ADDON_SUFFIX,
+ TemporaryInstallLocation,
+ XPIStates,
+ XPI_PERMISSION,
+ awaitPromise,
+ canRunInSafeMode,
+ getURIForResourceInFile,
+ isXPI,
+ iterDirectory,
+ maybeResolveURI,
+ migrateAddonLoader,
+ resolveDBReady,
+
+ // Used by tests to shut down AddonManager.
+ overrideAsyncShutdown(mockAsyncShutdown) {
+ lazy.AsyncShutdown = mockAsyncShutdown;
+ },
+};
diff --git a/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
new file mode 100644
index 0000000000..b1304bcdfc
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+export function computeHashAsString(hashType, input) {
+ const data = new Uint8Array(new TextEncoder().encode(input));
+ const crypto = CryptoHash(hashType);
+ crypto.update(data, data.length);
+ return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the string representation (hex) of the SHA256 hash of `input`.
+ *
+ * @param {string} input
+ * The value to hash.
+ * @returns {string}
+ * The hex representation of a SHA256 hash.
+ */
+export function computeSha256HashAsString(input) {
+ return computeHashAsString("sha256", input);
+}
+
+/**
+ * Returns the string representation (hex) of the SHA1 hash of `input`.
+ *
+ * @param {string} input
+ * The value to hash.
+ * @returns {string}
+ * The hex representation of a SHA1 hash.
+ */
+export function computeSha1HashAsString(input) {
+ return computeHashAsString("sha1", input);
+}
+
+/**
+ * Returns the string representation (hex) of a given CryptoHashInstance.
+ *
+ * @param {CryptoHash} aCrypto
+ * @returns {string}
+ * The hex representation of a SHA256 hash.
+ */
+export function getHashStringForCrypto(aCrypto) {
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ // convert the binary hash data to a hex string.
+ let binary = aCrypto.finish(/* base64 */ false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return hash.join("").toLowerCase();
+}
diff --git a/toolkit/mozapps/extensions/internal/moz.build b/toolkit/mozapps/extensions/internal/moz.build
new file mode 100644
index 0000000000..4fcf6657d5
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/moz.build
@@ -0,0 +1,29 @@
+# -*- 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.addons += [
+ "AddonRepository.sys.mjs",
+ "AddonSettings.sys.mjs",
+ "AddonUpdateChecker.sys.mjs",
+ "crypto-utils.sys.mjs",
+ "ProductAddonChecker.sys.mjs",
+ "siteperms-addon-utils.sys.mjs",
+ "XPIDatabase.sys.mjs",
+ "XPIExports.sys.mjs",
+ "XPIInstall.sys.mjs",
+ "XPIProvider.sys.mjs",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android":
+ EXTRA_JS_MODULES.addons += [
+ "GMPProvider.sys.mjs",
+ ## TODO consider extending it to mobile builds too (See Bug 1790084).
+ "SitePermsAddonProvider.sys.mjs",
+ ]
+
+TESTING_JS_MODULES += [
+ "AddonTestUtils.sys.mjs",
+]
diff --git a/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
new file mode 100644
index 0000000000..86853d7168
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+export const GATED_PERMISSIONS = ["midi", "midi-sysex"];
+export const SITEPERMS_ADDON_PROVIDER_PREF =
+ "dom.sitepermsaddon-provider.enabled";
+export const SITEPERMS_ADDON_TYPE = "sitepermission";
+export const SITEPERMS_ADDON_BLOCKEDLIST_PREF =
+ "dom.sitepermsaddon-provider.separatedBlocklistedDomains";
+
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "blocklistedOriginsSet",
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ // Default value
+ "",
+ // onUpdate
+ null,
+ // transform
+ prefValue => new Set(prefValue.split(","))
+);
+
+/**
+ * @param {string} type
+ * @returns {boolean}
+ */
+export function isGatedPermissionType(type) {
+ return GATED_PERMISSIONS.includes(type);
+}
+
+/**
+ * @param {string} siteOrigin
+ * @returns {boolean}
+ */
+export function isKnownPublicSuffix(siteOrigin) {
+ const { host } = new URL(siteOrigin);
+
+ let isPublic = false;
+ // getKnownPublicSuffixFromHost throws when passed an IP, in such case, assume
+ // this is not a public etld.
+ try {
+ isPublic = Services.eTLD.getKnownPublicSuffixFromHost(host) == host;
+ } catch (e) {}
+
+ return isPublic;
+}
+
+/**
+ * ⚠️ This should be only used for testing purpose ⚠️
+ *
+ * @param {Array<String>} permissionTypes
+ * @throws if not called from xpcshell test
+ */
+export function addGatedPermissionTypesForXpcShellTests(permissionTypes) {
+ if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ throw new Error("This should only be called from XPCShell tests");
+ }
+
+ GATED_PERMISSIONS.push(...permissionTypes);
+}
+
+/**
+ * @param {nsIPrincipal} principal
+ * @returns {Boolean}
+ */
+export function isPrincipalInSitePermissionsBlocklist(principal) {
+ return lazy.blocklistedOriginsSet.has(principal.baseDomain);
+}
diff --git a/toolkit/mozapps/extensions/jar.mn b/toolkit/mozapps/extensions/jar.mn
new file mode 100644
index 0000000000..a881ed3843
--- /dev/null
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -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/.
+
+toolkit.jar:
+% content mozapps %content/mozapps/
+#ifndef MOZ_FENNEC
+ content/mozapps/extensions/OpenH264-license.txt (content/OpenH264-license.txt)
+ content/mozapps/extensions/aboutaddons.html (content/aboutaddons.html)
+ content/mozapps/extensions/aboutaddons.js (content/aboutaddons.js)
+ content/mozapps/extensions/aboutaddonsCommon.js (content/aboutaddonsCommon.js)
+ content/mozapps/extensions/aboutaddons.css (content/aboutaddons.css)
+ content/mozapps/extensions/abuse-reports.js (content/abuse-reports.js)
+ content/mozapps/extensions/abuse-report-frame.html (content/abuse-report-frame.html)
+ content/mozapps/extensions/abuse-report-panel.css (content/abuse-report-panel.css)
+ content/mozapps/extensions/abuse-report-panel.js (content/abuse-report-panel.js)
+ content/mozapps/extensions/drag-drop-addon-installer.js (content/drag-drop-addon-installer.js)
+ content/mozapps/extensions/shortcuts.css (content/shortcuts.css)
+ content/mozapps/extensions/shortcuts.js (content/shortcuts.js)
+ content/mozapps/extensions/view-controller.js (content/view-controller.js)
+
+% resource default-theme %content/mozapps/extensions/default-theme/
+ content/mozapps/extensions/default-theme (default-theme/*.svg)
+ content/mozapps/extensions/default-theme/manifest.json (default-theme/manifest.json)
+#endif
diff --git a/toolkit/mozapps/extensions/metrics.yaml b/toolkit/mozapps/extensions/metrics.yaml
new file mode 100644
index 0000000000..d5935ae436
--- /dev/null
+++ b/toolkit/mozapps/extensions/metrics.yaml
@@ -0,0 +1,527 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Toolkit :: Add-ons Manager'
+
+addons_manager:
+ install: &install_update_event
+ type: event
+ description: |
+ These events are recorded during the install and update flow for
+ extensions and themes.
+ bugs:
+ - https://bugzilla.mozilla.org/1433335
+ - https://bugzilla.mozilla.org/1515697
+ - https://bugzilla.mozilla.org/1523641
+ - https://bugzilla.mozilla.org/1549770
+ - https://bugzilla.mozilla.org/1590736
+ - https://bugzilla.mozilla.org/1630596
+ - https://bugzilla.mozilla.org/1672570
+ - https://bugzilla.mozilla.org/1714251
+ - https://bugzilla.mozilla.org/1749878
+ - https://bugzilla.mozilla.org/1781974
+ - https://bugzilla.mozilla.org/1817100
+ - https://bugzilla.mozilla.org/1820153
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820153#c3
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ extra_keys:
+ addon_id:
+ description: Id of the addon (when available).
+ type: string
+ addon_type:
+ description: |
+ Addon type, one of: extension, theme, locale, dictionary,
+ sitepermission, siteperm_deprecated, other, unknown.
+ type: string
+ install_id:
+ description: |
+ Shared by events related to the same install or update flow.
+ type: string
+ download_time:
+ description: The number of ms needed to complete the download.
+ type: quantity
+ error:
+ description: |
+ The AddonManager error related to an install or update failure.
+ type: string
+ source:
+ description: |
+ The source that originally triggered the installation, one
+ of: "about:addons", "about:debugging", "about:preferences",
+ "amo", "browser-import", "disco", "distribution",
+ "extension", "enterprise-policy", "file-url",
+ "geckoview-app", "gmp-plugin", "internal", "plugin",
+ "rtamo", "siteperm-addon-provider" "sync", "system-addon",
+ "temporary-addon", "unknown".
+ For events with method set to "sideload", the source value
+ is derived from the XPIProvider location name (e.g. possible
+ values are "app-builtin", "app-global", "app-profile",
+ "app-system-addons", "app-system-defaults",
+ "app-system-local", "app-system-profile",
+ "app-system-share", "app-system-user", "winreg-app-user",
+ "winreg-app-gobal").
+ type: string
+ source_method:
+ description: |
+ The method used by the source to install the add-on
+ (included when the source can use more than one, e.g.
+ install events with source "about:addons" may have
+ "install-from-file" or "url" as method), one of: "amWebAPI",
+ "drag-and-drop", "installTrigger", "install-from-file",
+ "link", "management-webext-api", "sideload",
+ "synthetic-install", "url", "product-updates".
+ type: string
+ num_strings:
+ description: |
+ The number of permission description strings in the
+ extension permission doorhanger.
+ type: quantity
+ updated_from:
+ description: |
+ Determine if an update has been requested by the user or
+ the application ("app" / "user").
+ type: string
+ install_origins:
+ description: |
+ This flag indicates whether install_origins is defined in
+ the addon manifest. ("1" / "0")
+ type: string
+ step:
+ description: |
+ The current step in the install or update flow: started,
+ postponed, cancelled, failed, permissions_prompt, completed,
+ site_warning, site_blocked,install_disabled_warning,
+ download_started, download_completed, download_failed
+ type: string
+ expires: 132
+
+ update: *install_update_event
+
+ install_stats:
+ type: event
+ description: |
+ These events are recorded at the end of the install flow, but only
+ when the source that originally triggered the add-on installation
+ is "amo", "rtamo" or "disco".
+ bugs:
+ - https://bugzilla.mozilla.org/1653020
+ - https://bugzilla.mozilla.org/1699225
+ - https://bugzilla.mozilla.org/1820153
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820153#c3
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ extra_keys:
+ addon_id:
+ description: Id of the addon.
+ type: string
+ addon_type:
+ description: Type of the add-on.
+ type: string
+ taar_based:
+ description: |
+ This extra key is only set for install flows related to the
+ discovery addon. When available it is going to be a string
+ set to "1" for TAAR based recommendations, "0" for manually
+ curated and unset if not relevant for the particular
+ install flow.
+ type: string
+ utm_campaign:
+ description: |
+ The specific product promotion or strategic campaign that
+ drives traffic to the install page.
+ type: string
+ utm_content:
+ description: |
+ The specific item that a person clicks on to access the
+ install page (such as an A/B test, a website banner, or a
+ specific ad).
+ type: string
+ utm_medium:
+ description: The channel used to share the install page.
+ type: string
+ utm_source:
+ description: |
+ The name of the product, domain of the website that drives
+ traffic to the install page.
+ type: string
+ expires: never
+
+ manage:
+ type: event
+ description: |
+ This events are recorded when an installed add-ons is being
+ disable/enabled/uninstalled.
+ bugs:
+ - https://bugzilla.mozilla.org/1433335
+ - https://bugzilla.mozilla.org/1515697
+ - https://bugzilla.mozilla.org/1523641
+ - https://bugzilla.mozilla.org/1549770
+ - https://bugzilla.mozilla.org/1590736
+ - https://bugzilla.mozilla.org/1630596
+ - https://bugzilla.mozilla.org/1672570
+ - https://bugzilla.mozilla.org/1714251
+ - https://bugzilla.mozilla.org/1749878
+ - https://bugzilla.mozilla.org/1781974
+ - https://bugzilla.mozilla.org/1817100
+ - https://bugzilla.mozilla.org/1820153
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820153#c3
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ extra_keys:
+ addon_id:
+ description: Id of the addon being managed.
+ type: string
+ addon_type:
+ description: The type of the add-on being managed.
+ type: string
+ method:
+ description: |
+ One of: disable, enable, sideload_prompt, uninstall.
+ type: string
+ source:
+ description: |
+ The source from which the addon has been installed.
+ See extra_keys.source description from install event.
+ type: string
+ source_method:
+ description: |
+ The method used by the source to install the add-on
+ (included when the source can use more than one, e.g.
+ install events with source "about:addons" may have
+ "install-from-file" or "url" as method).
+ type: string
+ num_strings:
+ description: |
+ The number of permission description strings in the
+ extension permission doorhanger.
+ type: quantity
+ expires: 132
+
+ report:
+ type: event
+ description: An abuse report submitted by a user for a given extension.
+ bugs:
+ - https://bugzilla.mozilla.org/1544927
+ - https://bugzilla.mozilla.org/1580561
+ - https://bugzilla.mozilla.org/1590736
+ - https://bugzilla.mozilla.org/1630596
+ - https://bugzilla.mozilla.org/1672570
+ - https://bugzilla.mozilla.org/1714251
+ - https://bugzilla.mozilla.org/1749878
+ - https://bugzilla.mozilla.org/1780746
+ - https://bugzilla.mozilla.org/1781974
+ - https://bugzilla.mozilla.org/1817100
+ - https://bugzilla.mozilla.org/1820153
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820153#c3
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ extra_keys:
+ addon_id:
+ description: Id of the addon being reported.
+ type: string
+ addon_type:
+ description: |
+ The type of the add-on being reported (missing on
+ ERROR_ADDON_NOT_FOUND, ERROR_AMODETAILS_NOTFOUND
+ and ERROR_AMODETAILS_FAILURE).
+ type: string
+ entry_point:
+ description: |
+ Report entry point, one of: amo, menu,
+ toolbar_context_menu, unified_context_menu, uninstall.
+ type: string
+ error_type:
+ description: |
+ AbuseReport Error Type (included in case of submission
+ failures). The error types include ERROR_ABORTED_SUBMIT,
+ ERROR_ADDON_NOT_FOUND, ERROR_CLIENT, ERROR_NETWORK,
+ ERROR_UNKNOWN, ERROR_RECENT_SUBMIT, ERROR_SERVER,
+ ERROR_AMODETAILS_NOTFOUND, ERROR_AMODETAILS_FAILURE.
+ type: string
+ expires: 132
+
+ report_suspicious_site:
+ type: event
+ description: |
+ Sent when a user clicks "Report Suspicious Site" on the dropdown
+ menu of the third-party xpinstall doorhanger.
+ bugs:
+ - https://bugzilla.mozilla.org/1806056
+ - https://bugzilla.mozilla.org/1817100
+ - https://bugzilla.mozilla.org/1820153
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820153#c3
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ extra_keys:
+ suspicious_site:
+ description: The domain of the site that was reported.
+ type: string
+ expires: 132
+
+blocklist:
+ last_modified_rs_addons_mblf:
+ type: datetime
+ description: >
+ Keep track of the last time the "addons-bloomfilters" remotesetting
+ blocklist has been successfully updated.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1572711
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1607744
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1607744#c11
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296#c5
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 132
+ mlbf_source:
+ type: string
+ description: >
+ The source of the RemoteSettings attachment that
+ holds the bloom filter. Possible values are "dump_match",
+ "cache_match", "remote_match","dump_fallback", "cache_fallback",
+ "unknown". "dump_match", "cache_match" and "remote_match"
+ are expected known-good values, and means that the loaded
+ bloomfilter matches the blocklist record in
+ the RemoteSettings collection. The prefix denotes
+ the immediate source of the loaded data: "dump"
+ means packaged with the application, "remote"
+ means a freshly downloaded bloomfilter, "cache"
+ means a previously downloaded bloomfilter. "dump_fallback"
+ and "cache_fallback" means that the last known bloomfilter
+ was used, despite it not matching the latest record
+ in the RemoteSettings collection. In this case
+ the outdated bloomfilter is used as a fallback
+ (e.g. because the latest version cannot be downloaded).
+ "unknown" means that the bloomfilter cannot
+ be loaded at all. This can happen if the blocklist
+ is disabled via preferences or enterprise policies.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857#c11
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296#c5
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 132
+ telemetry_mirror: BLOCKLIST_MLBF_SOURCE
+ mlbf_generation_time:
+ type: datetime
+ description: >
+ Keep track of the generation time of the addon
+ blocklist's bloom filter. This marks the point
+ in time until which signed add-ons are recognized
+ by the selected bloom filter from the addons-bloomfilters
+ collection.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466#c3
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296#c5
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 132
+ mlbf_stash_time_oldest:
+ type: datetime
+ description: >
+ Keep track of the timestamp of the oldest stash
+ of the addons blocklist.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466#c9
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296#c5
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 132
+ mlbf_stash_time_newest:
+ type: datetime
+ description: >
+ Keep track of the timestamp of the most recent
+ stash of the addons blocklist.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1649960
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1689274
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1633466#c9
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296#c5
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 132
+ addon_block_change:
+ type: event
+ description: >
+ An add-on is blocked, or an installed add-on is unblocked.
+ When an add-on install/update is blocked, its installation
+ is aborted and the add-on is no longer listed in the activeAddons
+ field of TelemetryEnvironment.
+ extra_keys:
+ value:
+ type: string
+ description: >
+ The value is the ID of the add-on.
+ object:
+ type: string
+ description: >
+ The object represents the reason for triggering
+ the blocklistState check: "addon_install" is when
+ an add-on is installed. "addon_update" is when an
+ add-on is updated after an update check.
+ "addon_update_check" is when an add-on is blocked
+ during the update check. "addon_db_modified" is when
+ an add-on's blocklistState was altered
+ between application restarts. "blocklist_update"
+ is when an add-on's blocklistState changed due to a
+ blocklist update. This may be
+ due to the blocklist being disabled by preferences
+ or enterprise policies, but it is more commonly
+ the result of updating entries in the blocklist.
+ objects: ["addon_install", "addon_update",
+ "addon_update_check", "addon_db_modified",
+ "blocklist_update"]
+ blocklist_state:
+ type: string
+ description: >
+ The blocklistState of the add-on. 0 is unblocked,
+ 2 is blocked. 1 is soft blocked (this state does not
+ exist in blocklist v3).
+ addon_version:
+ type: string
+ description: >
+ Version of the add-on. Used together with an
+ add-on's ID (value) to identify add-ons to block.
+ signed_date:
+ type: string
+ description: >
+ Timestamp of the add-on (when it was signed via AMO).
+ the add-on was installed or updated.
+ At least zero when the blocklist is updated, -1 otherwise.
+ hours_since:
+ type: string
+ description: >
+ The number of hours that have passed since this version of
+ This field is missing (0) for "addon_update_check".
+ mlbf_last_time:
+ type: string
+ description: >
+ The generation time of the most recent
+ entry in the blocklist. Time generated by
+ AMO when the blocklist entry was created.
+ May be 0 when the blocklist is disabled.
+ mlbf_generation:
+ type: string
+ description: >
+ The generation time to identify the bloomfilter
+ that was used for this blocklist decision.
+ The bloomfilter is updated less frequently
+ than the so-called stashes in the RemoteSettings
+ collection that holds the blocklist data.
+ The stashes take precedence over the bloomfilter
+ in blocklist decisions.
+ Time generated by AMO when the blocklist
+ entry was created.
+ May be 0 when the blocklist is disabled.
+ mlbf_source:
+ type: string
+ description: >
+ The source of the RemoteSettings attachment
+ that holds the bloom filter. This field is
+ documented in more detail in the definition
+ of the blocklist.mlbf_source. Possible values
+ are "dump_match", "cache_match", "remote_match",
+ "dump_fallback", "cache_fallback", "unknown".
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1730037
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1763529
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1811159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1820155
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1662857#c11
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1861296#c5
+ data_sensitivity:
+ - technical
+ metadata:
+ tags:
+ - 'Toolkit :: Blocklist Implementation'
+ notification_emails:
+ - addons-dev-internal@mozilla.com
+ - rwu@mozilla.com
+ expires: 132
diff --git a/toolkit/mozapps/extensions/moz.build b/toolkit/mozapps/extensions/moz.build
new file mode 100644
index 0000000000..5b884b4936
--- /dev/null
+++ b/toolkit/mozapps/extensions/moz.build
@@ -0,0 +1,101 @@
+# -*- 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/.
+
+SPHINX_TREES["addon-manager"] = "docs"
+
+with Files("docs/**"):
+ SCHEDULES.exclusive = ["docs"]
+
+if CONFIG["MOZ_BUILD_APP"] == "browser":
+ DEFINES["MOZ_BUILD_APP_IS_BROWSER"] = True
+
+if CONFIG["MOZ_BUILD_APP"] == "mobile/android":
+ DEFINES["MOZ_FENNEC"] = True
+
+DIRS += [
+ "internal",
+]
+TEST_DIRS += ["test"]
+
+XPIDL_SOURCES += [
+ "amIAddonManagerStartup.idl",
+ "amIWebInstallPrompt.idl",
+]
+
+XPIDL_MODULE = "extensions"
+
+built_in_addons = "built_in_addons.json"
+GENERATED_FILES += [built_in_addons]
+manifest = GENERATED_FILES[built_in_addons]
+manifest.script = "gen_built_in_addons.py"
+
+if CONFIG["MOZ_BUILD_APP"] == "browser":
+ manifest.flags = ["--features=browser/features"]
+
+ FINAL_TARGET_FILES.browser.chrome.browser.content.browser += [
+ "!%s" % built_in_addons,
+ ]
+elif CONFIG["MOZ_BUILD_APP"] == "mobile/android":
+ manifest.flags = ["--features=features"]
+
+ FINAL_TARGET_FILES.chrome.chrome.content += [
+ "!%s" % built_in_addons,
+ ]
+elif CONFIG["MOZ_BUILD_APP"] == "comm/mail":
+ manifest.flags = ["--features=features"]
+
+ FINAL_TARGET_FILES.chrome.browser.content += [
+ "!%s" % built_in_addons,
+ ]
+
+EXTRA_PP_COMPONENTS += [
+ "extensions.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "AbuseReporter.sys.mjs",
+ "AddonManager.sys.mjs",
+ "amContentHandler.sys.mjs",
+ "amInstallTrigger.sys.mjs",
+ "amManager.sys.mjs",
+ "amWebAPI.sys.mjs",
+ "Blocklist.sys.mjs",
+ "LightweightThemeManager.sys.mjs",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+EXPORTS.mozilla += [
+ "AddonContentPolicy.h",
+ "AddonManagerStartup.h",
+ "AddonManagerWebAPI.h",
+]
+
+UNIFIED_SOURCES += [
+ "AddonContentPolicy.cpp",
+ "AddonManagerStartup.cpp",
+ "AddonManagerWebAPI.cpp",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+LOCAL_INCLUDES += [
+ "/chrome",
+ "/dom/base",
+]
+
+FINAL_LIBRARY = "xul"
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Add-ons Manager")
+
+with Files("Blocklist.sys.mjs"):
+ BUG_COMPONENT = ("Toolkit", "Blocklist Implementation")
+
+with Files("content/blocklist**"):
+ BUG_COMPONENT = ("Toolkit", "Blocklist Implementation")
diff --git a/toolkit/mozapps/extensions/test/browser/.eslintrc.js b/toolkit/mozapps/extensions/test/browser/.eslintrc.js
new file mode 100644
index 0000000000..f2b9e072f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/.eslintrc.js
@@ -0,0 +1,14 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+
+ rules: {
+ "no-unused-vars": [
+ "error",
+ { args: "none", varsIgnorePattern: "^end_test$" },
+ ],
+ },
+};
diff --git a/toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml b/toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml
new file mode 100644
index 0000000000..e8cde29666
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addon_prefs.xhtml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ id="addon-test-pref-window">
+ <label value="Oh hai!"/>
+</window>
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf
new file mode 100644
index 0000000000..725ac8016f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: mCLu38qfGN3trj7qKQQeEA==
+SHA1-Digest: A1BaJErQY6KqnYDijP0lglrehk0=
+SHA256-Digest: p2vjGP7DRqrK81NfT4LqnF7a5p8+lEuout5WLBhk9AA=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..046a0285c7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf
new file mode 100644
index 0000000000..ad4e81b574
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: LrrwWBKNYWeVd205Hq+JwQ==
+SHA1-Digest-Manifest: MeqqQN+uuf0MVesMXxbBtYN+5tU=
+SHA256-Digest-Manifest: iWCxfAJX593Cn4l8R63jaQETO5HX3XOhcnpQ7nMiPlg=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json
new file mode 100644
index 0000000000..91012f24ed
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop1/manifest.json
@@ -0,0 +1,12 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "dragdrop-1@tests.mozilla.org"
+ }
+ },
+
+ "name": "Drag Drop test 1",
+ "version": "1.0"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf
new file mode 100644
index 0000000000..1da3c41b23
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: 3dL7JFDBPC63pSFI5x+Z7Q==
+SHA1-Digest: l1cKPyWJIYdZyvumH9VfJ6fpqVA=
+SHA256-Digest: QHTjPqTMXxt5tl8zOaAzpQ8FZLqZx8LRF9LmzY+RCDQ=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..170a361620
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf
new file mode 100644
index 0000000000..5301e431f7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: c30hzcI1ISlt46ODjVVJ2w==
+SHA1-Digest-Manifest: 2yMpQHuLM0J61T7vt11NHoYI1tU=
+SHA256-Digest-Manifest: qtsYxiv1zGWBp7JWxLWrIztIdxIt+i3CToReEx5fkyw=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json
new file mode 100644
index 0000000000..958aa03649
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop2/manifest.json
@@ -0,0 +1,12 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "dragdrop-2@tests.mozilla.org"
+ }
+ },
+
+ "name": "Drag Drop test 2",
+ "version": "1.1"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf
new file mode 100644
index 0000000000..e508bcd22f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: Wzo/k6fhArpFb4UB2hIKlg==
+SHA1-Digest: D/WDy9api0X7OgRM6Gkvfbyzogo=
+SHA256-Digest: IWBdbytHgPLtCMKKhiZ3jenxKmKiRAhh3ce8iP5AVWU=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..a026680e91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf
new file mode 100644
index 0000000000..16a1461f37
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: ovtNeIie34gMM5l18zP2MA==
+SHA1-Digest-Manifest: c5owdrvcOINxKp/HprYkWXXI/js=
+SHA256-Digest-Manifest: uLPmoONlxFYxWeSTOEPJ9hN2yMDDZMJL1PoNIWcqKG4=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json
new file mode 100644
index 0000000000..b204e1bca7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_dragdrop_incompat/manifest.json
@@ -0,0 +1,13 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "dragdrop-incompat@tests.mozilla.org",
+ "strict_max_version": "45.0"
+ }
+ },
+
+ "name": "Incomatible Drag Drop test",
+ "version": "1.1"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf
new file mode 100644
index 0000000000..eea5cbd501
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/manifest.mf
@@ -0,0 +1,8 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1 SHA256
+MD5-Digest: b4Q2C4GsIJfRLsXc7T2ldQ==
+SHA1-Digest: UG5rHxpzKmdlGrquXaguiAGDu8E=
+SHA256-Digest: WZrN9SdGBux9t3lV7TVIvyUG/L1px4er2dU3TsBpC4s=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..68621e19be
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf
new file mode 100644
index 0000000000..fe6baa8dac
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/META-INF/mozilla.sf
@@ -0,0 +1,5 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: zqRm8+jxS0iRUGWeArGkXg==
+SHA1-Digest-Manifest: pa/31Ll1PYx0dPBQ6C+fd1/wJO4=
+SHA256-Digest-Manifest: DJELIyswfwgeL0kaRqogXW2bzUKhn+Pickfv6WHBsW8=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json
new file mode 100644
index 0000000000..adc0ae09ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_installssl/manifest.json
@@ -0,0 +1,12 @@
+{
+ "manifest_version": 2,
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "sslinstall-1@tests.mozilla.org"
+ }
+ },
+
+ "name": "SSL Install Tests",
+ "version": "1.0"
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json
new file mode 100644
index 0000000000..7a399ddc17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/browser_theme/manifest.json
@@ -0,0 +1,22 @@
+{
+ "manifest_version": 2,
+
+ "name": "Theme test",
+ "version": "1.0",
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "theme@tests.mozilla.org"
+ }
+ },
+
+ "theme": {
+ "images": {
+ "theme_frame": "testImage.png"
+ },
+ "colors": {
+ "frame": "#000000",
+ "tab_background_text": "#ffffff"
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf
new file mode 100644
index 0000000000..a8c72c4794
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/manifest.mf
@@ -0,0 +1,12 @@
+Manifest-Version: 1.0
+
+Name: manifest.json
+Digest-Algorithms: MD5 SHA1
+MD5-Digest: Rnoaa6yWePDor5y5/SLFaw==
+SHA1-Digest: k51DtKj7bYrwkFJDdmYNDQeUBlA=
+
+Name: options.html
+Digest-Algorithms: MD5 SHA1
+MD5-Digest: vTjxWlRpioEhTZGKTNUqIw==
+SHA1-Digest: Y/mr6A34LsvekgRpdhyZRwPF1Vw=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa
new file mode 100644
index 0000000000..8b6320adda
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.rsa
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf
new file mode 100644
index 0000000000..ba5fd22caa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/META-INF/mozilla.sf
@@ -0,0 +1,4 @@
+Signature-Version: 1.0
+MD5-Digest-Manifest: rdmx8VMNzkZ5tRf7tt8G1w==
+SHA1-Digest-Manifest: gjtTe8X9Tg46Hz2h4Tru3T02hmE=
+
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json b/toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json
new file mode 100644
index 0000000000..e808cd5ab6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/manifest.json
@@ -0,0 +1,11 @@
+{
+ "manifest_version": 2,
+
+ "name": "Test options_ui",
+ "description": "Test add-ons manager handling options_ui with no id in manifest.json",
+ "version": "1.2",
+
+ "options_ui": {
+ "page": "options.html"
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html b/toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html
new file mode 100644
index 0000000000..ea804601b5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/addons/options_signed/options.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div id="options-test-panel" />
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/browser.toml b/toolkit/mozapps/extensions/test/browser/browser.toml
new file mode 100644
index 0000000000..1daf6211f8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser.toml
@@ -0,0 +1,193 @@
+[DEFAULT]
+tags = "addons"
+support-files = [
+ "addons/browser_dragdrop1.xpi",
+ "addons/browser_dragdrop1.zip",
+ "addons/browser_dragdrop2.xpi",
+ "addons/browser_dragdrop2.zip",
+ "addons/browser_dragdrop_incompat.xpi",
+ "addons/browser_installssl.xpi",
+ "addons/browser_theme.xpi",
+ "addons/options_signed.xpi",
+ "addons/options_signed/*",
+ "addon_prefs.xhtml",
+ "discovery/api_response.json",
+ "discovery/api_response_empty.json",
+ "discovery/small-1x1.png",
+ "head.js",
+ "redirect.sjs",
+ "browser_updatessl.json",
+ "browser_updatessl.json^headers^",
+ "sandboxed.html",
+ "sandboxed.html^headers^",
+ "webapi_addon_listener.html",
+ "webapi_checkavailable.html",
+ "webapi_checkchromeframe.xhtml",
+ "webapi_checkframed.html",
+ "webapi_checknavigatedwindow.html",
+ "!/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi",
+ "!/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi",
+ "!/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html",
+ "!/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi",
+ "!/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi",
+]
+
+generated-files = [
+ "addons/browser_dragdrop1.xpi",
+ "addons/browser_dragdrop1.zip",
+ "addons/browser_dragdrop2.xpi",
+ "addons/browser_dragdrop2.zip",
+ "addons/browser_dragdrop_incompat.xpi",
+ "addons/browser_installssl.xpi",
+ "addons/browser_theme.xpi",
+ "addons/options_signed.xpi",
+]
+
+skip-if = [
+ "os == 'linux' && asan", # Bug 1713895 - new Fission platform triage
+]
+prefs = [
+ "dom.webmidi.enabled=true",
+ "midi.testing=true",
+]
+
+["browser_AMBrowserExtensionsImport.js"]
+
+["browser_about_debugging_link.js"]
+
+["browser_addon_list_reordering.js"]
+
+["browser_amo_abuse_report.js"]
+
+["browser_bug572561.js"]
+
+["browser_checkAddonCompatibility.js"]
+
+["browser_colorwaybuiltins_migration.js"]
+skip-if = [
+ "app-name != 'firefox'",
+]
+
+["browser_dragdrop.js"]
+skip-if = ["true"] # Bug 1626824
+
+["browser_file_xpi_no_process_switch.js"]
+
+["browser_globalwarnings.js"]
+
+["browser_gmpProvider.js"]
+
+["browser_history_navigation.js"]
+https_first_disabled = true
+
+["browser_html_abuse_report.js"]
+support-files = ["head_abuse_report.js"]
+
+["browser_html_abuse_report_dialog.js"]
+support-files = ["head_abuse_report.js"]
+
+["browser_html_detail_permissions.js"]
+
+["browser_html_detail_view.js"]
+
+["browser_html_discover_view.js"]
+https_first_disabled = true
+support-files = ["head_disco.js"]
+
+["browser_html_discover_view_clientid.js"]
+
+["browser_html_discover_view_prefs.js"]
+
+["browser_html_list_view.js"]
+
+["browser_html_list_view_recommendations.js"]
+skip-if = ["a11y_checks"] # Bug 1858037 to investigate intermittent a11y_checks results for .popup-notification-primary-button.primary.footer-button
+
+["browser_html_message_bar.js"]
+
+["browser_html_options_ui.js"]
+
+["browser_html_options_ui_in_tab.js"]
+
+["browser_html_pending_updates.js"]
+
+["browser_html_recent_updates.js"]
+
+["browser_html_recommendations.js"]
+https_first_disabled = true
+
+["browser_html_scroll_restoration.js"]
+skip-if = ["os == 'mac' && verify && debug"] # Bug 1850159
+
+["browser_html_sitepermission_addons.js"]
+
+["browser_html_updates.js"]
+https_first_disabled = true
+
+["browser_html_warning_messages.js"]
+
+["browser_installssl.js"]
+
+["browser_installtrigger_install.js"]
+
+["browser_local_install.js"]
+
+["browser_manage_shortcuts.js"]
+
+["browser_manage_shortcuts_hidden.js"]
+
+["browser_manage_shortcuts_remove.js"]
+
+["browser_menu_button_accessibility.js"]
+
+["browser_page_accessibility.js"]
+
+["browser_page_options_install_addon.js"]
+
+["browser_page_options_updates.js"]
+
+["browser_permission_prompt.js"]
+
+["browser_reinstall.js"]
+
+["browser_shortcuts_duplicate_check.js"]
+
+["browser_sidebar_categories.js"]
+
+["browser_sidebar_hidden_categories.js"]
+
+["browser_sidebar_restore_category.js"]
+
+["browser_subframe_install.js"]
+
+["browser_task_next_test.js"]
+
+["browser_updateid.js"]
+
+["browser_updatessl.js"]
+
+["browser_verify_l10n_strings.js"]
+
+["browser_webapi.js"]
+
+["browser_webapi_abuse_report.js"]
+support-files = ["head_abuse_report.js"]
+
+["browser_webapi_access.js"]
+https_first_disabled = true
+
+["browser_webapi_addon_listener.js"]
+
+["browser_webapi_enable.js"]
+
+["browser_webapi_install.js"]
+
+["browser_webapi_install_disabled.js"]
+
+["browser_webapi_theme.js"]
+
+["browser_webapi_uninstall.js"]
+
+["browser_webext_icon.js"]
+
+["browser_webext_incognito.js"]
diff --git a/toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js b/toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js
new file mode 100644
index 0000000000..654e3cd91e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js
@@ -0,0 +1,220 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { AMBrowserExtensionsImport } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+// This test verifies the global notification in `about:addons` when there are
+// pending imported add-ons. The appmenu UI is covered by tests in:
+// `browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js`.
+
+AddonTestUtils.initMochitest(this);
+
+const TEST_SERVER = AddonTestUtils.createHttpServer();
+
+const ADDONS = {
+ ext1: {
+ manifest: {
+ name: "Ext 1",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "ff@ext-1" } },
+ permissions: ["history"],
+ },
+ },
+ ext2: {
+ manifest: {
+ name: "Ext 2",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "ff@ext-2" } },
+ permissions: ["history"],
+ },
+ },
+};
+// Populated in `setup()`.
+const XPIS = {};
+// Populated in `setup()`.
+const ADDON_SEARCH_RESULTS = {};
+
+const mockAddonRepository = ({ addons = [] }) => {
+ return {
+ async getMappedAddons(browserID, extensionIDs) {
+ return Promise.resolve({
+ addons,
+ matchedIDs: [],
+ unmatchedIDs: [],
+ });
+ },
+ };
+};
+
+const assertWarningShown = async (
+ win,
+ stack,
+ expectedWarningType = "imported-addons",
+ expectAction = true
+) => {
+ Assert.equal(stack.childElementCount, 1, "expected a global warning");
+ const messageBar = stack.firstElementChild;
+ Assert.equal(
+ messageBar.getAttribute("warning-type"),
+ expectedWarningType,
+ `expected a warning for ${expectedWarningType}`
+ );
+ Assert.equal(
+ messageBar.getAttribute("data-l10n-id"),
+ `extensions-warning-${expectedWarningType}2`,
+ "expected correct l10n ID"
+ );
+ await win.document.l10n.translateElements([messageBar]);
+
+ if (expectAction) {
+ const button = messageBar.querySelector("button");
+ Assert.equal(
+ button.getAttribute("action"),
+ expectedWarningType,
+ `expected a button for ${expectedWarningType}`
+ );
+ Assert.equal(
+ button.getAttribute("data-l10n-id"),
+ `extensions-warning-${expectedWarningType}-button`,
+ "expected correct l10n ID on the button"
+ );
+ await win.document.l10n.translateElements([button]);
+ }
+};
+
+add_setup(async function setup() {
+ for (const [name, data] of Object.entries(ADDONS)) {
+ XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data);
+ TEST_SERVER.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+
+ ADDON_SEARCH_RESULTS[name] = {
+ id: data.manifest.browser_specific_settings.gecko.id,
+ name: data.name,
+ version: data.version,
+ sourceURI: Services.io.newURI(
+ `http://localhost:${TEST_SERVER.identity.primaryPort}/addons/${name}.xpi`
+ ),
+ icons: {},
+ };
+ }
+
+ registerCleanupFunction(() => {
+ // Clear the add-on repository override.
+ AMBrowserExtensionsImport._addonRepository = null;
+ });
+});
+
+add_task(async function test_aboutaddons_global_message() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ });
+
+ // Global warnings should be displayed in all the `about:addons` views but
+ // the migration wizard links to the default view. That's why we load this
+ // view here, too (as opposed to, e.g., `"extensions"`).
+ const win = await loadInitialView();
+ const stack = win.document.querySelector("global-warnings");
+
+ Assert.equal(stack.childElementCount, 0, "expected no global warning");
+
+ let promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ // Start a first import...
+ await AMBrowserExtensionsImport.stageInstalls(browserID, extensionIDs);
+ await promiseTopic;
+ // We expect a warning about the imported add-ons to be shown.
+ await assertWarningShown(win, stack);
+
+ // ...then cancel it.
+ promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-cancelled"
+ );
+ await AMBrowserExtensionsImport.cancelInstalls();
+ await promiseTopic;
+
+ // At this point, the warning about the imported add-ons should be hidden.
+ Assert.equal(stack.childElementCount, 0, "expected no global warning");
+
+ // We start a second import here, then we make sure an imported-addons
+ // messagebar doesn't prevent the other global warning types to be shown.
+ promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ const result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ await assertWarningShown(win, stack);
+
+ info("Verify safe-mode is not hidden by an imported-addons messagebar");
+ stack.inSafeMode = true;
+ stack.refresh();
+ await assertWarningShown(
+ win,
+ stack,
+ "safe-mode",
+ false /* no button expected */
+ );
+ stack.inSafeMode = false;
+
+ info(
+ "Verify check-compatibility is not hidden by an imported-addons messagebar"
+ );
+ AddonManager.checkCompatibility = false;
+ stack.refresh();
+ await assertWarningShown(win, stack, "check-compatibility");
+ AddonManager.checkCompatibility = true;
+
+ info("Verify update-security is not hidden by an imported-addons messagebar");
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.checkUpdateSecurity", false]],
+ });
+ stack.refresh();
+ await assertWarningShown(win, stack, "update-security");
+ await SpecialPowers.popPrefEnv();
+
+ // After making sure the imported-addons messagebar is visible again, we
+ // finally complete the pending import with the UI from the global warning.
+ info(
+ "Verify pending imported addons can be completed from the messagebar action"
+ );
+ stack.refresh();
+ await assertWarningShown(win, stack, "imported-addons");
+
+ // Complete the installation of the add-ons by clicking on the button in the
+ // global warning.
+ promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-complete"
+ );
+ const endedPromises = result.importedAddonIDs.map(id =>
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id === id
+ )
+ );
+ stack.firstElementChild.querySelector("button").click();
+ await Promise.all([...endedPromises, promiseTopic]);
+
+ // At this point, the warning about the imported add-ons should be hidden
+ // because the add-ons are installed.
+ Assert.equal(stack.childElementCount, 0, "expected no global warning");
+
+ for (const id of result.importedAddonIDs) {
+ const addon = await AddonManager.getAddonByID(id);
+ Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
+ await addon.uninstall();
+ }
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js b/toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js
new file mode 100644
index 0000000000..c7351f054c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_about_debugging_link.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+// Allow rejections related to closing an about:debugging too soon after it has been
+// just opened in a new tab and loaded.
+PromiseTestUtils.allowMatchingRejectionsGlobally(/Connection closed/);
+
+function waitForDispatch(store, type) {
+ return new Promise(resolve => {
+ store.dispatch({
+ type: "@@service/waitUntil",
+ predicate: action => action.type === type,
+ run: (dispatch, getState, action) => {
+ resolve(action);
+ },
+ });
+ });
+}
+
+/**
+ * Wait for all client requests to settle, meaning here that no new request has been
+ * dispatched after the provided delay. (NOTE: same test helper used in about:debugging tests)
+ */
+async function waitForRequestsToSettle(store, delay = 500) {
+ let hasSettled = false;
+
+ // After each iteration of this while loop, we check is the timerPromise had the time
+ // to resolve or if we captured a REQUEST_*_SUCCESS action before.
+ while (!hasSettled) {
+ let timer;
+
+ // This timer will be executed only if no REQUEST_*_SUCCESS action is dispatched
+ // during the delay. We consider that when no request are received for some time, it
+ // means there are no ongoing requests anymore.
+ const timerPromise = new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ timer = setTimeout(() => {
+ hasSettled = true;
+ resolve();
+ }, delay);
+ });
+
+ // Wait either for a REQUEST_*_SUCCESS to be dispatched, or for the timer to resolve.
+ await Promise.race([
+ waitForDispatch(store, "REQUEST_EXTENSIONS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_TABS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_WORKERS_SUCCESS"),
+ timerPromise,
+ ]);
+
+ // Clear the timer to avoid setting hasSettled to true accidently unless timerPromise
+ // was the first to resolve.
+ clearTimeout(timer);
+ }
+}
+
+function waitForRequestsSuccess(store) {
+ return Promise.all([
+ waitForDispatch(store, "REQUEST_EXTENSIONS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_TABS_SUCCESS"),
+ waitForDispatch(store, "REQUEST_WORKERS_SUCCESS"),
+ ]);
+}
+
+add_task(async function testAboutDebugging() {
+ let win = await loadInitialView("extension");
+
+ let aboutAddonsTab = gBrowser.selectedTab;
+ let debugAddonsBtn = win.document.querySelector(
+ '#page-options [action="debug-addons"]'
+ );
+
+ // Verify the about:debugging is loaded.
+ info(`Check about:debugging loads`);
+ let loaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:debugging#/runtime/this-firefox",
+ true
+ );
+ debugAddonsBtn.click();
+ await loaded;
+ let aboutDebuggingTab = gBrowser.selectedTab;
+ const { AboutDebugging } = aboutDebuggingTab.linkedBrowser.contentWindow;
+ // Avoid test failures due to closing the about:debugging tab
+ // while it is still initializing.
+ info("Wait until about:debugging actions are finished");
+ await waitForRequestsSuccess(AboutDebugging.store);
+
+ info("Switch back to about:addons");
+ await BrowserTestUtils.switchTab(gBrowser, aboutAddonsTab);
+ is(gBrowser.selectedTab, aboutAddonsTab, "Back to about:addons");
+
+ info("Re-open about:debugging");
+ let switched = TestUtils.waitForCondition(
+ () => gBrowser.selectedTab == aboutDebuggingTab
+ );
+ debugAddonsBtn.click();
+ await switched;
+ await waitForRequestsToSettle(AboutDebugging.store);
+
+ info("Force about:debugging to a different hash URL");
+ aboutDebuggingTab.linkedBrowser.contentWindow.location.hash = "/setup";
+
+ info("Switch back to about:addons again");
+ await BrowserTestUtils.switchTab(gBrowser, aboutAddonsTab);
+ is(gBrowser.selectedTab, aboutAddonsTab, "Back to about:addons");
+
+ info("Re-open about:debugging a second time");
+ switched = TestUtils.waitForCondition(
+ () => gBrowser.selectedTab == aboutDebuggingTab
+ );
+ debugAddonsBtn.click();
+ await switched;
+
+ info("Wait until any new about:debugging request did settle");
+ // Avoid test failures due to closing the about:debugging tab
+ // while it is still initializing.
+ await waitForRequestsToSettle(AboutDebugging.store);
+
+ info("Remove the about:debugging tab");
+ BrowserTestUtils.removeTab(aboutDebuggingTab);
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js b/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js
new file mode 100644
index 0000000000..a80a57bb7e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_addon_list_reordering.js
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+function assertInSection(card, sectionName, msg) {
+ let section = card.closest("section");
+ let heading = section.querySelector(".list-section-heading");
+ is(
+ card.ownerDocument.l10n.getAttributes(heading).id,
+ `extension-${sectionName}-heading`,
+ msg
+ );
+}
+
+function waitForAnimationFrame(win) {
+ return new Promise(resolve => win.requestAnimationFrame(resolve));
+}
+
+async function clickEnableToggle(card) {
+ let isDisabled = card.addon.userDisabled;
+ let addonEvent = isDisabled ? "onEnabled" : "onDisabled";
+ let addonStateChanged = AddonTestUtils.promiseAddonEvent(addonEvent);
+ let win = card.ownerGlobal;
+ let button = card.querySelector(".extension-enable-button");
+
+ // Centre the button since "start" could be behind the sticky header.
+ button.scrollIntoView({ block: "center" });
+ EventUtils.synthesizeMouseAtCenter(button, { type: "mousemove" }, win);
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+
+ await addonStateChanged;
+ await waitForAnimationFrame(win);
+}
+
+function mouseOver(el) {
+ let win = el.ownerGlobal;
+ el.scrollIntoView({ block: "center" });
+ EventUtils.synthesizeMouseAtCenter(el, { type: "mousemove" }, win);
+ return waitForAnimationFrame(win);
+}
+
+function mouseOutOfList(win) {
+ return mouseOver(win.document.querySelector(".header-name"));
+}
+
+function pressKey(win, key) {
+ EventUtils.synthesizeKey(key, {}, win);
+ return waitForAnimationFrame(win);
+}
+
+function waitForTransitionEnd(...els) {
+ return Promise.all(
+ els.map(el =>
+ BrowserTestUtils.waitForEvent(el, "transitionend", false, e => {
+ let cardEl = el.firstElementChild;
+ return e.target == cardEl && e.propertyName == "transform";
+ })
+ )
+ );
+}
+
+add_setup(async function () {
+ // Ensure prefers-reduced-motion isn't set. Some linux environments will have
+ // this enabled by default.
+ await SpecialPowers.pushPrefEnv({
+ set: [["ui.prefersReducedMotion", 0]],
+ });
+});
+
+add_task(async function testReordering() {
+ let addonIds = [
+ "one@mochi.test",
+ "two@mochi.test",
+ "three@mochi.test",
+ "four@mochi.test",
+ "five@mochi.test",
+ ];
+ let extensions = addonIds.map(id =>
+ ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: id,
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ })
+ );
+
+ await Promise.all(extensions.map(ext => ext.startup()));
+
+ let win = await loadInitialView("extension", { withAnimations: true });
+
+ let cardOne = getAddonCard(win, "one@mochi.test");
+ ok(!cardOne.addon.userDisabled, "extension one is enabled");
+ assertInSection(cardOne, "enabled", "cardOne is initially in Enabled");
+
+ await clickEnableToggle(cardOne);
+
+ ok(cardOne.addon.userDisabled, "extension one is now disabled");
+ assertInSection(cardOne, "enabled", "cardOne is still in Enabled");
+
+ let cardThree = getAddonCard(win, "three@mochi.test");
+ ok(!cardThree.addon.userDisabled, "extension three is enabled");
+ assertInSection(cardThree, "enabled", "cardThree is initially in Enabled");
+
+ await clickEnableToggle(cardThree);
+
+ ok(cardThree.addon.userDisabled, "extension three is now disabled");
+ assertInSection(cardThree, "enabled", "cardThree is still in Enabled");
+
+ let transitionsEnded = waitForTransitionEnd(cardOne, cardThree);
+ await mouseOutOfList(win);
+ await transitionsEnded;
+
+ assertInSection(cardOne, "disabled", "cardOne has moved to disabled");
+ assertInSection(cardThree, "disabled", "cardThree has moved to disabled");
+
+ await clickEnableToggle(cardThree);
+ await clickEnableToggle(cardOne);
+
+ assertInSection(cardOne, "disabled", "cardOne is still in disabled");
+ assertInSection(cardThree, "disabled", "cardThree is still in disabled");
+
+ info("Opening a more options menu");
+ let panel = cardThree.querySelector("panel-list");
+ EventUtils.synthesizeMouseAtCenter(
+ cardThree.querySelector('[action="more-options"]'),
+ {},
+ win
+ );
+
+ await BrowserTestUtils.waitForEvent(panel, "shown");
+ await mouseOutOfList(win);
+
+ assertInSection(cardOne, "disabled", "cardOne stays in disabled, menu open");
+ assertInSection(cardThree, "disabled", "cardThree stays in disabled");
+
+ transitionsEnded = waitForTransitionEnd(cardOne, cardThree);
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive element to clear the focused
+ // state with a mouse which can be done by assistive technology and keyboard
+ // by pressing `Esc` key, this rule check shall be ignored by a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ // Click outside the list to clear any focus.
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.querySelector(".header-name"),
+ {},
+ win
+ );
+ AccessibilityUtils.resetEnv();
+ await transitionsEnded;
+
+ assertInSection(cardOne, "enabled", "cardOne is now in enabled");
+ assertInSection(cardThree, "enabled", "cardThree is now in enabled");
+
+ let cardOneToggle = cardOne.querySelector(".extension-enable-button");
+ cardOneToggle.scrollIntoView({ block: "center" });
+ cardOneToggle.focus();
+ await pressKey(win, " ");
+ await waitForAnimationFrame(win);
+
+ let cardThreeToggle = cardThree.querySelector(".extension-enable-button");
+ let addonList = win.document.querySelector("addon-list");
+ // Tab down to cardThreeToggle.
+ while (
+ addonList.contains(win.document.activeElement) &&
+ win.document.activeElement !== cardThreeToggle
+ ) {
+ await pressKey(win, "VK_TAB");
+ }
+ await pressKey(win, " ");
+
+ assertInSection(cardOne, "enabled", "cardOne is still in enabled");
+ assertInSection(cardThree, "enabled", "cardThree is still in enabled");
+
+ transitionsEnded = waitForTransitionEnd(cardOne, cardThree);
+ win.document.querySelector('[action="page-options"]').focus();
+ await transitionsEnded;
+ assertInSection(
+ cardOne,
+ "disabled",
+ "cardOne is now in the disabled section"
+ );
+ assertInSection(
+ cardThree,
+ "disabled",
+ "cardThree is now in the disabled section"
+ );
+
+ // Ensure an uninstalled extension is removed right away.
+ // Hover a card in the middle of the list.
+ await mouseOver(getAddonCard(win, "two@mochi.test"));
+ await cardOne.addon.uninstall(true);
+ ok(!cardOne.parentNode, "cardOne has been removed from the document");
+
+ await closeView(win);
+ await Promise.all(extensions.map(ext => ext.unload()));
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_amo_abuse_report.js b/toolkit/mozapps/extensions/test/browser/browser_amo_abuse_report.js
new file mode 100644
index 0000000000..b470cf2d82
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_amo_abuse_report.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "extensions.abuseReport.amoFormURL",
+ "https://example.org/%LOCALE%/%APP%/feedback/addon/%addonID%/",
+ ],
+ ],
+ });
+
+ // Explicitly flip the amoFormEnabled pref on builds where the pref is
+ // expected to not be set to true by default.
+ if (AppConstants.MOZ_APP_NAME != "firefox") {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.abuseReport.amoFormEnabled", true]],
+ });
+ }
+
+ const { AbuseReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+ );
+
+ Assert.equal(
+ AbuseReporter.amoFormEnabled,
+ true,
+ "Expect AMO abuse report form to be enabled"
+ );
+
+ // Setting up MockProvider to mock various addon types
+ // as installed.
+ await AbuseReportTestUtils.setup();
+});
+
+add_task(async function test_opens_amo_form_in_a_tab() {
+ await openAboutAddons();
+
+ const ADDON_ID = "test-ext@mochitest";
+ const expectedUrl = Services.urlFormatter
+ .formatURLPref("extensions.abuseReport.amoFormURL")
+ .replace("%addonID%", ADDON_ID);
+
+ const promiseWaitForAMOFormTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ expectedUrl
+ );
+ info("Call about:addons openAbuseReport helper function");
+ gManagerWindow.openAbuseReport({ addonId: ADDON_ID });
+ info(`Wait for the AMO form url ${expectedUrl} to be opened in a new tab`);
+ const tab = await promiseWaitForAMOFormTab;
+ Assert.equal(
+ tab.linkedBrowser.currentURI.spec,
+ expectedUrl,
+ "The newly opened tab has the expected url"
+ );
+ Assert.equal(gBrowser.selectedTab, tab, "The newly opened tab is selected");
+
+ BrowserTestUtils.removeTab(tab);
+ await closeAboutAddons();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_report_button_shown_on_dictionary_addons() {
+ await openAboutAddons("dictionary");
+ await AbuseReportTestUtils.assertReportActionShown(
+ gManagerWindow,
+ EXT_DICTIONARY_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_langpack_addons() {
+ await openAboutAddons("locale");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_LANGPACK_ADDON_ID
+ );
+ await closeAboutAddons();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_bug572561.js b/toolkit/mozapps/extensions/test/browser/browser_bug572561.js
new file mode 100644
index 0000000000..6f8a56bfba
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_bug572561.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that the locale category is shown if there are no locale packs
+// installed but some are pending install
+
+var gManagerWindow;
+var gCategoryUtilities;
+var gProvider;
+var gInstallProperties = [
+ {
+ name: "Locale Category Test",
+ type: "locale",
+ },
+];
+var gInstall;
+var gExpectedCancel = false;
+var gTestInstallListener = {
+ onInstallStarted(aInstall) {
+ check_hidden(false);
+ },
+
+ onInstallEnded(aInstall) {
+ check_hidden(false);
+ run_next_test();
+ },
+
+ onInstallCancelled(aInstall) {
+ ok(gExpectedCancel, "Should expect install cancel");
+ check_hidden(false);
+ run_next_test();
+ },
+
+ onInstallFailed(aInstall) {
+ ok(false, "Did not expect onInstallFailed");
+ run_next_test();
+ },
+};
+
+async function test() {
+ waitForExplicitFinish();
+
+ gProvider = new MockProvider();
+
+ let aWindow = await open_manager("addons://list/extension");
+ gManagerWindow = aWindow;
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ run_next_test();
+}
+
+async function end_test() {
+ await close_manager(gManagerWindow);
+ finish();
+}
+
+function check_hidden(aExpectedHidden) {
+ var hidden = !gCategoryUtilities.isTypeVisible("locale");
+ is(hidden, aExpectedHidden, "Should have correct hidden state");
+}
+
+// Tests that a non-active install does not make the locale category show
+add_test(function () {
+ check_hidden(true);
+ gInstall = gProvider.createInstalls(gInstallProperties)[0];
+ gInstall.addTestListener(gTestInstallListener);
+ check_hidden(true);
+ run_next_test();
+});
+
+// Test that restarting the add-on manager with a non-active install
+// does not cause the locale category to show
+add_test(async function () {
+ let aWindow = await restart_manager(gManagerWindow, null);
+ gManagerWindow = aWindow;
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ check_hidden(true);
+ run_next_test();
+});
+
+// Test that installing the install shows the locale category
+add_test(function () {
+ gInstall.install();
+});
+
+// Test that restarting the add-on manager does not cause the locale category
+// to become hidden
+add_test(async function () {
+ let aWindow = await restart_manager(gManagerWindow, null);
+ gManagerWindow = aWindow;
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ check_hidden(false);
+
+ gExpectedCancel = true;
+ gInstall.cancel();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js b/toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js
new file mode 100644
index 0000000000..9cea5b5045
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_checkAddonCompatibility.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that all bundled add-ons are compatible.
+
+async function test() {
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref(PREF_STRICT_COMPAT, true);
+ ok(
+ AddonManager.strictCompatibility,
+ "Strict compatibility should be enabled"
+ );
+
+ let aAddons = await AddonManager.getAllAddons();
+ aAddons.sort(function compareTypeName(a, b) {
+ return a.type.localeCompare(b.type) || a.name.localeCompare(b.name);
+ });
+
+ let allCompatible = true;
+ for (let a of aAddons) {
+ // Ignore plugins.
+ if (a.type == "plugin" || a.id == "workerbootstrap-test@mozilla.org") {
+ continue;
+ }
+
+ ok(
+ a.isCompatible,
+ a.type + " " + a.name + " " + a.version + " should be compatible"
+ );
+ allCompatible = allCompatible && a.isCompatible;
+ }
+
+ finish();
+}
diff --git a/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js b/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js
new file mode 100644
index 0000000000..772e327afc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_colorwaybuiltins_migration.js
@@ -0,0 +1,265 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from ../../../../../browser/base/content/test/webextensions/head.js */
+loadTestSubscript(
+ "../../../../../browser/base/content/test/webextensions/head.js"
+);
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer();
+
+const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+const EXPIRED_COLORWAY_THEME_ID1 = "2022red-colorway@mozilla.org";
+const EXPIRED_COLORWAY_THEME_ID2 = "2022orange-colorway@mozilla.org";
+const ICON_SVG = `
+ <svg width="63" height="62" viewBox="0 0 63 62" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="31.5" cy="31" r="31" fill="url(#paint0_linear)"/>
+ <defs>
+ <linearGradient id="paint0_linear" x1="44.4829" y1="19" x2="10.4829" y2="53" gradientUnits="userSpaceOnUse">
+ <stop stop-color="hsl(147, 94%, 25%)"/>
+ <stop offset="1" stop-color="hsl(146, 38%, 49%)"/>
+ </linearGradient>
+ </defs>
+ </svg>
+`;
+
+AddonTestUtils.registerJSON(server, "/updates.json", {
+ addons: {
+ [EXPIRED_COLORWAY_THEME_ID1]: {
+ updates: [
+ {
+ version: "2.0.0",
+ update_link: `${SERVER_BASE_URL}/${EXPIRED_COLORWAY_THEME_ID1}.xpi`,
+ },
+ ],
+ },
+ [EXPIRED_COLORWAY_THEME_ID2]: {
+ updates: [
+ {
+ version: "3.0.0",
+ update_link: `${SERVER_BASE_URL}/${EXPIRED_COLORWAY_THEME_ID2}.xpi`,
+ },
+ ],
+ },
+ },
+});
+
+const createMockThemeManifest = (id, version) => ({
+ name: `Mock theme ${id} ${version}`,
+ author: "Mozilla",
+ version,
+ icons: { 32: "icon.svg" },
+ theme: {
+ colors: {
+ toolbar: "red",
+ },
+ },
+ browser_specific_settings: {
+ gecko: { id },
+ },
+});
+
+function createWebExtensionFile(id, version) {
+ return AddonTestUtils.createTempWebExtensionFile({
+ files: { "icon.svg": ICON_SVG },
+ manifest: createMockThemeManifest(id, version),
+ });
+}
+
+let expiredThemeUpdate1 = createWebExtensionFile(
+ EXPIRED_COLORWAY_THEME_ID1,
+ "2.0.0"
+);
+let expiredThemeUpdate2 = createWebExtensionFile(
+ EXPIRED_COLORWAY_THEME_ID2,
+ "3.0.0"
+);
+
+server.registerFile(`/${EXPIRED_COLORWAY_THEME_ID1}.xpi`, expiredThemeUpdate1);
+server.registerFile(`/${EXPIRED_COLORWAY_THEME_ID2}.xpi`, expiredThemeUpdate2);
+
+const goBack = async win => {
+ let loaded = waitForViewLoad(win);
+ let backButton = win.document.querySelector(".back-button");
+ ok(!backButton.disabled, "back button is enabled");
+ backButton.click();
+ await loaded;
+};
+
+const assertAddonCardFound = (win, { addonId, expectColorwayBuiltIn }) => {
+ const msg = expectColorwayBuiltIn
+ ? `Found addon card for colorway builtin ${addonId}`
+ : `Found addon card for migrated colorway ${addonId}`;
+
+ Assert.equal(
+ getAddonCard(win, addonId)?.addon.isBuiltinColorwayTheme,
+ expectColorwayBuiltIn,
+ msg
+ );
+};
+
+const assertDetailView = async (win, { addonId, expectThemeName }) => {
+ let loadedDetailView = waitForViewLoad(win);
+ await gBrowser.ownerGlobal.promiseDocumentFlushed(() => {});
+ const themeCard = getAddonCard(win, addonId);
+ // Ensure that we send a click on the control that is accessible (while a
+ // mouse user could also activate a card by clicking on the entire container):
+ const themeCardLink = themeCard.querySelector(".addon-name-link");
+ themeCardLink.click();
+ await loadedDetailView;
+ Assert.equal(
+ themeCard.querySelector(".addon-name")?.textContent,
+ expectThemeName,
+ `Got the expected addon name in the addon details for ${addonId}`
+ );
+};
+
+async function test_update_expired_colorways_builtins() {
+ // Set expired theme as a retained colorway theme
+ const retainedThemePrefName = "browser.theme.retainedExpiredThemes";
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_UPDATEURL, `${SERVER_BASE_URL}/updates.json`],
+ ["extensions.checkUpdateSecurity", false],
+ ["browser.theme.colorway-migration", true],
+ [
+ retainedThemePrefName,
+ JSON.stringify([
+ EXPIRED_COLORWAY_THEME_ID1,
+ EXPIRED_COLORWAY_THEME_ID2,
+ ]),
+ ],
+ ],
+ });
+
+ await BuiltInThemes.ensureBuiltInThemes();
+ async function uninstallTestAddons() {
+ for (const addonId of [
+ EXPIRED_COLORWAY_THEME_ID1,
+ EXPIRED_COLORWAY_THEME_ID2,
+ ]) {
+ info(`Uninstalling test theme ${addonId}`);
+ let addon = await AddonManager.getAddonByID(addonId);
+ await addon?.uninstall();
+ }
+ }
+ registerCleanupFunction(uninstallTestAddons);
+
+ const expiredAddon1 = await AddonManager.getAddonByID(
+ EXPIRED_COLORWAY_THEME_ID1
+ );
+ const expiredAddon2 = await AddonManager.getAddonByID(
+ EXPIRED_COLORWAY_THEME_ID2
+ );
+ await expiredAddon2.disable();
+ await expiredAddon1.enable();
+
+ info("Open about:addons theme list view");
+ let win = await loadInitialView("theme");
+
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: true,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: true,
+ });
+
+ info("Trigger addon update check");
+ const promiseInstallsEnded = Promise.all([
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id === EXPIRED_COLORWAY_THEME_ID1
+ ),
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id === EXPIRED_COLORWAY_THEME_ID1
+ ),
+ ]);
+ // Wait for active theme to also execute the update bootstrap method.
+ let promiseUpdatedAddon1 = waitForUpdate(expiredAddon1);
+ triggerPageOptionsAction(win, "check-for-updates");
+
+ info("Wait for addon update to be completed");
+ await Promise.all([promiseUpdatedAddon1, promiseInstallsEnded]);
+
+ info("Verify theme list view addon cards");
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: false,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: false,
+ });
+
+ info(`Switch to detail view for theme ${EXPIRED_COLORWAY_THEME_ID1}`);
+ await assertDetailView(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectThemeName: `Mock theme ${EXPIRED_COLORWAY_THEME_ID1} 2.0.0`,
+ });
+
+ info("Switch back to list view");
+ await goBack(win);
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: false,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: false,
+ });
+
+ info(`Switch to detail view for theme ${EXPIRED_COLORWAY_THEME_ID2}`);
+ await assertDetailView(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectThemeName: `Mock theme ${EXPIRED_COLORWAY_THEME_ID2} 3.0.0`,
+ });
+
+ info("Switch back to list view");
+ await goBack(win);
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID1,
+ expectColorwayBuiltIn: false,
+ });
+ assertAddonCardFound(win, {
+ addonId: EXPIRED_COLORWAY_THEME_ID2,
+ expectColorwayBuiltIn: false,
+ });
+
+ Assert.deepEqual(
+ JSON.parse(
+ Services.prefs.getStringPref("browser.theme.retainedExpiredThemes")
+ ),
+ [],
+ "Migrated colorways theme have been removed from the retainedExpiredThemes pref"
+ );
+
+ await closeView(win);
+ await uninstallTestAddons();
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_colorways_builtin_theme_migration() {
+ await test_update_expired_colorways_builtins();
+});
+
+add_task(
+ async function test_colorways_builtin_theme_migration_on_disabledAutoUpdates() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.update.autoUpdateDefault", false]],
+ });
+
+ await test_update_expired_colorways_builtins();
+
+ await SpecialPowers.popPrefEnv();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_dragdrop.js b/toolkit/mozapps/extensions/test/browser/browser_dragdrop.js
new file mode 100644
index 0000000000..ae8625a18a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_dragdrop.js
@@ -0,0 +1,270 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ABOUT_ADDONS_URL = "chrome://mozapps/content/extensions/aboutaddons.html";
+
+const dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
+ Ci.nsIDragService
+);
+
+// Test that the drag-drop-addon-installer component installs add-ons and is
+// included in about:addons. There is an issue with EventUtils.synthesizeDrop
+// where it throws an exception when you give it an subbrowser so we test
+// the component directly.
+
+async function checkInstallConfirmation(...names) {
+ let notificationCount = 0;
+ let observer = {
+ observe(aSubject, aTopic, aData) {
+ let installInfo = aSubject.wrappedJSObject;
+ isnot(
+ installInfo.browser,
+ null,
+ "Notification should have non-null browser"
+ );
+
+ is(
+ installInfo.installs.length,
+ 1,
+ "Got one AddonInstall instance as expected"
+ );
+
+ Assert.deepEqual(
+ installInfo.installs[0].installTelemetryInfo,
+ { source: "about:addons", method: "drag-and-drop" },
+ "Got the expected installTelemetryInfo"
+ );
+
+ notificationCount++;
+ },
+ };
+ Services.obs.addObserver(observer, "addon-install-started");
+
+ let results = [];
+
+ let promise = promisePopupNotificationShown("addon-webext-permissions");
+ for (let i = 0; i < names.length; i++) {
+ let panel = await promise;
+ let name = panel.getAttribute("name");
+ results.push(name);
+
+ info(`Saw install for ${name}`);
+ if (results.length < names.length) {
+ info(
+ `Waiting for installs for ${names.filter(n => !results.includes(n))}`
+ );
+
+ promise = promisePopupNotificationShown("addon-webext-permissions");
+ }
+ panel.secondaryButton.click();
+ }
+
+ Assert.deepEqual(results.sort(), names.sort(), "Got expected installs");
+
+ is(
+ notificationCount,
+ names.length,
+ `Saw ${names.length} addon-install-started notification`
+ );
+ Services.obs.removeObserver(observer, "addon-install-started");
+}
+
+function getDragOverTarget(win) {
+ return win.document.querySelector("categories-box");
+}
+
+function getDropTarget(win) {
+ return win.document.querySelector("drag-drop-addon-installer");
+}
+
+function withTestPage(fn) {
+ return BrowserTestUtils.withNewTab(
+ { url: ABOUT_ADDONS_URL, gBrowser },
+ async browser => {
+ let win = browser.contentWindow;
+ await win.customElements.whenDefined("drag-drop-addon-installer");
+ await fn(browser);
+ }
+ );
+}
+
+function initDragSession({ dragData, dropEffect }) {
+ let dropAction;
+ switch (dropEffect) {
+ case null:
+ case undefined:
+ case "move":
+ dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_MOVE;
+ break;
+ case "copy":
+ dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_COPY;
+ break;
+ case "link":
+ dropAction = _EU_Ci.nsIDragService.DRAGDROP_ACTION_LINK;
+ break;
+ default:
+ throw new Error(`${dropEffect} is an invalid drop effect value`);
+ }
+
+ const dataTransfer = new DataTransfer();
+ dataTransfer.dropEffect = dropEffect;
+
+ for (let i = 0; i < dragData.length; i++) {
+ const item = dragData[i];
+ for (let j = 0; j < item.length; j++) {
+ dataTransfer.mozSetDataAt(item[j].type, item[j].data, i);
+ }
+ }
+
+ dragService.startDragSessionForTests(dropAction);
+ const session = dragService.getCurrentSession();
+ session.dataTransfer = dataTransfer;
+
+ return session;
+}
+
+async function simulateDragAndDrop(win, dragData) {
+ const dropTarget = getDropTarget(win);
+ const dragOverTarget = getDragOverTarget(win);
+ const dropEffect = "move";
+
+ const session = initDragSession({ dragData, dropEffect });
+
+ info("Simulate drag over and wait for the drop target to be visible");
+
+ EventUtils.synthesizeDragOver(
+ dragOverTarget,
+ dragOverTarget,
+ dragData,
+ dropEffect,
+ win
+ );
+
+ // This make sure that the fake dataTransfer has still
+ // the expected drop effect after the synthesizeDragOver call.
+ session.dataTransfer.dropEffect = "move";
+
+ await BrowserTestUtils.waitForCondition(
+ () => !dropTarget.hidden,
+ "Wait for the drop target element to be visible"
+ );
+
+ info("Simulate drop dragData on drop target");
+
+ EventUtils.synthesizeDropAfterDragOver(
+ null,
+ session.dataTransfer,
+ dropTarget,
+ win,
+ { _domDispatchOnly: true }
+ );
+
+ dragService.endDragSession(true);
+}
+
+// Simulates dropping a URL onto the manager
+add_task(async function test_drop_url() {
+ for (let fileType of ["xpi", "zip"]) {
+ await withTestPage(async browser => {
+ const url = TESTROOT + `addons/browser_dragdrop1.${fileType}`;
+ const promise = checkInstallConfirmation("Drag Drop test 1");
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url }],
+ ]);
+
+ await promise;
+ });
+ }
+});
+
+// Simulates dropping a file onto the manager
+add_task(async function test_drop_file() {
+ for (let fileType of ["xpi", "zip"]) {
+ await withTestPage(async browser => {
+ let fileurl = get_addon_file_url(`browser_dragdrop1.${fileType}`);
+ let promise = checkInstallConfirmation("Drag Drop test 1");
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "application/x-moz-file", data: fileurl.file }],
+ ]);
+
+ await promise;
+ });
+ }
+});
+
+// Simulates dropping two urls onto the manager
+add_task(async function test_drop_multiple_urls() {
+ await withTestPage(async browser => {
+ let url1 = TESTROOT + "addons/browser_dragdrop1.xpi";
+ let url2 = TESTROOT2 + "addons/browser_dragdrop2.zip";
+ let promise = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url1 }],
+ [{ type: "text/x-moz-url", data: url2 }],
+ ]);
+
+ await promise;
+ });
+}).skip(); // TODO(rpl): this fails because mozSetDataAt throws IndexSizeError.
+
+// Simulates dropping two files onto the manager
+add_task(async function test_drop_multiple_files() {
+ await withTestPage(async browser => {
+ let fileurl1 = get_addon_file_url("browser_dragdrop1.zip");
+ let fileurl2 = get_addon_file_url("browser_dragdrop2.xpi");
+ let promise = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "application/x-moz-file", data: fileurl1.file }],
+ [{ type: "application/x-moz-file", data: fileurl2.file }],
+ ]);
+
+ await promise;
+ });
+}).skip(); // TODO(rpl): this fails because mozSetDataAt throws IndexSizeError.
+
+// Simulates dropping a file and a url onto the manager (weird, but should still work)
+add_task(async function test_drop_file_and_url() {
+ await withTestPage(async browser => {
+ let url = TESTROOT + "addons/browser_dragdrop1.xpi";
+ let fileurl = get_addon_file_url("browser_dragdrop2.zip");
+ let promise = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url }],
+ [{ type: "application/x-moz-file", data: fileurl.file }],
+ ]);
+
+ await promise;
+ });
+}).skip(); // TODO(rpl): this fails because mozSetDataAt throws IndexSizeError.
+
+// Test that drag-and-drop of an incompatible addon generates
+// an error.
+add_task(async function test_drop_incompat_file() {
+ await withTestPage(async browser => {
+ let url = `${TESTROOT}/addons/browser_dragdrop_incompat.xpi`;
+
+ let panelPromise = promisePopupNotificationShown("addon-install-failed");
+ await simulateDragAndDrop(browser.contentWindow, [
+ [{ type: "text/x-moz-url", data: url }],
+ ]);
+
+ let panel = await panelPromise;
+ ok(panel, "Got addon-install-failed popup");
+ panel.button.click();
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js b/toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js
new file mode 100644
index 0000000000..6793363698
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_file_xpi_no_process_switch.js
@@ -0,0 +1,122 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const ADDON_INSTALL_ID = "addon-webext-permissions";
+
+let fileurl1 = get_addon_file_url("browser_dragdrop1.xpi");
+let fileurl2 = get_addon_file_url("browser_dragdrop2.xpi");
+
+function promiseInstallNotification(aBrowser) {
+ return new Promise(resolve => {
+ function popupshown(event) {
+ let notification = PopupNotifications.getNotification(
+ ADDON_INSTALL_ID,
+ aBrowser
+ );
+ if (!notification) {
+ return;
+ }
+
+ if (gBrowser.selectedBrowser !== aBrowser) {
+ return;
+ }
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ ok(true, `Got ${ADDON_INSTALL_ID} popup for browser`);
+ event.target.firstChild.secondaryButton.click();
+ resolve();
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function CheckBrowserInPid(browser, expectedPid, message) {
+ return SpecialPowers.spawn(browser, [{ expectedPid, message }], arg => {
+ is(Services.appinfo.processID, arg.expectedPid, arg.message);
+ });
+}
+
+async function testOpenedAndDraggedXPI(aBrowser) {
+ // Get the current pid for browser for comparison later.
+ let browserPid = await SpecialPowers.spawn(aBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+
+ // No process switch for XPI file:// URI in the urlbar.
+ let promiseNotification = promiseInstallNotification(aBrowser);
+ let urlbar = gURLBar;
+ urlbar.value = fileurl1.spec;
+ urlbar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ await promiseNotification;
+ await CheckBrowserInPid(
+ aBrowser,
+ browserPid,
+ "Check that browser has not switched process."
+ );
+
+ // No process switch for XPI file:// URI dragged to tab.
+ let tab = gBrowser.getTabForBrowser(aBrowser);
+ promiseNotification = promiseInstallNotification(aBrowser);
+ let effect = EventUtils.synthesizeDrop(
+ tab,
+ tab,
+ [[{ type: "text/uri-list", data: fileurl1.spec }]],
+ "move"
+ );
+ is(effect, "move", "Drag should be accepted");
+ await promiseNotification;
+ await CheckBrowserInPid(
+ aBrowser,
+ browserPid,
+ "Check that browser has not switched process."
+ );
+
+ // No process switch for two XPI file:// URIs dragged to tab.
+ promiseNotification = promiseInstallNotification(aBrowser);
+ let promiseNewTab = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ effect = EventUtils.synthesizeDrop(
+ tab,
+ tab,
+ [
+ [{ type: "text/uri-list", data: fileurl1.spec }],
+ [{ type: "text/uri-list", data: fileurl2.spec }],
+ ],
+ "move"
+ );
+ is(effect, "move", "Drag should be accepted");
+ // When drag'n'dropping two XPIs, one is loaded in the current tab while the
+ // other one is loaded in a new tab.
+ let { target: newTab } = await promiseNewTab;
+ // This is the prompt for the first XPI in the current tab.
+ await promiseNotification;
+
+ let promiseSecondNotification = promiseInstallNotification(
+ newTab.linkedBrowser
+ );
+
+ // We switch to the second tab and wait for the prompt for the second XPI.
+ BrowserTestUtils.switchTab(gBrowser, newTab);
+ await promiseSecondNotification;
+
+ BrowserTestUtils.removeTab(newTab);
+
+ await CheckBrowserInPid(
+ aBrowser,
+ browserPid,
+ "Check that browser has not switched process."
+ );
+}
+
+// Test for bug 1175267.
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ "http://example.com",
+ testOpenedAndDraggedXPI
+ );
+ await BrowserTestUtils.withNewTab("about:robots", testOpenedAndDraggedXPI);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js b/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js
new file mode 100644
index 0000000000..368160698f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_globalwarnings.js
@@ -0,0 +1,176 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Bug 566194 - safe mode / security & compatibility check status are not exposed in new addon manager UI
+
+async function loadDetail(win, id) {
+ let loaded = waitForViewLoad(win);
+ // Check the detail view.
+ let card = win.document.querySelector(`addon-card[addon-id="${id}"]`);
+ EventUtils.synthesizeMouseAtCenter(
+ card.querySelector(".addon-name-link"),
+ {},
+ win
+ );
+ await loaded;
+}
+
+function checkMessageShown(win, type, hasButton) {
+ let stack = win.document.querySelector("global-warnings");
+ is(stack.childElementCount, 1, "There is one message");
+ let messageBar = stack.firstElementChild;
+ ok(messageBar, "There is a message bar");
+ is(
+ messageBar.localName,
+ "moz-message-bar",
+ "The message bar is a moz-message-bar"
+ );
+ is_element_visible(messageBar, "Message bar is visible");
+ is(messageBar.getAttribute("warning-type"), type);
+ if (hasButton) {
+ let button = messageBar.querySelector("button");
+ is_element_visible(button, "Button is visible");
+ is(button.getAttribute("action"), type, "Button action is set");
+ }
+}
+
+function checkNoMessages(win) {
+ let stack = win.document.querySelector("global-warnings");
+ if (stack.childElementCount) {
+ // The safe mode message is hidden in CSS on the plugin list.
+ for (let child of stack.children) {
+ is_element_hidden(child, "The message is hidden");
+ }
+ } else {
+ is(stack.childElementCount, 0, "There are no message bars");
+ }
+}
+
+function clickMessageAction(win) {
+ let stack = win.document.querySelector("global-warnings");
+ let button = stack.firstElementChild.querySelector("button");
+ EventUtils.synthesizeMouseAtCenter(button, {}, win);
+}
+
+add_task(async function checkCompatibility() {
+ info("Testing compatibility checking warning");
+
+ info("Setting checkCompatibility to false");
+ AddonManager.checkCompatibility = false;
+
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ // Check the extension list view.
+ checkMessageShown(win, "check-compatibility", true);
+
+ // Check the detail view.
+ await loadDetail(win, id);
+ checkMessageShown(win, "check-compatibility", true);
+
+ // Check other views.
+ let views = ["plugin", "theme"];
+ for (let view of views) {
+ await switchView(win, view);
+ checkMessageShown(win, "check-compatibility", true);
+ }
+
+ // Check the button works.
+ info("Clicking 'Enable' button");
+ clickMessageAction(win);
+ is(
+ AddonManager.checkCompatibility,
+ true,
+ "Check Compatibility pref should be cleared"
+ );
+ checkNoMessages(win);
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function checkSecurity() {
+ info("Testing update security checking warning");
+
+ var pref = "extensions.checkUpdateSecurity";
+ info("Setting " + pref + " pref to false");
+ await SpecialPowers.pushPrefEnv({
+ set: [[pref, false]],
+ });
+
+ let id = "test-security@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ // Check extension list view.
+ checkMessageShown(win, "update-security", true);
+
+ // Check detail view.
+ await loadDetail(win, id);
+ checkMessageShown(win, "update-security", true);
+
+ // Check other views.
+ let views = ["plugin", "theme"];
+ for (let view of views) {
+ await switchView(win, view);
+ checkMessageShown(win, "update-security", true);
+ }
+
+ // Check the button works.
+ info("Clicking 'Enable' button");
+ clickMessageAction(win);
+ is(
+ Services.prefs.prefHasUserValue(pref),
+ false,
+ "Check Update Security pref should be cleared"
+ );
+ checkNoMessages(win);
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function checkSafeMode() {
+ info("Testing safe mode warning");
+
+ let id = "test-safemode@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ // Check extension list view hidden.
+ checkNoMessages(win);
+
+ let globalWarnings = win.document.querySelector("global-warnings");
+ globalWarnings.inSafeMode = true;
+ globalWarnings.refresh();
+
+ // Check detail view.
+ await loadDetail(win, id);
+ checkMessageShown(win, "safe-mode");
+
+ // Check other views.
+ await switchView(win, "theme");
+ checkMessageShown(win, "safe-mode");
+ await switchView(win, "plugin");
+ checkNoMessages(win);
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js b/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js
new file mode 100644
index 0000000000..51ffbc6cdd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_gmpProvider.js
@@ -0,0 +1,406 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { GMPInstallManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/GMPInstallManager.sys.mjs"
+);
+const { GMPPrefs, GMP_PLUGIN_IDS, WIDEVINE_L1_ID, WIDEVINE_L3_ID } =
+ ChromeUtils.importESModule("resource://gre/modules/GMPUtils.sys.mjs");
+
+const TEST_DATE = new Date(2013, 0, 1, 12);
+
+var gMockAddons = [];
+
+for (let pluginId of GMP_PLUGIN_IDS) {
+ let mockAddon = Object.freeze({
+ id: pluginId,
+ isValid: true,
+ isInstalled: false,
+ isEME: pluginId == WIDEVINE_L1_ID || pluginId == WIDEVINE_L3_ID,
+ usedFallback: true,
+ });
+ gMockAddons.push(mockAddon);
+}
+
+var gInstalledAddonId = "";
+var gInstallDeferred = null;
+var gPrefs = Services.prefs;
+var getKey = GMPPrefs.getPrefKey;
+
+const MockGMPInstallManagerPrototype = {
+ checkForAddons: () =>
+ Promise.resolve({
+ addons: gMockAddons,
+ }),
+
+ installAddon: addon => {
+ gInstalledAddonId = addon.id;
+ gInstallDeferred.resolve();
+ return Promise.resolve();
+ },
+};
+
+function openDetailsView(win, id) {
+ let item = getAddonCard(win, id);
+ Assert.ok(item, "Should have got add-on element.");
+ is_element_visible(item, "Add-on element should be visible.");
+
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(
+ item.querySelector(".addon-name-link"),
+ {},
+ item.ownerGlobal
+ );
+ return loaded;
+}
+
+add_task(async function initializeState() {
+ gPrefs.setBoolPref(GMPPrefs.KEY_LOGGING_DUMP, true);
+ gPrefs.setIntPref(GMPPrefs.KEY_LOGGING_LEVEL, 0);
+
+ registerCleanupFunction(async function () {
+ for (let addon of gMockAddons) {
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id));
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id));
+ gPrefs.clearUserPref(
+ getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id)
+ );
+ }
+ gPrefs.clearUserPref(GMPPrefs.KEY_LOGGING_DUMP);
+ gPrefs.clearUserPref(GMPPrefs.KEY_LOGGING_LEVEL);
+ gPrefs.clearUserPref(GMPPrefs.KEY_UPDATE_LAST_CHECK);
+ gPrefs.clearUserPref(GMPPrefs.KEY_EME_ENABLED);
+ });
+
+ // Start out with plugins not being installed, disabled and automatic updates
+ // disabled.
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+ for (let addon of gMockAddons) {
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), false);
+ gPrefs.setIntPref(getKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, addon.id), 0);
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id), false);
+ gPrefs.setCharPref(getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id), "");
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id), true);
+ gPrefs.setBoolPref(
+ getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id),
+ true
+ );
+ }
+});
+
+add_task(async function testNotInstalledDisabled() {
+ let win = await loadInitialView("extension");
+
+ Assert.ok(isCategoryVisible(win, "plugin"), "Plugin tab visible.");
+ await switchView(win, "plugin");
+
+ for (let addon of gMockAddons) {
+ let addonCard = getAddonCard(win, addon.id);
+ Assert.ok(addonCard, "Got add-on element:" + addon.id);
+
+ is(
+ addonCard.ownerDocument.l10n.getAttributes(addonCard.addonNameEl).id,
+ "addon-name-disabled",
+ "The addon name should include a disabled postfix"
+ );
+
+ let cardMessage = addonCard.querySelector(
+ "moz-message-bar.addon-card-message"
+ );
+ is_element_hidden(cardMessage, "Warning notification is hidden");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testNotInstalledDisabledDetails() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ await openDetailsView(win, addon.id);
+ let addonCard = getAddonCard(win, addon.id);
+ ok(addonCard, "Got add-on element: " + addon.id);
+
+ is(
+ win.document.l10n.getAttributes(addonCard.addonNameEl).id,
+ "addon-name-disabled",
+ "The addon name should include a disabled postfix"
+ );
+
+ let updatesBtn = addonCard.querySelector("[action=update-check]");
+ is_element_visible(updatesBtn, "Check for Updates action is visible");
+ let cardMessage = addonCard.querySelector(
+ "moz-message-bar.addon-card-message"
+ );
+ is_element_hidden(cardMessage, "Warning notification is hidden");
+
+ await switchView(win, "plugin");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testNotInstalled() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+ let item = getAddonCard(win, addon.id);
+ Assert.ok(item, "Got add-on element:" + addon.id);
+
+ let warningMessageBar = await BrowserTestUtils.waitForCondition(() => {
+ return item.querySelector(
+ "moz-message-bar.addon-card-message[type=warning]"
+ );
+ }, "Wait for the addon card message to be updated");
+
+ is_element_visible(warningMessageBar, "Warning notification is visible");
+
+ is(item.parentNode.getAttribute("section"), "0", "Should be enabled");
+ // Open the options menu (needed to check the disabled buttons).
+ const pluginOptions = item.querySelector("plugin-options");
+ pluginOptions.querySelector("panel-list").open = true;
+ const alwaysActivate = pluginOptions.querySelector(
+ "panel-item[action=always-activate]"
+ );
+ ok(
+ alwaysActivate.hasAttribute("checked"),
+ "Plugin state should be always-activate"
+ );
+ pluginOptions.querySelector("panel-list").open = false;
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testNotInstalledDetails() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ await openDetailsView(win, addon.id);
+
+ const addonCard = getAddonCard(win, addon.id);
+ let el = addonCard.querySelector("[action=update-check]");
+ is_element_visible(el, "Check for Updates action is visible");
+
+ let warningMessageBar = await BrowserTestUtils.waitForCondition(() => {
+ return addonCard.querySelector(
+ "moz-message-bar.addon-card-message[type=warning]"
+ );
+ }, "Wait for the addon card message to be updated");
+ is_element_visible(warningMessageBar, "Warning notification is visible");
+
+ await switchView(win, "plugin");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testInstalled() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ gPrefs.setIntPref(
+ getKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, addon.id),
+ TEST_DATE.getTime()
+ );
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id), false);
+ gPrefs.setCharPref(
+ getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ "1.2.3.4"
+ );
+
+ let item = getAddonCard(win, addon.id);
+ Assert.ok(item, "Got add-on element.");
+
+ is(item.parentNode.getAttribute("section"), "0", "Should be enabled");
+ // Open the options menu (needed to check the disabled buttons).
+ const pluginOptions = item.querySelector("plugin-options");
+ pluginOptions.querySelector("panel-list").open = true;
+ const alwaysActivate = pluginOptions.querySelector(
+ "panel-item[action=always-activate]"
+ );
+ ok(
+ alwaysActivate.hasAttribute("checked"),
+ "Plugin state should be always-activate"
+ );
+ pluginOptions.querySelector("panel-list").open = false;
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testInstalledDetails() {
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ await openDetailsView(win, addon.id);
+
+ let card = getAddonCard(win, addon.id);
+ ok(card, "Got add-on element:" + addon.id);
+
+ is_element_visible(
+ card.querySelector("[action=update-check]"),
+ "Find updates link is visible"
+ );
+
+ await switchView(win, "plugin");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testInstalledGlobalEmeDisabled() {
+ let win = await loadInitialView("plugin");
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, false);
+
+ for (let addon of gMockAddons) {
+ let item = getAddonCard(win, addon.id);
+ if (addon.isEME) {
+ is(item.parentNode.getAttribute("section"), "1", "Should be disabled");
+ } else {
+ Assert.ok(item, "Got add-on element.");
+ }
+ }
+
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+ await closeView(win);
+});
+
+add_task(async function testPreferencesButton() {
+ let prefValues = [
+ { enabled: false, version: "" },
+ { enabled: false, version: "1.2.3.4" },
+ { enabled: true, version: "" },
+ { enabled: true, version: "1.2.3.4" },
+ ];
+
+ for (let preferences of prefValues) {
+ info(
+ "Testing preferences button with pref settings: " +
+ JSON.stringify(preferences)
+ );
+ for (let addon of gMockAddons) {
+ let win = await loadInitialView("plugin");
+ gPrefs.setCharPref(
+ getKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ preferences.version
+ );
+ gPrefs.setBoolPref(
+ getKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id),
+ preferences.enabled
+ );
+
+ let item = getAddonCard(win, addon.id);
+
+ // Open the options menu (needed to check the more options action is enabled).
+ const pluginOptions = item.querySelector("plugin-options");
+ pluginOptions.querySelector("panel-list").open = true;
+ const moreOptions = pluginOptions.querySelector(
+ "panel-item[action=expand]"
+ );
+ ok(
+ !moreOptions.shadowRoot.querySelector("button").disabled,
+ "more options action should be enabled"
+ );
+ moreOptions.click();
+
+ await waitForViewLoad(win);
+
+ item = getAddonCard(win, addon.id);
+ ok(item, "The right view is loaded");
+
+ await closeView(win);
+ }
+ }
+});
+
+add_task(async function testUpdateButton() {
+ gPrefs.clearUserPref(GMPPrefs.KEY_UPDATE_LAST_CHECK);
+
+ // The GMPInstallManager constructor has an empty body,
+ // so replacing the prototype is safe.
+ let originalInstallManager = GMPInstallManager.prototype;
+ GMPInstallManager.prototype = MockGMPInstallManagerPrototype;
+
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ let item = getAddonCard(win, addon.id);
+
+ gInstalledAddonId = "";
+ gInstallDeferred = Promise.withResolvers();
+
+ let loaded = waitForViewLoad(win);
+ item.querySelector("[action=expand]").click();
+ await loaded;
+ let detail = getAddonCard(win, addon.id);
+ detail.querySelector("[action=update-check]").click();
+
+ await gInstallDeferred.promise;
+ Assert.equal(gInstalledAddonId, addon.id);
+
+ await switchView(win, "plugin");
+ }
+
+ GMPInstallManager.prototype = originalInstallManager;
+
+ await closeView(win);
+});
+
+add_task(async function testEmeSupport() {
+ for (let addon of gMockAddons) {
+ gPrefs.clearUserPref(getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id));
+ }
+
+ let win = await loadInitialView("plugin");
+
+ for (let addon of gMockAddons) {
+ let item = getAddonCard(win, addon.id);
+ if (addon.id == WIDEVINE_L1_ID) {
+ if (
+ AppConstants.MOZ_WMF_CDM &&
+ AppConstants.platform == "win" &&
+ UpdateUtils.ABI.match(/x64/)
+ ) {
+ Assert.ok(item, "Widevine L1 supported, found add-on element.");
+ } else {
+ Assert.ok(
+ !item,
+ "Widevine L1 not supported, couldn't find add-on element."
+ );
+ }
+ } else if (addon.id == WIDEVINE_L3_ID) {
+ if (
+ AppConstants.platform == "win" ||
+ AppConstants.platform == "macosx" ||
+ AppConstants.platform == "linux"
+ ) {
+ Assert.ok(item, "Widevine L3 supported, found add-on element.");
+ } else {
+ Assert.ok(
+ !item,
+ "Widevine L3 not supported, couldn't find add-on element."
+ );
+ }
+ } else {
+ Assert.ok(item, "Found add-on element.");
+ }
+ }
+
+ await closeView(win);
+
+ for (let addon of gMockAddons) {
+ gPrefs.setBoolPref(getKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id), true);
+ gPrefs.setBoolPref(
+ getKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id),
+ true
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js b/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js
new file mode 100644
index 0000000000..2b177bc7cd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_history_navigation.js
@@ -0,0 +1,623 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint max-nested-callbacks: ["warn", 12] */
+
+/**
+ * Tests that history navigation works for the add-ons manager.
+ */
+
+// Request a longer timeout, because this tests run twice
+// (once on XUL views and once on the HTML views).
+requestLongerTimeout(4);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const DISCOAPI_URL = `http://example.com/${RELATIVE_DIR}/discovery/api_response_empty.json`;
+
+SpecialPowers.pushPrefEnv({
+ set: [["browser.navigation.requireUserInteraction", false]],
+});
+
+var gProvider = new MockProvider();
+gProvider.createAddons([
+ {
+ id: "test1@tests.mozilla.org",
+ name: "Test add-on 1",
+ description: "foo",
+ },
+ {
+ id: "test2@tests.mozilla.org",
+ name: "Test add-on 2",
+ description: "bar",
+ },
+ {
+ id: "test3@tests.mozilla.org",
+ name: "Test add-on 3",
+ type: "theme",
+ description: "bar",
+ },
+]);
+
+function go_back() {
+ gBrowser.goBack();
+}
+
+const goBackKeyModifier =
+ AppConstants.platform == "macosx" ? { metaKey: true } : { altKey: true };
+
+function go_back_key() {
+ EventUtils.synthesizeKey("KEY_ArrowLeft", goBackKeyModifier);
+}
+
+function go_forward_key() {
+ EventUtils.synthesizeKey("KEY_ArrowRight", goBackKeyModifier);
+}
+
+function go_forward() {
+ gBrowser.goForward();
+}
+
+function check_state(canGoBack, canGoForward) {
+ is(gBrowser.canGoBack, canGoBack, "canGoBack should be correct");
+ is(gBrowser.canGoForward, canGoForward, "canGoForward should be correct");
+}
+
+function is_in_list(aManager, view, canGoBack, canGoForward) {
+ var categoryUtils = new CategoryUtilities(aManager);
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ view,
+ "Should be on the right category"
+ );
+
+ ok(
+ aManager.document.querySelector("addon-list"),
+ "Got a list-view in about:addons"
+ );
+
+ check_state(canGoBack, canGoForward);
+}
+
+function is_in_detail(aManager, view, canGoBack, canGoForward) {
+ var categoryUtils = new CategoryUtilities(aManager);
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ view,
+ "Should be on the right category"
+ );
+
+ is(
+ aManager.document.querySelectorAll("addon-card").length,
+ 1,
+ "Got a detail-view in about:addons"
+ );
+
+ check_state(canGoBack, canGoForward);
+}
+
+function is_in_discovery(aManager, canGoBack, canGoForward) {
+ ok(
+ aManager.document.querySelector("discovery-pane"),
+ "Got a discovery panel in the HTML about:addons browser"
+ );
+
+ check_state(canGoBack, canGoForward);
+}
+
+async function expand_addon_element(aManagerWin, aId) {
+ var addon = getAddonCard(aManagerWin, aId);
+ // Ensure that we send a click on the control that is accessible (while a
+ // mouse user could also activate a card by clicking on the entire container):
+ const addonLink = addon.querySelector(".addon-name-link");
+ addonLink.click();
+}
+
+function wait_for_page_load(browser) {
+ return BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+}
+
+// Tests simple forward and back navigation and that the right heading and
+// category is selected
+add_task(async function test_navigate_history() {
+ let aManager = await open_manager("addons://list/extension");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ go_forward();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 5");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 6");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 7");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that browsing to the add-ons manager from a website and going back works
+add_task(async function test_navigate_between_webpage_and_aboutaddons() {
+ info("Part 1");
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://example.com/",
+ true,
+ true
+ );
+
+ info("Part 2");
+ ok(!gBrowser.canGoBack, "Should not be able to go back");
+ ok(!gBrowser.canGoForward, "Should not be able to go forward");
+
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ "about:addons"
+ );
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ let manager = await wait_for_manager_load(
+ gBrowser.selectedBrowser.contentWindow
+ );
+
+ info("Part 3");
+ is_in_list(manager, "addons://list/extension", true, false);
+
+ // XXX: This is less than ideal, as it's currently difficult to deal with
+ // the browser frame switching between remote/non-remote in e10s mode.
+ let promiseLoaded;
+ if (gMultiProcessBrowser) {
+ promiseLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ } else {
+ promiseLoaded = BrowserTestUtils.waitForEvent(
+ gBrowser.selectedBrowser,
+ "pageshow"
+ );
+ }
+
+ go_back(manager);
+ await promiseLoaded;
+
+ info("Part 4");
+ is(
+ gBrowser.currentURI.spec,
+ "http://example.com/",
+ "Should be showing the webpage"
+ );
+ ok(!gBrowser.canGoBack, "Should not be able to go back");
+ ok(gBrowser.canGoForward, "Should be able to go forward");
+
+ promiseLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ go_forward(manager);
+ await promiseLoaded;
+
+ manager = gBrowser.selectedBrowser.contentWindow;
+ info("Part 5");
+ await TestUtils.waitForCondition(
+ () => manager.document.querySelector("addon-list"),
+ "The add-on list should render."
+ );
+
+ is_in_list(manager, "addons://list/extension", true, false);
+
+ await close_manager(manager);
+});
+
+// Tests simple forward and back navigation and that the right heading and
+// category is selected -- Keyboard navigation [Bug 565359]
+add_task(async function test_keyboard_history_navigation() {
+ let aManager = await open_manager("addons://list/extension");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ // Backspace should not navigate back. We should still be on the same view.
+ is(
+ Services.prefs.getIntPref("browser.backspace_action"),
+ 2,
+ "Backspace should not navigate back"
+ );
+ EventUtils.synthesizeKey("KEY_Backspace");
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2b");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ go_forward_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 5");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 6");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ go_back_key();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 7");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that opening a custom first view only stores a single history entry
+add_task(async function test_single_history_entry() {
+ let aManager = await open_manager("addons://list/plugin");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(
+ categoryUtils.get("extension"),
+ {},
+ aManager
+ );
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/extension", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that opening a view while the manager is already open adds a new
+// history entry
+add_task(async function test_new_history_entry_while_opened() {
+ let aManager = await open_manager("addons://list/extension");
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ aManager.loadView("addons://list/plugin");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 3");
+ is_in_list(aManager, "addons://list/extension", false, true);
+
+ go_forward();
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ await close_manager(aManager);
+});
+
+// Tests than navigating to a website and then going back returns to the
+// previous view
+add_task(async function test_navigate_back_from_website() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.allow_eval_with_system_principal", true]],
+ });
+
+ let aManager = await open_manager("addons://list/plugin");
+ info("Part 1");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ BrowserTestUtils.startLoadingURIString(gBrowser, "http://example.com/");
+ await wait_for_page_load(gBrowser.selectedBrowser);
+
+ info("Part 2");
+
+ await new Promise(resolve =>
+ executeSoon(function () {
+ ok(gBrowser.canGoBack, "Should be able to go back");
+ ok(!gBrowser.canGoForward, "Should not be able to go forward");
+
+ go_back();
+
+ gBrowser.addEventListener("pageshow", async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 3");
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ executeSoon(() => go_forward());
+ wait_for_page_load(gBrowser.selectedBrowser).then(() => {
+ info("Part 4");
+
+ executeSoon(function () {
+ ok(gBrowser.canGoBack, "Should be able to go back");
+ ok(!gBrowser.canGoForward, "Should not be able to go forward");
+
+ go_back();
+
+ gBrowser.addEventListener(
+ "pageshow",
+ async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 5");
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ resolve();
+ }
+ );
+ });
+ });
+ });
+ })
+ );
+
+ await close_manager(aManager);
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests that refreshing a list view does not affect the history
+add_task(async function test_refresh_listview_donot_add_history_entries() {
+ let aManager = await open_manager("addons://list/extension");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ await new Promise(resolve => {
+ gBrowser.reload();
+ gBrowser.addEventListener("pageshow", async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 3");
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/extension", false, true);
+ resolve();
+ });
+ });
+
+ await close_manager(aManager);
+});
+
+// Tests that refreshing a detail view does not affect the history
+add_task(async function test_refresh_detailview_donot_add_history_entries() {
+ let aManager = await open_manager(null);
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ await new Promise(resolve => {
+ gBrowser.reload();
+ gBrowser.addEventListener("pageshow", async function listener(event) {
+ if (event.target.location != "about:addons") {
+ return;
+ }
+ gBrowser.removeEventListener("pageshow", listener);
+
+ aManager = await wait_for_view_load(
+ gBrowser.contentWindow.wrappedJSObject
+ );
+ info("Part 3");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ go_back();
+ aManager = await wait_for_view_load(aManager);
+ info("Part 4");
+ is_in_list(aManager, "addons://list/extension", false, true);
+ resolve();
+ });
+ });
+
+ await close_manager(aManager);
+});
+
+// Tests that removing an extension from the detail view goes back and doesn't
+// allow you to go forward again.
+add_task(async function test_history_on_detailview_extension_removed() {
+ let aManager = await open_manager("addons://list/extension");
+
+ info("Part 1");
+ is_in_list(aManager, "addons://list/extension", false, false);
+
+ await expand_addon_element(aManager, "test1@tests.mozilla.org");
+
+ aManager = await wait_for_view_load(aManager);
+ info("Part 2");
+ is_in_detail(aManager, "addons://list/extension", true, false);
+
+ const addonCard = aManager.document.querySelector(
+ 'addon-card[addon-id="test1@tests.mozilla.org"]'
+ );
+ const promptService = mockPromptService();
+ promptService._response = 0;
+ addonCard.querySelector("[action=remove]").click();
+
+ await wait_for_view_load(aManager);
+ await TestUtils.waitForCondition(
+ () => aManager.document.querySelector("addon-list"),
+ "The add-on list should render."
+ );
+ is_in_list(aManager, "addons://list/extension", true, false);
+
+ const addon = await AddonManager.getAddonByID("test1@tests.mozilla.org");
+ addon.cancelUninstall();
+
+ await close_manager(aManager);
+});
+
+// Tests that opening the manager opens the last view
+add_task(async function test_open_last_view() {
+ let aManager = await open_manager("addons://list/plugin");
+ info("Part 1");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ await close_manager(aManager);
+ aManager = await open_manager(null);
+ info("Part 2");
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ await close_manager(aManager);
+});
+
+// Tests that navigating the discovery page works when that was the first view
+add_task(async function test_discopane_first_history_entry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.discovery.api_url", DISCOAPI_URL]],
+ });
+
+ let aManager = await open_manager("addons://discover/");
+ let categoryUtils = new CategoryUtilities(aManager);
+ info("1");
+ is_in_discovery(aManager, false, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+ aManager = await wait_for_view_load(aManager);
+
+ is_in_discovery(aManager, false, true);
+
+ await close_manager(aManager);
+});
+
+// Tests that navigating the discovery page works when that was the second view
+add_task(async function test_discopane_second_history_entry() {
+ let aManager = await open_manager("addons://list/plugin");
+ let categoryUtils = new CategoryUtilities(aManager);
+ is_in_list(aManager, "addons://list/plugin", false, false);
+
+ EventUtils.synthesizeMouseAtCenter(
+ categoryUtils.get("discover"),
+ {},
+ aManager
+ );
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_discovery(aManager, true, false);
+
+ EventUtils.synthesizeMouseAtCenter(categoryUtils.get("plugin"), {}, aManager);
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_list(aManager, "addons://list/plugin", true, false);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_discovery(aManager, true, true);
+
+ go_back();
+
+ aManager = await wait_for_view_load(aManager);
+ is_in_list(aManager, "addons://list/plugin", false, true);
+
+ await close_manager(aManager);
+});
+
+add_task(async function test_initialSelectedView_on_aboutaddons_reload() {
+ let managerWindow = await open_manager("addons://list/extension");
+ isnot(
+ managerWindow.gViewController.currentViewId,
+ null,
+ "Got a non null currentViewId on first load"
+ );
+
+ managerWindow.location.reload();
+ await wait_for_manager_load(managerWindow);
+ await wait_for_view_load(managerWindow);
+
+ isnot(
+ managerWindow.gViewController.currentViewId,
+ null,
+ "Got a non null currentViewId on reload"
+ );
+
+ await close_manager(managerWindow);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
new file mode 100644
index 0000000000..3ad8510aea
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report.js
@@ -0,0 +1,1093 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+add_setup(async function () {
+ // Make sure the integrated abuse report panel is the one enabled
+ // while this test file runs (instead of the AMO hosted form).
+ // NOTE: behaviors expected when amoFormEnabled is true are tested
+ // in the separate browser_amo_abuse_report.js test file.
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.abuseReport.amoFormEnabled", false]],
+ });
+ await AbuseReportTestUtils.setup();
+});
+
+/**
+ * Base tests on abuse report panel webcomponents.
+ */
+
+// This test case verified that the abuse report panels contains a radio
+// button for all the expected "abuse report reasons", they are grouped
+// together under the same form field named "reason".
+add_task(async function test_abusereport_issuelist() {
+ const extension = await installTestExtension();
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(extension.id);
+
+ const reasonsPanel = abuseReportEl._reasonsPanel;
+ const radioButtons = reasonsPanel.querySelectorAll("[type=radio]");
+ const selectedRadios = reasonsPanel.querySelectorAll("[type=radio]:checked");
+
+ is(selectedRadios.length, 1, "Expect only one radio button selected");
+ is(
+ selectedRadios[0],
+ radioButtons[0],
+ "Expect the first radio button to be selected"
+ );
+
+ is(
+ abuseReportEl.reason,
+ radioButtons[0].value,
+ `The reason property has the expected value: ${radioButtons[0].value}`
+ );
+
+ const reasons = Array.from(radioButtons).map(el => el.value);
+ Assert.deepEqual(
+ reasons.sort(),
+ AbuseReportTestUtils.getReasons(abuseReportEl).sort(),
+ `Got a radio button for the expected reasons`
+ );
+
+ for (const radio of radioButtons) {
+ const reasonInfo = AbuseReportTestUtils.getReasonInfo(
+ abuseReportEl,
+ radio.value
+ );
+ const expectExampleHidden =
+ reasonInfo && reasonInfo.isExampleHidden("extension");
+ is(
+ radio.parentNode.querySelector(".reason-example").hidden,
+ expectExampleHidden,
+ `Got expected visibility on the example for reason "${radio.value}"`
+ );
+ }
+
+ info("Change the selected reason to " + radioButtons[3].value);
+ radioButtons[3].checked = true;
+ is(
+ abuseReportEl.reason,
+ radioButtons[3].value,
+ "The reason property has the expected value"
+ );
+
+ await extension.unload();
+ await closeAboutAddons();
+});
+
+// This test case verifies that the abuse report panel:
+// - switches from its "reasons list" mode to its "submit report" mode when the
+// "next" button is clicked
+// - goes back to the "reasons list" mode when the "go back" button is clicked
+// - the abuse report panel is closed when the "close" icon is clicked
+add_task(async function test_abusereport_submitpanel() {
+ const extension = await installTestExtension();
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(extension.id);
+
+ ok(
+ !abuseReportEl._reasonsPanel.hidden,
+ "The list of abuse reasons is the currently visible"
+ );
+ ok(
+ abuseReportEl._submitPanel.hidden,
+ "The submit panel is the currently hidden"
+ );
+
+ let onceUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ const MozButtonGroup =
+ abuseReportEl.ownerGlobal.customElements.get("moz-button-group");
+
+ ok(MozButtonGroup, "Expect MozButtonGroup custom element to be defined");
+
+ const assertButtonInMozButtonGroup = (
+ btnEl,
+ { expectPrimary = false } = {}
+ ) => {
+ // Let's include the l10n id into the assertion messages,
+ // to make it more likely to be immediately clear which
+ // button hit a failure if any of the following assertion
+ // fails.
+ let l10nId = btnEl.getAttribute("data-l10n-id");
+ is(
+ btnEl.classList.contains("primary"),
+ expectPrimary,
+ `Expect button ${l10nId} to have${
+ expectPrimary ? "" : " NOT"
+ } the primary class set`
+ );
+
+ ok(
+ btnEl.parentElement instanceof MozButtonGroup,
+ `Expect button ${l10nId} to be slotted inside the expected custom element`
+ );
+
+ is(
+ btnEl.getAttribute("slot"),
+ expectPrimary ? "primary" : null,
+ `Expect button ${l10nId} slot to ${
+ expectPrimary ? "" : "NOT "
+ } be set to primary`
+ );
+ };
+
+ // Verify button group from the initial panel.
+ assertButtonInMozButtonGroup(abuseReportEl._btnNext, { expectPrimary: true });
+ assertButtonInMozButtonGroup(abuseReportEl._btnCancel, {
+ expectPrimary: false,
+ });
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext);
+ await onceUpdated;
+ // Verify button group from the submit panel mode.
+ assertButtonInMozButtonGroup(abuseReportEl._btnSubmit, {
+ expectPrimary: true,
+ });
+ assertButtonInMozButtonGroup(abuseReportEl._btnGoBack, {
+ expectPrimary: false,
+ });
+ onceUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "reasons"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnGoBack);
+ await onceUpdated;
+
+ const onceReportClosed =
+ AbuseReportTestUtils.promiseReportClosed(abuseReportEl);
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnCancel);
+ await onceReportClosed;
+
+ await extension.unload();
+ await closeAboutAddons();
+});
+
+// This test case verifies that the abuse report panel sends the expected data
+// in the "abuse-report:submit" event detail.
+add_task(async function test_abusereport_submit() {
+ // Reset the timestamp of the last report between tests.
+ AbuseReporter._lastReportTimestamp = null;
+ const extension = await installTestExtension();
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(extension.id);
+
+ ok(
+ !abuseReportEl._reasonsPanel.hidden,
+ "The list of abuse reasons is the currently visible"
+ );
+
+ let onceUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext);
+ await onceUpdated;
+
+ is(abuseReportEl.message, "", "The abuse report message is initially empty");
+
+ info("Test typing a message in the abuse report submit panel textarea");
+ const typedMessage = "Description of the extension abuse report";
+
+ EventUtils.synthesizeComposition(
+ {
+ data: typedMessage,
+ type: "compositioncommit",
+ },
+ abuseReportEl.ownerGlobal
+ );
+
+ is(
+ abuseReportEl.message,
+ typedMessage,
+ "Got the expected typed message in the abuse report"
+ );
+
+ const expectedDetail = {
+ addonId: extension.id,
+ };
+
+ const expectedReason = abuseReportEl.reason;
+ const expectedMessage = abuseReportEl.message;
+
+ function handleSubmitRequest({ request, response }) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json", false);
+ response.write("{}");
+ }
+
+ let reportSubmitted;
+ const onReportSubmitted = AbuseReportTestUtils.promiseReportSubmitHandled(
+ ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ }
+ );
+
+ const onceReportClosed =
+ AbuseReportTestUtils.promiseReportClosed(abuseReportEl);
+
+ const onMessageBarsCreated = AbuseReportTestUtils.promiseMessageBars(2);
+
+ const onceSubmitEvent = BrowserTestUtils.waitForEvent(
+ abuseReportEl,
+ "abuse-report:submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnSubmit);
+ const submitEvent = await onceSubmitEvent;
+
+ const actualDetail = {
+ addonId: submitEvent.detail.addonId,
+ };
+ Assert.deepEqual(
+ actualDetail,
+ expectedDetail,
+ "Got the expected detail in the abuse-report:submit event"
+ );
+
+ ok(
+ submitEvent.detail.report,
+ "Got a report object in the abuse-report:submit event detail"
+ );
+
+ // Verify that, when the "abuse-report:submit" has been sent,
+ // the abuse report panel has been hidden, the report has been
+ // submitted and the expected message bar is created in the
+ // HTML about:addons page.
+ info("Wait the report to be submitted to the api server");
+ await onReportSubmitted;
+ info("Wait the report panel to be closed");
+ await onceReportClosed;
+
+ is(
+ reportSubmitted.addon,
+ ADDON_ID,
+ "Got the expected addon in the submitted report"
+ );
+ is(
+ reportSubmitted.reason,
+ expectedReason,
+ "Got the expected reason in the submitted report"
+ );
+ is(
+ reportSubmitted.message,
+ expectedMessage,
+ "Got the expected message in the submitted report"
+ );
+ is(
+ reportSubmitted.report_entry_point,
+ REPORT_ENTRY_POINT,
+ "Got the expected report_entry_point in the submitted report"
+ );
+
+ info("Waiting the expected message bars to be created");
+ const barDetails = await onMessageBarsCreated;
+ is(barDetails.length, 2, "Expect two message bars to have been created");
+ is(
+ barDetails[0].definitionId,
+ "submitting",
+ "Got a submitting message bar as expected"
+ );
+ is(
+ barDetails[1].definitionId,
+ "submitted",
+ "Got a submitted message bar as expected"
+ );
+
+ await extension.unload();
+ await closeAboutAddons();
+});
+
+// This helper does verify that the abuse report panel contains the expected
+// suggestions when the selected reason requires it (and urls are being set
+// on the links elements included in the suggestions when expected).
+async function test_abusereport_suggestions(addonId) {
+ const addon = await AddonManager.getAddonByID(addonId);
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(addonId);
+
+ const {
+ _btnNext,
+ _btnGoBack,
+ _reasonsPanel,
+ _submitPanel,
+ _submitPanel: { _suggestions },
+ } = abuseReportEl;
+
+ for (const reason of AbuseReportTestUtils.getReasons(abuseReportEl)) {
+ const reasonInfo = AbuseReportTestUtils.getReasonInfo(
+ abuseReportEl,
+ reason
+ );
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ const addonType =
+ addon.type === "sitepermission-deprecated"
+ ? "sitepermission"
+ : addon.type;
+
+ if (reasonInfo.isReasonHidden(addonType)) {
+ continue;
+ }
+
+ info(`Test suggestions for abuse reason "${reason}"`);
+
+ // Select a reason with suggestions.
+ let radioEl = abuseReportEl.querySelector(`#abuse-reason-${reason}`);
+ ok(radioEl, `Found radio button for "${reason}"`);
+ radioEl.checked = true;
+
+ // Make sure the element localization is completed before
+ // checking the content isn't empty.
+ await document.l10n.translateFragment(radioEl);
+
+ // Verify each radio button has a non-empty localized string.
+ const localizedRadioContent = Array.from(
+ radioEl.closest("label").querySelectorAll("[data-l10n-id]")
+ ).filter(el => !el.hidden);
+
+ for (let el of localizedRadioContent) {
+ isnot(
+ el.textContent,
+ "",
+ `Fluent string id '${el.getAttribute("data-l10n-id")}' missing`
+ );
+ }
+
+ // Switch to the submit form with the current reason radio selected.
+ let oncePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(_btnNext);
+ await oncePanelUpdated;
+
+ const localizedSuggestionsContent = Array.from(
+ _suggestions.querySelectorAll("[data-l10n-id]")
+ ).filter(el => !el.hidden);
+
+ is(
+ !_suggestions.hidden,
+ !!reasonInfo.hasSuggestions,
+ `Suggestions block has the expected visibility for "${reason}"`
+ );
+ if (reasonInfo.hasSuggestions) {
+ ok(
+ !!localizedSuggestionsContent.length,
+ `Category suggestions should not be empty for "${reason}"`
+ );
+ } else {
+ Assert.strictEqual(
+ localizedSuggestionsContent.length,
+ 0,
+ `Category suggestions should be empty for "${reason}"`
+ );
+ }
+
+ const extSupportLink = _suggestions.querySelector(
+ ".extension-support-link"
+ );
+ if (extSupportLink) {
+ is(
+ extSupportLink.getAttribute("href"),
+ BASE_TEST_MANIFEST.homepage_url,
+ "Got the expected extension-support-url"
+ );
+ }
+
+ const learnMoreLinks = [];
+ learnMoreLinks.push(
+ ..._suggestions.querySelectorAll(
+ 'a[is="moz-support-link"], .abuse-policy-learnmore'
+ )
+ );
+
+ if (learnMoreLinks.length) {
+ is(
+ _suggestions.querySelectorAll(
+ 'a[is="moz-support-link"]:not([support-page])'
+ ).length,
+ 0,
+ "Every SUMO link should point to a specific page"
+ );
+ ok(
+ learnMoreLinks.every(el => el.getAttribute("target") === "_blank"),
+ "All the learn more links have target _blank"
+ );
+ ok(
+ learnMoreLinks.every(el => el.hasAttribute("href")),
+ "All the learn more links have a url set"
+ );
+ }
+
+ oncePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "reasons"
+ );
+ await AbuseReportTestUtils.clickPanelButton(_btnGoBack);
+ await oncePanelUpdated;
+ ok(!_reasonsPanel.hidden, "Reasons panel should be visible");
+ ok(_submitPanel.hidden, "Submit panel should be hidden");
+ }
+
+ await closeAboutAddons();
+}
+
+add_task(async function test_abusereport_suggestions_extension() {
+ const EXT_ID = "test-extension-suggestions@mochi.test";
+ const extension = await installTestExtension(EXT_ID);
+ await test_abusereport_suggestions(EXT_ID);
+ await extension.unload();
+});
+
+add_task(async function test_abusereport_suggestions_theme() {
+ const THEME_ID = "theme@mochi.test";
+ const theme = await installTestExtension(THEME_ID, "theme");
+ await test_abusereport_suggestions(THEME_ID);
+ await theme.unload();
+});
+
+// TODO(Bug 1789718): adapt to SitePermAddonProvider implementation.
+add_task(async function test_abusereport_suggestions_sitepermission() {
+ const SITEPERM_ADDON_ID = "webmidi@mochi.test";
+ const sitePermAddon = await installTestExtension(
+ SITEPERM_ADDON_ID,
+ "sitepermission-deprecated"
+ );
+ await test_abusereport_suggestions(SITEPERM_ADDON_ID);
+ await sitePermAddon.unload();
+});
+
+// This test case verifies the message bars created on other
+// scenarios (e.g. report creation and submissions errors).
+//
+// TODO(Bug 1789718): adapt to SitePermAddonProvider implementation.
+add_task(async function test_abusereport_messagebars() {
+ const EXT_ID = "test-extension-report@mochi.test";
+ const EXT_ID2 = "test-extension-report-2@mochi.test";
+ const THEME_ID = "test-theme-report@mochi.test";
+ const SITEPERM_ADDON_ID = "webmidi-report@mochi.test";
+ const extension = await installTestExtension(EXT_ID);
+ const extension2 = await installTestExtension(EXT_ID2);
+ const theme = await installTestExtension(THEME_ID, "theme");
+ const sitePermAddon = await installTestExtension(
+ SITEPERM_ADDON_ID,
+ "sitepermission-deprecated"
+ );
+
+ async function assertMessageBars(
+ expectedMessageBarIds,
+ testSetup,
+ testMessageBarDetails
+ ) {
+ await openAboutAddons();
+ const expectedLength = expectedMessageBarIds.length;
+ const onMessageBarsCreated =
+ AbuseReportTestUtils.promiseMessageBars(expectedLength);
+ // Reset the timestamp of the last report between tests.
+ AbuseReporter._lastReportTimestamp = null;
+ await testSetup();
+ info(`Waiting for ${expectedLength} message-bars to be created`);
+ const barDetails = await onMessageBarsCreated;
+ Assert.deepEqual(
+ barDetails.map(d => d.definitionId),
+ expectedMessageBarIds,
+ "Got the expected message bars"
+ );
+ if (testMessageBarDetails) {
+ await testMessageBarDetails(barDetails);
+ }
+ await closeAboutAddons();
+ }
+
+ function setTestRequestHandler(responseStatus, responseData) {
+ AbuseReportTestUtils.promiseReportSubmitHandled(({ request, response }) => {
+ response.setStatusLine(request.httpVersion, responseStatus, "Error");
+ response.write(responseData);
+ });
+ }
+
+ await assertMessageBars(["ERROR_ADDON_NOTFOUND"], async () => {
+ info("Test message bars on addon not found");
+ AbuseReportTestUtils.triggerNewReport(
+ "non-existend-addon-id@mochi.test",
+ REPORT_ENTRY_POINT
+ );
+ });
+
+ await assertMessageBars(["submitting", "ERROR_RECENT_SUBMIT"], async () => {
+ info("Test message bars on recent submission");
+ const promiseRendered = AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await promiseRendered;
+ AbuseReporter.updateLastReportTimestamp();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_ABORTED_SUBMIT"], async () => {
+ info("Test message bars on aborted submission");
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ const { _report } = AbuseReportTestUtils.getReportPanel();
+ _report.abort();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_SERVER"], async () => {
+ info("Test message bars on server error");
+ setTestRequestHandler(500);
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_CLIENT"], async () => {
+ info("Test message bars on client error");
+ setTestRequestHandler(400);
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_UNKNOWN"], async () => {
+ info("Test message bars on unexpected status code");
+ setTestRequestHandler(604);
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ await assertMessageBars(["submitting", "ERROR_UNKNOWN"], async () => {
+ info("Test message bars on invalid json in the response data");
+ setTestRequestHandler(200, "");
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, REPORT_ENTRY_POINT);
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ // Verify message bar on add-on without perm_can_uninstall.
+ await assertMessageBars(
+ ["submitting", "submitted-no-remove-action"],
+ async () => {
+ info("Test message bars on report submitted on an addon without remove");
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(THEME_NO_UNINSTALL_ID, "menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ }
+ );
+
+ // Verify the 3 expected entry points:
+ // menu, toolbar_context_menu and uninstall
+ // (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html).
+ await assertMessageBars(["submitting", "submitted"], async () => {
+ info("Test message bars on report opened from addon options menu");
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(EXT_ID, "menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ });
+
+ for (const extId of [EXT_ID, THEME_ID]) {
+ await assertMessageBars(
+ ["submitting", "submitted"],
+ async () => {
+ info(`Test message bars on ${extId} reported from toolbar contextmenu`);
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(extId, "toolbar_context_menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ },
+ ([submittingDetails, submittedDetails]) => {
+ const buttonsL10nId = Array.from(
+ submittedDetails.messagebar.querySelectorAll("button")
+ ).map(el => el.getAttribute("data-l10n-id"));
+ if (extId === THEME_ID) {
+ ok(
+ buttonsL10nId.every(id => id.endsWith("-theme")),
+ "submitted bar actions should use the Fluent id for themes"
+ );
+ } else {
+ ok(
+ buttonsL10nId.every(id => id.endsWith("-extension")),
+ "submitted bar actions should use the Fluent id for extensions"
+ );
+ }
+ }
+ );
+ }
+
+ for (const extId of [EXT_ID2, THEME_ID, SITEPERM_ADDON_ID]) {
+ const testFn = async () => {
+ info(`Test message bars on ${extId} reported opened from addon removal`);
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(extId, "uninstall");
+ await AbuseReportTestUtils.promiseReportRendered();
+ const addon = await AddonManager.getAddonByID(extId);
+ // Ensure that the test extension is pending uninstall as it would be
+ // when a user trigger this scenario on an actual addon uninstall.
+ await addon.uninstall(true);
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ };
+ const assertMessageBarDetails = async ([
+ submittingDetails,
+ submittedDetails,
+ ]) => AbuseReportTestUtils.assertFluentStrings(submittedDetails.messagebar);
+ await assertMessageBars(
+ ["submitting", "submitted-and-removed"],
+ testFn,
+ assertMessageBarDetails
+ );
+ }
+
+ // Verify message bar on sitepermission add-on type.
+ await assertMessageBars(
+ ["submitting", "submitted"],
+ async () => {
+ info(
+ "Test message bars for report submitted on an sitepermission addon type"
+ );
+ setTestRequestHandler(200, "{}");
+ AbuseReportTestUtils.triggerNewReport(SITEPERM_ADDON_ID, "menu");
+ await AbuseReportTestUtils.promiseReportRendered();
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+ },
+ ([submittingDetails, submittedDetails]) =>
+ AbuseReportTestUtils.assertFluentStrings(submittedDetails.messagebar)
+ );
+
+ await extension.unload();
+ await extension2.unload();
+ await theme.unload();
+ await sitePermAddon.unload();
+});
+
+add_task(async function test_abusereport_from_aboutaddons_menu() {
+ const EXT_ID = "test-report-from-aboutaddons-menu@mochi.test";
+ const extension = await installTestExtension(EXT_ID);
+
+ await openAboutAddons();
+
+ AbuseReportTestUtils.assertReportPanelHidden();
+
+ const addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${extension.id}"]`
+ );
+ ok(addonCard, "Got the addon-card for the test extension");
+
+ const reportButton = addonCard.querySelector("[action=report]");
+ ok(reportButton, "Got the report action for the test extension");
+
+ info("Click the report action and wait for the 'abuse-report:new' event");
+
+ let onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: extension.id,
+ reportEntryPoint: "menu",
+ });
+ reportButton.click();
+ const panelEl = await onceReportOpened;
+
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+add_task(async function test_abusereport_from_aboutaddons_remove() {
+ const EXT_ID = "test-report-from-aboutaddons-remove@mochi.test";
+
+ // Test on a theme addon to cover the report checkbox included in the
+ // uninstall dialog also on a theme.
+ const extension = await installTestExtension(EXT_ID, "theme");
+
+ await openAboutAddons("theme");
+
+ AbuseReportTestUtils.assertReportPanelHidden();
+
+ const addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${extension.id}"]`
+ );
+ ok(addonCard, "Got the addon-card for the test theme extension");
+
+ const removeButton = addonCard.querySelector("[action=remove]");
+ ok(removeButton, "Got the remove action for the test theme extension");
+
+ // Prepare the mocked prompt service.
+ const promptService = mockPromptService();
+ promptService.confirmEx = createPromptConfirmEx({
+ remove: true,
+ report: true,
+ });
+
+ info("Click the report action and wait for the 'abuse-report:new' event");
+
+ const onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: extension.id,
+ reportEntryPoint: "uninstall",
+ });
+ removeButton.click();
+ const panelEl = await onceReportOpened;
+
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+add_task(async function test_abusereport_from_browserAction_remove() {
+ const EXT_ID = "test-report-from-browseraction-remove@mochi.test";
+ const xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ ...BASE_TEST_MANIFEST,
+ browser_action: {
+ default_area: "navbar",
+ },
+ browser_specific_settings: { gecko: { id: EXT_ID } },
+ },
+ });
+ const addon = await AddonManager.installTemporaryAddon(xpiFile);
+
+ const buttonId = `${makeWidgetId(EXT_ID)}-browser-action`;
+
+ async function promiseAnimationFrame() {
+ await new Promise(resolve => window.requestAnimationFrame(resolve));
+
+ let { tm } = Services;
+ return new Promise(resolve => tm.dispatchToMainThread(resolve));
+ }
+
+ async function reportFromContextMenuRemove() {
+ const menu = document.getElementById("toolbar-context-menu");
+ const node = document.getElementById(CSS.escape(buttonId));
+ const shown = BrowserTestUtils.waitForEvent(
+ menu,
+ "popupshown",
+ "Wair for contextmenu popup"
+ );
+
+ // Wait for an animation frame as we do for the other mochitest-browser
+ // tests related to the browserActions.
+ await promiseAnimationFrame();
+ EventUtils.synthesizeMouseAtCenter(node, { type: "contextmenu" });
+ await shown;
+
+ info(`Clicking on "Remove Extension" context menu item`);
+ let removeExtension = menu.querySelector(
+ ".customize-context-removeExtension"
+ );
+ removeExtension.click();
+
+ return menu;
+ }
+
+ // Prepare the mocked prompt service.
+ const promptService = mockPromptService();
+ promptService.confirmEx = createPromptConfirmEx({
+ remove: true,
+ report: true,
+ });
+
+ await BrowserTestUtils.withNewTab("about:blank", async function () {
+ info(`Open browserAction context menu in toolbar context menu`);
+ let promiseMenu = reportFromContextMenuRemove();
+
+ // Wait about:addons to be loaded.
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: EXT_ID,
+ reportEntryPoint: "uninstall",
+ managerWindow: browser.contentWindow,
+ });
+
+ is(
+ browser.currentURI.spec,
+ "about:addons",
+ "about:addons tab currently selected"
+ );
+
+ let menu = await promiseMenu;
+ menu.hidePopup();
+
+ let panelEl = await onceReportOpened;
+
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ let onceExtStarted = AddonTestUtils.promiseWebExtensionStartup(EXT_ID);
+ addon.cancelUninstall();
+ await onceExtStarted;
+
+ // Reload the tab to verify Bug 1559124 didn't regressed.
+ browser.contentWindow.location.reload();
+ await BrowserTestUtils.browserLoaded(browser);
+ is(
+ browser.currentURI.spec,
+ "about:addons",
+ "about:addons tab currently selected"
+ );
+
+ onceReportOpened = AbuseReportTestUtils.promiseReportOpened({
+ addonId: EXT_ID,
+ reportEntryPoint: "uninstall",
+ managerWindow: browser.contentWindow,
+ });
+
+ menu = await reportFromContextMenuRemove();
+ info("Wait for the report panel");
+ panelEl = await onceReportOpened;
+
+ info("Wait for the report panel to be closed");
+ await AbuseReportTestUtils.closeReportPanel(panelEl);
+
+ menu.hidePopup();
+
+ onceExtStarted = AddonTestUtils.promiseWebExtensionStartup(EXT_ID);
+ addon.cancelUninstall();
+ await onceExtStarted;
+ });
+
+ await addon.uninstall();
+});
+
+/*
+ * Test report action hidden on non-supported extension types.
+ */
+add_task(async function test_report_action_hidden_on_builtin_addons() {
+ await openAboutAddons("theme");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ DEFAULT_BUILTIN_THEME_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_system_addons() {
+ await openAboutAddons("extension");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_SYSTEM_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_dictionary_addons() {
+ await openAboutAddons("dictionary");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_DICTIONARY_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+add_task(async function test_report_action_hidden_on_langpack_addons() {
+ await openAboutAddons("locale");
+ await AbuseReportTestUtils.assertReportActionHidden(
+ gManagerWindow,
+ EXT_LANGPACK_ADDON_ID
+ );
+ await closeAboutAddons();
+});
+
+// This test verifies that triggering a report that would be immediately
+// cancelled (e.g. because abuse reports for that extension type are not
+// supported) the abuse report is being hidden as expected.
+add_task(async function test_report_hidden_on_report_unsupported_addontype() {
+ await openAboutAddons();
+
+ let onceCreateReportFailed = AbuseReportTestUtils.promiseMessageBars(1);
+
+ AbuseReportTestUtils.triggerNewReport(EXT_UNSUPPORTED_TYPE_ADDON_ID, "menu");
+
+ await onceCreateReportFailed;
+
+ ok(!AbuseReporter.getOpenDialog(), "report dialog should not be open");
+
+ await closeAboutAddons();
+});
+
+/*
+ * Test regression fixes.
+ */
+
+add_task(async function test_no_broken_suggestion_on_missing_supportURL() {
+ const EXT_ID = "test-no-author@mochi.test";
+ const extension = await installTestExtension(EXT_ID, "extension", {
+ homepage_url: undefined,
+ });
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(EXT_ID);
+
+ info("Select broken as the abuse reason");
+ abuseReportEl.querySelector("#abuse-reason-broken").checked = true;
+
+ let oncePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ abuseReportEl,
+ "submit"
+ );
+ await AbuseReportTestUtils.clickPanelButton(abuseReportEl._btnNext);
+ await oncePanelUpdated;
+
+ const suggestionEl = abuseReportEl.querySelector(
+ "abuse-report-reason-suggestions"
+ );
+ is(suggestionEl.reason, "broken", "Got the expected suggestion element");
+ ok(suggestionEl.hidden, "suggestion element should be empty");
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+// This test verify that the abuse report panel is opening the
+// author link using a null triggeringPrincipal.
+add_task(async function test_abusereport_open_author_url() {
+ const abuseReportEl = await AbuseReportTestUtils.openReport(
+ EXT_WITH_PRIVILEGED_URL_ID
+ );
+
+ const authorLink = abuseReportEl._linkAddonAuthor;
+ ok(authorLink, "Got the author link element");
+ is(
+ authorLink.href,
+ "about:config",
+ "Got a privileged url in the link element"
+ );
+
+ SimpleTest.waitForExplicitFinish();
+ let waitForConsole = new Promise(resolve => {
+ SimpleTest.monitorConsole(resolve, [
+ {
+ message:
+ // eslint-disable-next-line max-len
+ /Security Error: Content at moz-nullprincipal:{.*} may not load or link to about:config/,
+ },
+ ]);
+ });
+
+ let tabSwitched = BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone");
+ authorLink.click();
+ await tabSwitched;
+
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "about:blank",
+ "Got about:blank loaded in the new tab"
+ );
+
+ SimpleTest.endMonitorConsole();
+ await waitForConsole;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await closeAboutAddons();
+});
+
+add_task(async function test_no_report_checkbox_for_unsupported_addon_types() {
+ async function test_report_checkbox_hidden(addon) {
+ await openAboutAddons(addon.type);
+
+ const addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${addon.id}"]`
+ );
+ ok(addonCard, "Got the addon-card for the test extension");
+
+ const removeButton = addonCard.querySelector("[action=remove]");
+ ok(removeButton, "Got the remove action for the test extension");
+
+ // Prepare the mocked prompt service.
+ const promptService = mockPromptService();
+ promptService.confirmEx = createPromptConfirmEx({
+ remove: true,
+ report: false,
+ expectCheckboxHidden: true,
+ });
+
+ info("Click the report action and wait for the addon to be removed");
+ const promiseCardRemoved = BrowserTestUtils.waitForEvent(
+ addonCard.closest("addon-list"),
+ "remove"
+ );
+ removeButton.click();
+ await promiseCardRemoved;
+
+ await closeAboutAddons();
+ }
+
+ const reportNotSupportedAddons = [
+ {
+ id: "fake-langpack-to-remove@mochi.test",
+ name: "This is a fake langpack",
+ version: "1.1",
+ type: "locale",
+ },
+ {
+ id: "fake-dictionary-to-remove@mochi.test",
+ name: "This is a fake dictionary",
+ version: "1.1",
+ type: "dictionary",
+ },
+ ];
+
+ AbuseReportTestUtils.createMockAddons(reportNotSupportedAddons);
+
+ for (const { id } of reportNotSupportedAddons) {
+ const addon = await AddonManager.getAddonByID(id);
+ await test_report_checkbox_hidden(addon);
+ }
+});
+
+add_task(async function test_author_hidden_when_missing() {
+ const EXT_ID = "test-no-author@mochi.test";
+ const extension = await installTestExtension(EXT_ID, "extension", {
+ author: undefined,
+ });
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(EXT_ID);
+
+ const addon = await AddonManager.getAddonByID(EXT_ID);
+
+ ok(!addon.creator, "addon.creator should not be undefined");
+ ok(
+ abuseReportEl._addonAuthorContainer.hidden,
+ "author container should be hidden"
+ );
+
+ await closeAboutAddons();
+ await extension.unload();
+});
+
+// Verify addon.siteOrigin is used as a fallback when homepage_url/developer.url
+// or support url are missing.
+//
+// TODO(Bug 1789718): adapt to SitePermAddonProvider implementation.
+add_task(async function test_siteperm_siteorigin_fallback() {
+ const SITEPERM_ADDON_ID = "webmidi-site-origin@mochi.test";
+ const sitePermAddon = await installTestExtension(
+ SITEPERM_ADDON_ID,
+ "sitepermission-deprecated",
+ {
+ homepage_url: undefined,
+ }
+ );
+
+ const abuseReportEl = await AbuseReportTestUtils.openReport(
+ SITEPERM_ADDON_ID
+ );
+ const addon = await AddonManager.getAddonByID(SITEPERM_ADDON_ID);
+
+ ok(addon.siteOrigin, "addon.siteOrigin should not be undefined");
+ ok(!addon.supportURL, "addon.supportURL should not be set");
+ ok(!addon.homepageURL, "addon.homepageURL should not be set");
+ is(
+ abuseReportEl.supportURL,
+ addon.siteOrigin,
+ "Got the expected support_url"
+ );
+
+ await closeAboutAddons();
+ await sitePermAddon.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js
new file mode 100644
index 0000000000..1efb28add3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_abuse_report_dialog.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+add_setup(async function () {
+ // Make sure the integrated abuse report panel is the one enabled
+ // while this test file runs (instead of the AMO hosted form).
+ // NOTE: behaviors expected when amoFormEnabled is true are tested
+ // in the separate browser_amo_abuse_report.js test file.
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.abuseReport.amoFormEnabled", false]],
+ });
+ await AbuseReportTestUtils.setup();
+});
+
+/**
+ * Test tasks specific to the abuse report opened in its own dialog window.
+ */
+
+add_task(async function test_close_icon_button_hidden_when_dialog() {
+ const addonId = "addon-to-report@mochi.test";
+ const extension = await installTestExtension(addonId);
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ const panelEl = await reportDialog.promiseReportPanel;
+
+ let promiseClosedWindow = waitClosedWindow();
+
+ EventUtils.synthesizeKey("VK_RETURN", {}, panelEl.ownerGlobal);
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+
+ await promiseClosedWindow;
+ ok(
+ await reportDialog.promiseReport,
+ "expect the report to not be cancelled by pressing enter"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_report_triggered_when_report_dialog_is_open() {
+ const addonId = "addon-to-report@mochi.test";
+ const extension = await installTestExtension(addonId);
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseClosedWindow = waitClosedWindow();
+
+ const reportDialog2 = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+
+ await promiseClosedWindow;
+
+ // Trigger the report submit and check that the second report is
+ // resolved as expected.
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ ok(
+ !reportDialog.window || reportDialog.window.closed,
+ "expect the first dialog to be closed"
+ );
+ ok(!!reportDialog2.window, "expect the second dialog to be open");
+
+ is(
+ reportDialog2.window,
+ AbuseReportTestUtils.getReportDialog(),
+ "Got a report dialog as expected"
+ );
+
+ AbuseReportTestUtils.triggerSubmit("fake-reason", "fake-message");
+
+ // promiseReport is resolved to undefined if the report has been
+ // cancelled, otherwise it is resolved to a report object.
+ ok(
+ !(await reportDialog.promiseReport),
+ "expect the first report to be cancelled"
+ );
+ ok(
+ !!(await reportDialog2.promiseReport),
+ "expect the second report to be resolved"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_report_dialog_window_closed_by_user() {
+ const addonId = "addon-to-report@mochi.test";
+ const extension = await installTestExtension(addonId);
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseClosedWindow = waitClosedWindow();
+
+ reportDialog.close();
+
+ await promiseClosedWindow;
+
+ ok(
+ !(await reportDialog.promiseReport),
+ "expect promiseReport to be resolved as user cancelled"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_amo_details_for_not_installed_addon() {
+ const addonId = "not-installed-addon@mochi.test";
+ const fakeAMODetails = {
+ name: "fake name",
+ current_version: { version: "1.0" },
+ type: "extension",
+ icon_url: "http://test.addons.org/asserts/fake-icon-url.png",
+ homepage: "http://fake.url/homepage",
+ support_url: "http://fake.url/support",
+ authors: [
+ { name: "author1", url: "http://fake.url/author1" },
+ { name: "author2", url: "http://fake.url/author2" },
+ ],
+ is_recommended: true,
+ };
+
+ AbuseReportTestUtils.amoAddonDetailsMap.set(addonId, fakeAMODetails);
+ registerCleanupFunction(() =>
+ AbuseReportTestUtils.amoAddonDetailsMap.clear()
+ );
+
+ const reportDialog = await AbuseReporter.openDialog(
+ addonId,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+
+ const reportEl = await reportDialog.promiseReportPanel;
+
+ // Assert that the panel has been able to retrieve from AMO
+ // all the addon details needed to render the panel correctly.
+ is(reportEl.addonId, addonId, "Got the expected addonId");
+ is(reportEl.addonName, fakeAMODetails.name, "Got the expected addon name");
+ is(reportEl.addonType, fakeAMODetails.type, "Got the expected addon type");
+ is(
+ reportEl.authorName,
+ fakeAMODetails.authors[0].name,
+ "Got the first author name as expected"
+ );
+ is(
+ reportEl.authorURL,
+ fakeAMODetails.authors[0].url,
+ "Got the first author url as expected"
+ );
+ is(reportEl.iconURL, fakeAMODetails.icon_url, "Got the expected icon url");
+ is(
+ reportEl.supportURL,
+ fakeAMODetails.support_url,
+ "Got the expected support url"
+ );
+ is(
+ reportEl.homepageURL,
+ fakeAMODetails.homepage,
+ "Got the expected homepage url"
+ );
+
+ reportDialog.close();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js b/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js
new file mode 100644
index 0000000000..939fe421c3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_permissions.js
@@ -0,0 +1,827 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { PERMISSION_L10N, PERMISSION_L10N_ID_OVERRIDES } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissionMessages.sys.mjs"
+ );
+
+AddonTestUtils.initMochitest(this);
+
+async function background() {
+ browser.permissions.onAdded.addListener(perms => {
+ browser.test.sendMessage("permission-added", perms);
+ });
+ browser.permissions.onRemoved.addListener(perms => {
+ browser.test.sendMessage("permission-removed", perms);
+ });
+}
+
+async function getExtensions({ manifest_version = 2 } = {}) {
+ let extensions = {
+ "addon0@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 0",
+ browser_specific_settings: { gecko: { id: "addon0@mochi.test" } },
+ permissions: ["alarms", "contextMenus"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon1@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 1",
+ browser_specific_settings: { gecko: { id: "addon1@mochi.test" } },
+ permissions: ["alarms", "contextMenus", "tabs", "webNavigation"],
+ // Note: for easier testing, we merge host_permissions into permissions
+ // when loading mv2 extensions, see ExtensionTestCommon.generateFiles.
+ host_permissions: ["<all_urls>", "file://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon2@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 2",
+ browser_specific_settings: { gecko: { id: "addon2@mochi.test" } },
+ permissions: ["alarms", "contextMenus"],
+ optional_permissions: ["http://mochi.test/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon3@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 3",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "addon3@mochi.test" } },
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon4@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 4",
+ browser_specific_settings: { gecko: { id: "addon4@mochi.test" } },
+ optional_permissions: ["tabs", "webNavigation"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon5@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 5",
+ browser_specific_settings: { gecko: { id: "addon5@mochi.test" } },
+ optional_permissions: ["*://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "priv6@mochi.test": ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ manifest: {
+ manifest_version,
+ name: "Privileged add-on 6",
+ browser_specific_settings: { gecko: { id: "priv6@mochi.test" } },
+ optional_permissions: [
+ "file://*/*",
+ "about:reader*",
+ "resource://pdf.js/*",
+ "*://*.mozilla.com/*",
+ "*://*/*",
+ "<all_urls>",
+ ],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon7@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 7",
+ browser_specific_settings: { gecko: { id: "addon7@mochi.test" } },
+ optional_permissions: ["<all_urls>", "https://*/*", "file://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "addon8@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 8",
+ browser_specific_settings: { gecko: { id: "addon8@mochi.test" } },
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ optional_permissions: ["https://*/*", "http://*/*", "file://*/*"],
+ },
+ background,
+ useAddonManager: "temporary",
+ }),
+ "other@mochi.test": ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version,
+ name: "Test add-on 6",
+ browser_specific_settings: { gecko: { id: "other@mochi.test" } },
+ optional_permissions: [
+ "tabs",
+ "webNavigation",
+ "<all_urls>",
+ "*://*/*",
+ ],
+ },
+ useAddonManager: "temporary",
+ }),
+ };
+ for (let ext of Object.values(extensions)) {
+ await ext.startup();
+ }
+ return extensions;
+}
+
+async function runTest(options) {
+ let {
+ extension,
+ addonId,
+ permissions = [],
+ optional_permissions = [],
+ optional_overlapping = [],
+ optional_enabled = [],
+ // Map<permission->string> to check optional_permissions against, if set.
+ optional_strings = {},
+ view,
+ } = options;
+ if (extension) {
+ addonId = extension.id;
+ }
+
+ let win = view || (await loadInitialView("extension"));
+
+ let card = getAddonCard(win, addonId);
+ let permsSection = card.querySelector("addon-permissions-list");
+ if (!permsSection) {
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+ }
+
+ card = getAddonCard(win, addonId);
+ let { deck, tabGroup } = card.details;
+
+ let permsBtn = tabGroup.querySelector('[name="permissions"]');
+ let permsShown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ permsBtn.click();
+ await permsShown;
+
+ permsSection = card.querySelector("addon-permissions-list");
+
+ let rows = Array.from(permsSection.querySelectorAll(".addon-detail-row"));
+ let permission_rows = Array.from(
+ permsSection.querySelectorAll(".permission-info")
+ );
+
+ // Last row is the learn more link.
+ info("Check learn more link");
+ let link = rows[rows.length - 1].firstElementChild;
+ let rootUrl = Services.urlFormatter.formatURLPref("app.support.baseURL");
+ let url = rootUrl + "extension-permissions";
+ is(link.href, url, "The URL is set");
+ is(link.getAttribute("target"), "_blank", "The link opens in a new tab");
+
+ // We should have one more row (learn more) that the combined permissions,
+ // or if no permissions, 2 rows.
+ let num_permissions = permissions.length + optional_permissions.length;
+ is(
+ permission_rows.length,
+ num_permissions,
+ "correct number of details rows are present"
+ );
+
+ info("Check displayed permissions");
+ if (!num_permissions) {
+ is(
+ win.document.l10n.getAttributes(rows[0]).id,
+ "addon-permissions-empty",
+ "There's a message when no permissions are shown"
+ );
+ }
+ if (permissions.length) {
+ for (let name of permissions) {
+ // Check the permission-info class to make sure it's for a permission.
+ let row = permission_rows.shift();
+ ok(
+ row.classList.contains("permission-info"),
+ `required permission row for ${name}`
+ );
+ }
+ }
+
+ let addon = await AddonManager.getAddonByID(addonId);
+ info(`addon ${addon.id} is ${addon.userDisabled ? "disabled" : "enabled"}`);
+
+ function waitForPermissionChange(id) {
+ return new Promise(resolve => {
+ info(`listening for change on ${id}`);
+ let listener = (type, data) => {
+ info(`change permissions ${JSON.stringify(data)}`);
+ if (data.extensionId !== id) {
+ return;
+ }
+ ExtensionPermissions.removeListener(listener);
+ resolve(data);
+ };
+ ExtensionPermissions.addListener(listener);
+ });
+ }
+
+ // This tests the permission change and button state when the user
+ // changes the state in about:addons.
+ async function testTogglePermissionButton(
+ permissions,
+ button,
+ excpectDisabled = false
+ ) {
+ let enabled = permissions.some(perm => optional_enabled.includes(perm));
+ if (excpectDisabled) {
+ enabled = !enabled;
+ }
+ is(
+ button.pressed,
+ enabled,
+ `permission is set correctly for ${permissions}: ${button.pressed}`
+ );
+ let change;
+ if (addon.userDisabled || !extension) {
+ change = waitForPermissionChange(addonId);
+ } else if (!enabled) {
+ change = extension.awaitMessage("permission-added");
+ } else {
+ change = extension.awaitMessage("permission-removed");
+ }
+
+ button.click();
+
+ let perms = await change;
+ if (addon.userDisabled || !extension) {
+ perms = enabled ? perms.removed : perms.added;
+ }
+
+ Assert.greater(
+ perms.permissions.length + perms.origins.length,
+ 0,
+ "Some permission(s) toggled."
+ );
+
+ if (perms.permissions.length) {
+ // Only check api permissions against the first passed permission,
+ // because we treat <all_urls> as an api permission, but not *://*/*.
+ is(perms.permissions.length, 1, "A single api permission toggled.");
+ is(perms.permissions[0], permissions[0], "Correct api permission.");
+ }
+ if (perms.origins.length) {
+ Assert.deepEqual(
+ perms.origins.slice().sort(),
+ permissions.slice().sort(),
+ "Toggled origin permission."
+ );
+ }
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ return button.pressed == !enabled;
+ }, "button changed state");
+ }
+
+ // This tests that the button changes state if the permission is
+ // changed outside of about:addons
+ async function testExternalPermissionChange(permission, button) {
+ let enabled = button.pressed;
+ let type = button.getAttribute("permission-type");
+ let change;
+ if (addon.userDisabled || !extension) {
+ change = waitForPermissionChange(addonId);
+ } else if (!enabled) {
+ change = extension.awaitMessage("permission-added");
+ } else {
+ change = extension.awaitMessage("permission-removed");
+ }
+
+ let permissions = { permissions: [], origins: [] };
+ if (type == "origin") {
+ permissions.origins = [permission];
+ } else {
+ permissions.permissions = [permission];
+ }
+
+ if (enabled) {
+ await ExtensionPermissions.remove(addonId, permissions);
+ } else {
+ await ExtensionPermissions.add(addonId, permissions);
+ }
+
+ let perms = await change;
+ if (addon.userDisabled || !extension) {
+ perms = enabled ? perms.removed : perms.added;
+ }
+ ok(
+ perms.permissions.includes(permission) ||
+ perms.origins.includes(permission),
+ "permission was toggled"
+ );
+
+ await BrowserTestUtils.waitForCondition(async () => {
+ return button.pressed == !enabled;
+ }, "button changed state");
+ }
+
+ // This tests that changing the permission on another addon does
+ // not change the UI for the addon we're testing.
+ async function testOtherPermissionChange(permission, toggle) {
+ let type = toggle.getAttribute("permission-type");
+ let otherId = "other@mochi.test";
+ let change = waitForPermissionChange(otherId);
+ let perms = await ExtensionPermissions.get(otherId);
+ let existing = type == "origin" ? perms.origins : perms.permissions;
+ let permissions = { permissions: [], origins: [] };
+ if (type == "origin") {
+ permissions.origins = [permission];
+ } else {
+ permissions.permissions = [permission];
+ }
+
+ if (existing.includes(permission)) {
+ await ExtensionPermissions.remove(otherId, permissions);
+ } else {
+ await ExtensionPermissions.add(otherId, permissions);
+ }
+ await change;
+ }
+
+ if (optional_permissions.length) {
+ for (let name of optional_permissions) {
+ // Set of permissions represented by this key.
+ let perms = [name];
+ if (name === optional_overlapping[0]) {
+ perms = optional_overlapping;
+ }
+
+ // Check the row is a permission row with the correct key on the toggle
+ // control.
+ let row = permission_rows.shift();
+ let toggle = row.querySelector("moz-toggle");
+ let label = toggle.labelEl;
+
+ let str = optional_strings[name];
+ if (str) {
+ is(label.textContent.trim(), str, `Expected permission string ${str}`);
+ }
+
+ ok(
+ row.classList.contains("permission-info"),
+ `optional permission row for ${name}`
+ );
+ is(
+ toggle.getAttribute("permission-key"),
+ name,
+ `optional permission toggle exists for ${name}`
+ );
+
+ await testTogglePermissionButton(perms, toggle);
+ await testTogglePermissionButton(perms, toggle, true);
+
+ for (let perm of perms) {
+ // make a change "outside" the UI and check the values.
+ // toggle twice to test both add/remove.
+ await testExternalPermissionChange(perm, toggle);
+ // change another addon to mess around with optional permission
+ // values to see if it effects the addon we're testing here. The
+ // next check would fail if anything bleeds onto other addons.
+ await testOtherPermissionChange(perm, toggle);
+ // repeat the "outside" test.
+ await testExternalPermissionChange(perm, toggle);
+ }
+ }
+ }
+
+ if (!view) {
+ await closeView(win);
+ }
+}
+
+async function testPermissionsView({ manifestV3enabled, manifest_version }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", manifestV3enabled]],
+ });
+
+ // pre-set a permission prior to starting extensions.
+ await ExtensionPermissions.add("addon4@mochi.test", {
+ permissions: ["tabs"],
+ origins: [],
+ });
+
+ let extensions = await getExtensions({ manifest_version });
+
+ info("Check add-on with required permissions");
+ if (manifest_version < 3) {
+ await runTest({
+ extension: extensions["addon1@mochi.test"],
+ permissions: ["<all_urls>", "tabs", "webNavigation"],
+ });
+ } else {
+ await runTest({
+ extension: extensions["addon1@mochi.test"],
+ permissions: ["tabs", "webNavigation"],
+ optional_permissions: ["<all_urls>"],
+ });
+ }
+
+ info("Check add-on without any displayable permissions");
+ await runTest({ extension: extensions["addon0@mochi.test"] });
+
+ info("Check add-on with only one optional origin.");
+ await runTest({
+ extension: extensions["addon2@mochi.test"],
+ optional_permissions: manifestV3enabled ? ["http://mochi.test/*"] : [],
+ optional_strings: {
+ "http://mochi.test/*": "Access your data for http://mochi.test",
+ },
+ });
+
+ info("Check add-on with both required and optional permissions");
+ await runTest({
+ extension: extensions["addon3@mochi.test"],
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ });
+
+ // Grant a specific optional host permission not listed in the manifest.
+ await ExtensionPermissions.add("addon3@mochi.test", {
+ permissions: [],
+ origins: ["https://example.com/*"],
+ });
+ await extensions["addon3@mochi.test"].awaitMessage("permission-added");
+
+ info("Check addon3 again and expect the new optional host permission");
+ await runTest({
+ extension: extensions["addon3@mochi.test"],
+ permissions: ["tabs"],
+ optional_permissions: [
+ "webNavigation",
+ "<all_urls>",
+ ...(manifestV3enabled ? ["https://example.com/*"] : []),
+ ],
+ optional_enabled: ["https://example.com/*"],
+ optional_strings: {
+ "https://example.com/*": "Access your data for https://example.com",
+ },
+ });
+
+ info("Check add-on with only optional permissions, tabs is pre-enabled");
+ await runTest({
+ extension: extensions["addon4@mochi.test"],
+ optional_permissions: ["tabs", "webNavigation"],
+ optional_enabled: ["tabs"],
+ });
+
+ info("Check add-on with a global match pattern in place of all urls");
+ await runTest({
+ extension: extensions["addon5@mochi.test"],
+ optional_permissions: ["*://*/*"],
+ });
+
+ info("Check privileged add-on with non-web origin permissions");
+ await runTest({
+ extension: extensions["priv6@mochi.test"],
+ optional_permissions: [
+ "<all_urls>",
+ ...(manifestV3enabled ? ["*://*.mozilla.com/*"] : []),
+ ],
+ optional_overlapping: ["<all_urls>", "*://*/*"],
+ optional_strings: {
+ "*://*.mozilla.com/*":
+ "Access your data for sites in the *://mozilla.com domain",
+ },
+ });
+
+ info(`Check that <all_urls> is used over other "all websites" permissions`);
+ await runTest({
+ extension: extensions["addon7@mochi.test"],
+ optional_permissions: ["<all_urls>"],
+ optional_overlapping: ["<all_urls>", "https://*/*"],
+ });
+
+ info(`Also check different "all sites" permissions in the manifest`);
+ await runTest({
+ extension: extensions["addon8@mochi.test"],
+ optional_permissions: ["https://*/*"],
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ optional_overlapping: ["https://*/*", "http://*/*"],
+ });
+
+ for (let ext of Object.values(extensions)) {
+ await ext.unload();
+ }
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function testPermissionsView_MV2_manifestV3disabled() {
+ await testPermissionsView({ manifestV3enabled: false, manifest_version: 2 });
+});
+
+add_task(async function testPermissionsView_MV2_manifestV3enabled() {
+ await testPermissionsView({ manifestV3enabled: true, manifest_version: 2 });
+});
+
+add_task(async function testPermissionsView_MV3() {
+ await testPermissionsView({ manifestV3enabled: true, manifest_version: 3 });
+});
+
+add_task(async function testPermissionsViewStates() {
+ let ID = "addon@mochi.test";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test add-on 3",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ info(
+ "Check toggling permissions on a disabled addon with addon3@mochi.test."
+ );
+ let view = await loadInitialView("extension");
+ let addon = await AddonManager.getAddonByID(ID);
+ await addon.disable();
+ ok(addon.userDisabled, "addon is disabled");
+ await runTest({
+ extension,
+ permissions: ["tabs"],
+ optional_permissions: ["webNavigation", "<all_urls>"],
+ view,
+ });
+ await addon.enable();
+ ok(!addon.userDisabled, "addon is enabled");
+
+ async function install_addon(extensionData) {
+ let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData);
+ let { addon } = await AddonTestUtils.promiseInstallFile(xpi);
+ return addon;
+ }
+
+ function wait_for_addon_item_updated(addonId) {
+ return BrowserTestUtils.waitForEvent(getAddonCard(view, addonId), "update");
+ }
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ addon = await install_addon({
+ manifest: {
+ name: "Test add-on 3",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ optional_permissions: ["webNavigation"],
+ },
+ useAddonManager: "permanent",
+ });
+ is(addon.version, "2.0", "addon upgraded");
+ await promiseItemUpdated;
+
+ await runTest({
+ addonId: addon.id,
+ optional_permissions: ["webNavigation"],
+ view,
+ });
+
+ // While the view is still available, test setting a permission
+ // that is not in the manifest of the addon.
+ let card = getAddonCard(view, addon.id);
+ await Assert.rejects(
+ card.setAddonPermission("webRequest", "permission", "add"),
+ /permission missing from manifest/,
+ "unable to set the addon permission"
+ );
+
+ await closeView(view);
+ await extension.unload();
+});
+
+add_task(async function testAllUrlsNotGrantedUnconditionally_MV3() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.manifestV3.enabled", true]],
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 3,
+ host_permissions: ["<all_urls>"],
+ },
+ async background() {
+ const perms = await browser.permissions.getAll();
+ browser.test.sendMessage("granted-permissions", perms);
+ },
+ });
+
+ await extension.startup();
+ const perms = await extension.awaitMessage("granted-permissions");
+ ok(
+ !perms.origins.includes("<all_urls>"),
+ "Optional <all_urls> should not be granted as host permission yet"
+ );
+ ok(
+ !perms.permissions.includes("<all_urls>"),
+ "Optional <all_urls> should not be granted as an API permission neither"
+ );
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_OneOfMany_AllSites_toggle() {
+ // ESLint autofix will silently convert http://*/* match patterns into https.
+ /* eslint-disable @microsoft/sdl/no-insecure-url */
+ let id = "addon9@mochi.test";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test add-on 9",
+ browser_specific_settings: { gecko: { id } },
+ optional_permissions: ["http://*/*", "https://*/*"],
+ },
+ background,
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ // Grant the second "all sites" permission as listed in the manifest.
+ await ExtensionPermissions.add("addon9@mochi.test", {
+ permissions: [],
+ origins: ["https://*/*"],
+ });
+ await extension.awaitMessage("permission-added");
+
+ let view = await loadInitialView("extension");
+ let addon = await AddonManager.getAddonByID(id);
+
+ let card = getAddonCard(view, addon.id);
+
+ let permsSection = card.querySelector("addon-permissions-list");
+ if (!permsSection) {
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(view);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+ }
+
+ card = getAddonCard(view, addon.id);
+ let { deck, tabGroup } = card.details;
+
+ let permsBtn = tabGroup.querySelector('[name="permissions"]');
+ let permsShown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ permsBtn.click();
+ await permsShown;
+
+ permsSection = card.querySelector("addon-permissions-list");
+ let permission_rows = permsSection.querySelectorAll(".permission-info");
+ is(permission_rows.length, 1, "Only one 'all sites' permission toggle.");
+
+ let row = permission_rows[0];
+ let toggle = row.querySelector("moz-toggle");
+ ok(
+ row.classList.contains("permission-info"),
+ `optional permission row for "http://*/*"`
+ );
+ is(
+ toggle.getAttribute("permission-key"),
+ "http://*/*",
+ `optional permission toggle exists for "http://*/*"`
+ );
+ ok(toggle.pressed, "Expect 'all sites' toggle to be set.");
+
+ // Revoke the second "all sites" permission, expect toggle to be unchecked.
+ await ExtensionPermissions.remove("addon9@mochi.test", {
+ permissions: [],
+ origins: ["https://*/*"],
+ });
+ await extension.awaitMessage("permission-removed");
+ ok(!toggle.pressed, "Expect 'all sites' toggle not to be pressed.");
+
+ toggle.click();
+
+ let granted = await extension.awaitMessage("permission-added");
+ Assert.deepEqual(granted, {
+ permissions: [],
+ origins: ["http://*/*", "https://*/*"],
+ });
+
+ await closeView(view);
+ await extension.unload();
+ /* eslint-enable @microsoft/sdl/no-insecure-url */
+});
+
+add_task(async function testOverrideLocalization() {
+ // Mock a fluent file.
+ const l10nReg = L10nRegistry.getInstance();
+ const source = L10nFileSource.createMock(
+ "mock",
+ "app",
+ ["en-US"],
+ "/localization/",
+ [
+ {
+ path: "/localization/mock.ftl",
+ source: `
+webext-perms-description-test-tabs = Custom description for the tabs permission
+`,
+ },
+ ]
+ );
+ l10nReg.registerSources([source]);
+
+ // Add the mocked fluent file to PERMISSION_L10N and override the tabs
+ // permission to use the alternative string. In a real world use-case, this
+ // would be used to add non-toolkit fluent files with permission strings of
+ // APIs which are defined outside of toolkit.
+ PERMISSION_L10N.addResourceIds(["mock.ftl"]);
+ PERMISSION_L10N_ID_OVERRIDES.set(
+ "tabs",
+ "webext-perms-description-test-tabs"
+ );
+
+ let mockCleanup = () => {
+ // Make sure cleanup is executed only once.
+ mockCleanup = () => {};
+
+ // Remove the non-toolkit permission string.
+ PERMISSION_L10N.removeResourceIds(["mock.ftl"]);
+ PERMISSION_L10N_ID_OVERRIDES.delete("tabs");
+ l10nReg.removeSources(["mock"]);
+ };
+ registerCleanupFunction(mockCleanup);
+
+ // Load an example add-on which uses the tabs permission.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ manifest_version: 2,
+ name: "Simple test add-on",
+ browser_specific_settings: { gecko: { id: "testAddon@mochi.test" } },
+ permissions: ["tabs"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let addonId = extension.id;
+
+ let win = await loadInitialView("extension");
+
+ // Open the card and navigate to its permission list.
+ let card = getAddonCard(win, addonId);
+ let permsSection = card.querySelector("addon-permissions-list");
+ if (!permsSection) {
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+ }
+
+ card = getAddonCard(win, addonId);
+ let { deck, tabGroup } = card.details;
+
+ let permsBtn = tabGroup.querySelector('[name="permissions"]');
+ let permsShown = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ permsBtn.click();
+ await permsShown;
+ let permissionList = card.querySelector("addon-permissions-list");
+ let permissionEntries = Array.from(permissionList.querySelectorAll("li"));
+ Assert.equal(
+ permissionEntries.length,
+ 1,
+ "Should find a single permission entry"
+ );
+ Assert.equal(
+ permissionEntries[0].textContent,
+ "Custom description for the tabs permission",
+ "Should find the non-default permission description"
+ );
+
+ await closeView(win);
+ await extension.unload();
+
+ mockCleanup();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
new file mode 100644
index 0000000000..76e7f2b255
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -0,0 +1,1675 @@
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+const { QuarantinedDomains } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+const SUPPORT_URL = Services.urlFormatter.formatURL(
+ Services.prefs.getStringPref("app.support.baseURL")
+);
+const PB_SUMO_URL = SUPPORT_URL + "extensions-pb";
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+const DARK_THEME_ID = "firefox-compact-dark@mozilla.org";
+
+let gProvider;
+let promptService;
+
+AddonTestUtils.initMochitest(this);
+
+function getDetailRows(card) {
+ return Array.from(
+ card.querySelectorAll('[name="details"] .addon-detail-row:not([hidden])')
+ );
+}
+
+async function checkLabel(row, name) {
+ let id;
+ if (name == "private-browsing") {
+ // This id is carried over from the old about:addons.
+ id = "detail-private-browsing-label";
+ } else {
+ id = `addon-detail-${name}-label`;
+ }
+ const doc = row.ownerDocument;
+ await doc.l10n.translateElements([row]);
+ const rowHeaderEl = row.firstElementChild;
+ is(doc.l10n.getAttributes(rowHeaderEl).id, id, `The ${name} label is set`);
+ if (row.getAttribute("role") === "group") {
+ // For the rows on which the role="group" attribute is set,
+ // let's make sure that the element itself includes an aria-label
+ // which provides to the screen reader a label similar to the one
+ // rendered as the visual section header.
+ //
+ // NOTE: more screen reader accessibility assertions are being
+ // covered by the checkRowScreenReaderAccessibility test helper.
+ is(
+ row.getAttribute("aria-label"),
+ rowHeaderEl.textContent,
+ "expect an aria-label from role=group row to match row header el text"
+ );
+ // For these rows we expect rowHeaderEl to be a span.
+ is(rowHeaderEl.tagName, "SPAN", "row header element should be a span");
+ } else {
+ // For the other rows which we have not set a role="group" attribute
+ // on, we expect the rowHeaderEl to still be a label.
+ is(
+ rowHeaderEl.tagName,
+ "LABEL",
+ "row header element expected to be a label"
+ );
+ }
+}
+
+async function checkRowScreenReaderAccessibility(
+ row,
+ { groupName, expectedFluentId }
+) {
+ const doc = row.ownerDocument;
+ // Make sure the row isn't missing any strings expected to be associated
+ // to the fluent ids (which would make translateElements to reject
+ // and the test to fail explicitly).
+ await doc.l10n.translateElements([row]);
+ is(
+ row.getAttribute("role"),
+ "group",
+ `Expect ${groupName} row to have role group`
+ );
+ is(
+ doc.l10n.getAttributes(row).id,
+ expectedFluentId,
+ `Got expected fluent id associated to the ${groupName} row`
+ );
+ // Make sure that screen readers will be able to announce to the
+ // user what is the group of controls being entered.
+ ok(
+ !!row.getAttribute("aria-label")?.length,
+ `Expect non empty aria-label on the ${groupName} row`
+ );
+}
+
+async function checkQuarantinedDomainsUserAllowedRows(card, rows) {
+ // Account for the rows related to per-addon quarantineIgnoredByUser UI,
+ // underling functionality of the UI is checked in its own test task.
+ const doc = card.ownerDocument;
+ if (card.addon.canChangeQuarantineIgnored) {
+ let row = rows.shift();
+ await checkLabel(row, "quarantined-domains");
+ await checkRowScreenReaderAccessibility(row, {
+ groupName: "quarantined domains exempt controls",
+ expectedFluentId: "addon-detail-group-label-quarantined-domains",
+ });
+
+ // quarantineIgnoredByUser UI help text.
+ row = rows.shift();
+ ok(row.classList.contains("addon-detail-help-row"), "There's a help row");
+ ok(!row.hidden, "The help row is shown");
+ is(
+ doc.l10n.getAttributes(row.firstElementChild).id,
+ "addon-detail-quarantined-domains-help",
+ "The help row is for quarantined domains"
+ );
+ }
+}
+
+function formatUrl(contentAttribute, url) {
+ let parsedUrl = new URL(url);
+ parsedUrl.searchParams.set("utm_source", "firefox-browser");
+ parsedUrl.searchParams.set("utm_medium", "firefox-browser");
+ parsedUrl.searchParams.set("utm_content", contentAttribute);
+ return parsedUrl.href;
+}
+
+function checkLink(link, url, text = url) {
+ ok(link, "There is a link");
+ is(link.href, url, "The link goes to the URL");
+ if (text instanceof Object) {
+ // Check the fluent data.
+ Assert.deepEqual(
+ link.ownerDocument.l10n.getAttributes(link),
+ text,
+ "The fluent data is set correctly"
+ );
+ } else {
+ // Just check text.
+ is(link.textContent, text, "The text is set");
+ }
+ is(link.getAttribute("target"), "_blank", "The link opens in a new tab");
+}
+
+function checkOptions(doc, options, expectedOptions) {
+ let numOptions = expectedOptions.length;
+ is(options.length, numOptions, `There are ${numOptions} options`);
+ for (let i = 0; i < numOptions; i++) {
+ let option = options[i];
+ is(option.children.length, 2, "There are 2 children for the option");
+ let input = option.firstElementChild;
+ is(input.tagName, "INPUT", "The input is first");
+ let text = option.lastElementChild;
+ is(text.tagName, "SPAN", "The label text is second");
+ let expected = expectedOptions[i];
+ is(input.value, expected.value, "The value is right");
+ is(input.checked, expected.checked, "The checked property is correct");
+ Assert.deepEqual(
+ doc.l10n.getAttributes(text),
+ { id: expected.label, args: null },
+ "The label has the right text"
+ );
+ }
+}
+
+function assertDeckHeadingHidden(group) {
+ ok(group.hidden, "The tab group is hidden");
+ let buttons = group.querySelectorAll(".tab-button");
+ for (let button of buttons) {
+ Assert.equal(button.offsetHeight, 0, `The ${button.name} is hidden`);
+ }
+}
+
+function assertDeckHeadingButtons(group, visibleButtons) {
+ ok(!group.hidden, "The tab group is shown");
+ let buttons = group.querySelectorAll(".tab-button");
+ Assert.greaterOrEqual(
+ buttons.length,
+ visibleButtons.length,
+ `There should be at least ${visibleButtons.length} buttons`
+ );
+ for (let button of buttons) {
+ if (visibleButtons.includes(button.name)) {
+ ok(!button.hidden, `The ${button.name} is shown`);
+ } else {
+ ok(button.hidden, `The ${button.name} is hidden`);
+ }
+ }
+}
+
+async function hasPrivateAllowed(id) {
+ let perms = await ExtensionPermissions.get(id);
+ return perms.permissions.includes("internal:privateBrowsingAllowed");
+}
+
+async function assertBackButtonIsDisabled(win) {
+ let backButton = await BrowserTestUtils.waitForCondition(async () => {
+ let backButton = win.document.querySelector(".back-button");
+
+ // Wait until the button is visible in the page.
+ return backButton && !backButton.hidden ? backButton : false;
+ });
+
+ ok(backButton, "back button is rendered");
+ ok(backButton.disabled, "back button is disabled");
+}
+
+add_setup(async function enableHtmlViews() {
+ gProvider = new MockProvider(["extension", "sitepermission"]);
+ gProvider.createAddons([
+ {
+ id: "addon1@mochi.test",
+ name: "Test add-on 1",
+ creator: { name: "The creator", url: "http://addons.mozilla.org/me" },
+ version: "3.1",
+ description: "Short description",
+ fullDescription: "Longer description\nWith brs!",
+ type: "extension",
+ contributionURL: "http://example.com/contribute",
+ averageRating: 4.279,
+ userPermissions: {
+ origins: ["<all_urls>", "file://*/*"],
+ permissions: ["alarms", "contextMenus", "tabs", "webNavigation"],
+ },
+ reviewCount: 5,
+ reviewURL: "http://addons.mozilla.org/reviews",
+ homepageURL: "http://example.com/addon1",
+ updateDate: new Date("2019-03-07T01:00:00"),
+ applyBackgroundUpdates: AddonManager.AUTOUPDATE_ENABLE,
+ },
+ {
+ id: "addon2@mochi.test",
+ name: "Test add-on 2",
+ creator: { name: "I made it" },
+ description: "Short description",
+ userPermissions: {
+ origins: [],
+ permissions: ["alarms", "contextMenus"],
+ },
+ type: "extension",
+ },
+ {
+ id: "addon3@mochi.test",
+ name: "Test add-on 3",
+ creator: { name: "Look a super long description" },
+ description: "Short description",
+ fullDescription: "Mozilla\n".repeat(100),
+ userPermissions: {
+ origins: [],
+ permissions: ["alarms", "contextMenus"],
+ },
+ type: "extension",
+ contributionURL: "http://example.com/contribute",
+ updateDate: new Date("2022-03-07T01:00:00"),
+ },
+ {
+ id: "addon4@mochi.test",
+ name: "Test add-on 4",
+ creator: { name: "Some name" },
+ description: "Short description",
+ userPermissions: {
+ origins: [],
+ permissions: ["alarms", "contextMenus"],
+ },
+ type: "extension",
+ reviewCount: 0,
+ reviewURL: "http://addons.mozilla.org/reviews",
+ averageRating: 0,
+ },
+ {
+ // NOTE: Keep the mock properties in sync with the one that
+ // SitePermsAddonWrapper would be providing in real synthetic
+ // addon entries managed by the SitePermsAddonProvider.
+ id: "sitepermission@mochi.test",
+ version: "2.0",
+ name: "Test site permission add-on",
+ description: "permission description",
+ fullDescription: "detailed description",
+ siteOrigin: "http://mochi.test",
+ sitePermissions: ["midi"],
+ type: "sitepermission",
+ permissions: AddonManager.PERM_CAN_UNINSTALL,
+ },
+ {
+ id: "theme1@mochi.test",
+ name: "Test theme",
+ creator: { name: "Artist", url: "http://example.com/artist" },
+ description: "A nice tree",
+ type: "theme",
+ screenshots: [
+ {
+ url: "http://example.com/preview-wide.png",
+ width: 760,
+ height: 92,
+ },
+ {
+ url: "http://example.com/preview.png",
+ width: 680,
+ height: 92,
+ },
+ ],
+ },
+ ]);
+
+ promptService = mockPromptService();
+});
+
+add_task(async function testOpenDetailView() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ let id2 = "test2@mochi.test";
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id: id2 } },
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension2.startup();
+
+ const goBack = async win => {
+ let loaded = waitForViewLoad(win);
+ let backButton = win.document.querySelector(".back-button");
+ ok(!backButton.disabled, "back button is enabled");
+ backButton.click();
+ await loaded;
+ };
+
+ let win = await loadInitialView("extension");
+
+ // Test click on card to open details.
+ let card = getAddonCard(win, id);
+ ok(!card.querySelector("addon-details"), "The card doesn't have details");
+ let loaded = waitForViewLoad(win);
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive container to open the card
+ // with a mouse, while its inner link element is accessible and is being
+ // tested in other test cases, thus this rule check shall be ignored by
+ // a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win);
+ AccessibilityUtils.resetEnv();
+ await loaded;
+
+ card = getAddonCard(win, id);
+ ok(card.querySelector("addon-details"), "The card now has details");
+
+ await goBack(win);
+
+ // Test using more options menu.
+ card = getAddonCard(win, id);
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, id);
+ ok(card.querySelector("addon-details"), "The card now has details");
+
+ await goBack(win);
+
+ card = getAddonCard(win, id2);
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ await goBack(win);
+
+ // Test click on add-on name.
+ card = getAddonCard(win, id2);
+ ok(!card.querySelector("addon-details"), "The card isn't expanded");
+ let addonName = card.querySelector(".addon-name");
+ loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(addonName, {}, win);
+ await loaded;
+ card = getAddonCard(win, id2);
+ ok(card.querySelector("addon-details"), "The card is expanded");
+
+ await closeView(win);
+ await extension.unload();
+ await extension2.unload();
+});
+
+add_task(async function testDetailOperations() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ ok(!card.querySelector("addon-details"), "The card doesn't have details");
+ let loaded = waitForViewLoad(win);
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive container to open the card
+ // with a mouse, while its inner link element is accessible and is being
+ // tested in other test cases, thus this rule check shall be ignored by
+ // a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ EventUtils.synthesizeMouseAtCenter(card, { clickCount: 1 }, win);
+ AccessibilityUtils.resetEnv();
+ await loaded;
+
+ card = getAddonCard(win, id);
+ let panel = card.querySelector("panel-list");
+
+ // Check button visibility.
+ let disableButton = card.querySelector('[action="toggle-disabled"]');
+ ok(!disableButton.hidden, "The disable button is visible");
+
+ let removeButton = panel.querySelector('[action="remove"]');
+ ok(!removeButton.hidden, "The remove button is visible");
+
+ let separator = panel.querySelector("hr:last-of-type");
+ ok(separator.hidden, "The separator is hidden");
+
+ let expandButton = panel.querySelector('[action="expand"]');
+ ok(expandButton.hidden, "The expand button is hidden");
+
+ // Check toggling disabled.
+ let name = card.addonNameEl;
+ is(name.textContent, "Test", "The name is set when enabled");
+ is(doc.l10n.getAttributes(name).id, null, "There is no l10n name");
+
+ // Disable the extension.
+ let disableToggled = BrowserTestUtils.waitForEvent(card, "update");
+ disableButton.click();
+ await disableToggled;
+
+ // The (disabled) text should be shown now.
+ Assert.deepEqual(
+ doc.l10n.getAttributes(name),
+ { id: "addon-name-disabled", args: { name: "Test" } },
+ "The name is updated to the disabled text"
+ );
+
+ // Enable the add-on.
+ let extensionStarted = AddonTestUtils.promiseWebExtensionStartup(id);
+ disableToggled = BrowserTestUtils.waitForEvent(card, "update");
+ disableButton.click();
+ await Promise.all([disableToggled, extensionStarted]);
+
+ // Name is just the add-on name again.
+ is(name.textContent, "Test", "The name is reset when enabled");
+ is(doc.l10n.getAttributes(name).id, null, "There is no l10n name");
+
+ // Remove but cancel.
+ let cancelled = BrowserTestUtils.waitForEvent(card, "remove-cancelled");
+ removeButton.click();
+ await cancelled;
+
+ // Remove the extension.
+ let viewChanged = waitForViewLoad(win);
+ // Tell the mock prompt service that the prompt was accepted.
+ promptService._response = 0;
+ removeButton.click();
+ await viewChanged;
+
+ // We're on the list view now and there's no card for this extension.
+ const addonList = doc.querySelector("addon-list");
+ ok(addonList, "There's an addon-list now");
+ ok(!getAddonCard(win, id), "The extension no longer has a card");
+ let addon = await AddonManager.getAddonByID(id);
+ ok(
+ addon && !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The addon is pending uninstall"
+ );
+
+ // Ensure that a pending uninstall bar has been created for the
+ // pending uninstall extension, and pressing the undo button will
+ // refresh the list and render a card to the re-enabled extension.
+ assertHasPendingUninstalls(addonList, 1);
+ assertHasPendingUninstallAddon(addonList, addon);
+
+ extensionStarted = AddonTestUtils.promiseWebExtensionStartup(addon.id);
+ await testUndoPendingUninstall(addonList, addon);
+ info("Wait for the pending uninstall addon complete restart");
+ await extensionStarted;
+
+ card = getAddonCard(win, addon.id);
+ ok(card, "Addon card rendered after clicking pending uninstall undo button");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testFullDetails() {
+ let id = "addon1@mochi.test";
+ let headingId = "addon1_mochi_test-heading";
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // The list card.
+ let card = getAddonCard(win, id);
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ // Make sure the preview is hidden.
+ let preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.hidden, true, "The preview is hidden");
+
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // This is now the detail card.
+ card = getAddonCard(win, id);
+ ok(card.hasAttribute("expanded"), "The detail card is expanded");
+
+ let cardHeading = card.querySelector("h1");
+ is(cardHeading.textContent, "Test add-on 1", "Card heading is set");
+ is(cardHeading.id, headingId, "Heading has correct id");
+ is(
+ card.querySelector(".card").getAttribute("aria-labelledby"),
+ headingId,
+ "Card is labelled by the heading"
+ );
+
+ // Make sure the preview is hidden.
+ preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.hidden, true, "The preview is hidden");
+
+ let details = card.querySelector("addon-details");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingButtons(details.tabGroup, ["details", "permissions"]);
+
+ let desc = details.querySelector(".addon-detail-description");
+ is(
+ desc.innerHTML,
+ "Longer description<br>With brs!",
+ "The full description replaces newlines with <br>"
+ );
+
+ let sitepermissionsRow = details.querySelector(
+ ".addon-detail-sitepermissions"
+ );
+ is(
+ sitepermissionsRow.hidden,
+ true,
+ "AddonSitePermissionsList should be hidden for this addon type"
+ );
+
+ // Check the show more button is not there
+ const showMoreBtn = card.querySelector(".addon-detail-description-toggle");
+ ok(showMoreBtn.hidden, "The show more button is not visible");
+
+ let contrib = details.querySelector(".addon-detail-contribute");
+ ok(contrib, "The contribution section is visible");
+
+ let waitForTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "http://example.com/contribute"
+ );
+ contrib.querySelector("button").click();
+ BrowserTestUtils.removeTab(await waitForTab);
+
+ let rows = getDetailRows(card);
+
+ // Auto updates.
+ let row = rows.shift();
+
+ await checkLabel(row, "updates");
+ await checkRowScreenReaderAccessibility(row, {
+ groupName: "updates controls",
+ expectedFluentId: "addon-detail-group-label-updates",
+ });
+
+ let expectedOptions = [
+ { value: "1", label: "addon-detail-updates-radio-default", checked: false },
+ { value: "2", label: "addon-detail-updates-radio-on", checked: true },
+ { value: "0", label: "addon-detail-updates-radio-off", checked: false },
+ ];
+ let options = row.lastElementChild.querySelectorAll("label");
+ checkOptions(doc, options, expectedOptions);
+
+ // Private browsing, functionality checked in another test.
+ row = rows.shift();
+ await checkLabel(row, "private-browsing");
+ await checkRowScreenReaderAccessibility(row, {
+ groupName: "private browsing controls",
+ expectedFluentId: "addon-detail-group-label-private-browsing",
+ });
+
+ // Private browsing help text.
+ row = rows.shift();
+ ok(row.classList.contains("addon-detail-help-row"), "There's a help row");
+ ok(!row.hidden, "The help row is shown");
+ is(
+ doc.l10n.getAttributes(row).id,
+ "addon-detail-private-browsing-help",
+ "The help row is for private browsing"
+ );
+
+ await checkQuarantinedDomainsUserAllowedRows(card, rows);
+
+ // Author.
+ row = rows.shift();
+ await checkLabel(row, "author");
+ let link = row.querySelector("a");
+ let authorLink = formatUrl(
+ "addons-manager-user-profile-link",
+ "http://addons.mozilla.org/me"
+ );
+ checkLink(link, authorLink, "The creator");
+
+ // Version.
+ row = rows.shift();
+ await checkLabel(row, "version");
+ let text = row.lastChild;
+ is(text.textContent, "3.1", "The version is set");
+
+ // Last updated.
+ row = rows.shift();
+ await checkLabel(row, "last-updated");
+ text = row.lastChild;
+ is(text.textContent, "March 7, 2019", "The last updated date is set");
+
+ // Homepage.
+ row = rows.shift();
+ await checkLabel(row, "homepage");
+ link = row.querySelector("a");
+ checkLink(link, "http://example.com/addon1");
+
+ // Reviews.
+ row = rows.shift();
+ await checkLabel(row, "rating");
+ let rating = row.lastElementChild;
+ ok(rating.classList.contains("addon-detail-rating"), "Found the rating el");
+ let mozFiveStar = rating.querySelector("moz-five-star");
+ is(mozFiveStar.rating, 4.279, "Exact rating used for calculations");
+ let stars = Array.from(mozFiveStar.starEls);
+ let fullAttrs = stars.map(star => star.getAttribute("fill")).join(",");
+ is(fullAttrs, "full,full,full,full,half", "Four and a half stars are full");
+ link = rating.querySelector("a");
+ let reviewsLink = formatUrl(
+ "addons-manager-reviews-link",
+ "http://addons.mozilla.org/reviews"
+ );
+ checkLink(link, reviewsLink, {
+ id: "addon-detail-reviews-link",
+ args: { numberOfReviews: 5 },
+ });
+
+ // While we are here, let's test edge cases of star ratings.
+ async function testRating(rating, ratingRounded, expectation) {
+ mozFiveStar.rating = rating;
+ await mozFiveStar.updateComplete;
+ if (mozFiveStar.ownerDocument.hasPendingL10nMutations) {
+ await BrowserTestUtils.waitForEvent(
+ mozFiveStar.ownerDocument,
+ "L10nMutationsFinished"
+ );
+ }
+ let starsString = Array.from(mozFiveStar.starEls)
+ .map(star => star.getAttribute("fill"))
+ .join(",");
+ is(starsString, expectation, `Rendering of rating ${rating}`);
+
+ is(
+ mozFiveStar.starsWrapperEl.title,
+ `Rated ${ratingRounded} out of 5`,
+ "Rendered title must contain at most one fractional digit"
+ );
+ }
+ await testRating(0.0, "0", "empty,empty,empty,empty,empty");
+ await testRating(0.123, "0.1", "empty,empty,empty,empty,empty");
+ await testRating(0.249, "0.2", "empty,empty,empty,empty,empty");
+ await testRating(0.25, "0.3", "half,empty,empty,empty,empty");
+ await testRating(0.749, "0.7", "half,empty,empty,empty,empty");
+ await testRating(0.75, "0.8", "full,empty,empty,empty,empty");
+ await testRating(1.0, "1", "full,empty,empty,empty,empty");
+ await testRating(4.249, "4.2", "full,full,full,full,empty");
+ await testRating(4.25, "4.3", "full,full,full,full,half");
+ await testRating(4.749, "4.7", "full,full,full,full,half");
+ await testRating(5.0, "5", "full,full,full,full,full");
+
+ // That should've been all the rows.
+ is(rows.length, 0, "There are no more rows left");
+
+ await closeView(win);
+});
+
+add_task(async function testFullDetailsShowMoreButton() {
+ const id = "addon3@mochi.test";
+ const win = await loadInitialView("extension");
+
+ // The list card.
+ let card = getAddonCard(win, id);
+ const loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // This is now the detail card.
+ card = getAddonCard(win, id);
+
+ // Check the show more button is there
+ const showMoreBtn = card.querySelector(".addon-detail-description-toggle");
+ ok(!showMoreBtn.hidden, "The show more button is visible");
+
+ const descriptionWrapper = card.querySelector(
+ ".addon-detail-description-wrapper"
+ );
+ ok(
+ descriptionWrapper.classList.contains("addon-detail-description-collapse"),
+ "The long description is collapsed"
+ );
+
+ // After click the description should be expanded
+ showMoreBtn.click();
+ ok(
+ !descriptionWrapper.classList.contains("addon-detail-description-collapse"),
+ "The long description is expanded"
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testMinimalExtension() {
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, "addon2@mochi.test");
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, "addon2@mochi.test");
+ let details = card.querySelector("addon-details");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingButtons(details.tabGroup, ["details", "permissions"]);
+
+ let desc = details.querySelector(".addon-detail-description");
+ is(desc.textContent, "", "There is no full description");
+
+ let contrib = details.querySelector(".addon-detail-contribute");
+ ok(contrib.hidden, "The contribution element is hidden");
+
+ let rows = getDetailRows(card);
+
+ // Automatic updates.
+ let row = rows.shift();
+ await checkLabel(row, "updates");
+
+ // Private browsing settings.
+ row = rows.shift();
+ await checkLabel(row, "private-browsing");
+
+ // Private browsing help text.
+ row = rows.shift();
+ ok(row.classList.contains("addon-detail-help-row"), "There's a help row");
+ ok(!row.hidden, "The help row is shown");
+ is(
+ doc.l10n.getAttributes(row).id,
+ "addon-detail-private-browsing-help",
+ "The help row is for private browsing"
+ );
+
+ await checkQuarantinedDomainsUserAllowedRows(card, rows);
+
+ // Author.
+ row = rows.shift();
+ await checkLabel(row, "author");
+ let text = row.lastChild;
+ is(text.textContent, "I made it", "The author is set");
+ ok(Text.isInstance(text), "The author is a text node");
+
+ is(rows.length, 0, "There are no more rows");
+
+ await closeView(win);
+});
+
+add_task(async function testDefaultTheme() {
+ let win = await loadInitialView("theme");
+
+ // The list card.
+ let card = getAddonCard(win, DEFAULT_THEME_ID);
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ let preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ ok(!preview.hidden, "The preview is visible");
+
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, DEFAULT_THEME_ID);
+
+ preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ ok(!preview.hidden, "The preview is visible");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingHidden(card.details.tabGroup);
+
+ let rows = getDetailRows(card);
+
+ // Author.
+ let author = rows.shift();
+ await checkLabel(author, "author");
+ let text = author.lastChild;
+ is(text.textContent, "Mozilla", "The author is set");
+
+ // Version.
+ let version = rows.shift();
+ await checkLabel(version, "version");
+ is(version.lastChild.textContent, "1.3", "It's always version 1.3");
+
+ // Last updated.
+ let lastUpdated = rows.shift();
+ await checkLabel(lastUpdated, "last-updated");
+ let dateText = lastUpdated.lastChild.textContent;
+ ok(dateText, "There is a date set");
+ ok(!dateText.includes("Invalid Date"), `"${dateText}" should be a date`);
+
+ is(rows.length, 0, "There are no more rows");
+
+ await closeView(win);
+});
+
+add_task(async function testStaticTheme() {
+ let win = await loadInitialView("theme");
+
+ // The list card.
+ let card = getAddonCard(win, "theme1@mochi.test");
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ // Make sure the preview is set.
+ let preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.src, "http://example.com/preview.png", "The preview URL is set");
+ is(preview.width, 664, "The width is set");
+ is(preview.height, 90, "The height is set");
+ is(preview.hidden, false, "The preview is visible");
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, "theme1@mochi.test");
+
+ // Make sure the preview is still set.
+ preview = card.querySelector(".card-heading-image");
+ ok(preview, "There is a preview");
+ is(preview.src, "http://example.com/preview.png", "The preview URL is set");
+ is(preview.width, 664, "The width is set");
+ is(preview.height, 90, "The height is set");
+ is(preview.hidden, false, "The preview is visible");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingHidden(card.details.tabGroup);
+
+ let rows = getDetailRows(card);
+
+ // Automatic updates.
+ let row = rows.shift();
+ await checkLabel(row, "updates");
+
+ // Author.
+ let author = rows.shift();
+ await checkLabel(author, "author");
+ let text = author.lastElementChild;
+ is(text.textContent, "Artist", "The author is set");
+
+ is(rows.length, 0, "There was only 1 row");
+
+ await closeView(win);
+});
+
+add_task(async function testSitePermission() {
+ let win = await loadInitialView("sitepermission");
+
+ // The list card.
+ let card = getAddonCard(win, "sitepermission@mochi.test");
+ ok(!card.hasAttribute("expanded"), "The list card is not expanded");
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, "sitepermission@mochi.test");
+
+ // Check all the deck buttons are hidden.
+ assertDeckHeadingHidden(card.details.tabGroup);
+
+ let sitepermissionsRow = card.querySelector(".addon-detail-sitepermissions");
+ is(
+ BrowserTestUtils.isVisible(sitepermissionsRow),
+ true,
+ "AddonSitePermissionsList should be visible for this addon type"
+ );
+
+ let [versionRow, ...restRows] = getDetailRows(card);
+ await checkLabel(versionRow, "version");
+
+ Assert.deepEqual(
+ restRows.map(row => row.getAttribute("class")),
+ [],
+ "All other details row are hidden as expected"
+ );
+
+ let permissions = Array.from(
+ card.querySelectorAll(".addon-permissions-list .permission-info")
+ );
+ is(permissions.length, 1, "a permission is listed");
+ is(permissions[0].textContent, "Access MIDI devices", "got midi permission");
+
+ await closeView(win);
+});
+
+add_task(async function testPrivateBrowsingExtension() {
+ let id = "pb@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "My PB extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // The add-on shouldn't show that it's allowed yet.
+ let card = getAddonCard(win, id);
+ let badge = card.querySelector(".addon-badge-private-browsing-allowed");
+ ok(badge.hidden, "The PB badge is hidden initially");
+ ok(!(await hasPrivateAllowed(id)), "PB is not allowed");
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // The badge is still hidden on the detail view.
+ card = getAddonCard(win, id);
+ badge = card.querySelector(".addon-badge-private-browsing-allowed");
+ ok(badge.hidden, "The PB badge is hidden on the detail view");
+ ok(!(await hasPrivateAllowed(id)), "PB is not allowed");
+
+ let pbRow = card.querySelector(".addon-detail-row-private-browsing");
+ let name = card.querySelector(".addon-name");
+
+ // Allow private browsing.
+ let [allow, disallow] = pbRow.querySelectorAll("input");
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+
+ // Check that the disabled state isn't shown while reloading the add-on.
+ let addonDisabled = AddonTestUtils.promiseAddonEvent("onDisabled");
+ allow.click();
+ await addonDisabled;
+ is(
+ doc.l10n.getAttributes(name).id,
+ null,
+ "The disabled message is not shown for the add-on"
+ );
+
+ // Check the PB stuff.
+ await updated;
+
+ // Not sure what better to await here.
+ await TestUtils.waitForCondition(() => !badge.hidden);
+
+ ok(!badge.hidden, "The PB badge is now shown");
+ ok(await hasPrivateAllowed(id), "PB is allowed");
+ is(
+ doc.l10n.getAttributes(name).id,
+ null,
+ "The disabled message is not shown for the add-on"
+ );
+
+ info("Verify the badge links to the support page");
+ let tabOpened = BrowserTestUtils.waitForNewTab(gBrowser, PB_SUMO_URL);
+ EventUtils.synthesizeMouseAtCenter(badge, {}, win);
+ let tab = await tabOpened;
+ BrowserTestUtils.removeTab(tab);
+
+ // Disable the add-on and change the value.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.querySelector('[action="toggle-disabled"]').click();
+ await updated;
+
+ // It's still allowed in PB.
+ ok(await hasPrivateAllowed(id), "PB is allowed");
+ ok(!badge.hidden, "The PB badge is shown");
+
+ // Disallow PB.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ disallow.click();
+ await updated;
+
+ ok(badge.hidden, "The PB badge is hidden");
+ ok(!(await hasPrivateAllowed(id)), "PB is disallowed");
+
+ // Allow PB.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ allow.click();
+ await updated;
+
+ ok(!badge.hidden, "The PB badge is hidden");
+ ok(await hasPrivateAllowed(id), "PB is disallowed");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testInvalidExtension() {
+ let win = await open_manager("addons://detail/foo");
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.selectedCategory,
+ "discover",
+ "Should fall back to the discovery pane"
+ );
+
+ ok(!gBrowser.canGoBack, "The view has been replaced");
+
+ await close_manager(win);
+});
+
+add_task(async function testInvalidExtensionNoDiscover() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.showPane", false]],
+ });
+
+ let win = await open_manager("addons://detail/foo");
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.selectedCategory,
+ "extension",
+ "Should fall back to the extension list if discover is disabled"
+ );
+
+ ok(!gBrowser.canGoBack, "The view has been replaced");
+
+ await close_manager(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testExternalUninstall() {
+ let id = "remove@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Remove me",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Load the detail view.
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let detailsLoaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await detailsLoaded;
+
+ // Uninstall the add-on with undo. Should go to extension list.
+ let listLoaded = waitForViewLoad(win);
+ await addon.uninstall(true);
+ await listLoaded;
+
+ // Verify the list view was loaded and the card is gone.
+ let list = doc.querySelector("addon-list");
+ ok(list, "Moved to a list page");
+ is(list.type, "extension", "We're on the extension list page");
+ card = list.querySelector(`addon-card[addon-id="${id}"]`);
+ ok(!card, "The card has been removed");
+
+ await extension.unload();
+ closeView(win);
+});
+
+add_task(async function testExternalThemeUninstall() {
+ let id = "remove-theme@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ name: "Remove theme",
+ theme: {},
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Load the detail view.
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let detailsLoaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await detailsLoaded;
+
+ // Uninstall the add-on without undo. Should go to theme list.
+ let listLoaded = waitForViewLoad(win);
+ await addon.uninstall();
+ await listLoaded;
+
+ // Verify the list view was loaded and the card is gone.
+ let list = doc.querySelector("addon-list");
+ ok(list, "Moved to a list page");
+ is(list.type, "theme", "We're on the theme list page");
+ card = list.querySelector(`addon-card[addon-id="${id}"]`);
+ ok(!card, "The card has been removed");
+
+ await extension.unload();
+ closeView(win);
+});
+
+add_task(async function testPrivateBrowsingAllowedListView() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Allowed PB extension",
+ browser_specific_settings: { gecko: { id: "allowed@mochi.test" } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+ let perms = { permissions: ["internal:privateBrowsingAllowed"], origins: [] };
+ await ExtensionPermissions.add("allowed@mochi.test", perms);
+ let addon = await AddonManager.getAddonByID("allowed@mochi.test");
+ await addon.reload();
+
+ let win = await loadInitialView("extension");
+
+ // The allowed extension should have a badge on load.
+ let card = getAddonCard(win, "allowed@mochi.test");
+ let badge = card.querySelector(".addon-badge-private-browsing-allowed");
+ ok(!badge.hidden, "The PB badge is shown for the allowed add-on");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+// When the back button is used, its disabled state will be updated. If it
+// isn't updated when showing a view, then it will be disabled on the next
+// use (bug 1551213) if the last use caused it to become disabled.
+add_task(async function testGoBackButton() {
+ // Make sure the list view is the first loaded view so you cannot go back.
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/extension");
+
+ let id = "addon1@mochi.test";
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let backButton = doc.querySelector(".back-button");
+
+ let loadDetailView = () => {
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, id).querySelector("[action=expand]").click();
+ return loaded;
+ };
+
+ let checkBackButtonState = () => {
+ is_element_visible(backButton, "Back button is visible on the detail page");
+ ok(!backButton.disabled, "Back button is enabled");
+ };
+
+ // Load the detail view, first time should be fine.
+ await loadDetailView();
+ checkBackButtonState();
+
+ // Use the back button directly to pop from history and trigger its disabled
+ // state to be updated.
+ let loaded = waitForViewLoad(win);
+ backButton.click();
+ await loaded;
+
+ await loadDetailView();
+ checkBackButtonState();
+
+ await closeView(win);
+});
+
+add_task(async function testEmptyMoreOptionsMenu() {
+ let theme = await AddonManager.getAddonByID(DEFAULT_THEME_ID);
+ ok(theme.isActive, "The default theme is enabled");
+
+ let win = await loadInitialView("theme");
+
+ let card = getAddonCard(win, DEFAULT_THEME_ID);
+ let enabledItems = card.options.visibleItems;
+ is(enabledItems.length, 1, "There is one enabled item");
+ is(enabledItems[0].getAttribute("action"), "expand", "Expand is enabled");
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ ok(!moreOptionsButton.hidden, "The more options button is visible");
+
+ let loaded = waitForViewLoad(win);
+ enabledItems[0].click();
+ await loaded;
+
+ card = getAddonCard(win, DEFAULT_THEME_ID);
+ let toggleDisabledButton = card.querySelector('[action="toggle-disabled"]');
+ enabledItems = card.options.visibleItems;
+ is(enabledItems.length, 0, "There are no enabled items");
+ moreOptionsButton = card.querySelector(".more-options-button");
+ ok(moreOptionsButton.hidden, "The more options button is now hidden");
+ ok(toggleDisabledButton.hidden, "The disable button is hidden");
+
+ // Switch themes, the menu should be hidden, but enable button should appear.
+ let darkTheme = await AddonManager.getAddonByID(DARK_THEME_ID);
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ await darkTheme.enable();
+ await updated;
+
+ ok(moreOptionsButton.hidden, "The more options button is still hidden");
+ ok(!toggleDisabledButton.hidden, "The enable button is visible");
+
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ await toggleDisabledButton.click();
+ await updated;
+
+ ok(moreOptionsButton.hidden, "The more options button is hidden");
+ ok(toggleDisabledButton.hidden, "The disable button is hidden");
+
+ await closeView(win);
+});
+
+add_task(async function testGoBackButtonIsDisabledWhenHistoryIsEmpty() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { name: "Test Go Back Button" },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let viewID = `addons://detail/${encodeURIComponent(extension.id)}`;
+
+ // When we have a fresh new tab, `about:addons` is opened in it.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, null);
+ // Simulate a click on "Manage extension" from a context menu.
+ let win = await BrowserOpenAddonsMgr(viewID);
+ await assertBackButtonIsDisabled(win);
+
+ BrowserTestUtils.removeTab(tab);
+ await extension.unload();
+});
+
+add_task(async function testGoBackButtonIsDisabledWhenHistoryIsEmptyInNewTab() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { name: "Test Go Back Button" },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let viewID = `addons://detail/${encodeURIComponent(extension.id)}`;
+
+ // When we have a tab with a page loaded, `about:addons` will be opened in a
+ // new tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org"
+ );
+ let addonsTabLoaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:addons",
+ true
+ );
+ // Simulate a click on "Manage extension" from a context menu.
+ let win = await BrowserOpenAddonsMgr(viewID);
+ let addonsTab = await addonsTabLoaded;
+ await assertBackButtonIsDisabled(win);
+
+ BrowserTestUtils.removeTab(addonsTab);
+ BrowserTestUtils.removeTab(tab);
+ await extension.unload();
+});
+
+add_task(async function testGoBackButtonIsDisabledAfterBrowserBackButton() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { name: "Test Go Back Button" },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let viewID = `addons://detail/${encodeURIComponent(extension.id)}`;
+
+ // When we have a fresh new tab, `about:addons` is opened in it.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, null);
+ // Simulate a click on "Manage extension" from a context menu.
+ let win = await BrowserOpenAddonsMgr(viewID);
+ await assertBackButtonIsDisabled(win);
+
+ // Navigate to the extensions list.
+ await new CategoryUtilities(win).openType("extension");
+
+ // Click on the browser back button.
+ gBrowser.goBack();
+ await assertBackButtonIsDisabled(win);
+
+ BrowserTestUtils.removeTab(tab);
+ await extension.unload();
+});
+
+add_task(async function testQuarantinedDomainsUserAllowedUI() {
+ let regularExtId = "regular@mochi.test";
+ let privilegedExtId = "privileged@mochi.test";
+ let recommendedExtId = "recommended@mochi.test";
+ let themeId = "theme@mochi.test";
+ let provider = new MockProvider();
+ provider.createAddons([
+ {
+ id: privilegedExtId,
+ isPrivileged: true,
+ name: "A privileged extension",
+ type: "extension",
+ quarantineIgnoredByApp: true,
+ quarantineIgnoredByUser: false,
+ canChangeQuarantineIgnored: false,
+ },
+ {
+ id: recommendedExtId,
+ isRecommended: true,
+ recommendationStates: ["recommended"],
+ name: "A Recommended extension",
+ type: "extension",
+ quarantineIgnoredByApp: true,
+ quarantineIgnoredByUser: false,
+ canChangeQuarantineIgnored: false,
+ },
+ {
+ id: themeId,
+ name: "A fake regular theme",
+ type: "theme",
+ canChangeQuarantineIgnored: false,
+ },
+ ]);
+
+ let regularExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Some regular extension",
+ browser_specific_settings: { gecko: { id: regularExtId } },
+ },
+ useAddonManager: "permanent",
+ });
+
+ async function testQuarantinedUserAllowedUIRows(id, { expectVisible }) {
+ const perAddonPref = QuarantinedDomains.getUserAllowedAddonIdPrefName(id);
+ Services.prefs.clearUserPref(perAddonPref);
+
+ let card = getAddonCard(win, id);
+
+ const cardDetails = card.querySelector("addon-details");
+ ok(cardDetails, "Card details found");
+ const quarantinedUserAllowedControlsRow = cardDetails.querySelector(
+ ".addon-detail-row-quarantined-domains"
+ );
+
+ ok(
+ quarantinedUserAllowedControlsRow,
+ "Found quarantine domains controls row element"
+ );
+
+ is(
+ BrowserTestUtils.isVisible(quarantinedUserAllowedControlsRow),
+ expectVisible,
+ `Expect quarantineIgnoreByUser UI to ${
+ expectVisible ? "be" : "NOT be"
+ } visible`
+ );
+ const helpRow = quarantinedUserAllowedControlsRow.nextElementSibling;
+ is(
+ helpRow.classList.contains("addon-detail-help-row"),
+ true,
+ "Expect next sibling to be an addon-detail-help-row"
+ );
+ is(
+ BrowserTestUtils.isVisible(helpRow),
+ expectVisible,
+ `Expect quarantineIgnoredByUser UI help to ${
+ expectVisible ? "be" : "NOT be"
+ } visible`
+ );
+
+ if (!expectVisible) {
+ // The assertion that follows are going to be executed when the
+ // test helper function is called for an addon card detail view
+ // for which the quarantined domains rows are expected to be
+ // visible.
+ return;
+ }
+
+ is(
+ doc.l10n.getAttributes(helpRow.firstElementChild).id,
+ "addon-detail-quarantined-domains-help",
+ "Expect addon-detail-help-row to be localized"
+ );
+ const helpSupportLink = helpRow.querySelector("[is=moz-support-link]");
+ ok(helpSupportLink, "Expect a moz-support-link");
+ is(
+ helpSupportLink?.getAttribute("support-page"),
+ "quarantined-domains",
+ "Expect support link to point to SUMO quarantined-domains page"
+ );
+ // Make sure none of the elements in the help row are missing
+ // the expected strings associated to the fluent ids being set
+ // (if any is missing, l10n.translateElements will reject and
+ // trigger an explicit test failure);
+ await doc.l10n.translateElements([helpRow]);
+
+ const radioInputs = Array.from(
+ quarantinedUserAllowedControlsRow.querySelectorAll(
+ "input[name=quarantined-domains-user-allowed]"
+ )
+ );
+
+ Assert.deepEqual(
+ radioInputs.map(el => el.value),
+ ["1", "0"],
+ "Got the expected radio inputs values"
+ );
+
+ Assert.deepEqual(
+ radioInputs.map(el => doc.l10n.getAttributes(el.nextElementSibling).id),
+ ["allow", "disallow"].map(
+ txt => `addon-detail-quarantined-domains-${txt}`
+ ),
+ "Got the expected fluent ids on the radio input text"
+ );
+
+ const checkRadioInputsState = ({ expectUserAllowed }) => {
+ is(
+ card.addon.quarantineIgnoredByUser,
+ expectUserAllowed,
+ `Expect the test extension to ${
+ expectUserAllowed ? "be" : "NOT be"
+ } quarantineIgnoredByUser`
+ );
+ is(
+ radioInputs[0].checked,
+ expectUserAllowed,
+ `Expect 'allow' radio button to ${
+ expectUserAllowed ? "be" : "NOT be"
+ } checked`
+ );
+ is(
+ radioInputs[1].checked,
+ !expectUserAllowed,
+ `Expect 'disallow' radio button ${
+ expectUserAllowed ? "NOT be" : "be"
+ } checked`
+ );
+ };
+
+ info("Verify initially NOT allowed to access quarantine domains");
+ checkRadioInputsState({ expectUserAllowed: false });
+
+ info("Click 'allow' radio input");
+ radioInputs[0].click();
+ checkRadioInputsState({ expectUserAllowed: true });
+
+ info("Click 'disallow' radio input");
+ radioInputs[1].click();
+ checkRadioInputsState({ expectUserAllowed: false });
+
+ info("Verify quarantineIgnoredByUser changes reflected in about:addons UI");
+
+ info("Allow test extension on quarantined domains");
+ let promisePropertyChanged =
+ AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ card.addon.quarantineIgnoredByUser = true;
+ await promisePropertyChanged;
+ checkRadioInputsState({ expectUserAllowed: true });
+
+ info("Disallow test extension on quarantined domains");
+ promisePropertyChanged =
+ AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ card.addon.quarantineIgnoredByUser = false;
+ await promisePropertyChanged;
+ checkRadioInputsState({ expectUserAllowed: false });
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Make sure the quarantined domains feature is initially enabled
+ // otherwise the "quarantineIgnoredByUser UI" rows are
+ // going to be hidden.
+ ["extensions.quarantinedDomains.enabled", true],
+ // Make sure this test is always running with the
+ // "per-addon quarantineIgnoredByUser UI" feature enabled.
+ ["extensions.quarantinedDomains.uiDisabled", false],
+ ],
+ });
+
+ // Clear any per-addon pref once this test file is exiting.
+ registerCleanupFunction(() => {
+ const prefBranch = Services.prefs.getBranch(
+ QuarantinedDomains.PREF_ADDONS_BRANCH_NAME
+ );
+ for (const leafName of prefBranch.getChildList("")) {
+ const prefName = QuarantinedDomains.PREF_ADDONS_BRANCH_NAME + leafName;
+ info(`Clearing user pref ${prefName}`);
+ Services.prefs.clearUserPref(prefName);
+ }
+ });
+
+ await regularExtension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ info("Test quarantineIgnoredByUser UI on a regular extension");
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, regularExtId).querySelector('[action="expand"]').click();
+ await loaded;
+
+ await testQuarantinedUserAllowedUIRows(regularExtId, { expectVisible: true });
+
+ info("Go back to extensions list view");
+ loaded = waitForViewLoad(win);
+ win.history.back();
+ await loaded;
+
+ info("Test quarantineIgnoredByUser UI on a privileged extension");
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, privilegedExtId).querySelector('[action="expand"]').click();
+ await loaded;
+
+ await testQuarantinedUserAllowedUIRows(privilegedExtId, {
+ expectVisible: false,
+ });
+
+ info("Go back to extensions list view");
+ loaded = waitForViewLoad(win);
+ win.history.back();
+ await loaded;
+
+ info("Test quarantineIgnoredByUser UI on a recommended extension");
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, recommendedExtId)
+ .querySelector('[action="expand"]')
+ .click();
+ await loaded;
+
+ await testQuarantinedUserAllowedUIRows(recommendedExtId, {
+ expectVisible: false,
+ });
+
+ info("Switch to theme list view");
+ loaded = waitForViewLoad(win);
+ doc.querySelector("#categories > [name=theme]").click();
+ await loaded;
+
+ info("Test quarantineIgnoredByUser UI on a non extension addon type (theme)");
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, themeId).querySelector('[action="expand"]').click();
+ await loaded;
+
+ await testQuarantinedUserAllowedUIRows(themeId, { expectVisible: false });
+
+ info("Verify regular extension card on quarantined domains feature disabled");
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.quarantinedDomains.enabled", false]],
+ });
+
+ info("Switch to extension list view");
+ loaded = waitForViewLoad(win);
+ doc.querySelector("#categories > [name=extension]").click();
+ await loaded;
+
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, regularExtId).querySelector('[action="expand"]').click();
+ await loaded;
+
+ await testQuarantinedUserAllowedUIRows(regularExtId, {
+ expectVisible: false,
+ });
+
+ await SpecialPowers.popPrefEnv();
+
+ info("Verify regular extenson card uiDisabled pref set to true");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Make sure the quarantineIgnoredByUser UI is also hidden
+ // when the quarantine domains feature is enabled but the
+ // "per-addon quarantineIgnoredByUser UI" feature is disabled.
+ ["extensions.quarantinedDomains.uiDisabled", true],
+ ],
+ });
+
+ info("Switch to extension list view");
+ loaded = waitForViewLoad(win);
+ doc.querySelector("#categories > [name=extension]").click();
+ await loaded;
+
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, regularExtId).querySelector('[action="expand"]').click();
+ await loaded;
+
+ await testQuarantinedUserAllowedUIRows(regularExtId, {
+ expectVisible: false,
+ });
+
+ await closeView(win);
+ await regularExtension.unload();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testRatingsElementVisibleIfReviewURLExists() {
+ let win = await loadInitialView("extension");
+ let id = "addon4@mochi.test";
+ let card = getAddonCard(win, id);
+
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = getAddonCard(win, id);
+
+ let rows = getDetailRows(card);
+
+ let expectedRowCount = 5;
+ if (card.addon.canChangeQuarantineIgnored) {
+ expectedRowCount += 2;
+ }
+ is(rows.length, expectedRowCount, "Expected row count");
+
+ // Reviews.
+ // addon4@mochi.test is similar to addon1@mochi.test whose rows have already
+ // been checked in testFullDetails. Here we only check the last row
+ // which is unique to this test case due to the presence of "reviewURL".
+ let row = rows.pop();
+ await checkLabel(row, "rating");
+ let rating = row.lastElementChild;
+ ok(rating.classList.contains("addon-detail-rating"), "Found the rating el");
+ ok(!row.hidden, "The rating row is shown");
+ let mozFiveStar = rating.querySelector("moz-five-star");
+ is(mozFiveStar.rating, 0, "0 rating when there are no reviews");
+ let stars = Array.from(mozFiveStar.starEls);
+ let fullAttrs = stars.map(star => star.getAttribute("fill")).join(",");
+ is(fullAttrs, "empty,empty,empty,empty,empty", "All stars are empty");
+ let link = rating.querySelector("a");
+ let reviewsLink = formatUrl(
+ "addons-manager-reviews-link",
+ "http://addons.mozilla.org/reviews"
+ );
+ checkLink(link, reviewsLink, {
+ id: "addon-detail-reviews-link",
+ args: { numberOfReviews: 0 },
+ });
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
new file mode 100644
index 0000000000..bc84ffaf89
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
@@ -0,0 +1,668 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+loadTestSubscript("head_disco.js");
+
+// The response to the discovery API, as documented at:
+// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+//
+// The test is designed to easily verify whether the discopane works with the
+// latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
+// response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
+// The response must contain at least one theme, and one extension.
+
+const API_RESPONSE_FILE = PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ // Trim empty component from splitting with trailing slash.
+ ...RELATIVE_DIR.split("/").filter(c => c.length),
+ "discovery",
+ "api_response.json"
+);
+
+const AMO_TEST_HOST = "rewritten-for-testing.addons.allizom.org";
+
+const ArrayBufferInputStream = Components.Constructor(
+ "@mozilla.org/io/arraybuffer-input-stream;1",
+ "nsIArrayBufferInputStream",
+ "setData"
+);
+
+const amoServer = AddonTestUtils.createHttpServer({ hosts: [AMO_TEST_HOST] });
+
+amoServer.registerFile(
+ "/png",
+ new FileUtils.File(
+ PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ ...`${RELATIVE_DIR}discovery/small-1x1.png`.split("/")
+ )
+ )
+);
+amoServer.registerPathHandler("/dummy", (request, response) => {
+ response.write("Dummy");
+});
+
+// `result` is an element in the `results` array from AMO's discovery API,
+// stored in API_RESPONSE_FILE.
+function getTestExpectationFromApiResult(result) {
+ return {
+ typeIsTheme: result.addon.type === "statictheme",
+ addonName: result.addon.name,
+ authorName: result.addon.authors[0].name,
+ editorialBody: result.description_text,
+ dailyUsers: result.addon.average_daily_users,
+ rating: result.addon.ratings.average,
+ };
+}
+
+// A helper to declare a response to discovery API requests.
+class DiscoveryAPIHandler {
+ constructor(responseText) {
+ this.setResponseText(responseText);
+ this.requestCount = 0;
+
+ // Overwrite the previous discovery response handler.
+ amoServer.registerPathHandler("/discoapi", this);
+ }
+
+ setResponseText(responseText) {
+ this.responseBody = new TextEncoder().encode(responseText).buffer;
+ }
+
+ // Suspend discovery API requests until unblockResponses is called.
+ blockNextResponses() {
+ this._unblockPromise = new Promise(resolve => {
+ this.unblockResponses = resolve;
+ });
+ }
+
+ unblockResponses(responseText) {
+ throw new Error("You need to call blockNextResponses first!");
+ }
+
+ // nsIHttpRequestHandler::handle
+ async handle(request, response) {
+ ++this.requestCount;
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.processAsync();
+ await this._unblockPromise;
+
+ let body = this.responseBody;
+ let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
+ response.bodyOutputStream.writeFrom(binStream, body.byteLength);
+ response.finish();
+ }
+}
+
+// Retrieve the list of visible action elements inside a document or container.
+function getVisibleActions(documentOrElement) {
+ return Array.from(documentOrElement.querySelectorAll("[action]")).filter(
+ elem =>
+ elem.getAttribute("action") !== "page-options" &&
+ elem.offsetWidth &&
+ elem.offsetHeight
+ );
+}
+
+function getActionName(actionElement) {
+ return actionElement.getAttribute("action");
+}
+
+function getCardByAddonId(win, addonId) {
+ for (let card of win.document.querySelectorAll("recommended-addon-card")) {
+ if (card.addonId === addonId) {
+ return card;
+ }
+ }
+ return null;
+}
+
+// Switch to a different view so we can switch back to the discopane later.
+async function switchToNonDiscoView(win) {
+ // Listeners registered while the discopane was the active view continue to be
+ // active when the view switches to the extensions list, because both views
+ // share the same document.
+ win.gViewController.loadView("addons://list/extension");
+ await wait_for_view_load(win);
+ ok(
+ win.document.querySelector("addon-list"),
+ "Should be at the extension list view"
+ );
+}
+
+// Switch to the discopane and wait until it has fully rendered, including any
+// cards from the discovery API.
+async function switchToDiscoView(win) {
+ is(
+ getDiscoveryElement(win),
+ null,
+ "Cannot switch to discopane when the discopane is already shown"
+ );
+ win.gViewController.loadView("addons://discover/");
+ await wait_for_view_load(win);
+ await promiseDiscopaneUpdate(win);
+}
+
+// Wait until all images in the DOM have successfully loaded.
+// There must be at least one `<img>` in the document.
+// Returns the number of loaded images.
+async function waitForAllImagesLoaded(win) {
+ let imgs = Array.from(
+ win.document.querySelectorAll("discovery-pane img[src]")
+ );
+ function areAllImagesLoaded() {
+ let loadCount = imgs.filter(img => img.naturalWidth).length;
+ info(`Loaded ${loadCount} out of ${imgs.length} images`);
+ return loadCount === imgs.length;
+ }
+ if (!areAllImagesLoaded()) {
+ await promiseEvent(win.document, "load", true, areAllImagesLoaded);
+ }
+ return imgs.length;
+}
+
+// Install an add-on by clicking on the card.
+// The promise resolves once the card has been updated.
+async function testCardInstall(card) {
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["install-addon"],
+ "Should have an Install button before install"
+ );
+
+ let installButton =
+ card.querySelector("[data-l10n-id='install-extension-button']") ||
+ card.querySelector("[data-l10n-id='install-theme-button']");
+
+ let updatePromise = promiseEvent(card, "disco-card-updated");
+ installButton.click();
+ await updatePromise;
+
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["manage-addon"],
+ "Should have a Manage button after install"
+ );
+}
+
+// Uninstall the add-on (not via the card, since it has no uninstall button).
+// The promise resolves once the card has been updated.
+async function testAddonUninstall(card) {
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["manage-addon"],
+ "Should have a Manage button before uninstall"
+ );
+
+ let addon = await AddonManager.getAddonByID(card.addonId);
+
+ let updatePromise = promiseEvent(card, "disco-card-updated");
+ await addon.uninstall();
+ await updatePromise;
+
+ Assert.deepEqual(
+ getVisibleActions(card).map(getActionName),
+ ["install-addon"],
+ "Should have an Install button after uninstall"
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "extensions.getAddons.discovery.api_url",
+ `http://${AMO_TEST_HOST}/discoapi`,
+ ],
+ // Disable non-discopane recommendations to avoid unexpected discovery
+ // API requests.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ // Disable the telemetry client ID (and its associated UI warning).
+ // browser_html_discover_view_clientid.js covers this functionality.
+ ["browser.discovery.enabled", false],
+ // Disable mixed-content upgrading as this test is expecting an HTTP load
+ ["security.mixed_content.upgrade_display_content", false],
+ ],
+ });
+});
+
+// Test that the discopane can be loaded and that meaningful results are shown.
+// This relies on response data from the AMO API, stored in API_RESPONSE_FILE.
+add_task(async function discopane_with_real_api_data() {
+ const apiText = await readAPIResponseFixture(
+ AMO_TEST_HOST,
+ API_RESPONSE_FILE
+ );
+ let apiHandler = new DiscoveryAPIHandler(apiText);
+
+ const apiResultArray = JSON.parse(apiText).results;
+ ok(apiResultArray.length, `Mock has ${apiResultArray.length} results`);
+
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ [],
+ "The AMO button should be invisible when the AMO API hasn't responded"
+ );
+
+ apiHandler.unblockResponses();
+ await promiseDiscopaneUpdate(win);
+
+ let actionElements = getVisibleActions(win.document);
+ Assert.deepEqual(
+ actionElements.map(getActionName),
+ [
+ // Expecting an install button for every result.
+ ...new Array(apiResultArray.length).fill("install-addon"),
+ "open-amo",
+ ],
+ "All add-on cards should be rendered, with AMO button at the end."
+ );
+
+ let imgCount = await waitForAllImagesLoaded(win);
+ is(imgCount, apiResultArray.length, "Expected an image for every result");
+
+ // Check that the cards have the expected content.
+ let cards = Array.from(
+ win.document.querySelectorAll("recommended-addon-card")
+ );
+ is(cards.length, apiResultArray.length, "Every API result has a card");
+ for (let [i, card] of cards.entries()) {
+ let expectations = getTestExpectationFromApiResult(apiResultArray[i]);
+ info(`Expectations for card ${i}: ${JSON.stringify(expectations)}`);
+
+ let checkContent = (selector, expectation) => {
+ let text = card.querySelector(selector).textContent;
+ is(text, expectation, `Content of selector "${selector}"`);
+ };
+ checkContent(".disco-addon-name", expectations.addonName);
+ await win.document.l10n.translateFragment(card);
+ checkContent(
+ ".disco-addon-author [data-l10n-name='author']",
+ expectations.authorName
+ );
+
+ let amoListingLink = card.querySelector(".disco-addon-author a");
+ ok(
+ amoListingLink.search.includes("utm_source=firefox-browser"),
+ `Listing link should have attribution parameter, url=${amoListingLink}`
+ );
+
+ let actions = getVisibleActions(card);
+ is(actions.length, 1, "Card should only have one install button");
+ let installButton = actions[0];
+ if (expectations.typeIsTheme) {
+ // Theme button + screenshot
+ ok(
+ installButton.matches("[data-l10n-id='install-theme-button'"),
+ "Has theme install button"
+ );
+ ok(
+ card.querySelector(".card-heading-image").offsetWidth,
+ "Preview image must be visible"
+ );
+ } else {
+ // Extension button + extended description.
+ ok(
+ installButton.matches("[data-l10n-id='install-extension-button'"),
+ "Has extension install button"
+ );
+ checkContent(".disco-description-main", expectations.editorialBody);
+
+ let mozFiveStar = card.querySelector("moz-five-star");
+ if (expectations.rating) {
+ is(mozFiveStar.rating, expectations.rating, "Expected rating value");
+ ok(mozFiveStar.offsetWidth, "Rating element is visible");
+ } else {
+ is(mozFiveStar.offsetWidth, 0, "Rating element is not visible");
+ }
+
+ let userCountElem = card.querySelector(".disco-user-count");
+ if (expectations.dailyUsers) {
+ Assert.deepEqual(
+ win.document.l10n.getAttributes(userCountElem),
+ { id: "user-count", args: { dailyUsers: expectations.dailyUsers } },
+ "Card count should be rendered"
+ );
+ } else {
+ is(userCountElem.offsetWidth, 0, "User count element is not visible");
+ }
+ }
+ }
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// Test whether extensions and themes can be installed from the discopane.
+// Also checks that items in the list do not change position after installation,
+// and that they are shown at the bottom of the list when the discopane is
+// reopened.
+add_task(async function install_from_discopane() {
+ const apiText = await readAPIResponseFixture(
+ AMO_TEST_HOST,
+ API_RESPONSE_FILE
+ );
+ const apiResultArray = JSON.parse(apiText).results;
+ let getAddonIdByAMOAddonType = type =>
+ apiResultArray.find(r => r.addon.type === type).addon.guid;
+ const FIRST_EXTENSION_ID = getAddonIdByAMOAddonType("extension");
+ const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");
+
+ let apiHandler = new DiscoveryAPIHandler(apiText);
+
+ let win = await loadInitialView("discover");
+ await promiseDiscopaneUpdate(win);
+ await waitForAllImagesLoaded(win);
+
+ // Test extension install.
+ let installExtensionPromise = promiseAddonInstall(amoServer, {
+ manifest: {
+ name: "My Awesome Add-on",
+ description: "Test extension install button",
+ browser_specific_settings: { gecko: { id: FIRST_EXTENSION_ID } },
+ permissions: ["<all_urls>"],
+ },
+ });
+ await testCardInstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+ await installExtensionPromise;
+
+ // Test theme install.
+ let installThemePromise = promiseAddonInstall(amoServer, {
+ manifest: {
+ name: "My Fancy Theme",
+ description: "Test theme install button",
+ browser_specific_settings: { gecko: { id: FIRST_THEME_ID } },
+ theme: {
+ colors: {
+ tab_selected: "red",
+ },
+ },
+ },
+ });
+ let promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+ await testCardInstall(getCardByAddonId(win, FIRST_THEME_ID));
+ await installThemePromise;
+ await promiseThemeChange;
+
+ // After installing, the cards should have manage buttons instead of install
+ // buttons. The cards should still be at the top of the pane (and not be
+ // moved to the bottom).
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ [
+ "manage-addon",
+ "manage-addon",
+ ...new Array(apiResultArray.length - 2).fill("install-addon"),
+ "open-amo",
+ ],
+ "The Install buttons should be replaced with Manage buttons"
+ );
+
+ // End of the testing installation from a card.
+
+ // Click on the Manage button to verify that it does something useful,
+ // and in order to be able to force the discovery pane to be rendered again.
+ let loaded = waitForViewLoad(win);
+ getCardByAddonId(win, FIRST_EXTENSION_ID)
+ .querySelector("[action='manage-addon']")
+ .click();
+ await loaded;
+ {
+ let addonCard = win.document.querySelector(
+ `addon-card[addon-id="${FIRST_EXTENSION_ID}"]`
+ );
+ ok(addonCard, "Add-on details should be shown");
+ ok(addonCard.expanded, "The card should have been expanded");
+ // TODO bug 1540253: Check that the "recommended" badge is visible.
+ }
+
+ // Now we are going to force an updated rendering and check that the cards are
+ // in the expected order, and then test uninstallation of the above add-ons.
+ await switchToDiscoView(win);
+ await waitForAllImagesLoaded(win);
+
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ [
+ ...new Array(apiResultArray.length - 2).fill("install-addon"),
+ "manage-addon",
+ "manage-addon",
+ "open-amo",
+ ],
+ "Already-installed add-ons should be rendered at the end of the list"
+ );
+
+ promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
+ await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
+ await promiseThemeChange;
+ await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// Tests that the page is able to switch views while the discopane is loading,
+// without inadvertently replacing the page when the request finishes.
+add_task(async function discopane_navigate_while_loading() {
+ let apiHandler = new DiscoveryAPIHandler(`{"results": []}`);
+
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ let updatePromise = promiseDiscopaneUpdate(win);
+ let didUpdateDiscopane = false;
+ updatePromise.then(() => {
+ didUpdateDiscopane = true;
+ });
+
+ // Switch views while the request is pending.
+ await switchToNonDiscoView(win);
+
+ is(
+ didUpdateDiscopane,
+ false,
+ "discopane should still not be updated because the request is blocked"
+ );
+ is(
+ getDiscoveryElement(win),
+ null,
+ "Discopane should be removed after switching to the extension list"
+ );
+
+ // Release pending requests, to verify that completing the request will not
+ // cause changes to the visible view. The updatePromise will still resolve
+ // though, because the event is dispatched to the removed `<discovery-pane>`.
+ apiHandler.unblockResponses();
+
+ await updatePromise;
+ ok(
+ win.document.querySelector("addon-list"),
+ "Should still be at the extension list view"
+ );
+ is(
+ getDiscoveryElement(win),
+ null,
+ "Discopane should not be in the document when it is not the active view"
+ );
+
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ await closeView(win);
+});
+
+// Tests that invalid responses are handled correctly and not cached.
+// Also verifies that the response is cached as long as the page is active,
+// but not when the page is fully reloaded.
+add_task(async function discopane_cache_api_responses() {
+ const INVALID_RESPONSE_BODY = `{"This is some": invalid} JSON`;
+ let apiHandler = new DiscoveryAPIHandler(INVALID_RESPONSE_BODY);
+
+ let expectedErrMsg;
+ try {
+ JSON.parse(INVALID_RESPONSE_BODY);
+ ok(false, "JSON.parse should have thrown");
+ } catch (e) {
+ expectedErrMsg = e.message;
+ }
+
+ let invalidResponseHandledPromise = new Promise(resolve => {
+ Services.console.registerListener(function listener(msg) {
+ if (msg.message.includes(expectedErrMsg)) {
+ resolve();
+ Services.console.unregisterListener(listener);
+ }
+ });
+ });
+
+ let win = await loadInitialView("discover"); // Request #1
+ await promiseDiscopaneUpdate(win);
+
+ info("Waiting for expected error");
+ await invalidResponseHandledPromise;
+ is(apiHandler.requestCount, 1, "Discovery API should be fetched once");
+
+ Assert.deepEqual(
+ getVisibleActions(win.document).map(getActionName),
+ ["open-amo"],
+ "The AMO button should be visible even when the response was invalid"
+ );
+
+ // Change to a valid response, so that the next response will be cached.
+ apiHandler.setResponseText(`{"results": []}`);
+
+ await switchToNonDiscoView(win);
+ await switchToDiscoView(win); // Request #2
+
+ is(
+ apiHandler.requestCount,
+ 2,
+ "Should fetch new data because an invalid response should not be cached"
+ );
+
+ await switchToNonDiscoView(win);
+ await switchToDiscoView(win);
+ await closeView(win);
+
+ is(
+ apiHandler.requestCount,
+ 2,
+ "The previous response was valid and should have been reused"
+ );
+
+ // Now open a new about:addons page and verify that a new API request is sent.
+ let anotherWin = await loadInitialView("discover");
+ await promiseDiscopaneUpdate(anotherWin);
+ await closeView(anotherWin);
+
+ is(apiHandler.requestCount, 3, "discovery API should be requested again");
+});
+
+add_task(async function discopane_no_cookies() {
+ let requestPromise = new Promise(resolve => {
+ amoServer.registerPathHandler("/discoapi", resolve);
+ });
+ Services.cookies.add(
+ AMO_TEST_HOST,
+ "/",
+ "name",
+ "value",
+ false,
+ false,
+ false,
+ Date.now() / 1000 + 600,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+ let win = await loadInitialView("discover");
+ let request = await requestPromise;
+ ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
+ await closeView(win);
+});
+
+// The CSP of about:addons whitelists http:, but not data:, hence we are
+// loading a little red data: image which gets blocked by the CSP.
+add_task(async function csp_img_src() {
+ const RED_DATA_IMAGE =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAA" +
+ "AHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+
+ // Minimal API response to get the image in recommended-addon-card to render.
+ const DUMMY_EXTENSION_ID = "dummy-csp@extensionid";
+ const apiResponse = {
+ results: [
+ {
+ addon: {
+ guid: DUMMY_EXTENSION_ID,
+ type: "extension",
+ authors: [
+ {
+ name: "Some CSP author",
+ },
+ ],
+ url: `http://${AMO_TEST_HOST}/dummy`,
+ icon_url: RED_DATA_IMAGE,
+ },
+ },
+ ],
+ };
+
+ let apiHandler = new DiscoveryAPIHandler(JSON.stringify(apiResponse));
+ apiHandler.blockNextResponses();
+ let win = await loadInitialView("discover");
+
+ let cspPromise = new Promise(resolve => {
+ win.addEventListener("securitypolicyviolation", e => {
+ // non http(s) loads only report the scheme
+ is(e.blockedURI, "data", "CSP: blocked URI");
+ is(e.violatedDirective, "img-src", "CSP: violated directive");
+ resolve();
+ });
+ });
+
+ apiHandler.unblockResponses();
+ await cspPromise;
+
+ await closeView(win);
+});
+
+add_task(async function checkDiscopaneNotice() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.discovery.enabled", true],
+ // Enabling the Data Upload pref may upload data.
+ // Point data reporting services to localhost so the data doesn't escape.
+ ["toolkit.telemetry.server", "https://localhost:1337"],
+ ["telemetry.fog.test.localhost_port", -1],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.recommendations.hideNotice", false],
+ // Disable mixed-content upgrading as this test is expecting an HTTP load
+ ["security.mixed_content.upgrade_display_content", false],
+ ],
+ });
+
+ let win = await loadInitialView("extension");
+ let messageBar = win.document.querySelector(
+ "moz-message-bar.discopane-notice"
+ );
+ ok(messageBar, "Recommended notice should exist in extensions view");
+ await switchToDiscoView(win);
+ messageBar = win.document.querySelector("moz-message-bar.discopane-notice");
+ ok(messageBar, "Recommended notice should exist in disco view");
+
+ messageBar.closeButtonEl.click();
+ messageBar = win.document.querySelector("moz-message-bar.discopane-notice");
+ ok(!messageBar, "Recommended notice should not exist in disco view");
+ await switchToNonDiscoView(win);
+ messageBar = win.document.querySelector("moz-message-bar.discopane-notice");
+ ok(!messageBar, "Recommended notice should not exist in extensions view");
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
new file mode 100644
index 0000000000..ff95c88fe1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_clientid.js
@@ -0,0 +1,219 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const serverBaseUrl = `http://localhost:${server.identity.primaryPort}/`;
+server.registerPathHandler("/sumo/personalized-addons", (request, response) => {
+ response.write("This is a SUMO page that explains personalized add-ons.");
+});
+
+// Before a discovery API request is triggered, this method should be called.
+// Resolves with the value of the "telemetry-client-id" query parameter.
+async function promiseOneDiscoveryApiRequest() {
+ return new Promise(resolve => {
+ let requestCount = 0;
+ // Overwrite previous request handler, if any.
+ server.registerPathHandler("/discoapi", (request, response) => {
+ is(++requestCount, 1, "Expecting one discovery API request");
+ response.write(`{"results": []}`);
+ let searchParams = new URLSearchParams(request.queryString);
+ let clientId = searchParams.get("telemetry-client-id");
+ resolve(clientId);
+ });
+ });
+}
+
+function getNoticeButton(win) {
+ return win.document.querySelector("[action='notice-learn-more']");
+}
+
+function isNoticeVisible(win) {
+ let message = win.document.querySelector("taar-notice");
+ return message && message.offsetHeight > 0;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Enable clientid - see Discovery.sys.mjs for the first two prefs.
+ ["browser.discovery.enabled", true],
+ // Enabling the Data Upload pref may upload data.
+ // Point data reporting services to localhost so the data doesn't escape.
+ ["toolkit.telemetry.server", "https://localhost:1337"],
+ ["telemetry.fog.test.localhost_port", -1],
+ ["datareporting.healthreport.uploadEnabled", true],
+ ["extensions.getAddons.discovery.api_url", `${serverBaseUrl}discoapi`],
+ ["app.support.baseURL", `${serverBaseUrl}sumo/`],
+ // Discovery API requests can be triggered by the discopane and the
+ // recommendations in the list view. To make sure that the every test
+ // checks the behavior of the view they're testing, ensure that only one
+ // of the two views is enabled at a time.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ ],
+ });
+});
+
+// Test that the clientid is passed to the API when enabled via prefs.
+add_task(async function clientid_enabled() {
+ let EXPECTED_CLIENT_ID = await ClientID.getClientIdHash();
+ ok(EXPECTED_CLIENT_ID, "ClientID should be available");
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("discover");
+
+ ok(isNoticeVisible(win), "Notice about personalization should be visible");
+
+ // TODO: This should ideally check whether the result is the expected ID.
+ // But run with --verify, the test may fail with EXPECTED_CLIENT_ID being
+ // "baae8d197cf6b0865d7ba7ddf83829cd2d9844374d7271a5c704199d91059316",
+ // which is sha256(TelemetryUtils.knownClientId).
+ // This happens because at the end of the test, the pushPrefEnv from setup is
+ // reverted, which resets datareporting.healthreport.uploadEnabled to false.
+ // When TelemetryController.sys.mjs detects this, it asynchronously resets the
+ // ClientID to knownClientId - which may happen at the next run of the test.
+ // TODO: Fix this together with bug 1537933
+ //
+ // is(await requestPromise, EXPECTED_CLIENT_ID,
+ ok(
+ await requestPromise,
+ "Moz-Client-Id should be set when telemetry & discovery are enabled"
+ );
+
+ let tabbrowser = win.windowRoot.ownerGlobal.gBrowser;
+ let expectedUrl = `${serverBaseUrl}sumo/personalized-addons`;
+ let tabPromise = BrowserTestUtils.waitForNewTab(tabbrowser, expectedUrl);
+
+ getNoticeButton(win).click();
+
+ info(`Waiting for new tab with URL: ${expectedUrl}`);
+ let tab = await tabPromise;
+ BrowserTestUtils.removeTab(tab);
+
+ await closeView(win);
+});
+
+// Test that the clientid is not sent when disabled via prefs.
+add_task(async function clientid_disabled() {
+ // Temporarily override the prefs that we had set in setup.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.discovery.enabled", false]],
+ });
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("discover");
+ ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
+ is(
+ await requestPromise,
+ null,
+ "Moz-Client-Id should not be sent when discovery is disabled"
+ );
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+// Test that the clientid is not sent from private windows.
+add_task(async function clientid_from_private_window() {
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let managerWindow = await open_manager(
+ "addons://discover/",
+ null,
+ null,
+ null,
+ privateWindow
+ );
+ ok(
+ PrivateBrowsingUtils.isContentWindowPrivate(managerWindow),
+ "Addon-manager is in a private window"
+ );
+
+ is(
+ await requestPromise,
+ null,
+ "Moz-Client-Id should not be sent in private windows"
+ );
+
+ await close_manager(managerWindow);
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
+
+add_task(async function clientid_enabled_from_extension_list() {
+ await SpecialPowers.pushPrefEnv({
+ // Override prefs from setup to enable recommendations.
+ set: [
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.getAddons.showPane", false],
+ ],
+ });
+
+ // Force the extension list to be the first load. This pref will be
+ // overwritten once the view loads.
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/extension");
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("extension");
+
+ ok(isNoticeVisible(win), "Notice about personalization should be visible");
+
+ ok(
+ await requestPromise,
+ "Moz-Client-Id should be set when telemetry & discovery are enabled"
+ );
+
+ // Make sure switching to the theme view doesn't trigger another request.
+ await switchView(win, "theme");
+
+ // Wait until the request would have happened so promiseOneDiscoveryApiRequest
+ // can fail if it does.
+ let recommendations = win.document.querySelector("recommended-addon-list");
+ await recommendations.loadCardsIfNeeded();
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function clientid_enabled_from_theme_list() {
+ await SpecialPowers.pushPrefEnv({
+ // Override prefs from setup to enable recommendations.
+ set: [
+ ["extensions.htmlaboutaddons.recommendations.enabled", true],
+ ["extensions.getAddons.showPane", false],
+ ],
+ });
+
+ // Force the theme list to be the first load. This pref will be overwritten
+ // once the view loads.
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, "addons://list/theme");
+
+ let requestPromise = promiseOneDiscoveryApiRequest();
+ let win = await loadInitialView("theme");
+
+ ok(!isNoticeVisible(win), "Notice about personalization should be hidden");
+
+ is(
+ await requestPromise,
+ null,
+ "Moz-Client-Id should not be sent when loading themes initially"
+ );
+
+ info("Load the extension list and verify the client ID is now sent");
+
+ requestPromise = promiseOneDiscoveryApiRequest();
+ await switchView(win, "extension");
+
+ ok(await requestPromise, "Moz-Client-Id is now sent for extensions");
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js
new file mode 100644
index 0000000000..474cd424b9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_discover_view_prefs.js
@@ -0,0 +1,83 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const TEST_API_URL = `http://localhost:${server.identity.primaryPort}/discoapi`;
+
+async function checkIfDiscoverVisible(expectVisible) {
+ let requestCount = 0;
+ let requestPromise = new Promise(resolve => {
+ // Overwrites previous request handler, if any.
+ server.registerPathHandler("/discoapi", (request, response) => {
+ ++requestCount;
+ response.write(`{"results": []}`);
+ resolve();
+ });
+ });
+
+ // Open about:addons with default view.
+ let managerWindow = await open_manager(null);
+ let categoryUtilities = new CategoryUtilities(managerWindow);
+
+ is(
+ categoryUtilities.isTypeVisible("discover"),
+ expectVisible,
+ "Visibility of discopane"
+ );
+
+ await wait_for_view_load(managerWindow);
+ if (expectVisible) {
+ is(
+ categoryUtilities.selectedCategory,
+ "discover",
+ "Expected discopane as the default view"
+ );
+ await requestPromise;
+ is(requestCount, 1, "Expected discovery API request");
+ } else {
+ // The next view (after discopane) is the extension list.
+ is(
+ categoryUtilities.selectedCategory,
+ "extension",
+ "Should fall back to another view when the discopane is disabled"
+ );
+ is(requestCount, 0, "Discovery API should not be requested");
+ }
+
+ await close_manager(managerWindow);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.getAddons.discovery.api_url", TEST_API_URL],
+ // Disable recommendations at the HTML about:addons view to avoid sending
+ // a discovery API request from the fallback view (extension list) in the
+ // showPane_false test.
+ ["extensions.htmlaboutaddons.recommendations.enabled", false],
+ ],
+ });
+});
+
+add_task(async function showPane_true() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DISCOVER_ENABLED, true]],
+ clear: [[PREF_UI_LASTCATEGORY]],
+ });
+ await checkIfDiscoverVisible(true);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function showPane_false() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DISCOVER_ENABLED, false]],
+ clear: [[PREF_UI_LASTCATEGORY]],
+ });
+ await checkIfDiscoverVisible(false);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
new file mode 100644
index 0000000000..2631a164df
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view.js
@@ -0,0 +1,1063 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+let promptService;
+
+const SUPPORT_URL = Services.urlFormatter.formatURL(
+ Services.prefs.getStringPref("app.support.baseURL")
+);
+const REMOVE_SUMO_URL = SUPPORT_URL + "cant-remove-addon";
+
+function getTestCards(root) {
+ return root.querySelectorAll('addon-card[addon-id$="@mochi.test"]');
+}
+
+function getCardByAddonId(root, id) {
+ return root.querySelector(`addon-card[addon-id="${id}"]`);
+}
+
+function isEmpty(el) {
+ return !el.children.length;
+}
+
+function waitForThemeChange(list) {
+ // Wait for two move events. One theme will be enabled and another disabled.
+ let moveCount = 0;
+ return BrowserTestUtils.waitForEvent(list, "move", () => ++moveCount == 2);
+}
+
+let mockProvider;
+
+add_setup(async function () {
+ mockProvider = new MockProvider(["extension", "sitepermission"]);
+ promptService = mockPromptService();
+});
+
+let extensionsCreated = 0;
+
+function createExtensions(manifestExtras) {
+ return manifestExtras.map(extra =>
+ ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: {
+ gecko: { id: `test-${extensionsCreated++}@mochi.test` },
+ },
+ icons: {
+ 32: "test-icon.png",
+ },
+ ...extra,
+ },
+ useAddonManager: "temporary",
+ })
+ );
+}
+
+add_task(async function testExtensionList() {
+ let id = "test@mochi.test";
+ let headingId = "test_mochi_test-heading";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id } },
+ icons: {
+ 32: "test-icon.png",
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let addon = await AddonManager.getAddonByID(id);
+ ok(addon, "The add-on can be found");
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+
+ // There shouldn't be any disabled extensions.
+ let disabledSection = getSection(doc, "extension-disabled-section");
+ ok(isEmpty(disabledSection), "The disabled section is empty");
+
+ // The loaded extension should be in the enabled list.
+ let enabledSection = getSection(doc, "extension-enabled-section");
+ ok(
+ enabledSection && !isEmpty(enabledSection),
+ "The enabled section isn't empty"
+ );
+ let card = getCardByAddonId(enabledSection, id);
+ ok(card, "The card is in the enabled section");
+
+ // Check the properties of the card.
+ is(card.addonNameEl.textContent, "Test extension", "The name is set");
+ is(
+ card.querySelector("h3").id,
+ headingId,
+ "The add-on name has the correct id"
+ );
+ is(
+ card.querySelector(".card").getAttribute("aria-labelledby"),
+ headingId,
+ "The card is labelled by the heading"
+ );
+ let icon = card.querySelector(".addon-icon");
+ ok(icon.src.endsWith("/test-icon.png"), "The icon is set");
+
+ // Disable the extension.
+ let disableToggle = card.querySelector('[action="toggle-disabled"]');
+ ok(disableToggle.pressed, "The disable toggle is pressed");
+ is(
+ doc.l10n.getAttributes(disableToggle).id,
+ "extension-enable-addon-button-label",
+ "The toggle has the enable label"
+ );
+ ok(disableToggle.getAttribute("aria-label"), "There's an aria-label");
+ ok(!disableToggle.hidden, "The toggle is visible");
+
+ let disabled = BrowserTestUtils.waitForEvent(list, "move");
+ disableToggle.click();
+ await disabled;
+ is(
+ card.parentNode,
+ disabledSection,
+ "The card is now in the disabled section"
+ );
+
+ // The disable button is now enabled.
+ ok(!disableToggle.pressed, "The disable toggle is not pressed");
+ is(
+ doc.l10n.getAttributes(disableToggle).id,
+ "extension-enable-addon-button-label",
+ "The button has the same enable label"
+ );
+ ok(disableToggle.getAttribute("aria-label"), "There's an aria-label");
+
+ // Remove the add-on.
+ let removeButton = card.querySelector('[action="remove"]');
+ is(
+ doc.l10n.getAttributes(removeButton).id,
+ "remove-addon-button",
+ "The button has the remove label"
+ );
+ // There is a support link when the add-on isn't removeable, verify we don't
+ // always include one.
+ ok(!removeButton.querySelector("a"), "There isn't a link in the item");
+
+ // Remove but cancel.
+ let cancelled = BrowserTestUtils.waitForEvent(card, "remove-cancelled");
+ removeButton.click();
+ await cancelled;
+
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ // Tell the mock prompt service that the prompt was accepted.
+ promptService._response = 0;
+ removeButton.click();
+ await removed;
+
+ addon = await AddonManager.getAddonByID(id);
+ ok(
+ addon && !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The addon is pending uninstall"
+ );
+
+ // Ensure that a pending uninstall bar has been created for the
+ // pending uninstall extension, and pressing the undo button will
+ // refresh the list and render a card to the re-enabled extension.
+ assertHasPendingUninstalls(list, 1);
+ assertHasPendingUninstallAddon(list, addon);
+
+ // Add a second pending uninstall extension.
+ info("Install a second test extension and wait for addon card rendered");
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ const extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension 2",
+ browser_specific_settings: { gecko: { id: "test-2@mochi.test" } },
+ icons: {
+ 32: "test-icon.png",
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension2.startup();
+
+ await added;
+ ok(
+ getCardByAddonId(list, extension2.id),
+ "Got a card added for the second extension"
+ );
+
+ info("Uninstall the second test extension and wait for addon card removed");
+ removed = BrowserTestUtils.waitForEvent(list, "remove");
+ const addon2 = await AddonManager.getAddonByID(extension2.id);
+ addon2.uninstall(true);
+ await removed;
+
+ ok(
+ !getCardByAddonId(list, extension2.id),
+ "Addon card for the second extension removed"
+ );
+
+ assertHasPendingUninstalls(list, 2);
+ assertHasPendingUninstallAddon(list, addon2);
+
+ // Addon2 was enabled before entering the pending uninstall state,
+ // wait for its startup after pressing undo.
+ let addon2Started = AddonTestUtils.promiseWebExtensionStartup(addon2.id);
+ await testUndoPendingUninstall(list, addon);
+ await testUndoPendingUninstall(list, addon2);
+ info("Wait for the second pending uninstal add-ons startup");
+ await addon2Started;
+
+ ok(
+ getCardByAddonId(disabledSection, addon.id),
+ "The card for the first extension is in the disabled section"
+ );
+ ok(
+ getCardByAddonId(enabledSection, addon2.id),
+ "The card for the second extension is in the enabled section"
+ );
+
+ await extension2.unload();
+ await extension.unload();
+
+ // Install a theme and verify that it is not listed in the pending
+ // uninstall message bars while the list extensions view is loaded.
+ const themeXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "My theme",
+ browser_specific_settings: { gecko: { id: "theme@mochi.test" } },
+ theme: {},
+ },
+ });
+ const themeAddon = await AddonManager.installTemporaryAddon(themeXpi);
+ // Leave it pending uninstall, the following assertions related to
+ // the pending uninstall message bars will fail if the theme is listed.
+ await themeAddon.uninstall(true);
+
+ // Install a third addon to verify that is being fully removed once the
+ // about:addons page is closed.
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "Test extension 3",
+ browser_specific_settings: { gecko: { id: "test-3@mochi.test" } },
+ icons: {
+ 32: "test-icon.png",
+ },
+ },
+ });
+
+ added = BrowserTestUtils.waitForEvent(list, "add");
+ const addon3 = await AddonManager.installTemporaryAddon(xpi);
+ await added;
+ ok(
+ getCardByAddonId(list, addon3.id),
+ "Addon card for the third extension added"
+ );
+
+ removed = BrowserTestUtils.waitForEvent(list, "remove");
+ addon3.uninstall(true);
+ await removed;
+ ok(
+ !getCardByAddonId(list, addon3.id),
+ "Addon card for the third extension removed"
+ );
+
+ assertHasPendingUninstalls(list, 1);
+ ok(
+ addon3 && !!(addon3.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The third addon is pending uninstall"
+ );
+
+ await closeView(win);
+
+ ok(
+ !(await AddonManager.getAddonByID(addon3.id)),
+ "The third addon has been fully uninstalled"
+ );
+
+ ok(
+ themeAddon.pendingOperations & AddonManager.PENDING_UNINSTALL,
+ "The theme addon is pending after the list extension view is closed"
+ );
+
+ await themeAddon.uninstall();
+
+ ok(
+ !(await AddonManager.getAddonByID(themeAddon.id)),
+ "The theme addon is fully uninstalled"
+ );
+});
+
+add_task(async function testMouseSupport() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id: "test@mochi.test" } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let [card] = getTestCards(doc);
+ is(card.addon.id, "test@mochi.test", "The right card is found");
+
+ let panel = card.querySelector("panel-list");
+
+ ok(!panel.open, "The panel is initially closed");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "addon-card[addon-id$='@mochi.test'] button[action='more-options']",
+ { type: "mousedown" },
+ win.docShell.browsingContext
+ );
+ ok(panel.open, "The panel is now open");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testKeyboardSupport() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Some helpers.
+ let tab = event => EventUtils.synthesizeKey("VK_TAB", event);
+ let space = () => EventUtils.synthesizeKey(" ", {});
+ let isFocused = (el, msg) => is(doc.activeElement, el, msg);
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+ let enabledSection = getSection(doc, "extension-enabled-section");
+ let disabledSection = getSection(doc, "extension-disabled-section");
+
+ // Find the card.
+ let [card] = getTestCards(list);
+ is(card.addon.id, "test@mochi.test", "The right card is found");
+
+ // Focus the more options menu button.
+ let moreOptionsButton = card.querySelector('[action="more-options"]');
+ moreOptionsButton.focus();
+ isFocused(moreOptionsButton, "The more options button is focused");
+
+ // Test opening and closing the menu.
+ let moreOptionsMenu = card.querySelector("panel-list");
+ let expandButton = moreOptionsMenu.querySelector('[action="expand"]');
+ let removeButton = card.querySelector('[action="remove"]');
+ is(moreOptionsMenu.open, false, "The menu is closed");
+ let shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
+ space();
+ await shown;
+ is(moreOptionsMenu.open, true, "The menu is open");
+ isFocused(removeButton, "The remove button is now focused");
+ tab({ shiftKey: true });
+ is(moreOptionsMenu.open, true, "The menu stays open");
+ isFocused(expandButton, "The focus has looped to the bottom");
+ tab();
+ is(moreOptionsMenu.open, true, "The menu stays open");
+ isFocused(removeButton, "The focus has looped to the top");
+
+ let hidden = BrowserTestUtils.waitForEvent(moreOptionsMenu, "hidden");
+ EventUtils.synthesizeKey("Escape", {});
+ await hidden;
+ isFocused(moreOptionsButton, "Escape closed the menu");
+
+ // Disable the add-on.
+ let disableButton = card.querySelector('[action="toggle-disabled"]');
+ tab({ shiftKey: true });
+ isFocused(disableButton, "The disable toggle is focused");
+ is(card.parentNode, enabledSection, "The card is in the enabled section");
+ space();
+ // Wait for the add-on state to change.
+ let [disabledAddon] = await AddonTestUtils.promiseAddonEvent("onDisabled");
+ is(disabledAddon.id, id, "The right add-on was disabled");
+ is(
+ card.parentNode,
+ enabledSection,
+ "The card is still in the enabled section"
+ );
+ isFocused(disableButton, "The disable button is still focused");
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive element to clear the focused
+ // state with a mouse which can be done by assistive technology and keyboard
+ // by pressing `Esc`, this rule check shall be ignored by a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ // Click outside the list to clear any focus.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector(".header-name"),
+ {},
+ win
+ );
+ AccessibilityUtils.resetEnv();
+ await moved;
+ is(
+ card.parentNode,
+ disabledSection,
+ "The card moved when keyboard focus left the list"
+ );
+
+ // Remove the add-on.
+ moreOptionsButton.focus();
+ shown = BrowserTestUtils.waitForEvent(moreOptionsMenu, "shown");
+ space();
+ is(moreOptionsMenu.open, true, "The menu is open");
+ await shown;
+ isFocused(removeButton, "The remove button is focused");
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ space();
+ await removed;
+ is(card.parentNode, null, "The card is no longer on the page");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testOpenDetailFromNameKeyboard() {
+ let id = "details@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Detail extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+
+ let card = getCardByAddonId(win.document, id);
+
+ info("focus the add-on's name, which should be an <a>");
+ card.addonNameEl.focus();
+
+ let detailsLoaded = waitForViewLoad(win);
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ await detailsLoaded;
+
+ card = getCardByAddonId(win.document, id);
+ is(
+ card.addonNameEl.textContent,
+ "Detail extension",
+ "The right detail view is laoded"
+ );
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testExtensionReordering() {
+ let extensions = createExtensions([
+ { name: "Extension One" },
+ { name: "This is last" },
+ { name: "An extension, is first" },
+ ]);
+
+ await Promise.all(extensions.map(extension => extension.startup()));
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Get a reference to the addon-list for events.
+ let list = doc.querySelector("addon-list");
+
+ // Find the related cards, they should all have @mochi.test ids.
+ let enabledSection = getSection(doc, "extension-enabled-section");
+ let cards = getTestCards(enabledSection);
+
+ is(cards.length, 3, "Each extension has an addon-card");
+
+ let order = Array.from(cards).map(card => card.addon.name);
+ Assert.deepEqual(
+ order,
+ ["An extension, is first", "Extension One", "This is last"],
+ "The add-ons are sorted by name"
+ );
+
+ // Disable the second extension.
+ let disabledSection = getSection(doc, "extension-disabled-section");
+ ok(isEmpty(disabledSection), "The disabled section is initially empty");
+
+ // Disable the add-ons in a different order.
+ let reorderedCards = [cards[1], cards[0], cards[2]];
+ for (let { addon } of reorderedCards) {
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ await addon.disable();
+ await moved;
+ }
+
+ order = Array.from(getTestCards(disabledSection)).map(
+ card => card.addon.name
+ );
+ Assert.deepEqual(
+ order,
+ ["An extension, is first", "Extension One", "This is last"],
+ "The add-ons are sorted by name"
+ );
+
+ // All of our installed add-ons are disabled, install a new one.
+ let [newExtension] = createExtensions([{ name: "Extension New" }]);
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ await newExtension.startup();
+ await added;
+
+ let [newCard] = getTestCards(enabledSection);
+ is(
+ newCard.addon.name,
+ "Extension New",
+ "The new add-on is in the enabled list"
+ );
+
+ // Enable everything again.
+ for (let { addon } of cards) {
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ await addon.enable();
+ await moved;
+ }
+
+ order = Array.from(getTestCards(enabledSection)).map(card => card.addon.name);
+ Assert.deepEqual(
+ order,
+ [
+ "An extension, is first",
+ "Extension New",
+ "Extension One",
+ "This is last",
+ ],
+ "The add-ons are sorted by name"
+ );
+
+ // Remove the new extension.
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ await newExtension.unload();
+ await removed;
+ is(newCard.parentNode, null, "The new card has been removed");
+
+ await Promise.all(extensions.map(extension => extension.unload()));
+ await closeView(win);
+});
+
+add_task(async function testThemeList() {
+ let theme = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "theme@mochi.test" } },
+ name: "My theme",
+ theme: {},
+ },
+ useAddonManager: "temporary",
+ });
+
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ let list = doc.querySelector("addon-list");
+
+ let cards = getTestCards(list);
+ is(cards.length, 0, "There are no test themes to start");
+
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ await theme.startup();
+ await added;
+
+ cards = getTestCards(list);
+ is(cards.length, 1, "There is now one custom theme");
+
+ let [card] = cards;
+ is(card.addon.name, "My theme", "The card is for the test theme");
+
+ let enabledSection = getSection(doc, "theme-enabled-section");
+ let disabledSection = getSection(doc, "theme-disabled-section");
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ is(
+ card.parentNode,
+ enabledSection,
+ "The new theme card is in the enabled section"
+ );
+ is(
+ enabledSection.querySelectorAll("addon-card").length,
+ 1,
+ "There is one enabled theme"
+ );
+
+ let toggleThemeEnabled = async () => {
+ let themesChanged = waitForThemeChange(list);
+ card.querySelector('[action="toggle-disabled"]').click();
+ await themesChanged;
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+ };
+
+ await toggleThemeEnabled();
+
+ is(
+ card.parentNode,
+ disabledSection,
+ "The card is now in the disabled section"
+ );
+ is(
+ enabledSection.querySelectorAll("addon-card").length,
+ 1,
+ "There is one enabled theme"
+ );
+
+ // Re-enable the theme.
+ await toggleThemeEnabled();
+ is(card.parentNode, enabledSection, "Card is back in the Enabled section");
+
+ // Remove theme and verify that the default theme is re-enabled.
+ let removed = BrowserTestUtils.waitForEvent(list, "remove");
+ // Confirm removal.
+ promptService._response = 0;
+ card.querySelector('[action="remove"]').click();
+ await removed;
+ is(card.parentNode, null, "Card has been removed from the view");
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ let defaultTheme = getCardByAddonId(doc, "default-theme@mozilla.org");
+ is(defaultTheme.parentNode, enabledSection, "The default theme is reenabled");
+
+ await testUndoPendingUninstall(list, card.addon);
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+ is(defaultTheme.parentNode, disabledSection, "The default theme is disabled");
+ ok(getCardByAddonId(enabledSection, theme.id), "Theme should be reenabled");
+
+ await theme.unload();
+ await closeView(win);
+});
+
+add_task(async function testBuiltInThemeButtons() {
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+ let enabledSection = getSection(doc, "theme-enabled-section");
+ let disabledSection = getSection(doc, "theme-disabled-section");
+
+ let defaultTheme = getCardByAddonId(doc, "default-theme@mozilla.org");
+ let darkTheme = getCardByAddonId(doc, "firefox-compact-dark@mozilla.org");
+
+ // Check that themes are in the expected spots.
+ is(defaultTheme.parentNode, enabledSection, "The default theme is enabled");
+ is(darkTheme.parentNode, disabledSection, "The dark theme is disabled");
+
+ // The default theme shouldn't have remove or disable options.
+ let defaultButtons = {
+ toggleDisabled: defaultTheme.querySelector('[action="toggle-disabled"]'),
+ remove: defaultTheme.querySelector('[action="remove"]'),
+ };
+ is(defaultButtons.toggleDisabled.hidden, true, "Disable is hidden");
+ is(defaultButtons.remove.hidden, true, "Remove is hidden");
+
+ // The dark theme should have an enable button, but not remove.
+ let darkButtons = {
+ toggleDisabled: darkTheme.querySelector('[action="toggle-disabled"]'),
+ remove: darkTheme.querySelector('[action="remove"]'),
+ };
+ is(darkButtons.toggleDisabled.hidden, false, "Enable is visible");
+ is(darkButtons.remove.hidden, true, "Remove is hidden");
+
+ // Enable the dark theme and check the buttons again.
+ let themesChanged = waitForThemeChange(list);
+ darkButtons.toggleDisabled.click();
+ await themesChanged;
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ // Check the buttons.
+ is(defaultButtons.toggleDisabled.hidden, false, "Enable is visible");
+ is(defaultButtons.remove.hidden, true, "Remove is hidden");
+ is(darkButtons.toggleDisabled.hidden, false, "Disable is visible");
+ is(darkButtons.remove.hidden, true, "Remove is hidden");
+
+ // Disable the dark theme.
+ themesChanged = waitForThemeChange(list);
+ darkButtons.toggleDisabled.click();
+ await themesChanged;
+
+ await TestUtils.waitForCondition(
+ () => enabledSection.querySelectorAll("addon-card").length == 1
+ );
+
+ // The themes are back to their starting posititons.
+ is(defaultTheme.parentNode, enabledSection, "Default is enabled");
+ is(darkTheme.parentNode, disabledSection, "Dark is disabled");
+
+ await closeView(win);
+});
+
+add_task(async function testSideloadRemoveButton() {
+ const id = "sideload@mochi.test";
+ mockProvider.createAddons([
+ {
+ id,
+ name: "Sideloaded",
+ permissions: 0,
+ },
+ ]);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getCardByAddonId(doc, id);
+
+ let moreOptionsPanel = card.querySelector("panel-list");
+ let moreOptionsButton = card.querySelector('[action="more-options"]');
+ let panelOpened = BrowserTestUtils.waitForEvent(moreOptionsPanel, "shown");
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, win);
+ await panelOpened;
+
+ // Verify the remove button is visible with a SUMO link.
+ let removeButton = card.querySelector('[action="remove"]');
+ ok(removeButton.disabled, "Remove is disabled");
+ ok(!removeButton.hidden, "Remove is visible");
+
+ // Remove but cancel.
+ let prevented = BrowserTestUtils.waitForEvent(card, "remove-disabled");
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a disabled control to confirm the click event
+ // won't come through. It is not meant to be interactive and is not expected
+ // to be accessible, therefore the rule check shall be ignored by a11y_checks.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ removeButton.click();
+ AccessibilityUtils.resetEnv();
+ await prevented;
+
+ // reopen the panel
+ panelOpened = BrowserTestUtils.waitForEvent(moreOptionsPanel, "shown");
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, win);
+ await panelOpened;
+
+ let sumoLink = removeButton.querySelector("a");
+ ok(sumoLink, "There's a link");
+ is(
+ doc.l10n.getAttributes(removeButton).id,
+ "remove-addon-disabled-button",
+ "The can't remove text is shown"
+ );
+ sumoLink.focus();
+ is(doc.activeElement, sumoLink, "The link can be focused");
+
+ let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, REMOVE_SUMO_URL);
+ sumoLink.click();
+ BrowserTestUtils.removeTab(await newTabOpened);
+
+ await closeView(win);
+});
+
+add_task(async function testOnlyTypeIsShown() {
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Find the addon-list to listen for events.
+ let list = doc.querySelector("addon-list");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id: "test@mochi.test" } },
+ },
+ useAddonManager: "temporary",
+ });
+
+ let skipped = BrowserTestUtils.waitForEvent(
+ list,
+ "skip-add",
+ e => e.detail == "type-mismatch"
+ );
+ await extension.startup();
+ await skipped;
+
+ let cards = getTestCards(list);
+ is(cards.length, 0, "There are no test extension cards");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testPluginIcons() {
+ const pluginIconUrl = "chrome://global/skin/icons/plugin.svg";
+
+ let win = await loadInitialView("plugin");
+ let doc = win.document;
+
+ // Check that the icons are set to the plugin icon.
+ let icons = doc.querySelectorAll(".card-heading-icon");
+ ok(!!icons.length, "There are some plugins listed");
+
+ for (let icon of icons) {
+ is(icon.src, pluginIconUrl, "Plugins use the plugin icon");
+ }
+
+ await closeView(win);
+});
+
+add_task(async function testExtensionGenericIcon() {
+ const extensionIconUrl =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getCardByAddonId(doc, id);
+ let icon = card.querySelector(".addon-icon");
+ is(icon.src, extensionIconUrl, "Extensions without icon use the generic one");
+
+ await extension.unload();
+ await closeView(win);
+});
+
+add_task(async function testSectionHeadingKeys() {
+ mockProvider.createAddons([
+ {
+ id: "test-theme",
+ name: "Test Theme",
+ type: "theme",
+ },
+ {
+ id: "test-extension-disabled",
+ name: "Test Disabled Extension",
+ type: "extension",
+ userDisabled: true,
+ },
+ {
+ id: "test-plugin-disabled",
+ name: "Test Disabled Plugin",
+ type: "plugin",
+ userDisabled: true,
+ },
+ {
+ id: "test-locale",
+ name: "Test Enabled Locale",
+ type: "locale",
+ },
+ {
+ id: "test-locale-disabled",
+ name: "Test Disabled Locale",
+ type: "locale",
+ userDisabled: true,
+ },
+ {
+ id: "test-dictionary",
+ name: "Test Enabled Dictionary",
+ type: "dictionary",
+ },
+ {
+ id: "test-dictionary-disabled",
+ name: "Test Disabled Dictionary",
+ type: "dictionary",
+ userDisabled: true,
+ },
+ {
+ id: "test-sitepermission",
+ name: "Test Enabled Site Permission",
+ type: "sitepermission",
+ },
+ {
+ id: "test-sitepermission-disabled",
+ name: "Test Disabled Site Permission",
+ type: "sitepermission",
+ userDisabled: true,
+ },
+ ]);
+
+ for (let type of [
+ "extension",
+ "theme",
+ "plugin",
+ "locale",
+ "dictionary",
+ "sitepermission",
+ ]) {
+ info(`loading view for addon type ${type}`);
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ for (let status of ["enabled", "disabled"]) {
+ let section = getSection(doc, `${type}-${status}-section`);
+ let el = section?.querySelector(".list-section-heading");
+ isnot(el, null, `Should have ${status} heading for ${type} section`);
+ is(
+ el && doc.l10n.getAttributes(el).id,
+ win.getL10nIdMapping(`${type}-${status}-heading`),
+ `Should have correct ${status} heading for ${type} section`
+ );
+ }
+
+ await closeView(win);
+ }
+});
+
+add_task(async function testDisabledDimming() {
+ const id = "disabled@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Disable me",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let pageHeader = doc.querySelector("addon-page-header");
+
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive element to clear the focused
+ // state with a mouse which can be done by assistive technology and keyboard
+ // by pressing `Esc`, this rule check shall be ignored by a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ // Ensure there's no focus on the list.
+ EventUtils.synthesizeMouseAtCenter(pageHeader, {}, win);
+ AccessibilityUtils.resetEnv();
+
+ const checkOpacity = (card, expected, msg) => {
+ let { opacity } = card.ownerGlobal.getComputedStyle(card.firstElementChild);
+ let normalize = val => Math.floor(val * 10);
+ is(normalize(opacity), normalize(expected), msg);
+ };
+ const waitForTransition = card =>
+ BrowserTestUtils.waitForEvent(
+ card.firstElementChild,
+ "transitionend",
+ /* capture = */ false,
+ e => e.propertyName === "opacity" && e.target.classList.contains("card")
+ );
+
+ let card = getCardByAddonId(doc, id);
+ checkOpacity(card, "1", "The opacity is 1 when enabled");
+
+ // Disable the add-on, check again.
+ let list = doc.querySelector("addon-list");
+ let moved = BrowserTestUtils.waitForEvent(list, "move");
+ await addon.disable();
+ await moved;
+
+ let disabledSection = getSection(doc, "extension-disabled-section");
+ is(card.parentNode, disabledSection, "The card is in the disabled section");
+ checkOpacity(card, "0.6", "The opacity is dimmed when disabled");
+
+ // Click on the menu button, this should un-dim the card.
+ let transitionEnded = waitForTransition(card);
+ let moreOptionsButton = card.querySelector(".more-options-button");
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, win);
+ await transitionEnded;
+ checkOpacity(card, "1", "The opacity is 1 when the menu is open");
+
+ // Close the menu, opacity should return.
+ transitionEnded = waitForTransition(card);
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive element to dismiss the opened
+ // menu with a mouse which can be done by assistive technology and keyboard
+ // by pressing `Esc`, this rule check shall be ignored by a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ EventUtils.synthesizeMouseAtCenter(pageHeader, {}, win);
+ AccessibilityUtils.resetEnv();
+ await transitionEnded;
+ checkOpacity(card, "0.6", "The card is dimmed again");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testEmptyMessage() {
+ let tests = [
+ {
+ type: "extension",
+ message: "Get extensions and themes on ",
+ },
+ {
+ type: "theme",
+ message: "Get extensions and themes on ",
+ },
+ {
+ type: "plugin",
+ message: "Get extensions and themes on ",
+ },
+ {
+ type: "locale",
+ message: "Get language packs on ",
+ },
+ {
+ type: "dictionary",
+ message: "Get dictionaries on ",
+ },
+ ];
+
+ for (let test of tests) {
+ let win = await loadInitialView(test.type);
+ let doc = win.document;
+ let enabledSection = getSection(doc, `${test.type}-enabled-section`);
+ let disabledSection = getSection(doc, `${test.type}-disabled-section`);
+ const message = doc.querySelector("#empty-addons-message");
+
+ // Test if the correct locale has been applied.
+ ok(
+ message.textContent.startsWith(test.message),
+ `View ${test.type} has correct empty list message`
+ );
+
+ // With at least one enabled/disabled add-on (see testSectionHeadingKeys),
+ // the message is hidden.
+ is_element_hidden(message, "Empty addons message hidden");
+
+ // The test runner (Mochitest) relies on add-ons that should not be removed.
+ // Simulate the scenario of zero add-ons by clearing all rendered sections.
+ while (enabledSection.firstChild) {
+ enabledSection.firstChild.remove();
+ }
+
+ while (disabledSection.firstChild) {
+ disabledSection.firstChild.remove();
+ }
+
+ // Message should now be displayed
+ is_element_visible(message, "Empty addons message visible");
+
+ await closeView(win);
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js b/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js
new file mode 100644
index 0000000000..db4067ab35
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_list_view_recommendations.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+function makeResult({ guid, type }) {
+ return {
+ addon: {
+ authors: [{ name: "Some author" }],
+ current_version: {
+ files: [{ platform: "all", url: "data:," }],
+ },
+ url: "data:,",
+ guid,
+ type,
+ },
+ };
+}
+
+function mockResults() {
+ let types = ["extension", "theme", "extension", "extension", "theme"];
+ return {
+ results: types.map((type, i) =>
+ makeResult({
+ guid: `${type}${i}@mochi.test`,
+ type,
+ })
+ ),
+ };
+}
+
+add_setup(async function () {
+ let results = btoa(JSON.stringify(mockResults()));
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable personalized recommendations, they will break the data URI.
+ ["browser.discovery.enabled", false],
+ ["extensions.getAddons.discovery.api_url", `data:;base64,${results}`],
+ [
+ "extensions.recommendations.themeRecommendationUrl",
+ "https://example.com/theme",
+ ],
+ ],
+ });
+});
+
+function checkExtraContents(doc, type, opts = {}) {
+ let { showThemeRecommendationFooter = type === "theme" } = opts;
+ let footer = doc.querySelector("footer");
+ let amoButton = footer.querySelector('[action="open-amo"]');
+ let privacyPolicyLink = footer.querySelector(".privacy-policy-link");
+ let themeRecommendationFooter = footer.querySelector(".theme-recommendation");
+ let themeRecommendationLink =
+ themeRecommendationFooter && themeRecommendationFooter.querySelector("a");
+ let taarNotice = doc.querySelector("taar-notice");
+
+ is_element_visible(footer, "The footer is visible");
+
+ if (type == "extension") {
+ ok(taarNotice, "There is a TAAR notice");
+ is_element_visible(amoButton, "The AMO button is shown");
+ is_element_visible(privacyPolicyLink, "The privacy policy is visible");
+ } else if (type == "theme") {
+ ok(!taarNotice, "There is no TAAR notice");
+ ok(amoButton, "AMO button is shown");
+ ok(!privacyPolicyLink, "There is no privacy policy");
+ } else {
+ throw new Error(`Unknown type ${type}`);
+ }
+
+ if (showThemeRecommendationFooter) {
+ is_element_visible(
+ themeRecommendationFooter,
+ "There's a theme recommendation footer"
+ );
+ is_element_visible(themeRecommendationLink, "There's a link to the theme");
+ is(themeRecommendationLink.target, "_blank", "The link opens in a new tab");
+ is(
+ themeRecommendationLink.href,
+ "https://example.com/theme",
+ "The link goes to the pref's URL"
+ );
+ is(
+ doc.l10n.getAttributes(themeRecommendationFooter).id,
+ "recommended-theme-1",
+ "The recommendation has the right l10n-id"
+ );
+ } else {
+ ok(
+ !themeRecommendationFooter || themeRecommendationFooter.hidden,
+ "There's no theme recommendation"
+ );
+ }
+}
+
+async function installAddon({ card, recommendedList, manifestExtra = {} }) {
+ // Install an add-on to hide the card.
+ let hidden = BrowserTestUtils.waitForEvent(
+ recommendedList,
+ "card-hidden",
+ false,
+ e => e.detail.card == card
+ );
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: card.addonId } },
+ ...manifestExtra,
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ await hidden;
+ return extension;
+}
+
+async function testListRecommendations({ type, manifestExtra = {} }) {
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ checkExtraContents(doc, type);
+
+ // Check that the cards are all for the right type.
+ let cards = doc.querySelectorAll("recommended-addon-card");
+ ok(!!cards.length, "There were some cards found");
+ for (let card of cards) {
+ is(card.discoAddon.type, type, `The card is for a ${type}`);
+ is_element_visible(card, "The card is visible");
+ }
+
+ // Install an add-on for the first card, verify it is hidden.
+ let { addonId } = cards[0];
+ ok(addonId, "The card has an addonId");
+
+ // Installing the add-on will fail since the URL doesn't point to a valid
+ // XPI.
+ let installButton = cards[0].querySelector('[action="install-addon"]');
+ let { panel } = PopupNotifications;
+ let popupId = "addon-install-failed-notification";
+ let failPromise = TestUtils.topicObserved("addon-install-failed");
+ installButton.click();
+ await failPromise;
+ // Wait for the installing popup to be hidden and leave just the error popup.
+ await BrowserTestUtils.waitForCondition(() => {
+ return panel.children.length == 1 && panel.firstElementChild.id == popupId;
+ });
+
+ // Dismiss the popup.
+ panel.firstElementChild.button.click();
+ await BrowserTestUtils.waitForPopupEvent(panel, "hidden");
+
+ let extension = await installAddon({ card: cards[0], recommendedList });
+ is_element_hidden(cards[0], "The card is now hidden");
+
+ // Switch away and back, there should still be a hidden card.
+ await closeView(win);
+ win = await loadInitialView(type);
+ doc = win.document;
+ recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
+
+ let hiddenCard = cards.pop();
+ is(hiddenCard.addonId, addonId, "The expected card was found");
+ is_element_hidden(hiddenCard, "The card is still hidden");
+
+ ok(!!cards.length, "There are still some visible cards");
+ for (let card of cards) {
+ is(card.discoAddon.type, type, `The card is for a ${type}`);
+ is_element_visible(card, "The card is visible");
+ }
+
+ // Uninstall the add-on, verify the card is shown again.
+ let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
+ await extension.unload();
+ await shown;
+
+ is_element_visible(hiddenCard, "The card is now shown");
+
+ await closeView(win);
+}
+
+add_task(async function testExtensionList() {
+ await testListRecommendations({ type: "extension" });
+});
+
+add_task(async function testThemeList() {
+ await testListRecommendations({
+ type: "theme",
+ manifestExtra: { theme: {} },
+ });
+});
+
+add_task(async function testInstallAllExtensions() {
+ let type = "extension";
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ // Find more button is shown.
+ checkExtraContents(doc, type);
+
+ let cards = Array.from(doc.querySelectorAll("recommended-addon-card"));
+ is(cards.length, 3, "We found some cards");
+
+ let extensions = await Promise.all(
+ cards.map(card => installAddon({ card, recommendedList }))
+ );
+
+ // The find more on AMO button is shown.
+ checkExtraContents(doc, type);
+
+ // Uninstall one of the extensions, the button should still be shown.
+ let extension = extensions.pop();
+ let shown = BrowserTestUtils.waitForEvent(recommendedList, "card-shown");
+ await extension.unload();
+ await shown;
+
+ // The find more on AMO button is shown.
+ checkExtraContents(doc, type);
+
+ await Promise.all(extensions.map(extension => extension.unload()));
+ await closeView(win);
+});
+
+add_task(async function testError() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.discovery.api_url", "data:,"]],
+ });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ checkExtraContents(doc, "extension");
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testThemesNoRecommendationUrl() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.recommendations.themeRecommendationUrl", ""]],
+ });
+
+ let win = await loadInitialView("theme");
+ let doc = win.document;
+
+ // Wait for the list to render, rendering is tested with the discovery pane.
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ await recommendedList.cardsReady;
+
+ checkExtraContents(doc, "theme", { showThemeRecommendationFooter: false });
+
+ await closeView(win);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testRecommendationsDisabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.recommendations.enabled", false]],
+ });
+
+ let types = ["extension", "theme"];
+
+ for (let type of types) {
+ let win = await loadInitialView(type);
+ let doc = win.document;
+
+ let recommendedList = doc.querySelector("recommended-addon-list");
+ ok(!recommendedList, `There are no recommendations on the ${type} page`);
+
+ await closeView(win);
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js b/toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js
new file mode 100644
index 0000000000..b60baf8799
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_message_bar.js
@@ -0,0 +1,185 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+let htmlAboutAddonsWindow;
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+function clickElement(el) {
+ el.dispatchEvent(new CustomEvent("click"));
+}
+
+function createMessageBar(messageBarStack, { attrs, children, onclose } = {}) {
+ const win = messageBarStack.ownerGlobal;
+ const messageBar = win.document.createElementNS(HTML_NS, "message-bar");
+ if (attrs) {
+ for (const [k, v] of Object.entries(attrs)) {
+ messageBar.setAttribute(k, v);
+ }
+ }
+ if (children) {
+ if (Array.isArray(children)) {
+ messageBar.append(...children);
+ } else {
+ messageBar.append(children);
+ }
+ }
+ messageBar.addEventListener("message-bar:close", onclose, { once: true });
+ messageBarStack.append(messageBar);
+ return messageBar;
+}
+
+add_setup(async function () {
+ htmlAboutAddonsWindow = await loadInitialView("extension");
+ registerCleanupFunction(() => closeView(htmlAboutAddonsWindow));
+});
+
+add_task(async function test_message_bar_stack() {
+ const win = htmlAboutAddonsWindow;
+
+ let messageBarStack = win.document.getElementById("abuse-reports-messages");
+
+ ok(messageBarStack, "Got a message-bar-stack in HTML about:addons page");
+
+ is(
+ messageBarStack.maxMessageBarCount,
+ 3,
+ "Got the expected max-message-bar-count property"
+ );
+
+ is(
+ messageBarStack.childElementCount,
+ 0,
+ "message-bar-stack is initially empty"
+ );
+});
+
+add_task(async function test_create_message_bar_create_and_onclose() {
+ const win = htmlAboutAddonsWindow;
+ const messageBarStack = win.document.getElementById("abuse-reports-messages");
+
+ let messageEl = win.document.createElementNS(HTML_NS, "span");
+ messageEl.textContent = "A message bar text";
+ let buttonEl = win.document.createElementNS(HTML_NS, "button");
+ buttonEl.textContent = "An action button";
+
+ let messageBar;
+ let onceMessageBarClosed = new Promise(resolve => {
+ messageBar = createMessageBar(messageBarStack, {
+ children: [messageEl, buttonEl],
+ onclose: resolve,
+ });
+ });
+
+ is(
+ messageBarStack.childElementCount,
+ 1,
+ "message-bar-stack has a child element"
+ );
+ is(
+ messageBarStack.firstElementChild,
+ messageBar,
+ "newly created message-bar added as message-bar-stack child element"
+ );
+
+ const slot = messageBar.shadowRoot.querySelector("slot");
+ is(
+ slot.assignedNodes()[0],
+ messageEl,
+ "Got the expected span element assigned to the message-bar slot"
+ );
+ is(
+ slot.assignedNodes()[1],
+ buttonEl,
+ "Got the expected button element assigned to the message-bar slot"
+ );
+
+ let dismissed = BrowserTestUtils.waitForEvent(
+ messageBar,
+ "message-bar:user-dismissed"
+ );
+ info("Click the close icon on the newly created message-bar");
+ clickElement(messageBar.closeButton);
+ await dismissed;
+
+ info("Expect the onclose function to be called");
+ await onceMessageBarClosed;
+
+ is(
+ messageBarStack.childElementCount,
+ 0,
+ "message-bar-stack has no child elements"
+ );
+});
+
+add_task(async function test_max_message_bar_count() {
+ const win = htmlAboutAddonsWindow;
+ const messageBarStack = win.document.getElementById("abuse-reports-messages");
+
+ info("Create a new message-bar");
+ let messageElement = document.createElementNS(HTML_NS, "span");
+ messageElement = "message bar label";
+
+ let onceMessageBarClosed = new Promise(resolve => {
+ createMessageBar(messageBarStack, {
+ children: messageElement,
+ onclose: resolve,
+ });
+ });
+
+ is(
+ messageBarStack.childElementCount,
+ 1,
+ "message-bar-stack has the expected number of children"
+ );
+
+ info("Create 3 more message bars");
+ const allBarsPromises = [];
+ for (let i = 2; i <= 4; i++) {
+ allBarsPromises.push(
+ new Promise(resolve => {
+ createMessageBar(messageBarStack, {
+ attrs: { dismissable: "" },
+ children: [messageElement, i],
+ onclose: resolve,
+ });
+ })
+ );
+ }
+
+ info("Expect first message-bar to closed automatically");
+ await onceMessageBarClosed;
+
+ is(
+ messageBarStack.childElementCount,
+ 3,
+ "message-bar-stack has the expected number of children"
+ );
+
+ info("Click on close icon for the second message-bar");
+ clickElement(messageBarStack.firstElementChild.closeButton);
+
+ info("Expect the second message-bar to be closed");
+ await allBarsPromises[0];
+
+ is(
+ messageBarStack.childElementCount,
+ 2,
+ "message-bar-stack has the expected number of children"
+ );
+
+ info("Clear the entire message-bar-stack content");
+ messageBarStack.textContent = "";
+
+ info("Expect all the created message-bar to be closed automatically");
+ await Promise.all(allBarsPromises);
+
+ is(
+ messageBarStack.childElementCount,
+ 0,
+ "message-bar-stack has no child elements"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js
new file mode 100644
index 0000000000..c5bfa1022f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui.js
@@ -0,0 +1,651 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// This test function helps to detect when an addon options browser have been
+// inserted in the about:addons page.
+function waitOptionsBrowserInserted() {
+ return new Promise(resolve => {
+ async function listener(eventName, browser) {
+ // wait for a webextension XUL browser element that is owned by the
+ // "about:addons" page.
+ if (browser.ownerGlobal.top.location.href == "about:addons") {
+ ExtensionParent.apiManager.off("extension-browser-inserted", listener);
+ resolve(browser);
+ }
+ }
+ ExtensionParent.apiManager.on("extension-browser-inserted", listener);
+ });
+}
+
+add_task(async function enableHtmlViews() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.inline-options.enabled", true]],
+ });
+});
+
+add_task(async function testInlineOptions() {
+ const HEIGHT_SHORT = 300;
+ const HEIGHT_TALL = 600;
+
+ let id = "inline@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <head>
+ <style type="text/css">
+ body > p { height: ${HEIGHT_SHORT}px; margin: 0; }
+ body.bigger > p { height: ${HEIGHT_TALL}px; }
+ </style>
+ <script src="options.js"></script>
+ </head>
+ <body>
+ <p>Some text</p>
+ </body>
+ </html>
+ `,
+ "options.js": () => {
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "toggle-class") {
+ document.body.classList.toggle("bigger");
+ } else if (msg == "get-height") {
+ browser.test.sendMessage("height", document.body.clientHeight);
+ }
+ });
+
+ browser.test.sendMessage("options-loaded", window.location.href);
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Make sure we found the right card.
+ let card = getAddonCard(win, id);
+ ok(card, "Found the card");
+
+ // The preferences option should be visible.
+ let preferences = card.querySelector('[action="preferences"]');
+ ok(!preferences.hidden, "The preferences option is visible");
+
+ // Open the preferences page.
+ let loaded = waitForViewLoad(win);
+ preferences.click();
+ await loaded;
+
+ // Verify we're on the preferences tab.
+ card = doc.querySelector("addon-card");
+ is(card.addon.id, id, "The right page was loaded");
+ let { deck, tabGroup } = card.details;
+ let { selectedViewName } = deck;
+ is(selectedViewName, "preferences", "The preferences tab is shown");
+
+ info("Check that there are two buttons and they're visible");
+ let detailsBtn = tabGroup.querySelector('[name="details"]');
+ ok(!detailsBtn.hidden, "The details button is visible");
+ let prefsBtn = tabGroup.querySelector('[name="preferences"]');
+ ok(!prefsBtn.hidden, "The preferences button is visible");
+
+ // Wait for the browser to load.
+ let url = await extension.awaitMessage("options-loaded");
+
+ // Check the attributes of the options browser.
+ let browser = card.querySelector("inline-options-browser browser");
+ ok(browser, "The visible view has a browser");
+ is(
+ browser.currentURI.spec,
+ card.addon.optionsURL,
+ "The browser has the expected options URL"
+ );
+ is(url, card.addon.optionsURL, "Browser has the expected options URL loaded");
+ let stack = browser.closest("stack");
+ is(
+ browser.clientWidth,
+ stack.clientWidth,
+ "Browser should be the same width as its direct parent"
+ );
+ Assert.greater(stack.clientWidth, 0, "The stack has a width");
+ ok(
+ card.querySelector('[action="preferences"]').hidden,
+ "The preferences option is hidden now"
+ );
+
+ let waitForHeightChange = expectedHeight =>
+ TestUtils.waitForCondition(() => browser.clientHeight === expectedHeight);
+
+ await waitForHeightChange(HEIGHT_SHORT);
+
+ // Check resizing the browser through extension CSS.
+ await extension.sendMessage("get-height");
+ let height = await extension.awaitMessage("height");
+ is(height, HEIGHT_SHORT, "The height is smaller to start");
+ is(height, browser.clientHeight, "The browser is the same size");
+
+ info("Resize the browser to be taller");
+ await extension.sendMessage("toggle-class");
+ await waitForHeightChange(HEIGHT_TALL);
+ await extension.sendMessage("get-height");
+ height = await extension.awaitMessage("height");
+ is(height, HEIGHT_TALL, "The height is bigger now");
+ is(height, browser.clientHeight, "The browser is the same size");
+
+ info("Shrink the browser again");
+ await extension.sendMessage("toggle-class");
+ await waitForHeightChange(HEIGHT_SHORT);
+ await extension.sendMessage("get-height");
+ height = await extension.awaitMessage("height");
+ is(height, HEIGHT_SHORT, "The browser shrunk back");
+ is(height, browser.clientHeight, "The browser is the same size");
+
+ info("Switching to details view");
+ detailsBtn.click();
+
+ info("Check the browser dimensions to make sure it's hidden");
+ is(browser.clientWidth, 0, "The browser is hidden now");
+
+ info("Switch back, check browser is shown");
+ prefsBtn.click();
+
+ is(browser.clientWidth, stack.clientWidth, "The browser width is set again");
+ Assert.greater(stack.clientWidth, 0, "The stack has a width");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+// Regression test against bug 1409697
+add_task(async function testCardRerender() {
+ let id = "rerender@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <body>
+ <p>Some text</p>
+ </body>
+ </html>
+ `,
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is 1 inline-options-browser"
+ );
+ is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+ info("Reload the add-on and ensure there's still only one browser");
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.addon.reload();
+ await updated;
+
+ // Since the add-on was disabled, we'll be on the details tab.
+ is(card.details.deck.selectedViewName, "details", "View changed to details");
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is 1 inline-options-browser"
+ );
+ is(doc.querySelectorAll("browser").length, 0, "The browser was destroyed");
+
+ // Load the permissions tab again.
+ browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ // Switching to preferences will create a new browser element.
+ is(
+ card.details.deck.selectedViewName,
+ "preferences",
+ "View switched to preferences"
+ );
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is 1 inline-options-browser"
+ );
+ is(doc.querySelectorAll("browser").length, 1, "There is a new browser");
+
+ info("Re-rendering card to ensure a second browser isn't added");
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.render();
+ await updated;
+
+ is(
+ card.details.deck.selectedViewName,
+ "details",
+ "Rendering reverted to the details view"
+ );
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is still only 1 inline-options-browser after re-render"
+ );
+ is(doc.querySelectorAll("browser").length, 0, "There is no browser");
+
+ let newBrowserAdded = waitOptionsBrowserInserted();
+ card.showPrefs();
+ await newBrowserAdded;
+
+ is(
+ doc.querySelectorAll("inline-options-browser").length,
+ 1,
+ "There is still only 1 inline-options-browser after opening preferences"
+ );
+ is(doc.querySelectorAll("browser").length, 1, "There is 1 browser");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testRemovedOnDisable() {
+ let id = "disable@mochi.test";
+ const xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": "<h1>Options!</h1>",
+ },
+ });
+ let addon = await AddonManager.installTemporaryAddon(xpiFile);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Opens the prefs page.
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, id).querySelector("[action=preferences]").click();
+ await loaded;
+
+ let inlineOptions = doc.querySelector("inline-options-browser");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(inlineOptions.querySelector("browser"), "The browser exists");
+
+ let card = getAddonCard(win, id);
+ let { deck } = card.details;
+ is(deck.selectedViewName, "preferences", "Preferences are the active tab");
+
+ info("Disabling the add-on");
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ await addon.disable();
+ await updated;
+
+ is(deck.selectedViewName, "details", "Details are now the active tab");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(!inlineOptions.querySelector("browser"), "The browser has been removed");
+
+ info("Enabling the add-on");
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ await addon.enable();
+ await updated;
+
+ is(deck.selectedViewName, "details", "Details are still the active tab");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(!inlineOptions.querySelector("browser"), "The browser is not created yet");
+
+ info("Switching to preferences tab");
+ let changed = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ let browserAdded = waitOptionsBrowserInserted();
+ deck.selectedViewName = "preferences";
+ await changed;
+ await browserAdded;
+
+ is(deck.selectedViewName, "preferences", "Preferences are selected");
+ ok(inlineOptions, "There's an inline-options-browser element");
+ ok(inlineOptions.querySelector("browser"), "The browser is re-created");
+
+ await closeView(win);
+ await addon.uninstall();
+});
+
+add_task(async function testUpgradeTemporary() {
+ let id = "upgrade-temporary@mochi.test";
+ async function loadExtension(version) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version,
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <head>
+ <script src="options.js"></script>
+ </head>
+ <body>
+ <p>Version <pre>${version}</pre></p>
+ </body>
+ </html>
+ `,
+ "options.js": () => {
+ browser.test.onMessage.addListener(msg => {
+ if (msg === "get-version") {
+ let version = document.querySelector("pre").textContent;
+ browser.test.sendMessage("version", version);
+ }
+ });
+ window.onload = () => browser.test.sendMessage("options-loaded");
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ return extension;
+ }
+
+ let firstExtension = await loadExtension("1");
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ await firstExtension.awaitMessage("options-loaded");
+ await firstExtension.sendMessage("get-version");
+ let version = await firstExtension.awaitMessage("version");
+ is(version, "1", "Version 1 page is loaded");
+
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ browserAdded = waitOptionsBrowserInserted();
+ let secondExtension = await loadExtension("2");
+ await updated;
+ await browserAdded;
+ await secondExtension.awaitMessage("options-loaded");
+
+ await secondExtension.sendMessage("get-version");
+ version = await secondExtension.awaitMessage("version");
+ is(version, "2", "Version 2 page is loaded");
+ let { deck } = card.details;
+ is(deck.selectedViewName, "preferences", "Preferences are still shown");
+
+ await closeView(win);
+ await firstExtension.unload();
+ await secondExtension.unload();
+});
+
+add_task(async function testReloadExtension() {
+ let id = "reload@mochi.test";
+ let xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <head>
+ </head>
+ <body>
+ <p>Options</p>
+ </body>
+ </html>
+ `,
+ },
+ });
+ let addon = await AddonManager.installTemporaryAddon(xpiFile);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+ let { deck } = card.details;
+ is(deck.selectedViewName, "details", "Details load first");
+
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ await browserAdded;
+
+ is(deck.selectedViewName, "preferences", "Preferences are shown");
+
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ browserAdded = waitOptionsBrowserInserted();
+ let addonStarted = AddonTestUtils.promiseWebExtensionStartup(id);
+ await addon.reload();
+ await addonStarted;
+ await updated;
+ await browserAdded;
+ is(deck.selectedViewName, "preferences", "Preferences are still shown");
+
+ await closeView(win);
+ await addon.uninstall();
+});
+
+async function testSelectPosition(optionsBrowser, zoom) {
+ let popupShownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ await BrowserTestUtils.synthesizeMouseAtCenter("select", {}, optionsBrowser);
+ let popup = await popupShownPromise;
+ let popupLeft = popup.shadowRoot.querySelector(
+ ".menupopup-arrowscrollbox"
+ ).screenX;
+ let browserLeft = optionsBrowser.screenX * zoom;
+ Assert.lessOrEqual(
+ Math.abs(popupLeft - browserLeft),
+ 1,
+ `Popup should be correctly positioned: ${popupLeft} vs. ${browserLeft}`
+ );
+ popup.hidePopup();
+}
+
+async function testOptionsZoom(type = "full") {
+ let id = `${type}-zoom@mochi.test`;
+ let zoomProp = `${type}Zoom`;
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <!doctype html>
+ <script src="options.js"></script>
+ <body style="height: 500px">
+ <p>Some text</p>
+ <p>
+ <select>
+ <option>A</option>
+ <option>B</option>
+ </select>
+ </p>
+ </body>
+ `,
+ "options.js": () => {
+ window.addEventListener("load", function () {
+ browser.test.sendMessage("options-loaded");
+ });
+ },
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ gBrowser.selectedBrowser[zoomProp] = 2;
+
+ let card = getAddonCard(win, id);
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+
+ let browserAdded = waitOptionsBrowserInserted();
+ card.querySelector('.tab-button[name="preferences"]').click();
+ let optionsBrowser = await browserAdded;
+ // Wait for the browser to load.
+ await extension.awaitMessage("options-loaded");
+
+ is(optionsBrowser[zoomProp], 2, `Options browser inherited ${zoomProp}`);
+
+ await testSelectPosition(optionsBrowser, type == "full" ? 2 : 1);
+
+ gBrowser.selectedBrowser[zoomProp] = 0.5;
+
+ is(
+ optionsBrowser[zoomProp],
+ 0.5,
+ `Options browser reacts to ${zoomProp} change`
+ );
+
+ await closeView(win);
+ await extension.unload();
+}
+
+add_task(function testOptionsFullZoom() {
+ return testOptionsZoom("full");
+});
+
+add_task(function testOptionsTextZoom() {
+ return testOptionsZoom("text");
+});
+
+add_task(async function testInputAndQuickFind() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ files: {
+ "options.html": `
+ <html>
+ <body>
+ <input name="some-input" type="text">
+ <script src="options.js"></script>
+ </body>
+ </html>
+ `,
+ "options.js": () => {
+ let input = document.querySelector("input");
+ browser.test.assertEq(
+ "some-input",
+ input.getAttribute("name"),
+ "Expected options page input"
+ );
+ input.addEventListener("input", event => {
+ browser.test.sendMessage("input-changed", event.target.value);
+ });
+
+ browser.test.sendMessage("options-loaded", window.location.href);
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ // Make sure we found the right card.
+ let card = getAddonCard(win, extension.id);
+ ok(card, "Found the card");
+
+ // The preferences option should be visible.
+ let preferences = card.querySelector('[action="preferences"]');
+ ok(!preferences.hidden, "The preferences option is visible");
+
+ // Open the preferences page.
+ let loaded = waitForViewLoad(win);
+ preferences.click();
+ await loaded;
+
+ // Verify we're on the preferences tab.
+ card = doc.querySelector("addon-card");
+ is(card.addon.id, extension.id, "The right page was loaded");
+
+ // Wait for the browser to load.
+ let url = await extension.awaitMessage("options-loaded");
+
+ // Check the attributes of the options browser.
+ let browser = card.querySelector("inline-options-browser browser");
+ ok(browser, "The visible view has a browser");
+ ok(card.addon.optionsURL.length, "Options URL is not empty");
+ is(
+ browser.currentURI.spec,
+ card.addon.optionsURL,
+ "The browser has the expected options URL"
+ );
+ is(url, card.addon.optionsURL, "Browser has the expected options URL loaded");
+
+ // Focus the options browser.
+ browser.focus();
+
+ // Focus the input in the options page.
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.querySelector("input").focus();
+ });
+
+ info("input in options page should be focused, typing...");
+ // Type '/'.
+ EventUtils.synthesizeKey("/");
+
+ let inputValue = await extension.awaitMessage("input-changed");
+ is(inputValue, "/", "Expected input to contain a slash");
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js
new file mode 100644
index 0000000000..68faecfec0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_options_ui_in_tab.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+add_task(async function enableHtmlViews() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.inline-options.enabled", true]],
+ });
+});
+
+async function testOptionsInTab({ id, options_ui_options }) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Prefs extension",
+ browser_specific_settings: { gecko: { id } },
+ options_ui: {
+ page: "options.html",
+ ...options_ui_options,
+ },
+ },
+ background() {
+ browser.test.sendMessage(
+ "options-url",
+ browser.runtime.getURL("options.html")
+ );
+ },
+ files: {
+ "options.html": `<script src="options.js"></script>`,
+ "options.js": () => {
+ browser.test.sendMessage("options-loaded");
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let optionsUrl = await extension.awaitMessage("options-url");
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let aboutAddonsTab = gBrowser.selectedTab;
+
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ let prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(!prefsBtn.hidden, "The button is not hidden");
+
+ info("Open the preferences page from list");
+ let tabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, optionsUrl);
+ prefsBtn.click();
+ await extension.awaitMessage("options-loaded");
+ BrowserTestUtils.removeTab(await tabLoaded);
+
+ info("Load details page");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Find the expanded card.
+ card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ info("Check that the button is still visible");
+ prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(!prefsBtn.hidden, "The button is not hidden");
+
+ info("Open the preferences page from details");
+ tabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, optionsUrl);
+ prefsBtn.click();
+ let prefsTab = await tabLoaded;
+ await extension.awaitMessage("options-loaded");
+
+ info("Switch back to about:addons and open prefs again");
+ await BrowserTestUtils.switchTab(gBrowser, aboutAddonsTab);
+ let tabSwitched = BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone");
+ prefsBtn.click();
+ await tabSwitched;
+ is(gBrowser.selectedTab, prefsTab, "The prefs tab was selected");
+
+ BrowserTestUtils.removeTab(prefsTab);
+
+ await closeView(win);
+ await extension.unload();
+}
+
+add_task(async function testPreferencesLink() {
+ let id = "prefs@mochi.test";
+ await testOptionsInTab({ id, options_ui_options: { open_in_tab: true } });
+});
+
+add_task(async function testPreferencesInlineDisabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.htmlaboutaddons.inline-options.enabled", false]],
+ });
+
+ let id = "inline-disabled@mochi.test";
+ await testOptionsInTab({ id, options_ui_options: {} });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testNoPreferences() {
+ let id = "no-prefs@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "No Prefs extension",
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ info("Check button on list");
+ let prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(prefsBtn.hidden, "The button is hidden");
+
+ info("Load details page");
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Find the expanded card.
+ card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+
+ info("Check that the button is still hidden on detail");
+ prefsBtn = card.querySelector('panel-item[action="preferences"]');
+ ok(prefsBtn.hidden, "The button is hidden");
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js
new file mode 100644
index 0000000000..f3616cd080
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_pending_updates.js
@@ -0,0 +1,311 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer();
+
+const LOCALE_ADDON_ID = "postponed-langpack@mochi.test";
+
+let gProvider;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.checkUpdateSecurity", false]],
+ });
+
+ // Also include a langpack with a pending postponed install.
+ const fakeLocalePostponedInstall = {
+ name: "updated langpack",
+ version: "2.0",
+ state: AddonManager.STATE_POSTPONED,
+ };
+
+ gProvider = new MockProvider();
+ gProvider.createAddons([
+ {
+ id: LOCALE_ADDON_ID,
+ name: "Postponed Langpack",
+ type: "locale",
+ version: "1.0",
+ // Mock pending upgrade property on the mocked langpack add-on.
+ pendingUpgrade: {
+ install: fakeLocalePostponedInstall,
+ },
+ },
+ ]);
+
+ fakeLocalePostponedInstall.existingAddon = gProvider.addons[0];
+ gProvider.createInstalls([fakeLocalePostponedInstall]);
+
+ registerCleanupFunction(() => {
+ cleanupPendingNotifications();
+ });
+});
+
+function createTestExtension({
+ id = "test-pending-update@test",
+ newManifest = {},
+}) {
+ function background() {
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.sendMessage("update-available");
+ });
+
+ browser.test.sendMessage("bgpage-ready");
+ }
+
+ const serverHost = `http://localhost:${server.identity.primaryPort}`;
+ const updatesPath = `/ext-updates-${id}.json`;
+ const update_url = `${serverHost}${updatesPath}`;
+
+ const manifest = {
+ name: "Test Pending Update",
+ browser_specific_settings: {
+ gecko: { id, update_url },
+ },
+ version: "1",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background,
+ manifest,
+ // Use permanent so the add-on can be updated.
+ useAddonManager: "permanent",
+ });
+
+ let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ ...manifest,
+ ...newManifest,
+ version: "2",
+ },
+ });
+
+ let xpiFilename = `/update-${id}.xpi`;
+ server.registerFile(xpiFilename, updateXpi);
+ AddonTestUtils.registerJSON(server, updatesPath, {
+ addons: {
+ [id]: {
+ updates: [
+ {
+ version: "2",
+ update_link: serverHost + xpiFilename,
+ },
+ ],
+ },
+ },
+ });
+
+ return { extension, updateXpi };
+}
+
+async function promiseUpdateAvailable(extension) {
+ info("Wait for the extension to receive onUpdateAvailable event");
+ await extension.awaitMessage("update-available");
+}
+
+function expectUpdatesAvailableBadgeCount({ win, expectedNumber }) {
+ const categoriesSidebar = win.document.querySelector("categories-box");
+ ok(categoriesSidebar, "Found the categories-box element");
+ const availableButton =
+ categoriesSidebar.getButtonByName("available-updates");
+ is(
+ availableButton.badgeCount,
+ 1,
+ `Expect only ${expectedNumber} available updates`
+ );
+ ok(
+ !availableButton.hidden,
+ "Expecte the available updates category to be visible"
+ );
+}
+
+async function expectAddonInstallStatePostponed(id) {
+ const [addonInstall] = (await AddonManager.getAllInstalls()).filter(
+ install => install.existingAddon && install.existingAddon.id == id
+ );
+ is(
+ addonInstall && addonInstall.state,
+ AddonManager.STATE_POSTPONED,
+ "AddonInstall is in the postponed state"
+ );
+}
+
+function expectCardOptionsButtonBadged({ id, win, hasBadge = true }) {
+ const card = getAddonCard(win, id);
+ const moreOptionsEl = card.querySelector(".more-options-button");
+ is(
+ moreOptionsEl.classList.contains("more-options-button-badged"),
+ hasBadge,
+ `The options button should${hasBadge || "n't"} have the update badge`
+ );
+}
+
+function getCardPostponedBar({ id, win }) {
+ const card = getAddonCard(win, id);
+ return card.querySelector(".update-postponed-bar");
+}
+
+function waitCardAndAddonUpdated({ id, win }) {
+ const card = getAddonCard(win, id);
+ const updatedExtStarted = AddonTestUtils.promiseWebExtensionStartup(id);
+ const updatedCard = BrowserTestUtils.waitForEvent(card, "update");
+ return Promise.all([updatedExtStarted, updatedCard]);
+}
+
+async function testPostponedBarVisibility({ id, win, hidden = false }) {
+ const postponedBar = getCardPostponedBar({ id, win });
+ is(
+ postponedBar.hidden,
+ hidden,
+ `${id} update postponed message bar should be ${
+ hidden ? "hidden" : "visible"
+ }`
+ );
+
+ if (!hidden) {
+ await expectAddonInstallStatePostponed(id);
+ }
+}
+
+async function assertPostponedBarVisibleInAllViews({ id, win }) {
+ info("Test postponed bar visibility in extension list view");
+ await testPostponedBarVisibility({ id, win });
+
+ info("Test postponed bar visibility in available view");
+ await switchView(win, "available-updates");
+ await testPostponedBarVisibility({ id, win });
+
+ info("Test that available updates count do not include postponed langpacks");
+ expectUpdatesAvailableBadgeCount({ win, expectedNumber: 1 });
+
+ info("Test postponed langpacks are not listed in the available updates view");
+ ok(
+ !getAddonCard(win, LOCALE_ADDON_ID),
+ "Locale addon is expected to not be listed in the updates view"
+ );
+
+ info("Test that postponed bar isn't visible on postponed langpacks");
+ await switchView(win, "locale");
+ await testPostponedBarVisibility({ id: LOCALE_ADDON_ID, win, hidden: true });
+
+ info("Test postponed bar visibility in extension detail view");
+ await switchView(win, "extension");
+ await switchToDetailView({ win, id });
+ await testPostponedBarVisibility({ id, win });
+}
+
+async function completePostponedUpdate({ id, win }) {
+ expectCardOptionsButtonBadged({ id, win, hasBadge: false });
+
+ await testPostponedBarVisibility({ id, win });
+
+ let addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "1", "Addon version is 1");
+
+ const promiseUpdated = waitCardAndAddonUpdated({ id, win });
+ const postponedBar = getCardPostponedBar({ id, win });
+ postponedBar.querySelector("button").click();
+ await promiseUpdated;
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "2", "Addon version is 2");
+
+ await testPostponedBarVisibility({ id, win, hidden: true });
+}
+
+add_task(async function test_pending_update_with_prompted_permission() {
+ const id = "test-pending-update-with-prompted-permission@mochi.test";
+
+ const { extension } = createTestExtension({
+ id,
+ newManifest: { permissions: ["<all_urls>"] },
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage-ready");
+
+ const win = await loadInitialView("extension");
+
+ // Force about:addons to check for updates.
+ let promisePermissionHandled = handlePermissionPrompt({
+ addonId: extension.id,
+ assertIcon: false,
+ });
+ win.checkForUpdates();
+ await promisePermissionHandled;
+
+ await promiseUpdateAvailable(extension);
+ await expectAddonInstallStatePostponed(id);
+
+ await completePostponedUpdate({ id, win });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function test_pending_manual_install_over_existing() {
+ const id = "test-pending-manual-install-over-existing@mochi.test";
+
+ const { extension, updateXpi } = createTestExtension({
+ id,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage-ready");
+
+ let win = await loadInitialView("extension");
+
+ info("Manually install new xpi over the existing extension");
+ const promiseInstalled = AddonTestUtils.promiseInstallFile(updateXpi);
+ await promiseUpdateAvailable(extension);
+
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ info("Test postponed bar visibility after reopening about:addons");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ await completePostponedUpdate({ id, win });
+ await promiseInstalled;
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function test_pending_update_no_prompted_permission() {
+ const id = "test-pending-update-no-prompted-permission@mochi.test";
+
+ const { extension } = createTestExtension({ id });
+
+ await extension.startup();
+ await extension.awaitMessage("bgpage-ready");
+
+ let win = await loadInitialView("extension");
+
+ info("Force about:addons to check for updates");
+ win.checkForUpdates();
+ await promiseUpdateAvailable(extension);
+
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ info("Test postponed bar visibility after reopening about:addons");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ await assertPostponedBarVisibleInAllViews({ id, win });
+
+ await completePostponedUpdate({ id, win });
+
+ info("Reopen about:addons again and verify postponed bar hidden");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ await testPostponedBarVisibility({ id, win, hidden: true });
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
new file mode 100644
index 0000000000..66ce20b989
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_recent_updates.js
@@ -0,0 +1,180 @@
+/* eslint max-len: ["error", 80] */
+let gProvider;
+
+function dateHoursAgo(hours) {
+ let date = new Date();
+ date.setTime(date.getTime() - hours * 3600000);
+ return date;
+}
+
+add_task(async function enableHtmlViews() {
+ gProvider = new MockProvider();
+ gProvider.createAddons([
+ {
+ id: "addon-today-2@mochi.test",
+ name: "Updated today two",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(6),
+ },
+ {
+ id: "addon-today-3@mochi.test",
+ name: "Updated today three",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(9),
+ },
+ {
+ id: "addon-today-1@mochi.test",
+ name: "Updated today",
+ creator: { name: "The creator" },
+ version: "3.1",
+ type: "extension",
+ releaseNotesURI: "http://example.com/notes.txt",
+ updateDate: dateHoursAgo(1),
+ },
+ {
+ id: "addon-yesterday-1@mochi.test",
+ name: "Updated yesterday one",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(15),
+ },
+ {
+ id: "addon-earlier@mochi.test",
+ name: "Updated earlier",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(49),
+ },
+ {
+ id: "addon-yesterday-2@mochi.test",
+ name: "Updated yesterday",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(24),
+ },
+ {
+ id: "addon-lastweek@mochi.test",
+ name: "Updated last week",
+ creator: { name: "The creator" },
+ version: "3.3",
+ type: "extension",
+ updateDate: dateHoursAgo(192),
+ },
+ ]);
+});
+
+add_task(async function testRecentUpdatesList() {
+ // Load extension view first so we can mock the startOfDay property.
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let categoryUtils = new CategoryUtilities(win);
+ const RECENT_URL = "addons://updates/recent";
+ let recentCat = categoryUtils.get("recent-updates");
+
+ ok(recentCat.hidden, "Recent updates category is initially hidden");
+
+ // Load the recent updates view.
+ let loaded = waitForViewLoad(win);
+ doc.querySelector('#page-options [action="view-recent-updates"]').click();
+ await loaded;
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ RECENT_URL,
+ "Recent updates is selected"
+ );
+ ok(!recentCat.hidden, "Recent updates category is now shown");
+
+ // Find all the add-on ids.
+ let list = doc.querySelector("addon-list");
+ let addonsInOrder = () =>
+ Array.from(list.querySelectorAll("addon-card"))
+ .map(card => card.addon.id)
+ .filter(id => id.endsWith("@mochi.test"));
+
+ // Verify that the add-ons are in the right order.
+ Assert.deepEqual(
+ addonsInOrder(),
+ [
+ "addon-today-1@mochi.test",
+ "addon-today-2@mochi.test",
+ "addon-today-3@mochi.test",
+ "addon-yesterday-1@mochi.test",
+ "addon-yesterday-2@mochi.test",
+ ],
+ "The add-ons are in the right order"
+ );
+
+ info("Check that release notes are shown on the details page");
+ let card = list.querySelector(
+ 'addon-card[addon-id="addon-today-1@mochi.test"]'
+ );
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ card = doc.querySelector("addon-card");
+ ok(card.expanded, "The card is expanded");
+ ok(!card.details.tabGroup.hidden, "The tabs are shown");
+ ok(
+ !card.details.tabGroup.querySelector('[name="release-notes"]').hidden,
+ "The release notes button is shown"
+ );
+
+ info("Go back to the recent updates view");
+ loaded = waitForViewLoad(win);
+ doc.querySelector('#page-options [action="view-recent-updates"]').click();
+ await loaded;
+
+ // Find the list again.
+ list = doc.querySelector("addon-list");
+
+ info("Install a new add-on, it should be first in the list");
+ // Install a new extension.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "New extension",
+ browser_specific_settings: { gecko: { id: "new@mochi.test" } },
+ },
+ useAddonManager: "temporary",
+ });
+ let added = BrowserTestUtils.waitForEvent(list, "add");
+ await extension.startup();
+ await added;
+
+ // The new extension should now be at the top of the list.
+ Assert.deepEqual(
+ addonsInOrder(),
+ [
+ "new@mochi.test",
+ "addon-today-1@mochi.test",
+ "addon-today-2@mochi.test",
+ "addon-today-3@mochi.test",
+ "addon-yesterday-1@mochi.test",
+ "addon-yesterday-2@mochi.test",
+ ],
+ "The new add-on went to the top"
+ );
+
+ // Open the detail view for the new add-on.
+ card = list.querySelector('addon-card[addon-id="new@mochi.test"]');
+ loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/extension",
+ "The extensions category is selected"
+ );
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js b/toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js
new file mode 100644
index 0000000000..045e58d706
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_recommendations.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const SUPPORT_URL = "http://support.allizom.org/support-dummy/";
+const SUMO_URL = SUPPORT_URL + "add-on-badges";
+const SUPPORTED_BADGES = ["recommended", "line", "verified"];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["app.support.baseURL", SUPPORT_URL]],
+ });
+});
+
+const server = AddonTestUtils.createHttpServer({
+ hosts: ["support.allizom.org"],
+});
+server.registerPathHandler("/support-dummy", (request, response) => {
+ response.write("Dummy");
+});
+
+async function checkRecommendedBadge(id, badges = []) {
+ async function checkBadge() {
+ let card = win.document.querySelector(`addon-card[addon-id="${id}"]`);
+ for (let badgeName of SUPPORTED_BADGES) {
+ let badge = card.querySelector(`.addon-badge-${badgeName}`);
+ let hidden = !badges.includes(badgeName);
+ is(
+ badge.hidden,
+ hidden,
+ `badge ${badgeName} is ${hidden ? "hidden" : "shown"}`
+ );
+ // Verify the utm params.
+ ok(
+ badge.href.startsWith(SUMO_URL),
+ "links to sumo correctly " + badge.href
+ );
+ if (!hidden) {
+ info(`Verify the ${badgeName} badge links to the support page`);
+ let tabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, badge.href);
+ EventUtils.synthesizeMouseAtCenter(badge, {}, win);
+ BrowserTestUtils.removeTab(await tabLoaded);
+ }
+ let url = new URL(badge.href);
+ is(
+ url.searchParams.get("utm_content"),
+ "promoted-addon-badge",
+ "content param correct"
+ );
+ is(
+ url.searchParams.get("utm_source"),
+ "firefox-browser",
+ "source param correct"
+ );
+ is(
+ url.searchParams.get("utm_medium"),
+ "firefox-browser",
+ "medium param correct"
+ );
+ }
+ for (let badgeName of badges) {
+ if (!SUPPORTED_BADGES.includes(badgeName)) {
+ ok(
+ !card.querySelector(`.addon-badge-${badgeName}`),
+ `no badge element for ${badgeName}`
+ );
+ }
+ }
+ return card;
+ }
+
+ let win = await loadInitialView("extension");
+
+ // Check list view.
+ let card = await checkBadge();
+
+ // Load detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Check detail view.
+ await checkBadge();
+
+ await closeView(win);
+}
+
+add_task(async function testNotRecommended() {
+ let id = "not-recommended@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ await checkRecommendedBadge(id);
+
+ await extension.unload();
+});
+
+async function test_badged_addon(addon) {
+ let provider = new MockProvider();
+ provider.createAddons([addon]);
+ await checkRecommendedBadge(addon.id, addon.recommendationStates);
+
+ provider.unregister();
+}
+
+add_task(async function testRecommended() {
+ await test_badged_addon({
+ id: "recommended@mochi.test",
+ isRecommended: true,
+ recommendationStates: ["recommended"],
+ name: "Recommended",
+ type: "extension",
+ });
+});
+
+add_task(async function testLine() {
+ await test_badged_addon({
+ id: "line@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["line"],
+ name: "Line",
+ type: "extension",
+ });
+});
+
+add_task(async function testVerified() {
+ await test_badged_addon({
+ id: "verified@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["verified"],
+ name: "Verified",
+ type: "extension",
+ });
+});
+
+add_task(async function testOther() {
+ await test_badged_addon({
+ id: "other@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["other"],
+ name: "No Badge",
+ type: "extension",
+ });
+});
+
+add_task(async function testMultiple() {
+ await test_badged_addon({
+ id: "multiple@mochi.test",
+ isRecommended: false,
+ recommendationStates: ["verified", "recommended", "other"],
+ name: "Multiple",
+ type: "extension",
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js b/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js
new file mode 100644
index 0000000000..e4d88bc19a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_scroll_restoration.js
@@ -0,0 +1,229 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+const server = AddonTestUtils.createHttpServer();
+const TEST_API_URL = `http://localhost:${server.identity.primaryPort}/discoapi`;
+
+const EXT_ID_EXTENSION = "extension@example.com";
+const EXT_ID_THEME = "theme@example.com";
+
+let requestCount = 0;
+server.registerPathHandler("/discoapi", (request, response) => {
+ // This test is expected to load the results only once, and then cache the
+ // results.
+ is(++requestCount, 1, "Expect only one discoapi request");
+
+ let results = {
+ results: [
+ {
+ addon: {
+ authors: [{ name: "Some author" }],
+ current_version: {
+ files: [{ platform: "all", url: "data:," }],
+ },
+ url: "data:,",
+ guid: "recommendation@example.com",
+ type: "extension",
+ },
+ },
+ ],
+ };
+ response.write(JSON.stringify(results));
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.getAddons.discovery.api_url", TEST_API_URL]],
+ });
+
+ let mockProvider = new MockProvider();
+ mockProvider.createAddons([
+ {
+ id: EXT_ID_EXTENSION,
+ name: "Mock 1",
+ type: "extension",
+ userPermissions: {
+ origins: ["<all_urls>"],
+ permissions: ["tabs"],
+ },
+ },
+ {
+ id: EXT_ID_THEME,
+ name: "Mock 2",
+ type: "theme",
+ },
+ ]);
+});
+
+async function switchToView(win, type, param = "") {
+ let loaded = waitForViewLoad(win);
+ win.gViewController.loadView(`addons://${type}/${param}`);
+ await loaded;
+ await waitForStableLayout(win);
+}
+
+// delta = -1 = go back.
+// delta = +1 = go forwards.
+async function historyGo(win, delta, expectedViewType) {
+ let loaded = waitForViewLoad(win);
+ win.history.go(delta);
+ await loaded;
+ is(
+ win.gViewController.currentViewId,
+ expectedViewType,
+ "Expected view after history navigation"
+ );
+ await waitForStableLayout(win);
+}
+
+async function waitForStableLayout(win) {
+ // In the test, it is important that the layout is fully stable before we
+ // consider the view loaded, because those affect the offset calculations.
+ await TestUtils.waitForCondition(
+ () => isLayoutStable(win),
+ "Waiting for layout to stabilize"
+ );
+}
+
+function isLayoutStable(win) {
+ // <message-bar> elements may affect the layout of a page, and therefore we
+ // should check whether its embedded style sheet has finished loading.
+ for (let bar of win.document.querySelectorAll("message-bar")) {
+ // Check for the existence of a CSS property from message-bar.css.
+ if (!win.getComputedStyle(bar).getPropertyValue("--message-bar-icon-url")) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function getScrollOffset(win) {
+ let { scrollTop: top, scrollLeft: left } = win.document.documentElement;
+ return { top, left };
+}
+
+// Scroll an element into view. The purpose of this is to simulate a real-world
+// scenario where the user has moved part of the UI is in the viewport.
+function scrollTopLeftIntoView(elem) {
+ elem.scrollIntoView({ block: "start", inline: "start" });
+ // Sanity check: In this test, a large padding has been added to the top and
+ // left of the document. So when an element has been scrolled into view, the
+ // top and left offsets must be non-zero.
+ assertNonZeroScrollOffsets(getScrollOffset(elem.ownerGlobal));
+}
+
+function assertNonZeroScrollOffsets(offsets) {
+ ok(offsets.left, "Should have scrolled to the right");
+ ok(offsets.top, "Should have scrolled down");
+}
+
+function checkScrollOffset(win, expected, msg = "") {
+ let actual = getScrollOffset(win);
+ let fuzz = AppConstants.platform == "macosx" ? 3 : 1;
+ isfuzzy(actual.top, expected.top, fuzz, `Top scroll offset - ${msg}`);
+ isfuzzy(actual.left, expected.left, fuzz, `Left scroll offset - ${msg}`);
+}
+
+add_task(async function test_scroll_restoration() {
+ let win = await loadInitialView("discover");
+
+ // Wait until the recommendations have been loaded. These are cached after
+ // the first load, so we only need to wait once, at the start of the test.
+ await win.document.querySelector("recommended-addon-list").cardsReady;
+
+ // Force scrollbar to appear, by adding enough space around the content.
+ win.document.body.style.paddingBlock = "100vh";
+ win.document.body.style.paddingInline = "100vw";
+ win.document.body.style.width = "300vw";
+
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial page load");
+
+ scrollTopLeftIntoView(win.document.querySelector("recommended-addon-card"));
+ let discoOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(discoOffsets);
+
+ // Switch from disco pane to extension list
+
+ await switchToView(win, "list", "extension");
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial extension list");
+
+ scrollTopLeftIntoView(getAddonCard(win, EXT_ID_EXTENSION));
+ let extListOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(extListOffsets);
+
+ // Switch from extension list to details view.
+
+ let loaded = waitForViewLoad(win);
+ const addonCard = getAddonCard(win, EXT_ID_EXTENSION);
+ // Ensure that we send a click on the control that is accessible (while a
+ // mouse user could also activate a card by clicking on the entire container):
+ const addonCardLink = addonCard.querySelector(".addon-name-link");
+ addonCardLink.click();
+ await loaded;
+
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial details view");
+ scrollTopLeftIntoView(getAddonCard(win, EXT_ID_EXTENSION));
+ let detailsOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(detailsOffsets);
+
+ // Switch from details view back to extension list.
+
+ await historyGo(win, -1, "addons://list/extension");
+ checkScrollOffset(win, extListOffsets, "back to extension list");
+
+ // Now scroll to the bottom-right corner, so we can check whether the scroll
+ // offset is correctly restored when the extension view is loaded, even when
+ // the recommendations are loaded after the initial render.
+ ok(
+ win.document.querySelector("recommended-addon-card"),
+ "Recommendations have already been loaded"
+ );
+ win.document.body.scrollIntoView({ block: "end", inline: "end" });
+ extListOffsets = getScrollOffset(win);
+ assertNonZeroScrollOffsets(extListOffsets);
+
+ // Switch back from the extension list to the details view.
+
+ await historyGo(win, +1, `addons://detail/${EXT_ID_EXTENSION}`);
+ checkScrollOffset(win, detailsOffsets, "details view with default tab");
+
+ // Switch from the default details tab to the permissions tab.
+ // (this does not change the history).
+ win.document.querySelector(".tab-button[name='permissions']").click();
+
+ // Switch back from the details view to the extension list.
+
+ await historyGo(win, -1, "addons://list/extension");
+ checkScrollOffset(win, extListOffsets, "bottom-right of extension list");
+ ok(
+ win.document.querySelector("recommended-addon-card"),
+ "Recommendations should have been loaded again"
+ );
+
+ // Switch back from extension list to the details view.
+
+ await historyGo(win, +1, `addons://detail/${EXT_ID_EXTENSION}`);
+ // Scroll offsets are not remembered for the details view, because at the
+ // time of leaving the details view, the non-default tab was selected.
+ checkScrollOffset(win, { top: 0, left: 0 }, "details view, non-default tab");
+
+ // Switch back from the details view to the disco pane.
+
+ await historyGo(win, -2, "addons://discover/");
+ checkScrollOffset(win, discoOffsets, "after switching back to disco pane");
+
+ // Switch from disco pane to theme list.
+
+ // Verifies that the extension list and theme lists are independent.
+ await switchToView(win, "list", "theme");
+ checkScrollOffset(win, { top: 0, left: 0 }, "initial theme list");
+
+ let tabClosed = BrowserTestUtils.waitForTabClosing(gBrowser.selectedTab);
+ await closeView(win);
+ await tabClosed;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js b/toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js
new file mode 100644
index 0000000000..3c8ce5f447
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_sitepermission_addons.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { SITEPERMS_ADDON_PROVIDER_PREF, SITEPERMS_ADDON_TYPE } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+ );
+
+const html = `<!DOCTYPE html><h1>Test midi permission with synthetic site permission addon</h1>`;
+const EXAMPLE_COM_URL = `https://example.com/document-builder.sjs?html=${html}`;
+const EXAMPLE_ORG_URL = `https://example.org/document-builder.sjs?html=${html}`;
+
+async function uninstallAllSitePermissionAddons() {
+ const sitepermAddons = await AddonManager.getAddonsByTypes([
+ SITEPERMS_ADDON_TYPE,
+ ]);
+ for (const addon of sitepermAddons) {
+ await addon.uninstall();
+ }
+}
+
+add_setup(async () => {
+ registerCleanupFunction(uninstallAllSitePermissionAddons);
+});
+
+add_task(async function testAboutAddonUninstall() {
+ if (!AddonManager.hasProvider("SitePermsAddonProvider")) {
+ ok(
+ !Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF),
+ "Expect SitePermsAddonProvider to be disabled by prefs"
+ );
+ ok(true, "Skip test on SitePermsAddonProvider disabled");
+ return;
+ }
+
+ // Grant midi permission on example.com so about:addons does have a Site Permissions section
+ await SpecialPowers.addPermission("midi-sysex", true, EXAMPLE_COM_URL);
+
+ info("Open an about:addon tab so AMO event listeners are registered");
+ const aboutAddonWin = await loadInitialView("sitepermission");
+ // loadInitialView sets the about:addon as the active one, so we can grab it here.
+ const aboutAddonTab = gBrowser.selectedTab;
+
+ const addonList = aboutAddonWin.document.querySelector("addon-list");
+ let addonCards = aboutAddonWin.document.querySelectorAll("addon-card");
+ is(
+ addonCards.length,
+ 1,
+ "There's a card displayed for the example.com addon"
+ );
+
+ info("Open an example.org tab and install the midi site permission addon");
+ const exampleOrgTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ EXAMPLE_ORG_URL,
+ true /* waitForLoad */
+ );
+
+ let promiseAddonCardAdded = BrowserTestUtils.waitForEvent(addonList, "add");
+
+ info("Install midi");
+ await testInstallGatedPermission(
+ exampleOrgTab,
+ () => {
+ content.navigator.requestMIDIAccess();
+ },
+ "midi"
+ );
+
+ info("Install midi-sysex as well");
+ const newAddon = await testInstallGatedPermission(
+ exampleOrgTab,
+ () => {
+ content.navigator.requestMIDIAccess({ sysex: true });
+ },
+ "midi-sysex"
+ );
+
+ const newAddonId = newAddon.id;
+ ok(
+ newAddonId,
+ "Got the addon id for the newly installed sitepermission add-on"
+ );
+
+ info("Switch back to about:addon");
+ gBrowser.selectedTab = aboutAddonTab;
+
+ await promiseAddonCardAdded;
+
+ is(
+ aboutAddonWin.document.querySelectorAll("addon-card").length,
+ 2,
+ "A new addon card has been added as expected"
+ );
+
+ const exampleOrgAddonCard = getAddonCard(aboutAddonWin, newAddonId);
+
+ info("Remove the example.org addon");
+ const promptService = mockPromptService();
+ promptService._response = 0;
+
+ let promiseRemoved = BrowserTestUtils.waitForEvent(addonList, "remove");
+ exampleOrgAddonCard.querySelector("[action=remove]").click();
+ await promiseRemoved;
+
+ is(
+ aboutAddonWin.document.querySelectorAll("addon-card").length,
+ 1,
+ "addon card has been removed as expected"
+ );
+
+ ok(
+ await SpecialPowers.testPermission(
+ "midi",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: EXAMPLE_ORG_URL }
+ ),
+ "midi permission was revoked"
+ );
+ ok(
+ await SpecialPowers.testPermission(
+ "midi-sysex",
+ SpecialPowers.Services.perms.UNKNOWN_ACTION,
+ { url: EXAMPLE_ORG_URL }
+ ),
+ "midi-sysex permission was revoked as well"
+ );
+
+ await BrowserTestUtils.removeTab(exampleOrgTab);
+ await close_manager(aboutAddonWin);
+ await uninstallAllSitePermissionAddons();
+});
+
+/**
+ *
+ * Execute a function in the tab content page and check that the expected gated permission
+ * is set
+ *
+ * @param {Tab} tab: The tab in which we want to install the gated permission
+ * @param {Function} spawnCallback: function used in `SpecialPowers.spawn` that will trigger
+ * the install
+ * @param {String} expectedPermType: The name of the permission that should be granted
+ * @returns {Promise<Addon>} The installed addon instance
+ */
+async function testInstallGatedPermission(
+ tab,
+ spawnCallback,
+ expectedPermType
+) {
+ let onInstallEnded = AddonTestUtils.promiseInstallEvent("onInstallEnded");
+ let onAddonInstallBlockedNotification = promisePopupNotificationShown(
+ "addon-install-blocked"
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], spawnCallback);
+
+ let addonInstallPanel = await onAddonInstallBlockedNotification;
+ let dialogPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addonInstallPanel.button.click();
+ let installPermsDialog = await dialogPromise;
+ installPermsDialog.button.click();
+
+ const addon = await onInstallEnded.then(install => install[0].addon);
+ // Close the addon-installed dialog to avoid interfering with other tests
+ await acceptAppMenuNotificationWhenShown("addon-installed", addon.id);
+
+ ok(
+ await SpecialPowers.testPermission(
+ expectedPermType,
+ SpecialPowers.Services.perms.ALLOW_ACTION,
+ { url: EXAMPLE_ORG_URL }
+ ),
+ `"${expectedPermType}" permission was granted`
+ );
+ return addon;
+}
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_updates.js b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
new file mode 100644
index 0000000000..78ffc5678c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_updates.js
@@ -0,0 +1,750 @@
+/* eslint max-len: ["error", 80] */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const server = AddonTestUtils.createHttpServer();
+
+const initialAutoUpdate = AddonManager.autoUpdateDefault;
+registerCleanupFunction(() => {
+ AddonManager.autoUpdateDefault = initialAutoUpdate;
+});
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.checkUpdateSecurity", false]],
+ });
+
+ Services.telemetry.clearEvents();
+ registerCleanupFunction(() => {
+ cleanupPendingNotifications();
+ });
+});
+
+function loadDetailView(win, id) {
+ let doc = win.document;
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(
+ card.querySelector(".addon-name-link"),
+ { clickCount: 1 },
+ win
+ );
+ return loaded;
+}
+
+add_task(async function testChangeAutoUpdates() {
+ let id = "test@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test",
+ browser_specific_settings: { gecko: { id } },
+ },
+ // Use permanent so the add-on can be updated.
+ useAddonManager: "permanent",
+ });
+
+ await extension.startup();
+ let addon = await AddonManager.getAddonByID(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let getInputs = updateRow => ({
+ default: updatesRow.querySelector('input[value="1"]'),
+ on: updatesRow.querySelector('input[value="2"]'),
+ off: updatesRow.querySelector('input[value="0"]'),
+ checkForUpdate: updatesRow.querySelector('[action="update-check"]'),
+ });
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ ok(card.querySelector("addon-details"), "The card now has details");
+
+ let updatesRow = card.querySelector(".addon-detail-row-updates");
+ let inputs = getInputs(updatesRow);
+ is(addon.applyBackgroundUpdates, 1, "Default is set");
+ ok(inputs.default.checked, "The default option is selected");
+ ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+ inputs.on.click();
+ is(addon.applyBackgroundUpdates, "2", "Updates are now enabled");
+ ok(inputs.on.checked, "The on option is selected");
+ ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+ inputs.off.click();
+ is(addon.applyBackgroundUpdates, "0", "Updates are now disabled");
+ ok(inputs.off.checked, "The off option is selected");
+ ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+ // Go back to the list view and check the details view again.
+ let loaded = waitForViewLoad(win);
+ doc.querySelector(".back-button").click();
+ await loaded;
+
+ // Load the detail view again.
+ await loadDetailView(win, id);
+
+ card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ updatesRow = card.querySelector(".addon-detail-row-updates");
+ inputs = getInputs(updatesRow);
+
+ ok(inputs.off.checked, "Off is still selected");
+
+ // Disable global updates.
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ AddonManager.autoUpdateDefault = false;
+ await updated;
+
+ // Updates are still the same.
+ is(addon.applyBackgroundUpdates, "0", "Updates are now disabled");
+ ok(inputs.off.checked, "The off option is selected");
+ ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+ // Check default.
+ inputs.default.click();
+ is(addon.applyBackgroundUpdates, "1", "Default is set");
+ ok(inputs.default.checked, "The default option is selected");
+ ok(!inputs.checkForUpdate.hidden, "Update check is visible");
+
+ inputs.on.click();
+ is(addon.applyBackgroundUpdates, "2", "Updates are now enabled");
+ ok(inputs.on.checked, "The on option is selected");
+ ok(inputs.checkForUpdate.hidden, "Update check is hidden");
+
+ // Enable updates again.
+ updated = BrowserTestUtils.waitForEvent(card, "update");
+ AddonManager.autoUpdateDefault = true;
+ await updated;
+
+ await closeView(win);
+ await extension.unload();
+});
+
+async function setupExtensionWithUpdate(
+ id,
+ { releaseNotes, cancelUpdate } = {}
+) {
+ let serverHost = `http://localhost:${server.identity.primaryPort}`;
+ let updatesPath = `/ext-updates-${id}.json`;
+
+ let baseManifest = {
+ name: "Updates",
+ icons: { 48: "an-icon.png" },
+ browser_specific_settings: {
+ gecko: {
+ id,
+ update_url: serverHost + updatesPath,
+ },
+ },
+ };
+
+ let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ ...baseManifest,
+ version: "2",
+ // Include a permission in the updated extension, to make
+ // sure that we trigger the permission prompt as expected
+ // (and that we can accept or cancel the update by observing
+ // the underlying observerService notification).
+ permissions: ["http://*.example.com/*"],
+ },
+ });
+
+ let releaseNotesExtra = {};
+ if (releaseNotes) {
+ let notesPath = "/notes.txt";
+ server.registerPathHandler(notesPath, (request, response) => {
+ if (releaseNotes == "ERROR") {
+ response.setStatusLine(null, 404, "Not Found");
+ } else {
+ response.setStatusLine(null, 200, "OK");
+ response.write(releaseNotes);
+ }
+ response.processAsync();
+ response.finish();
+ });
+ releaseNotesExtra.update_info_url = serverHost + notesPath;
+ }
+
+ let xpiFilename = `/update-${id}.xpi`;
+ server.registerFile(xpiFilename, updateXpi);
+ AddonTestUtils.registerJSON(server, updatesPath, {
+ addons: {
+ [id]: {
+ updates: [
+ {
+ version: "2",
+ update_link: serverHost + xpiFilename,
+ ...releaseNotesExtra,
+ },
+ ],
+ },
+ },
+ });
+
+ handlePermissionPrompt({ addonId: id, reject: cancelUpdate });
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ ...baseManifest,
+ version: "1",
+ },
+ // Use permanent so the add-on can be updated.
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ return extension;
+}
+
+function disableAutoUpdates(card) {
+ // Check button should be hidden.
+ let updateCheckButton = card.querySelector('button[action="update-check"]');
+ ok(updateCheckButton.hidden, "The button is initially hidden");
+
+ // Disable updates, update check button is now visible.
+ card.querySelector('input[name="autoupdate"][value="0"]').click();
+ ok(!updateCheckButton.hidden, "The button is now visible");
+
+ // There shouldn't be an update shown to the user.
+ assertUpdateState({ card, shown: false });
+}
+
+function checkForUpdate(card, expected) {
+ let updateCheckButton = card.querySelector('button[action="update-check"]');
+ let updateFound = BrowserTestUtils.waitForEvent(card, expected);
+ updateCheckButton.click();
+ return updateFound;
+}
+
+function installUpdate(card, expected) {
+ // Install the update.
+ let updateInstalled = BrowserTestUtils.waitForEvent(card, expected);
+ let updated = BrowserTestUtils.waitForEvent(card, "update");
+ card.querySelector('panel-item[action="install-update"]').click();
+ return Promise.all([updateInstalled, updated]);
+}
+
+async function findUpdatesForAddonId(id) {
+ let addon = await AddonManager.getAddonByID(id);
+ await new Promise(resolve => {
+ addon.findUpdates(
+ { onUpdateAvailable: resolve },
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ });
+}
+
+function assertUpdateState({
+ card,
+ shown,
+ expanded = true,
+ releaseNotes = false,
+}) {
+ let menuButton = card.querySelector(".more-options-button");
+ Assert.equal(
+ menuButton.classList.contains("more-options-button-badged"),
+ shown,
+ "The menu button is badged"
+ );
+ let installButton = card.querySelector('panel-item[action="install-update"]');
+ Assert.notEqual(
+ installButton.hidden,
+ shown,
+ `The install button is ${shown ? "hidden" : "shown"}`
+ );
+ if (expanded) {
+ let updateCheckButton = card.querySelector('button[action="update-check"]');
+ Assert.equal(
+ updateCheckButton.hidden,
+ shown,
+ `The update check button is ${shown ? "hidden" : "shown"}`
+ );
+
+ let { tabGroup } = card.details;
+ is(tabGroup.hidden, false, "The tab group is shown");
+ let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+ is(
+ notesBtn.hidden,
+ !releaseNotes,
+ `The release notes button is ${releaseNotes ? "shown" : "hidden"}`
+ );
+ }
+}
+
+add_task(async function testUpdateAvailable() {
+ let id = "update@mochi.test";
+ let extension = await setupExtensionWithUpdate(id);
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector("addon-card");
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true });
+
+ // The version was 1.
+ let versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "1", "The version started as 1");
+
+ await installUpdate(card, "update-installed");
+
+ // The version is now 2.
+ versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "2", "The version has updated");
+
+ // No update is shown again.
+ assertUpdateState({ card, shown: false });
+
+ // Check for updates again, there shouldn't be an update.
+ await checkForUpdate(card, "no-update");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testReleaseNotesLoad() {
+ Services.telemetry.clearEvents();
+ let id = "update-with-notes@mochi.test";
+ let extension = await setupExtensionWithUpdate(id, {
+ releaseNotes: `
+ <html xmlns="http://www.w3.org/1999/xhtml">
+ <head><link rel="stylesheet" href="remove-me.css"/></head>
+ <body>
+ <script src="no-scripts.js"></script>
+ <h1>My release notes</h1>
+ <img src="http://example.com/tracker.png"/>
+ <ul>
+ <li onclick="alert('hi')">A thing</li>
+ </ul>
+ <a href="http://example.com/">Go somewhere</a>
+ </body>
+ </html>
+ `,
+ });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector("addon-card");
+ let { deck, tabGroup } = card.details;
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true, releaseNotes: true });
+
+ info("Check release notes");
+ let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+ let notes = card.querySelector("update-release-notes");
+ let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
+ let loaded = BrowserTestUtils.waitForEvent(notes, "release-notes-loaded");
+ // Don't use notesBtn.click() since it causes an assertion to fail.
+ // See bug 1551621 for more info.
+ EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
+ await loading;
+ is(
+ doc.l10n.getAttributes(notes.firstElementChild).id,
+ "release-notes-loading",
+ "The loading message is shown"
+ );
+ await loaded;
+ info("Checking HTML release notes");
+ let [h1, ul, a] = notes.children;
+ is(h1.tagName, "H1", "There's a heading");
+ is(h1.textContent, "My release notes", "The heading has content");
+ is(ul.tagName, "UL", "There's a list");
+ is(ul.children.length, 1, "There's one item in the list");
+ let [li] = ul.children;
+ is(li.tagName, "LI", "There's a list item");
+ is(li.textContent, "A thing", "The text is set");
+ ok(!li.hasAttribute("onclick"), "The onclick was removed");
+ ok(!notes.querySelector("link"), "The link tag was removed");
+ ok(!notes.querySelector("script"), "The script tag was removed");
+ is(a.textContent, "Go somewhere", "The link text is preserved");
+ is(a.href, "http://example.com/", "The link href is preserved");
+
+ info("Verify the link opened in a new tab");
+ let tabOpened = BrowserTestUtils.waitForNewTab(gBrowser, a.href);
+ a.click();
+ let tab = await tabOpened;
+ BrowserTestUtils.removeTab(tab);
+
+ let originalContent = notes.innerHTML;
+
+ info("Switch away and back to release notes");
+ // Load details view.
+ let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
+ let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ detailsBtn.click();
+ await viewChanged;
+
+ // Load release notes again, verify they weren't loaded.
+ viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ let notesCached = BrowserTestUtils.waitForEvent(
+ notes,
+ "release-notes-cached"
+ );
+ notesBtn.click();
+ await viewChanged;
+ await notesCached;
+ is(notes.innerHTML, originalContent, "The content didn't change");
+
+ info("Install the update to clean it up");
+ await installUpdate(card, "update-installed");
+
+ // There's no more update but release notes are still shown.
+ assertUpdateState({ card, shown: false, releaseNotes: true });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testReleaseNotesError() {
+ let id = "update-with-notes-error@mochi.test";
+ let extension = await setupExtensionWithUpdate(id, { releaseNotes: "ERROR" });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, id);
+
+ let card = doc.querySelector("addon-card");
+ let { deck, tabGroup } = card.details;
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true, releaseNotes: true });
+
+ info("Check release notes");
+ let notesBtn = tabGroup.querySelector('[name="release-notes"]');
+ let notes = card.querySelector("update-release-notes");
+ let loading = BrowserTestUtils.waitForEvent(notes, "release-notes-loading");
+ let errored = BrowserTestUtils.waitForEvent(notes, "release-notes-error");
+ // Don't use notesBtn.click() since it causes an assertion to fail.
+ // See bug 1551621 for more info.
+ EventUtils.synthesizeMouseAtCenter(notesBtn, {}, win);
+ await loading;
+ is(
+ doc.l10n.getAttributes(notes.firstElementChild).id,
+ "release-notes-loading",
+ "The loading message is shown"
+ );
+ await errored;
+ is(
+ doc.l10n.getAttributes(notes.firstElementChild).id,
+ "release-notes-error",
+ "The error message is shown"
+ );
+
+ info("Switch away and back to release notes");
+ // Load details view.
+ let detailsBtn = tabGroup.querySelector('.tab-button[name="details"]');
+ let viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ detailsBtn.click();
+ await viewChanged;
+
+ // Load release notes again, verify they weren't loaded.
+ viewChanged = BrowserTestUtils.waitForEvent(deck, "view-changed");
+ let notesCached = BrowserTestUtils.waitForEvent(
+ notes,
+ "release-notes-cached"
+ );
+ notesBtn.click();
+ await viewChanged;
+ await notesCached;
+
+ info("Install the update to clean it up");
+ await installUpdate(card, "update-installed");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testUpdateCancelled() {
+ let id = "update@mochi.test";
+ let extension = await setupExtensionWithUpdate(id, { cancelUpdate: true });
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ await loadDetailView(win, "update@mochi.test");
+ let card = doc.querySelector("addon-card");
+
+ // Disable updates and then check.
+ disableAutoUpdates(card);
+ await checkForUpdate(card, "update-found");
+
+ // There should now be an update.
+ assertUpdateState({ card, shown: true });
+
+ // The add-on starts as version 1.
+ let versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "1", "The version started as 1");
+
+ // Force the install to be cancelled.
+ let install = card.updateInstall;
+ ok(install, "There was an install found");
+
+ await installUpdate(card, "update-cancelled");
+
+ // The add-on is still version 1.
+ versionRow = card.querySelector(".addon-detail-row-version");
+ is(versionRow.lastChild.textContent, "1", "The version hasn't changed");
+
+ // The update has been removed.
+ assertUpdateState({ card, shown: false });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testAvailableUpdates() {
+ let ids = ["update1@mochi.test", "update2@mochi.test", "update3@mochi.test"];
+ let addons = await Promise.all(ids.map(id => setupExtensionWithUpdate(id)));
+
+ // Disable global add-on updates.
+ AddonManager.autoUpdateDefault = false;
+
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let updatesMessage = doc.getElementById("updates-message");
+ let categoryUtils = new CategoryUtilities(win);
+
+ let availableCat = categoryUtils.get("available-updates");
+
+ ok(availableCat.hidden, "Available updates is hidden");
+ is(availableCat.badgeCount, 0, "There are no updates");
+ ok(updatesMessage, "There is an updates message");
+ is_element_hidden(updatesMessage, "The message is hidden");
+ ok(!updatesMessage.message.textContent, "The message is empty");
+ ok(!updatesMessage.button.textContent, "The button is empty");
+
+ // Check for all updates.
+ let updatesFound = TestUtils.topicObserved("EM-update-check-finished");
+ doc.querySelector('#page-options [action="check-for-updates"]').click();
+
+ is_element_visible(updatesMessage, "The message is visible");
+ ok(!updatesMessage.message.textContent, "The message is empty");
+ ok(updatesMessage.button.hidden, "The view updates button is hidden");
+
+ // Make sure the message gets populated by fluent.
+ await TestUtils.waitForCondition(
+ () => updatesMessage.message.textContent,
+ "wait for message text"
+ );
+
+ await updatesFound;
+
+ // The button should be visible, and should get some text from fluent.
+ ok(!updatesMessage.button.hidden, "The view updates button is visible");
+ await TestUtils.waitForCondition(
+ () => updatesMessage.button.textContent,
+ "wait for button text"
+ );
+
+ // Wait for the available updates count to finalize, it's async.
+ await BrowserTestUtils.waitForCondition(() => availableCat.badgeCount == 3);
+
+ // The category shows the correct update count.
+ ok(!availableCat.hidden, "Available updates is visible");
+ is(availableCat.badgeCount, 3, "There are 3 updates");
+
+ // Go to the available updates page.
+ let loaded = waitForViewLoad(win);
+ availableCat.click();
+ await loaded;
+
+ // Check the updates are shown.
+ let cards = doc.querySelectorAll("addon-card");
+ is(cards.length, 3, "There are 3 cards");
+
+ // Each card should have an update.
+ for (let card of cards) {
+ assertUpdateState({ card, shown: true, expanded: false });
+ }
+
+ // Check the detail page for the first add-on.
+ await loadDetailView(win, ids[0]);
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/extension",
+ "The extensions category is selected"
+ );
+
+ // Go back to the last view.
+ loaded = waitForViewLoad(win);
+ doc.querySelector(".back-button").click();
+ await loaded;
+
+ // We're back on the updates view.
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://updates/available",
+ "The available updates category is selected"
+ );
+
+ // Find the cards again.
+ cards = doc.querySelectorAll("addon-card");
+ is(cards.length, 3, "There are 3 cards");
+
+ // Install the first update.
+ await installUpdate(cards[0], "update-installed");
+ assertUpdateState({ card: cards[0], shown: false, expanded: false });
+
+ // The count goes down but the card stays.
+ is(availableCat.badgeCount, 2, "There are only 2 updates now");
+ is(
+ doc.querySelectorAll("addon-card").length,
+ 3,
+ "All 3 cards are still visible on the updates page"
+ );
+
+ // Install the other two updates.
+ await installUpdate(cards[1], "update-installed");
+ assertUpdateState({ card: cards[1], shown: false, expanded: false });
+ await installUpdate(cards[2], "update-installed");
+ assertUpdateState({ card: cards[2], shown: false, expanded: false });
+
+ // The count goes down but the card stays.
+ is(availableCat.badgeCount, 0, "There are no more updates");
+ is(
+ doc.querySelectorAll("addon-card").length,
+ 3,
+ "All 3 cards are still visible on the updates page"
+ );
+
+ // Enable global add-on updates again.
+ AddonManager.autoUpdateDefault = true;
+
+ await closeView(win);
+ await Promise.all(addons.map(addon => addon.unload()));
+});
+
+add_task(async function testUpdatesShownOnLoad() {
+ let id = "has-update@mochi.test";
+ let addon = await setupExtensionWithUpdate(id);
+
+ // Find the update for our addon.
+ AddonManager.autoUpdateDefault = false;
+ await findUpdatesForAddonId(id);
+
+ let win = await loadInitialView("extension");
+ let categoryUtils = new CategoryUtilities(win);
+ let updatesButton = categoryUtils.get("available-updates");
+
+ ok(!updatesButton.hidden, "The updates button is shown");
+ is(updatesButton.badgeCount, 1, "There is an update");
+
+ let loaded = waitForViewLoad(win);
+ updatesButton.click();
+ await loaded;
+
+ let cards = win.document.querySelectorAll("addon-card");
+
+ is(cards.length, 1, "There is one update card");
+
+ let card = cards[0];
+ is(card.addon.id, id, "The update is for the expected add-on");
+
+ await installUpdate(card, "update-installed");
+
+ ok(!updatesButton.hidden, "The updates button is still shown");
+ is(updatesButton.badgeCount, 0, "There are no more updates");
+
+ info("Check that the updates section is hidden when re-opened");
+ await closeView(win);
+ win = await loadInitialView("extension");
+ categoryUtils = new CategoryUtilities(win);
+ updatesButton = categoryUtils.get("available-updates");
+
+ ok(updatesButton.hidden, "Available updates is hidden");
+ is(updatesButton.badgeCount, 0, "There are no updates");
+
+ AddonManager.autoUpdateDefault = true;
+ await closeView(win);
+ await addon.unload();
+});
+
+add_task(async function testPromptOnBackgroundUpdateCheck() {
+ const id = "test-prompt-on-background-check@mochi.test";
+ const extension = await setupExtensionWithUpdate(id);
+
+ AddonManager.autoUpdateDefault = false;
+
+ const addon = await AddonManager.getAddonByID(id);
+ await AddonTestUtils.promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ let win = await loadInitialView("extension");
+
+ let card = getAddonCard(win, id);
+
+ const promisePromptInfo = promisePermissionPrompt(id);
+ await installUpdate(card, "update-installed");
+ const promptInfo = await promisePromptInfo;
+ ok(promptInfo, "Got a permission prompt as expected");
+
+ AddonManager.autoUpdateDefault = true;
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testNoUpdateAvailableOnUnrelatedAddonCards() {
+ let idNoUpdate = "no-update@mochi.test";
+
+ let extensionNoUpdate = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "TestAddonNoUpdate",
+ browser_specific_settings: { gecko: { id: idNoUpdate } },
+ },
+ });
+ await extensionNoUpdate.startup();
+
+ let win = await loadInitialView("extension");
+
+ let cardNoUpdate = getAddonCard(win, idNoUpdate);
+ ok(cardNoUpdate, `Got AddonCard for ${idNoUpdate}`);
+
+ // Assert that there is not an update badge
+ assertUpdateState({ card: cardNoUpdate, shown: false, expanded: false });
+
+ // Trigger a onNewInstall event by install another unrelated addon.
+ const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+ let install = await AddonManager.getInstallForURL(XPI_URL);
+ await AddonManager.installAddonFromAOM(
+ gBrowser.selectedBrowser,
+ win.document.documentURIObject,
+ install
+ );
+
+ // Cancel the install used to trigger the onNewInstall install event.
+ await install.cancel();
+ // Assert that the previously installed addon isn't marked with the
+ // update available badge after installing an unrelated addon.
+ assertUpdateState({ card: cardNoUpdate, shown: false, expanded: false });
+
+ await closeView(win);
+ await extensionNoUpdate.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js b/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js
new file mode 100644
index 0000000000..de34cff82b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_warning_messages.js
@@ -0,0 +1,290 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint max-len: ["error", 80] */
+
+"use strict";
+
+let gProvider;
+const { STATE_BLOCKED, STATE_SOFTBLOCKED } = Ci.nsIBlocklistService;
+
+const appVersion = Services.appinfo.version;
+const SUPPORT_URL = Services.urlFormatter.formatURL(
+ Services.prefs.getStringPref("app.support.baseURL")
+);
+
+add_setup(async function () {
+ gProvider = new MockProvider();
+});
+
+async function checkMessageState(id, addonType, expected) {
+ async function checkAddonCard() {
+ let card = doc.querySelector(`addon-card[addon-id="${id}"]`);
+ let messageBar = card.querySelector(".addon-card-message");
+
+ if (!expected) {
+ ok(messageBar.hidden, "message is hidden");
+ } else {
+ const { linkUrl, text, type } = expected;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ messageBar,
+ { attributes: true },
+ () => !messageBar.hidden
+ );
+ ok(!messageBar.hidden, "message is visible");
+
+ is(messageBar.getAttribute("type"), type, "message has the right type");
+ Assert.deepEqual(
+ document.l10n.getAttributes(messageBar),
+ { id: `${text.id}2`, args: text.args },
+ "message l10n data is set correctly"
+ );
+
+ const link = messageBar.querySelector("button");
+ if (linkUrl) {
+ ok(!link.hidden, "link is visible");
+ is(
+ link.getAttribute("data-l10n-id"),
+ `${text.id}-link`,
+ "link l10n id is correct"
+ );
+ const newTab = BrowserTestUtils.waitForNewTab(gBrowser, linkUrl);
+ link.click();
+ BrowserTestUtils.removeTab(await newTab);
+ } else {
+ ok(link.hidden, "link is hidden");
+ }
+ }
+
+ return card;
+ }
+
+ let win = await loadInitialView(addonType);
+ let doc = win.document;
+
+ // Check the list view.
+ ok(doc.querySelector("addon-list"), "this is a list view");
+ let card = await checkAddonCard();
+
+ // Load the detail view.
+ let loaded = waitForViewLoad(win);
+ card.querySelector('[action="expand"]').click();
+ await loaded;
+
+ // Check the detail view.
+ ok(!doc.querySelector("addon-list"), "this isn't a list view");
+ await checkAddonCard();
+
+ await closeView(win);
+}
+
+add_task(async function testNoMessageExtension() {
+ let id = "no-message@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: { browser_specific_settings: { gecko: { id } } },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ await checkMessageState(id, "extension", null);
+
+ await extension.unload();
+});
+
+add_task(async function testNoMessageLangpack() {
+ let id = "no-message@mochi.test";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ name: "Signed Langpack",
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ type: "locale",
+ },
+ ]);
+
+ await checkMessageState(id, "locale", null);
+});
+
+add_task(async function testBlocked() {
+ const id = "blocked@mochi.test";
+ const linkUrl = "https://example.com/addon-blocked";
+ const name = "Blocked";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ blocklistState: STATE_BLOCKED,
+ blocklistURL: linkUrl,
+ id,
+ isActive: false,
+ name,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl,
+ text: { id: "details-notification-blocked", args: { name } },
+ type: "error",
+ });
+});
+
+add_task(async function testUnsignedDisabled() {
+ // This pref being disabled will cause the `specialpowers` addon to be
+ // uninstalled, which can cause a number of test failures due to features no
+ // longer working correctly.
+ // In order to avoid those issues, this code manually disables the pref, and
+ // ensures that `SpecialPowers` is fully re-enabled at the end of the test.
+ const sigPref = "xpinstall.signatures.required";
+ Services.prefs.setBoolPref(sigPref, true);
+
+ const id = "unsigned@mochi.test";
+ const name = "Unsigned";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned-and-disabled", args: { name } },
+ type: "error",
+ });
+
+ // Ensure that `SpecialPowers` is fully re-initialized at the end of this
+ // test. This requires removing the existing binding so that it's
+ // re-registered, re-enabling unsigned extensions, and then waiting for the
+ // actor to be registered and ready.
+ delete window.SpecialPowers;
+ Services.prefs.setBoolPref(sigPref, false);
+ await TestUtils.waitForCondition(() => {
+ try {
+ return !!windowGlobalChild.getActor("SpecialPowers");
+ } catch (e) {
+ return false;
+ }
+ }, "wait for SpecialPowers to be reloaded");
+ ok(window.SpecialPowers, "SpecialPowers should be re-defined");
+});
+
+add_task(async function testUnsignedLangpackDisabled() {
+ const id = "unsigned-langpack@mochi.test";
+ const name = "Unsigned";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ type: "locale",
+ },
+ ]);
+ await checkMessageState(id, "locale", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned-and-disabled", args: { name } },
+ type: "error",
+ });
+});
+
+add_task(async function testIncompatible() {
+ const id = "incompatible@mochi.test";
+ const name = "Incompatible";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ id,
+ isActive: false,
+ isCompatible: false,
+ name,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ text: {
+ id: "details-notification-incompatible",
+ args: { name, version: appVersion },
+ },
+ type: "error",
+ });
+});
+
+add_task(async function testUnsignedEnabled() {
+ const id = "unsigned-allowed@mochi.test";
+ const name = "Unsigned";
+ gProvider.createAddons([
+ {
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned", args: { name } },
+ type: "warning",
+ });
+});
+
+add_task(async function testUnsignedLangpackEnabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.langpacks.signatures.required", false]],
+ });
+
+ const id = "unsigned-allowed-langpack@mochi.test";
+ const name = "Unsigned Langpack";
+ gProvider.createAddons([
+ {
+ id,
+ name,
+ signedState: AddonManager.SIGNEDSTATE_MISSING,
+ type: "locale",
+ },
+ ]);
+ await checkMessageState(id, "locale", {
+ linkUrl: SUPPORT_URL + "unsigned-addons",
+ text: { id: "details-notification-unsigned", args: { name } },
+ type: "warning",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testSoftBlocked() {
+ const id = "softblocked@mochi.test";
+ const linkUrl = "https://example.com/addon-blocked";
+ const name = "Soft Blocked";
+ gProvider.createAddons([
+ {
+ appDisabled: true,
+ blocklistState: STATE_SOFTBLOCKED,
+ blocklistURL: linkUrl,
+ id,
+ isActive: false,
+ name,
+ },
+ ]);
+ await checkMessageState(id, "extension", {
+ linkUrl,
+ text: { id: "details-notification-softblocked", args: { name } },
+ type: "warning",
+ });
+});
+
+add_task(async function testPluginInstalling() {
+ const id = "plugin-installing@mochi.test";
+ const name = "Plugin Installing";
+ gProvider.createAddons([
+ {
+ id,
+ isActive: true,
+ isGMPlugin: true,
+ isInstalled: false,
+ name,
+ type: "plugin",
+ },
+ ]);
+ await checkMessageState(id, "plugin", {
+ text: { id: "details-notification-gmp-pending", args: { name } },
+ type: "warning",
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_installssl.js b/toolkit/mozapps/extensions/test/browser/browser_installssl.js
new file mode 100644
index 0000000000..4469b846bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_installssl.js
@@ -0,0 +1,378 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const xpi = RELATIVE_DIR + "addons/browser_installssl.xpi";
+const redirect = RELATIVE_DIR + "redirect.sjs?";
+const SUCCESS = 0;
+const NETWORK_FAILURE = AddonManager.ERROR_NETWORK_FAILURE;
+
+const HTTP = "http://example.com/";
+const HTTPS = "https://example.com/";
+const NOCERT = "https://nocert.example.com/";
+const SELFSIGNED = "https://self-signed.example.com/";
+const UNTRUSTED = "https://untrusted.example.com/";
+const EXPIRED = "https://expired.example.com/";
+
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+
+var gTests = [];
+var gStart = 0;
+var gLast = 0;
+var gPendingInstall = null;
+
+function test() {
+ gStart = Date.now();
+ requestLongerTimeout(4);
+ waitForExplicitFinish();
+
+ registerCleanupFunction(function () {
+ var cos = Cc["@mozilla.org/security/certoverride;1"].getService(
+ Ci.nsICertOverrideService
+ );
+ cos.clearValidityOverride("nocert.example.com", -1, {});
+ cos.clearValidityOverride("self-signed.example.com", -1, {});
+ cos.clearValidityOverride("untrusted.example.com", -1, {});
+ cos.clearValidityOverride("expired.example.com", -1, {});
+
+ if (gPendingInstall) {
+ gTests = [];
+ ok(
+ false,
+ "Timed out in the middle of downloading " +
+ gPendingInstall.sourceURI.spec
+ );
+ try {
+ gPendingInstall.cancel();
+ } catch (e) {}
+ }
+ });
+
+ run_next_test();
+}
+
+function end_test() {
+ info("All tests completed in " + (Date.now() - gStart) + "ms");
+ finish();
+}
+
+function add_install_test(mainURL, redirectURL, expectedStatus) {
+ gTests.push([mainURL, redirectURL, expectedStatus]);
+}
+
+function run_install_tests(callback) {
+ async function run_next_install_test() {
+ if (!gTests.length) {
+ callback();
+ return;
+ }
+ gLast = Date.now();
+
+ let [mainURL, redirectURL, expectedStatus] = gTests.shift();
+ if (redirectURL) {
+ var url = mainURL + redirect + redirectURL + xpi;
+ var message =
+ "Should have seen the right result for an install redirected from " +
+ mainURL +
+ " to " +
+ redirectURL;
+ } else {
+ url = mainURL + xpi;
+ message =
+ "Should have seen the right result for an install from " + mainURL;
+ }
+
+ let install = await AddonManager.getInstallForURL(url);
+ gPendingInstall = install;
+ install.addListener({
+ onDownloadEnded(install) {
+ is(SUCCESS, expectedStatus, message);
+ info("Install test ran in " + (Date.now() - gLast) + "ms");
+ // Don't proceed with the install
+ install.cancel();
+ gPendingInstall = null;
+ run_next_install_test();
+ return false;
+ },
+
+ onDownloadFailed(install) {
+ is(install.error, expectedStatus, message);
+ info("Install test ran in " + (Date.now() - gLast) + "ms");
+ gPendingInstall = null;
+ run_next_install_test();
+ },
+ });
+ install.install();
+ }
+
+ run_next_install_test();
+}
+
+// Runs tests with built-in certificates required, no certificate exceptions
+// and no hashes
+add_test(async function test_builtin_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, true]],
+ });
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, NETWORK_FAILURE);
+ add_install_test(NOCERT, null, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, null, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, null, NETWORK_FAILURE);
+ add_install_test(EXPIRED, null, NETWORK_FAILURE);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTP, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTP, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTP, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, NETWORK_FAILURE);
+ add_install_test(HTTPS, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTPS, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTPS, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTPS, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, NETWORK_FAILURE);
+ add_install_test(NOCERT, NOCERT, NETWORK_FAILURE);
+ add_install_test(NOCERT, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(NOCERT, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(NOCERT, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, NOCERT, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, NOCERT, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, NETWORK_FAILURE);
+ add_install_test(EXPIRED, NOCERT, NETWORK_FAILURE);
+ add_install_test(EXPIRED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, EXPIRED, NETWORK_FAILURE);
+
+ run_install_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates, no certificate
+// exceptions and no hashes
+add_test(async function test_builtin_not_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, SUCCESS);
+ add_install_test(NOCERT, null, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, null, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, null, NETWORK_FAILURE);
+ add_install_test(EXPIRED, null, NETWORK_FAILURE);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTP, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTP, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTP, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, SUCCESS);
+ add_install_test(HTTPS, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTPS, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTPS, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTPS, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, NETWORK_FAILURE);
+ add_install_test(NOCERT, NOCERT, NETWORK_FAILURE);
+ add_install_test(NOCERT, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(NOCERT, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(NOCERT, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, NOCERT, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, NOCERT, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, NETWORK_FAILURE);
+ add_install_test(EXPIRED, NOCERT, NETWORK_FAILURE);
+ add_install_test(EXPIRED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, EXPIRED, NETWORK_FAILURE);
+
+ run_install_tests(run_next_test);
+});
+
+// Set up overrides for the next test.
+add_test(() => {
+ addCertOverrides().then(run_next_test);
+});
+
+// Runs tests with built-in certificates required, all certificate exceptions
+// and no hashes
+add_test(async function test_builtin_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, true]],
+ });
+
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, NETWORK_FAILURE);
+ add_install_test(NOCERT, null, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, null, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, null, NETWORK_FAILURE);
+ add_install_test(EXPIRED, null, NETWORK_FAILURE);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, SUCCESS);
+ add_install_test(HTTP, SELFSIGNED, SUCCESS);
+ add_install_test(HTTP, UNTRUSTED, SUCCESS);
+ add_install_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, NETWORK_FAILURE);
+ add_install_test(HTTPS, NOCERT, NETWORK_FAILURE);
+ add_install_test(HTTPS, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(HTTPS, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(HTTPS, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, NETWORK_FAILURE);
+ add_install_test(NOCERT, NOCERT, NETWORK_FAILURE);
+ add_install_test(NOCERT, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(NOCERT, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(NOCERT, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, NOCERT, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, NOCERT, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, EXPIRED, NETWORK_FAILURE);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, NETWORK_FAILURE);
+ add_install_test(EXPIRED, NOCERT, NETWORK_FAILURE);
+ add_install_test(EXPIRED, SELFSIGNED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, UNTRUSTED, NETWORK_FAILURE);
+ add_install_test(EXPIRED, EXPIRED, NETWORK_FAILURE);
+
+ run_install_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates, all certificate
+// exceptions and no hashes
+add_test(async function test_builtin_not_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_INSTALL_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple install works as expected.
+ add_install_test(HTTP, null, SUCCESS);
+ add_install_test(HTTPS, null, SUCCESS);
+ add_install_test(NOCERT, null, SUCCESS);
+ add_install_test(SELFSIGNED, null, SUCCESS);
+ add_install_test(UNTRUSTED, null, SUCCESS);
+ add_install_test(EXPIRED, null, SUCCESS);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_install_test(HTTP, HTTP, SUCCESS);
+ add_install_test(HTTP, HTTPS, SUCCESS);
+ add_install_test(HTTP, NOCERT, SUCCESS);
+ add_install_test(HTTP, SELFSIGNED, SUCCESS);
+ add_install_test(HTTP, UNTRUSTED, SUCCESS);
+ add_install_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_install_test(HTTPS, HTTP, NETWORK_FAILURE);
+ add_install_test(HTTPS, HTTPS, SUCCESS);
+ add_install_test(HTTPS, NOCERT, SUCCESS);
+ add_install_test(HTTPS, SELFSIGNED, SUCCESS);
+ add_install_test(HTTPS, UNTRUSTED, SUCCESS);
+ add_install_test(HTTPS, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_install_test(NOCERT, HTTP, NETWORK_FAILURE);
+ add_install_test(NOCERT, HTTPS, SUCCESS);
+ add_install_test(NOCERT, NOCERT, SUCCESS);
+ add_install_test(NOCERT, SELFSIGNED, SUCCESS);
+ add_install_test(NOCERT, UNTRUSTED, SUCCESS);
+ add_install_test(NOCERT, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_install_test(SELFSIGNED, HTTP, NETWORK_FAILURE);
+ add_install_test(SELFSIGNED, HTTPS, SUCCESS);
+ add_install_test(SELFSIGNED, NOCERT, SUCCESS);
+ add_install_test(SELFSIGNED, SELFSIGNED, SUCCESS);
+ add_install_test(SELFSIGNED, UNTRUSTED, SUCCESS);
+ add_install_test(SELFSIGNED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_install_test(UNTRUSTED, HTTP, NETWORK_FAILURE);
+ add_install_test(UNTRUSTED, HTTPS, SUCCESS);
+ add_install_test(UNTRUSTED, NOCERT, SUCCESS);
+ add_install_test(UNTRUSTED, SELFSIGNED, SUCCESS);
+ add_install_test(UNTRUSTED, UNTRUSTED, SUCCESS);
+ add_install_test(UNTRUSTED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_install_test(EXPIRED, HTTP, NETWORK_FAILURE);
+ add_install_test(EXPIRED, HTTPS, SUCCESS);
+ add_install_test(EXPIRED, NOCERT, SUCCESS);
+ add_install_test(EXPIRED, SELFSIGNED, SUCCESS);
+ add_install_test(EXPIRED, UNTRUSTED, SUCCESS);
+ add_install_test(EXPIRED, EXPIRED, SUCCESS);
+
+ run_install_tests(run_next_test);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js b/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js
new file mode 100644
index 0000000000..1d50da2833
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_installtrigger_install.js
@@ -0,0 +1,362 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+const XPI_ADDON_ID = "amosigned-xpi@tests.mozilla.org";
+
+AddonTestUtils.initMochitest(this);
+
+AddonTestUtils.hookAMTelemetryEvents();
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ PermissionTestUtils.add(
+ "https://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ registerCleanupFunction(async () => {
+ PermissionTestUtils.remove("https://example.com", "install");
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+async function testInstallTrigger(
+ msg,
+ tabURL,
+ contentFnArgs,
+ contentFn,
+ expectedTelemetryInfo,
+ expectBlockedOrigin
+) {
+ // Clear collected events before each test, otherwise the test would fail
+ // intermittently when Glean is going to submit the events and clear them
+ // after reaching the max events length limit.
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(tabURL, async browser => {
+ if (expectBlockedOrigin) {
+ const promiseOriginBlocked = TestUtils.topicObserved(
+ "addon-install-origin-blocked"
+ );
+ await SpecialPowers.spawn(browser, contentFnArgs, contentFn);
+ const [subject] = await promiseOriginBlocked;
+ const installId = subject.wrappedJSObject.installs[0].installId;
+
+ let gleanEvents = AddonTestUtils.getAMGleanEvents("install", {
+ install_id: `${installId}`,
+ step: "site_blocked",
+ });
+ ok(!!gleanEvents.length, "Found Glean events for the blocked install.");
+ Assert.deepEqual(
+ { source: gleanEvents[0].source },
+ expectedTelemetryInfo,
+ `Got expected Glean telemetry on test case "${msg}"`
+ );
+
+ // Select all telemetry events related to the installId.
+ const telemetryEvents = AddonTestUtils.getAMTelemetryEvents().filter(
+ ev => {
+ return (
+ ev.method === "install" &&
+ ev.value === `${installId}` &&
+ ev.extra.step === "site_blocked"
+ );
+ }
+ );
+ ok(
+ !!telemetryEvents.length,
+ "Found telemetry events for the blocked install"
+ );
+
+ const source = telemetryEvents[0]?.extra.source;
+ Assert.deepEqual(
+ { source },
+ expectedTelemetryInfo,
+ `Got expected telemetry on test case "${msg}"`
+ );
+ return;
+ }
+
+ let installPromptPromise = promisePopupNotificationShown(
+ "addon-webext-permissions"
+ ).then(panel => {
+ panel.button.click();
+ });
+
+ let promptPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ XPI_ADDON_ID
+ );
+
+ await SpecialPowers.spawn(browser, contentFnArgs, contentFn);
+
+ await Promise.all([installPromptPromise, promptPromise]);
+
+ let addon = await promiseAddonByID(XPI_ADDON_ID);
+
+ registerCleanupFunction(async () => {
+ await addon.uninstall();
+ });
+
+ // Check that the expected installTelemetryInfo has been stored in the
+ // addon details.
+ AddonTestUtils.checkInstallInfo(
+ addon,
+ { method: "installTrigger", ...expectedTelemetryInfo },
+ `on "${msg}"`
+ );
+
+ await addon.uninstall();
+ });
+}
+
+add_task(function testInstallAfterHistoryPushState() {
+ return testInstallTrigger(
+ "InstallTrigger after history.pushState",
+ SECURE_TESTROOT,
+ [SECURE_TESTROOT, XPI_URL],
+ (secureTestRoot, xpiURL) => {
+ // `sourceURL` should match the exact location, even after a location
+ // update using the history API. In this case, we update the URL with
+ // query parameters and expect `sourceURL` to contain those parameters.
+ content.history.pushState(
+ {}, // state
+ "", // title
+ `${secureTestRoot}?some=query&par=am`
+ );
+ content.InstallTrigger.install({ URL: xpiURL });
+ },
+ {
+ source: "test-host",
+ sourceURL:
+ "https://example.com/browser/toolkit/mozapps/extensions/test/browser/?some=query&par=am",
+ }
+ );
+});
+
+add_task(async function testInstallTriggerFromSubframe() {
+ function runTestCase(msg, tabURL, testFrameAttrs, expected) {
+ info(
+ `InstallTrigger from iframe test: ${msg} - frame attributes ${JSON.stringify(
+ testFrameAttrs
+ )}`
+ );
+ return testInstallTrigger(
+ msg,
+ tabURL,
+ [XPI_URL, testFrameAttrs],
+ async (xpiURL, frameAttrs) => {
+ const frame = content.document.createElement("iframe");
+ if (frameAttrs) {
+ for (const attr of Object.keys(frameAttrs)) {
+ let value = frameAttrs[attr];
+ if (value === "blob:") {
+ const blob = new content.Blob(["blob-testpage"]);
+ value = content.URL.createObjectURL(blob, "text/html");
+ }
+ frame[attr] = value;
+ }
+ }
+ const promiseLoaded = new Promise(resolve =>
+ frame.addEventListener("load", resolve, { once: true })
+ );
+ content.document.body.appendChild(frame);
+ await promiseLoaded;
+ frame.contentWindow.InstallTrigger.install({ URL: xpiURL });
+ },
+ expected.telemetryInfo,
+ expected.blockedOrigin
+ );
+ }
+
+ // On Windows "file:///" does not load the default files index html page
+ // and the test would get stuck.
+ const fileURL = AppConstants.platform === "win" ? "file:///C:/" : "file:///";
+
+ const expected = {
+ http: {
+ telemetryInfo: {
+ source: "test-host",
+ sourceURL:
+ "https://example.com/browser/toolkit/mozapps/extensions/test/browser/",
+ },
+ blockedOrigin: false,
+ },
+ httpBlob: {
+ telemetryInfo: {
+ source: "test-host",
+ // Example: "blob:https://example.com/BLOB_URL_UUID"
+ sourceURL: /^blob:https:\/\/example\.com\//,
+ },
+ blockedOrigin: false,
+ },
+ file: {
+ telemetryInfo: {
+ source: "unknown",
+ sourceURL: fileURL,
+ },
+ blockedOrigin: false,
+ },
+ fileBlob: {
+ telemetryInfo: {
+ source: "unknown",
+ // Example: "blob:null/BLOB_URL_UUID"
+ sourceURL: /^blob:null\//,
+ },
+ blockedOrigin: false,
+ },
+ httpBlockedOnOrigin: {
+ telemetryInfo: {
+ source: "test-host",
+ },
+ blockedOrigin: true,
+ },
+ otherBlockedOnOrigin: {
+ telemetryInfo: {
+ source: "unknown",
+ },
+ blockedOrigin: true,
+ },
+ };
+
+ const testCases = [
+ ["blank iframe with no attributes", SECURE_TESTROOT, {}, expected.http],
+
+ // These are blocked by a Firefox doorhanger and the user can't allow it neither.
+ [
+ "http page iframe src='blob:...'",
+ SECURE_TESTROOT,
+ { src: "blob:" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "file page iframe src='blob:...'",
+ fileURL,
+ { src: "blob:" },
+ expected.otherBlockedOnOrigin,
+ ],
+ [
+ "iframe srcdoc=''",
+ SECURE_TESTROOT,
+ { srcdoc: "" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "blank iframe embedded into a top-level sandbox page",
+ `${SECURE_TESTROOT}sandboxed.html`,
+ {},
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "blank iframe with sandbox='allow-scripts'",
+ SECURE_TESTROOT,
+ { sandbox: "allow-scripts" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "iframe srcdoc='' sandbox='allow-scripts'",
+ SECURE_TESTROOT,
+ { srcdoc: "", sandbox: "allow-scripts" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "http page iframe src='blob:...' sandbox='allow-scripts'",
+ SECURE_TESTROOT,
+ { src: "blob:", sandbox: "allow-scripts" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "iframe src='data:...'",
+ SECURE_TESTROOT,
+ { src: "data:text/html,data-testpage" },
+ expected.httpBlockedOnOrigin,
+ ],
+ [
+ "blank frame embedded in a data url",
+ "data:text/html,data-testpage",
+ {},
+ expected.otherBlockedOnOrigin,
+ ],
+ [
+ "blank frame embedded into a about:blank page",
+ "about:blank",
+ {},
+ expected.otherBlockedOnOrigin,
+ ],
+ ];
+
+ for (const testCase of testCases) {
+ await runTestCase(...testCase);
+ }
+});
+
+add_task(function testInstallBlankFrameNestedIntoBlobURLPage() {
+ return testInstallTrigger(
+ "Blank frame nested into a blob url page",
+ SECURE_TESTROOT,
+ [XPI_URL],
+ async xpiURL => {
+ const url = content.URL.createObjectURL(
+ new content.Blob(["blob-testpage"]),
+ "text/html"
+ );
+ const topframe = content.document.createElement("iframe");
+ topframe.src = url;
+ const topframeLoaded = new Promise(resolve => {
+ topframe.addEventListener("load", resolve, { once: true });
+ });
+ content.document.body.appendChild(topframe);
+ await topframeLoaded;
+ const subframe = topframe.contentDocument.createElement("iframe");
+ topframe.contentDocument.body.appendChild(subframe);
+ subframe.contentWindow.InstallTrigger.install({ URL: xpiURL });
+ },
+ {
+ source: "test-host",
+ },
+ /* expectBlockedOrigin */ true
+ );
+});
+
+add_task(function testInstallTriggerTopLevelDataURL() {
+ return testInstallTrigger(
+ "Blank frame nested into a blob url page",
+ "data:text/html,testpage",
+ [XPI_URL],
+ async xpiURL => {
+ this.content.InstallTrigger.install({ URL: xpiURL });
+ },
+ {
+ source: "unknown",
+ },
+ /* expectBlockedOrigin */ true
+ );
+});
+
+add_task(function teardown_clearUnexamitedTelemetry() {
+ // Clear collected telemetry events when we are not going to run any assertion on them.
+ // (otherwise the test will fail because of unexamined telemetry events).
+ AddonTestUtils.getAMTelemetryEvents();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_local_install.js b/toolkit/mozapps/extensions/test/browser/browser_local_install.js
new file mode 100644
index 0000000000..5200b69e39
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_local_install.js
@@ -0,0 +1,245 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const XPI_INCOMPATIBLE_ID = "incompatible-xpi@tests.mozilla.org";
+// NOTE: we are using an HTTP url on purpose here, the test case fails
+// otherwise... We disable `AddonManager.checkUpdateSecurity` to allow
+// retrieving updates from HTTP (which is restored in a
+// `registerCleanupFunction()` or at the end of the task).
+//
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const BASE_URL = "http://fake-updates.example.com";
+
+const server = AddonTestUtils.createHttpServer({
+ hosts: ["fake-updates.example.com"],
+});
+
+const UPDATE_ENTRY_COMPATIBLE = {
+ // NOTE: this version must be the exact same one associated than the
+ // initially incompatible XPI, otherwise it won't override the initial
+ // compatibility range.
+ // See the check in `AddonUpdateChecker.getCompatibilityUpdate` here:
+ // https://searchfox.org/mozilla-central/rev/4044c340/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs#489
+ version: "4.0",
+ // An empty compatibility range will make this update to be overriding the
+ // incompatible range in the xpi and makes the xpi version to be considered
+ // compatible.
+ applications: { gecko: {} },
+};
+
+const UPDATE_ENTRY_INCOMPATIBLE = {
+ ...UPDATE_ENTRY_COMPATIBLE,
+ // This update entry instead is including a compatibility range that would
+ // makes the xpi version being installed to be considered still incompatible.
+ applications: {
+ gecko: {
+ strict_min_version: "41",
+ strict_max_version: "41.*",
+ },
+ },
+};
+
+AddonTestUtils.registerJSON(server, "/updates-still-incompatible.json", {
+ addons: {
+ [XPI_INCOMPATIBLE_ID]: {
+ updates: [UPDATE_ENTRY_INCOMPATIBLE],
+ },
+ },
+});
+
+AddonTestUtils.registerJSON(server, "/updates-now-compatible.json", {
+ addons: {
+ [XPI_INCOMPATIBLE_ID]: {
+ updates: [UPDATE_ENTRY_COMPATIBLE],
+ },
+ },
+});
+
+add_task(async function test_local_install_blocklisted() {
+ let id = "amosigned-xpi@tests.mozilla.org";
+ let version = "2.1";
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {
+ stash: { blocked: [`${id}:${version}`], unblocked: [] },
+ stash_time: 0,
+ },
+ ],
+ });
+ let needsCleanupBlocklist = true;
+ const cleanupBlocklist = async () => {
+ if (!needsCleanupBlocklist) {
+ return;
+ }
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {
+ stash: { blocked: [], unblocked: [] },
+ stash_time: 0,
+ },
+ ],
+ });
+ needsCleanupBlocklist = false;
+ };
+ registerCleanupFunction(cleanupBlocklist);
+
+ const xpiFilePath = getTestFilePath("../xpinstall/amosigned.xpi");
+ const xpiFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ xpiFile.initWithPath(xpiFilePath);
+ ok(xpiFile.exists(), "Expect the xpi file to exist");
+ const xpiFileURI = Services.io.newFileURI(xpiFile);
+
+ let install = await AddonManager.getInstallForURL(xpiFileURI.spec, {
+ telemetryInfo: { source: "file-url" },
+ });
+ const promiseInstallFailed = BrowserUtils.promiseObserved(
+ "addon-install-failed",
+ subject => {
+ return subject.wrappedJSObject.installs[0] == install;
+ }
+ );
+
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ gBrowser.selectedBrowser,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ install
+ );
+
+ info("Wait for addon-install-failed to be notified");
+ await promiseInstallFailed;
+ Assert.equal(
+ install.error,
+ AddonManager.ERROR_BLOCKLISTED,
+ "LocalInstall cancelled with the expected error"
+ );
+
+ await cleanupBlocklist();
+});
+
+add_task(async function test_local_install_incompatible() {
+ const xpiFilePath = getTestFilePath("../xpinstall/incompatible.xpi");
+ const xpiFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ xpiFile.initWithPath(xpiFilePath);
+ ok(xpiFile.exists(), "Expect the xpi file to exist");
+ const xpiFileURI = Services.io.newFileURI(xpiFile);
+
+ const installTestExtension = async ({ expectIncompatible }) => {
+ let install = await AddonManager.getInstallForURL(xpiFileURI.spec, {
+ telemetryInfo: { source: "file-url" },
+ });
+ const promiseInstallDone = expectIncompatible
+ ? BrowserUtils.promiseObserved(
+ "addon-install-failed",
+ subject => subject.wrappedJSObject.installs[0] == install
+ )
+ : BrowserUtils.promiseObserved(
+ "webextension-permission-prompt",
+ subject => subject.wrappedJSObject.info.addon == install.addon
+ );
+
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ gBrowser.selectedBrowser,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ install
+ );
+
+ if (expectIncompatible) {
+ info("Wait for addon-install-failed to be notified");
+ await promiseInstallDone;
+ Assert.equal(
+ install.error,
+ AddonManager.ERROR_INCOMPATIBLE,
+ "LocalInstall cancelled with the expected error"
+ );
+ } else {
+ info("Wait for webextension-permission-prompt to be notified");
+ await promiseInstallDone;
+ Assert.equal(
+ install.error,
+ 0,
+ "no error expected on the LocalInstall instance"
+ );
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_DOWNLOADED,
+ "Got the expected LocalInstall state"
+ );
+ Assert.ok(
+ install.addon.isCompatible,
+ "updated Addon XPI is expected to be compatible"
+ );
+ Assert.equal(
+ install.addon.version,
+ "4.0",
+ "Addon version expected to match the updated xpi file"
+ );
+ // Cancel the installation, before exiting the test.
+ await install.cancel();
+ }
+ };
+
+ info("Test incompatible xpi without a compatibility override");
+ // Use a new tab to make sure the doorhanger will be gone when
+ // the test tab is being removed (same when repeating the
+ // test with expectIncompatible set to false).
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ await installTestExtension({ expectIncompatible: true });
+ });
+
+ // Add the prefs to ignore signature checks for this test (allowed on all
+ // channels while running in automation).
+ SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.update.url", `${BASE_URL}/updates.json`],
+ ["xpinstall.signatures.required", false],
+ ["extensions.ui.ignoreUnsigned", true],
+ ],
+ });
+ AddonManager.checkUpdateSecurity = false;
+ registerCleanupFunction(() => {
+ AddonManager.checkUpdateSecurity = true;
+ });
+
+ info(
+ "Test incompatible xpi with a compatibility override that is still incompatible"
+ );
+ // Add the prefs to provide a compatibility range override which is still
+ // incompatible.
+ SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.update.url", `${BASE_URL}/updates-still-incompatible.json`],
+ ],
+ });
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ await installTestExtension({ expectIncompatible: true });
+ });
+ SpecialPowers.popPrefEnv();
+
+ info(
+ "Test incompatible xpi with a compatibility override that makes it compatible"
+ );
+ // Add the prefs to provide a compatibility range override which is
+ // compatible.
+ SpecialPowers.pushPrefEnv({
+ set: [["extensions.update.url", `${BASE_URL}/updates-now-compatible.json`]],
+ });
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ await installTestExtension({ expectIncompatible: false });
+ });
+ SpecialPowers.popPrefEnv();
+
+ SpecialPowers.popPrefEnv();
+ AddonManager.checkUpdateSecurity = true;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js
new file mode 100644
index 0000000000..aee47dd049
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js
@@ -0,0 +1,331 @@
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+function extensionShortcutsReady(id) {
+ let extension = WebExtensionPolicy.getByID(id).extension;
+ return BrowserTestUtils.waitForCondition(() => {
+ return extension.shortcuts.keysetsMap.has(window);
+ }, "Wait for add-on keyset to be registered");
+}
+
+async function loadShortcutsView() {
+ // Load the theme view initially so we can verify that the category is switched
+ // to "extension" when the shortcuts view is loaded.
+ let win = await loadInitialView("theme");
+ let categoryUtils = new CategoryUtilities(win);
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/theme",
+ "The theme category is selected"
+ );
+
+ let shortcutsLink = win.document.querySelector(
+ '#page-options [action="manage-shortcuts"]'
+ );
+ ok(!shortcutsLink.hidden, "The shortcuts link is visible");
+
+ let loaded = waitForViewLoad(win);
+ shortcutsLink.click();
+ await loaded;
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ "addons://list/extension",
+ "The extension category is now selected"
+ );
+
+ return win;
+}
+
+add_task(async function testUpdatingCommands() {
+ let commands = {
+ commandZero: {},
+ commandOne: {
+ suggested_key: { default: "Shift+Alt+7" },
+ },
+ commandTwo: {
+ description: "Command Two!",
+ suggested_key: { default: "Alt+4" },
+ },
+ _execute_browser_action: {
+ suggested_key: { default: "Shift+Alt+9" },
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands,
+ browser_action: { default_popup: "popup.html" },
+ },
+ background() {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.sendMessage("oncommand", commandName);
+ });
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extensionShortcutsReady(extension.id);
+
+ async function checkShortcut(name, key, modifiers) {
+ EventUtils.synthesizeKey(key, modifiers);
+ let message = await extension.awaitMessage("oncommand");
+ is(
+ message,
+ name,
+ `Expected onCommand listener to fire with the correct name: ${name}`
+ );
+ }
+
+ // Load the about:addons shortcut view before verify that emitting
+ // the key events does trigger the expected extension commands.
+ // There is apparently a race (more likely to be triggered on an
+ // optimized build) between:
+ // - the new opened browser window to be ready to listen for the
+ // keyboard events that are expected to triggered one of the key
+ // in the extension keyset
+ // - and the test calling EventUtils.syntesizeKey to test that
+ // the expected extension command listener is notified.
+ //
+ // Loading the shortcut view before calling checkShortcut seems to be
+ // enough to consistently avoid that race condition.
+ let win = await loadShortcutsView();
+
+ // Check that the original shortcuts work.
+ await checkShortcut("commandOne", "7", { shiftKey: true, altKey: true });
+ await checkShortcut("commandTwo", "4", { altKey: true });
+
+ let doc = win.document;
+
+ let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+ ok(card, `There is a card for the extension`);
+
+ let inputs = card.querySelectorAll(".shortcut-input");
+ is(
+ inputs.length,
+ Object.keys(commands).length,
+ "There is an input for each command"
+ );
+
+ let nameOrder = Array.from(inputs).map(input => input.getAttribute("name"));
+ Assert.deepEqual(
+ nameOrder,
+ ["commandOne", "commandTwo", "_execute_browser_action", "commandZero"],
+ "commandZero should be last since it is unset"
+ );
+
+ let count = 1;
+ for (let input of inputs) {
+ // Change the shortcut.
+ input.focus();
+ EventUtils.synthesizeKey("8", { shiftKey: true, altKey: true });
+ count++;
+
+ // Wait for the shortcut attribute to change.
+ await BrowserTestUtils.waitForCondition(
+ () => input.getAttribute("shortcut") == "Alt+Shift+8",
+ "Wait for shortcut to update to Alt+Shift+8"
+ );
+
+ // Check that the change worked (but skip if browserAction).
+ if (input.getAttribute("name") != "_execute_browser_action") {
+ await checkShortcut(input.getAttribute("name"), "8", {
+ shiftKey: true,
+ altKey: true,
+ });
+ }
+
+ // Change it again so it doesn't conflict with the next command.
+ input.focus();
+ EventUtils.synthesizeKey(count.toString(), {
+ shiftKey: true,
+ altKey: true,
+ });
+ await BrowserTestUtils.waitForCondition(
+ () => input.getAttribute("shortcut") == `Alt+Shift+${count}`,
+ `Wait for shortcut to update to Alt+Shift+${count}`
+ );
+ }
+
+ // Check that errors can be shown.
+ let input = inputs[0];
+ let error = doc.querySelector(".error-message");
+ let label = error.querySelector(".error-message-label");
+ is(error.style.visibility, "hidden", "The error is initially hidden");
+
+ // Try a shortcut with only shift for a modifier.
+ input.focus();
+ EventUtils.synthesizeKey("J", { shiftKey: true });
+ let possibleErrors = ["shortcuts-modifier-mac", "shortcuts-modifier-other"];
+ ok(possibleErrors.includes(label.dataset.l10nId), `The message is set`);
+ is(error.style.visibility, "visible", "The error is shown");
+
+ // Escape should clear the focus and hide the error.
+ is(doc.activeElement, input, "The input is focused");
+ EventUtils.synthesizeKey("Escape", {});
+ Assert.notEqual(doc.activeElement, input, "The input is no longer focused");
+ is(error.style.visibility, "hidden", "The error is hidden");
+
+ // Check if assigning already assigned shortcut is prevented.
+ input.focus();
+ EventUtils.synthesizeKey("2", { shiftKey: true, altKey: true });
+ is(label.dataset.l10nId, "shortcuts-exists", `The message is set`);
+ is(error.style.visibility, "visible", "The error is shown");
+
+ // Check the label uses the description first, and has a default for the special cases.
+ function checkLabel(name, value) {
+ let input = doc.querySelector(`.shortcut-input[name="${name}"]`);
+ let label = input.previousElementSibling;
+ if (label.dataset.l10nId) {
+ is(label.dataset.l10nId, value, "The l10n-id is set");
+ } else {
+ is(label.textContent, value, "The textContent is set");
+ }
+ }
+ checkLabel("commandOne", "commandOne");
+ checkLabel("commandTwo", "Command Two!");
+ checkLabel("_execute_browser_action", "shortcuts-browserAction2");
+
+ await closeView(win);
+ await extension.unload();
+});
+
+async function startExtensionWithCommands(numCommands) {
+ let commands = {};
+
+ for (let i = 0; i < numCommands; i++) {
+ commands[`command-${i}`] = {};
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands,
+ },
+ background() {
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+ await extensionShortcutsReady(extension.id);
+
+ return extension;
+}
+
+add_task(async function testExpanding() {
+ const numCommands = 7;
+ const visibleCommands = 5;
+
+ let extension = await startExtensionWithCommands(numCommands);
+
+ let win = await loadShortcutsView();
+ let doc = win.document;
+
+ let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+ ok(!card.hasAttribute("expanded"), "The card is not expanded");
+
+ let shortcutRows = card.querySelectorAll(".shortcut-row");
+ is(shortcutRows.length, numCommands, `There are ${numCommands} shortcuts`);
+
+ function assertCollapsedVisibility() {
+ for (let i = 0; i < shortcutRows.length; i++) {
+ let row = shortcutRows[i];
+ if (i < visibleCommands) {
+ Assert.notEqual(
+ getComputedStyle(row).display,
+ "none",
+ `The first ${visibleCommands} rows are visible`
+ );
+ } else {
+ is(getComputedStyle(row).display, "none", "The other rows are hidden");
+ }
+ }
+ }
+
+ // Check the visibility of the rows.
+ assertCollapsedVisibility();
+
+ let expandButton = card.querySelector(".expand-button");
+ ok(expandButton, "There is an expand button");
+ let l10nAttrs = doc.l10n.getAttributes(expandButton);
+ is(l10nAttrs.id, "shortcuts-card-expand-button", "The expand text is shown");
+ is(
+ l10nAttrs.args.numberToShow,
+ numCommands - visibleCommands,
+ "The number to be shown is set on the expand button"
+ );
+
+ // Expand the card.
+ expandButton.click();
+
+ is(card.getAttribute("expanded"), "true", "The card is now expanded");
+
+ for (let row of shortcutRows) {
+ Assert.notEqual(
+ getComputedStyle(row).display,
+ "none",
+ "All the rows are visible"
+ );
+ }
+
+ // The collapse text is now shown.
+ l10nAttrs = doc.l10n.getAttributes(expandButton);
+ is(
+ l10nAttrs.id,
+ "shortcuts-card-collapse-button",
+ "The colapse text is shown"
+ );
+
+ // Collapse the card.
+ expandButton.click();
+
+ ok(!card.hasAttribute("expanded"), "The card is now collapsed again");
+
+ assertCollapsedVisibility({ collapsed: true });
+
+ await closeView(win);
+ await extension.unload();
+});
+
+add_task(async function testOneExtraCommandIsNotCollapsed() {
+ const numCommands = 6;
+ let extension = await startExtensionWithCommands(numCommands);
+
+ let win = await loadShortcutsView();
+ let doc = win.document;
+
+ // The card is not expanded, since it doesn't collapse.
+ let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+ ok(!card.hasAttribute("expanded"), "The card is not expanded");
+
+ // Each shortcut has a row.
+ let shortcutRows = card.querySelectorAll(".shortcut-row");
+ is(shortcutRows.length, numCommands, `There are ${numCommands} shortcuts`);
+
+ // There's no expand button, since it can't be expanded.
+ let expandButton = card.querySelector(".expand-button");
+ ok(!expandButton, "There is no expand button");
+
+ // All of the rows are visible, to avoid a "Show 1 More" button.
+ for (let row of shortcutRows) {
+ Assert.notEqual(
+ getComputedStyle(row).display,
+ "none",
+ "All the rows are visible"
+ );
+ }
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js
new file mode 100644
index 0000000000..327a99af9e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_hidden.js
@@ -0,0 +1,198 @@
+/* eslint max-len: ["error", 80] */
+"use strict";
+
+async function loadShortcutsView() {
+ let managerWin = await open_manager(null);
+ managerWin.gViewController.loadView("addons://shortcuts/shortcuts");
+ await wait_for_view_load(managerWin);
+ return managerWin.document;
+}
+
+async function closeShortcutsView(doc) {
+ let managerWin = doc.defaultView.parent;
+ await close_manager(managerWin);
+}
+
+async function registerAndStartExtension(mockProvider, ext) {
+ // Shortcuts are registered when an extension is started, so we need to load
+ // and start an extension.
+ let extension = ExtensionTestUtils.loadExtension(ext);
+ await extension.startup();
+
+ // Extensions only appear in the add-on manager when they are registered with
+ // the add-on manager, e.g. by passing "useAddonManager" to `loadExtension`.
+ // "useAddonManager" can however not be used, because the resulting add-ons
+ // are unsigned, and only add-ons with privileged signatures can be hidden.
+ mockProvider.createAddons([
+ {
+ id: extension.id,
+ name: ext.manifest.name,
+ type: "extension",
+ version: "1",
+ // We use MockProvider because the "hidden" property cannot
+ // be set when "useAddonManager" is passed to loadExtension.
+ hidden: ext.manifest.hidden,
+ isSystem: ext.isSystem,
+ },
+ ]);
+ return extension;
+}
+
+function getShortcutCard(doc, extension) {
+ return doc.querySelector(`.shortcut[addon-id="${extension.id}"]`);
+}
+
+function getShortcutByName(doc, extension, name) {
+ let card = getShortcutCard(doc, extension);
+ return card && card.querySelector(`.shortcut-input[name="${name}"]`);
+}
+
+function getNoShortcutListItem(doc, extension) {
+ let { id } = extension;
+ let li = doc.querySelector(`.shortcuts-no-commands-list [addon-id="${id}"]`);
+ return li && li.textContent;
+}
+
+add_task(async function extension_with_shortcuts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "shortcut addon",
+ commands: {
+ theShortcut: {},
+ },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let doc = await loadShortcutsView();
+
+ ok(
+ getShortcutByName(doc, extension, "theShortcut"),
+ "Extension with shortcuts should have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, extension),
+ null,
+ "Extension with shortcuts should not be listed"
+ );
+
+ await closeShortcutsView(doc);
+ await extension.unload();
+});
+
+add_task(async function extension_without_shortcuts() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "no shortcut addon",
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let doc = await loadShortcutsView();
+
+ is(
+ getShortcutCard(doc, extension),
+ null,
+ "Extension without shortcuts should not have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, extension),
+ "no shortcut addon",
+ "The add-on's name is set in the list"
+ );
+
+ await closeShortcutsView(doc);
+ await extension.unload();
+});
+
+// Hidden add-ons without shortcuts should be hidden,
+// but their card should be shown if there is a shortcut.
+add_task(async function hidden_extension() {
+ let mockProvider = new MockProvider();
+ let hiddenExt1 = await registerAndStartExtension(mockProvider, {
+ manifest: {
+ name: "hidden with shortcuts",
+ hidden: true,
+ commands: {
+ hiddenShortcut: {},
+ },
+ },
+ });
+ let hiddenExt2 = await registerAndStartExtension(mockProvider, {
+ manifest: {
+ name: "hidden without shortcuts",
+ hidden: true,
+ },
+ });
+
+ let doc = await loadShortcutsView();
+
+ ok(
+ getShortcutByName(doc, hiddenExt1, "hiddenShortcut"),
+ "Hidden extension with shortcuts should have a card"
+ );
+
+ is(
+ getShortcutCard(doc, hiddenExt2),
+ null,
+ "Hidden extension without shortcuts should not have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, hiddenExt2),
+ null,
+ "Hidden extension without shortcuts should not be listed"
+ );
+
+ await closeShortcutsView(doc);
+ await hiddenExt1.unload();
+ await hiddenExt2.unload();
+
+ mockProvider.unregister();
+});
+
+add_task(async function system_addons_and_shortcuts() {
+ let mockProvider = new MockProvider();
+ let systemExt1 = await registerAndStartExtension(mockProvider, {
+ isSystem: true,
+ manifest: {
+ name: "system with shortcuts",
+ // In practice, all XPIStateLocations with isSystem=true also have
+ // isBuiltin=true, which implies that hidden=true as well.
+ hidden: true,
+ commands: {
+ systemShortcut: {},
+ },
+ },
+ });
+ let systemExt2 = await registerAndStartExtension(mockProvider, {
+ isSystem: true,
+ manifest: {
+ name: "system without shortcuts",
+ hidden: true,
+ },
+ });
+
+ let doc = await loadShortcutsView();
+
+ ok(
+ getShortcutByName(doc, systemExt1, "systemShortcut"),
+ "System add-on with shortcut should have a card"
+ );
+
+ is(
+ getShortcutCard(doc, systemExt2),
+ null,
+ "System add-on without shortcut should not have a card"
+ );
+ is(
+ getNoShortcutListItem(doc, systemExt2),
+ null,
+ "System add-on without shortcuts should not be listed"
+ );
+
+ await closeShortcutsView(doc);
+ await systemExt1.unload();
+ await systemExt2.unload();
+
+ mockProvider.unregister();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js
new file mode 100644
index 0000000000..259c10d730
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts_remove.js
@@ -0,0 +1,180 @@
+"use strict";
+
+async function loadShortcutsView() {
+ let managerWin = await open_manager(null);
+ managerWin.gViewController.loadView("addons://shortcuts/shortcuts");
+ await wait_for_view_load(managerWin);
+ return managerWin.document;
+}
+
+async function closeShortcutsView(doc) {
+ let managerWin = doc.defaultView.parent;
+ await close_manager(managerWin);
+}
+
+function getShortcutCard(doc, extension) {
+ return doc.querySelector(`.shortcut[addon-id="${extension.id}"]`);
+}
+
+function getShortcutByName(doc, extension, name) {
+ let card = getShortcutCard(doc, extension);
+ return card && card.querySelector(`.shortcut-input[name="${name}"]`);
+}
+
+async function waitForShortcutSet(input, expected) {
+ let doc = input.ownerDocument;
+ await BrowserTestUtils.waitForCondition(
+ () => input.getAttribute("shortcut") == expected,
+ `Shortcut should be set to ${JSON.stringify(expected)}`
+ );
+ Assert.notEqual(doc.activeElement, input, "The input is no longer focused");
+ checkHasRemoveButton(input, expected !== "");
+}
+
+function removeButtonForInput(input) {
+ let removeButton = input.parentNode.querySelector(".shortcut-remove-button");
+ ok(removeButton, "has remove button");
+ ok(
+ removeButton.hasAttribute("aria-label"),
+ "The remove button has an accessible name"
+ );
+ return removeButton;
+}
+
+function checkHasRemoveButton(input, expected) {
+ let removeButton = removeButtonForInput(input);
+ let visibility = input.ownerGlobal.getComputedStyle(removeButton).visibility;
+ if (expected) {
+ is(visibility, "visible", "Remove button should be visible");
+ } else {
+ is(visibility, "hidden", "Remove button should be hidden");
+ }
+}
+
+add_task(async function test_remove_shortcut() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands: {
+ commandEmpty: {},
+ commandOne: {
+ suggested_key: { default: "Shift+Alt+1" },
+ },
+ commandTwo: {
+ suggested_key: { default: "Shift+Alt+2" },
+ },
+ },
+ },
+ background() {
+ browser.commands.onCommand.addListener(commandName => {
+ browser.test.sendMessage("oncommand", commandName);
+ });
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let doc = await loadShortcutsView();
+
+ let input = getShortcutByName(doc, extension, "commandOne");
+
+ checkHasRemoveButton(input, true);
+
+ // First: Verify that Shift-Del is not valid, but doesn't do anything.
+ input.focus();
+ EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true });
+ let errorElem = doc.querySelector(".error-message");
+ is(errorElem.style.visibility, "visible", "Expected error message");
+ let errorId = doc.l10n.getAttributes(
+ errorElem.querySelector(".error-message-label")
+ ).id;
+ if (AppConstants.platform == "macosx") {
+ is(errorId, "shortcuts-modifier-mac", "Shift-Del is not a valid shortcut");
+ } else {
+ is(errorId, "shortcuts-modifier-other", "Shift-Del isn't a valid shortcut");
+ }
+ checkHasRemoveButton(input, true);
+
+ // Now, verify that the original shortcut still works.
+ EventUtils.synthesizeKey("KEY_Escape");
+ Assert.notEqual(doc.activeElement, input, "The input is no longer focused");
+ is(errorElem.style.visibility, "hidden", "The error is hidden");
+
+ EventUtils.synthesizeKey("1", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+
+ // Alt-Shift-Del is a valid shortcut.
+ input.focus();
+ EventUtils.synthesizeKey("KEY_Delete", { altKey: true, shiftKey: true });
+ await waitForShortcutSet(input, "Alt+Shift+Delete");
+ EventUtils.synthesizeKey("KEY_Delete", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+
+ // Del without modifiers should clear the shortcut.
+ input.focus();
+ EventUtils.synthesizeKey("KEY_Delete");
+ await waitForShortcutSet(input, "");
+ // Trigger the shortcuts that were originally associated with commandOne,
+ // and then trigger commandTwo. The extension should only see commandTwo.
+ EventUtils.synthesizeKey("1", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("KEY_Delete", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("2", { altKey: true, shiftKey: true });
+ is(
+ await extension.awaitMessage("oncommand"),
+ "commandTwo",
+ "commandOne should be disabled, commandTwo should still be enabled"
+ );
+
+ // Set a shortcut where the default was not set.
+ let inputEmpty = getShortcutByName(doc, extension, "commandEmpty");
+ is(inputEmpty.getAttribute("shortcut"), "", "Empty shortcut by default");
+ checkHasRemoveButton(input, false);
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ await waitForShortcutSet(inputEmpty, "Alt+Shift+3");
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ await extension.awaitMessage("oncommand");
+ // Clear shortcut.
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("KEY_Delete");
+ await waitForShortcutSet(inputEmpty, "");
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("2", { altKey: true, shiftKey: true });
+ is(
+ await extension.awaitMessage("oncommand"),
+ "commandTwo",
+ "commandEmpty should be disabled, commandTwo should still be enabled"
+ );
+
+ // Now verify that the Backspace button does the same thing as Delete.
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ await waitForShortcutSet(inputEmpty, "Alt+Shift+3");
+ inputEmpty.focus();
+ EventUtils.synthesizeKey("KEY_Backspace");
+ await waitForShortcutSet(input, "");
+ EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true });
+ EventUtils.synthesizeKey("2", { altKey: true, shiftKey: true });
+ is(
+ await extension.awaitMessage("oncommand"),
+ "commandTwo",
+ "commandEmpty should be disabled again by Backspace"
+ );
+
+ // Check that the remove button works as expected.
+ let inputTwo = getShortcutByName(doc, extension, "commandTwo");
+ is(inputTwo.getAttribute("shortcut"), "Shift+Alt+2", "initial shortcut");
+ checkHasRemoveButton(inputTwo, true);
+ removeButtonForInput(inputTwo).click();
+ is(inputTwo.getAttribute("shortcut"), "", "cleared shortcut");
+ checkHasRemoveButton(inputTwo, false);
+ Assert.notEqual(
+ doc.activeElement,
+ inputTwo,
+ "input of removed shortcut is not focused"
+ );
+
+ await closeShortcutsView(doc);
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js b/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js
new file mode 100644
index 0000000000..a602d84999
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_menu_button_accessibility.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function testOpenMenu(btn, method) {
+ let shown = BrowserTestUtils.waitForEvent(btn.ownerGlobal, "shown", true);
+ await method();
+ await shown;
+ is(btn.getAttribute("aria-expanded"), "true", "expanded when open");
+}
+
+async function testCloseMenu(btn, method) {
+ let hidden = BrowserTestUtils.waitForEvent(btn.ownerGlobal, "hidden", true);
+ await method();
+ await hidden;
+ is(btn.getAttribute("aria-expanded"), "false", "not expanded when closed");
+}
+
+async function testButton(btn) {
+ let win = btn.ownerGlobal;
+
+ is(btn.getAttribute("aria-haspopup"), "menu", "it has a menu");
+ is(btn.getAttribute("aria-expanded"), "false", "not expanded");
+
+ info("Test open/close with mouse");
+ await testOpenMenu(btn, () => {
+ EventUtils.synthesizeMouseAtCenter(btn, {}, win);
+ });
+ await testCloseMenu(btn, () => {
+ let spacer = win.document.querySelector(".main-heading .spacer");
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive element to dismiss the
+ // opened menu with a mouse which can be done by assistive technology and
+ // keyboard by pressing `Esc` key, this rule check shall be ignored by
+ // a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ EventUtils.synthesizeMouseAtCenter(spacer, {}, win);
+ AccessibilityUtils.resetEnv();
+ });
+
+ info("Test open/close with keyboard");
+ await testOpenMenu(btn, async () => {
+ btn.focus();
+ EventUtils.synthesizeKey(" ", {}, win);
+ });
+ await testCloseMenu(btn, () => {
+ EventUtils.synthesizeKey("Escape", {}, win);
+ });
+}
+
+add_task(async function testPageOptionsMenuButton() {
+ let win = await loadInitialView("extension");
+
+ await testButton(
+ win.document.querySelector(".page-options-menu .more-options-button")
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testCardMoreOptionsButton() {
+ let id = "more-options-button@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let card = getAddonCard(win, id);
+
+ info("Check list page");
+ await testButton(card.querySelector(".more-options-button"));
+
+ let viewLoaded = waitForViewLoad(win);
+
+ EventUtils.synthesizeMouseAtCenter(
+ card.querySelector(".addon-name-link"),
+ {},
+ win
+ );
+ await viewLoaded;
+
+ info("Check detail page");
+ card = getAddonCard(win, id);
+ await testButton(card.querySelector(".more-options-button"));
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js b/toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js
new file mode 100644
index 0000000000..e049cbd618
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_page_accessibility.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function testPageTitle() {
+ let win = await loadInitialView("extension");
+ let title = win.document.querySelector("title");
+ is(
+ win.document.l10n.getAttributes(title).id,
+ "addons-page-title",
+ "The page title is set"
+ );
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js b/toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js
new file mode 100644
index 0000000000..5007731927
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_page_options_install_addon.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests bug 567127 - Add install button to the add-ons manager
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+async function checkInstallConfirmation(...names) {
+ let notificationCount = 0;
+ let observer = {
+ observe(aSubject, aTopic, aData) {
+ var installInfo = aSubject.wrappedJSObject;
+ isnot(
+ installInfo.browser,
+ null,
+ "Notification should have non-null browser"
+ );
+ Assert.deepEqual(
+ installInfo.installs[0].installTelemetryInfo,
+ {
+ source: "about:addons",
+ method: "install-from-file",
+ },
+ "Got the expected installTelemetryInfo"
+ );
+ notificationCount++;
+ },
+ };
+ Services.obs.addObserver(observer, "addon-install-started");
+
+ let results = [];
+
+ let promise = promisePopupNotificationShown("addon-webext-permissions");
+ for (let i = 0; i < names.length; i++) {
+ let panel = await promise;
+ let name = panel.getAttribute("name");
+ results.push(name);
+
+ info(`Saw install for ${name}`);
+ if (results.length < names.length) {
+ info(
+ `Waiting for installs for ${names.filter(n => !results.includes(n))}`
+ );
+
+ promise = promisePopupNotificationShown("addon-webext-permissions");
+ }
+ panel.secondaryButton.click();
+ }
+
+ Assert.deepEqual(results.sort(), names.sort(), "Got expected installs");
+
+ is(
+ notificationCount,
+ names.length,
+ `Saw ${names.length} addon-install-started notification`
+ );
+ Services.obs.removeObserver(observer, "addon-install-started");
+}
+
+add_task(async function test_install_from_file() {
+ let win = await loadInitialView("extension");
+
+ var filePaths = [
+ get_addon_file_url("browser_dragdrop1.xpi"),
+ get_addon_file_url("browser_dragdrop2.xpi"),
+ ];
+ for (let uri of filePaths) {
+ Assert.notEqual(uri.file, null, `Should have file for ${uri.spec}`);
+ ok(uri.file instanceof Ci.nsIFile, `Should have nsIFile for ${uri.spec}`);
+ }
+ MockFilePicker.setFiles(filePaths.map(aPath => aPath.file));
+
+ // Set handler that executes the core test after the window opens,
+ // and resolves the promise when the window closes
+ let pInstallURIClosed = checkInstallConfirmation(
+ "Drag Drop test 1",
+ "Drag Drop test 2"
+ );
+
+ win.document
+ .querySelector('#page-options [action="install-from-file"]')
+ .click();
+
+ await pInstallURIClosed;
+
+ MockFilePicker.cleanup();
+ await closeView(win);
+});
+
+add_task(async function test_install_disabled() {
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+
+ let pageOptionsMenu = doc.querySelector("addon-page-options panel-list");
+
+ function openPageOptions() {
+ let opened = BrowserTestUtils.waitForEvent(pageOptionsMenu, "shown");
+ pageOptionsMenu.open = true;
+ return opened;
+ }
+
+ function closePageOptions() {
+ let closed = BrowserTestUtils.waitForEvent(pageOptionsMenu, "hidden");
+ pageOptionsMenu.open = false;
+ return closed;
+ }
+
+ await openPageOptions();
+ let installButton = doc.querySelector('[action="install-from-file"]');
+ ok(!installButton.hidden, "The install button is shown");
+ await closePageOptions();
+
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_XPI_ENABLED, false]] });
+
+ await openPageOptions();
+ ok(installButton.hidden, "The install button is now hidden");
+ await closePageOptions();
+
+ await SpecialPowers.popPrefEnv();
+
+ await openPageOptions();
+ ok(!installButton.hidden, "The install button is shown again");
+ await closePageOptions();
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js b/toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js
new file mode 100644
index 0000000000..bd7572a061
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_page_options_updates.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Make sure we don't accidentally start a background update while the prefs
+// are enabled.
+disableBackgroundUpdateTimer();
+registerCleanupFunction(() => {
+ enableBackgroundUpdateTimer();
+});
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const PREF_UPDATE_ENABLED = "extensions.update.enabled";
+const PREF_AUTOUPDATE_DEFAULT = "extensions.update.autoUpdateDefault";
+
+add_task(async function testUpdateAutomaticallyButton() {
+ SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_UPDATE_ENABLED, true],
+ [PREF_AUTOUPDATE_DEFAULT, true],
+ ],
+ });
+
+ let win = await loadInitialView("extension");
+
+ let toggleAutomaticButton = win.document.querySelector(
+ '#page-options [action="set-update-automatically"]'
+ );
+
+ info("Verify the checked state reflects the update state");
+ ok(toggleAutomaticButton.checked, "Automatic updates button is checked");
+
+ AddonManager.autoUpdateDefault = false;
+ ok(!toggleAutomaticButton.checked, "Automatic updates button is unchecked");
+
+ AddonManager.autoUpdateDefault = true;
+ ok(toggleAutomaticButton.checked, "Automatic updates button is re-checked");
+
+ info("Verify that clicking the button changes the update state");
+ ok(AddonManager.autoUpdateDefault, "Auto updates are default");
+ ok(AddonManager.updateEnabled, "Updates are enabled");
+
+ toggleAutomaticButton.click();
+ ok(!AddonManager.autoUpdateDefault, "Auto updates are disabled");
+ ok(AddonManager.updateEnabled, "Updates are enabled");
+
+ toggleAutomaticButton.click();
+ ok(AddonManager.autoUpdateDefault, "Auto updates are enabled again");
+ ok(AddonManager.updateEnabled, "Updates are enabled");
+
+ await closeView(win);
+});
+
+add_task(async function testResetUpdateStates() {
+ let id = "update-state@mochi.test";
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ let win = await loadInitialView("extension");
+ let resetStateButton = win.document.querySelector(
+ '#page-options [action="reset-update-states"]'
+ );
+
+ info("Changing add-on update state");
+ let addon = await AddonManager.getAddonByID(id);
+
+ let setAddonUpdateState = async updateState => {
+ let changed = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ addon.applyBackgroundUpdates = updateState;
+ await changed;
+ let addonState = addon.applyBackgroundUpdates;
+ is(addonState, updateState, `Add-on updates are ${updateState}`);
+ };
+
+ await setAddonUpdateState(AddonManager.AUTOUPDATE_DISABLE);
+
+ let propertyChanged = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ resetStateButton.click();
+ await propertyChanged;
+ is(
+ addon.applyBackgroundUpdates,
+ AddonManager.AUTOUPDATE_DEFAULT,
+ "Add-on is reset to default updates"
+ );
+
+ await setAddonUpdateState(AddonManager.AUTOUPDATE_ENABLE);
+
+ propertyChanged = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ resetStateButton.click();
+ await propertyChanged;
+ is(
+ addon.applyBackgroundUpdates,
+ AddonManager.AUTOUPDATE_DEFAULT,
+ "Add-on is reset to default updates again"
+ );
+
+ info("Check the label on the button as the global state changes");
+ is(
+ win.document.l10n.getAttributes(resetStateButton).id,
+ "addon-updates-reset-updates-to-automatic",
+ "The reset button label says it resets to automatic"
+ );
+
+ info("Disable auto updating globally");
+ AddonManager.autoUpdateDefault = false;
+
+ is(
+ win.document.l10n.getAttributes(resetStateButton).id,
+ "addon-updates-reset-updates-to-manual",
+ "The reset button label says it resets to manual"
+ );
+
+ await closeView(win);
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js b/toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js
new file mode 100644
index 0000000000..d58eb8c027
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_permission_prompt.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/*
+ * Test Permission Popup for Sideloaded Extensions.
+ */
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const ADDON_ID = "addon1@test.mozilla.org";
+const CUSTOM_THEME_ID = "theme1@test.mozilla.org";
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+AddonTestUtils.initMochitest(this);
+
+function assertDisabledSideloadedExtensionElement(managerWindow, addonElement) {
+ const doc = addonElement.ownerDocument;
+ const toggleDisabled = addonElement.querySelector(
+ '[action="toggle-disabled"]'
+ );
+ is(
+ doc.l10n.getAttributes(toggleDisabled).id,
+ "extension-enable-addon-button-label",
+ "Addon toggle-disabled action has the enable label"
+ );
+ ok(!toggleDisabled.checked, "toggle-disable isn't checked");
+}
+
+function assertEnabledSideloadedExtensionElement(managerWindow, addonElement) {
+ const doc = addonElement.ownerDocument;
+ const toggleDisabled = addonElement.querySelector(
+ '[action="toggle-disabled"]'
+ );
+ is(
+ doc.l10n.getAttributes(toggleDisabled).id,
+ "extension-enable-addon-button-label",
+ "Addon toggle-disabled action has the enable label"
+ );
+ ok(!toggleDisabled.checked, "toggle-disable isn't checked");
+}
+
+function clickEnableExtension(addonElement) {
+ addonElement.querySelector('[action="toggle-disabled"]').click();
+}
+
+// Test for bug 1647931
+// Install a theme, enable it and then enable the default theme again
+add_task(async function test_theme_enable() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ],
+ });
+
+ let theme = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: CUSTOM_THEME_ID } },
+ name: "Theme 1",
+ theme: {
+ colors: {
+ frame: "#000000",
+ tab_background_text: "#ffffff",
+ },
+ },
+ },
+ };
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(theme);
+ await AddonTestUtils.manuallyInstall(xpi);
+
+ let changePromise = new Promise(resolve =>
+ ExtensionsUI.once("change", resolve)
+ );
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // enable fresh installed theme
+ let manager = await open_manager("addons://list/theme");
+ let customTheme = getAddonCard(manager, CUSTOM_THEME_ID);
+ clickEnableExtension(customTheme);
+
+ // enable default theme again
+ let defaultTheme = getAddonCard(manager, DEFAULT_THEME_ID);
+ clickEnableExtension(defaultTheme);
+
+ let addon = await AddonManager.getAddonByID(CUSTOM_THEME_ID);
+ await close_manager(manager);
+ await addon.uninstall();
+});
+
+// Loading extension by sideloading method
+add_task(async function test_sideloaded_extension_permissions_prompt() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ],
+ });
+
+ let options = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ name: "Test 1",
+ permissions: ["history", "https://*/*"],
+ icons: { 64: "foo-icon.png" },
+ },
+ };
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+ await AddonTestUtils.manuallyInstall(xpi);
+
+ let changePromise = new Promise(resolve =>
+ ExtensionsUI.once("change", resolve)
+ );
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // Test click event on permission cancel option.
+ let manager = await open_manager("addons://list/extension");
+ let addon = getAddonCard(manager, ADDON_ID);
+
+ Assert.notEqual(addon, null, "Found sideloaded addon in about:addons");
+
+ assertDisabledSideloadedExtensionElement(manager, addon);
+
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addon);
+ let panel = await popupPromise;
+
+ ok(PopupNotifications.isPanelOpen, "Permission popup should be visible");
+ panel.secondaryButton.click();
+ ok(
+ !PopupNotifications.isPanelOpen,
+ "Permission popup should be closed / closing"
+ );
+
+ addon = await AddonManager.getAddonByID(ADDON_ID);
+ ok(
+ !addon.seen,
+ "Seen flag should remain false after permissions are refused"
+ );
+
+ // Test click event on permission accept option.
+ addon = getAddonCard(manager, ADDON_ID);
+ Assert.notEqual(addon, null, "Found sideloaded addon in about:addons");
+
+ assertEnabledSideloadedExtensionElement(manager, addon);
+
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addon);
+ panel = await popupPromise;
+
+ ok(PopupNotifications.isPanelOpen, "Permission popup should be visible");
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ ADDON_ID
+ );
+
+ panel.button.click();
+ ok(
+ !PopupNotifications.isPanelOpen,
+ "Permission popup should be closed / closing"
+ );
+ await notificationPromise;
+
+ addon = await AddonManager.getAddonByID(ADDON_ID);
+ ok(addon.seen, "Seen flag should be true after permissions are accepted");
+
+ ok(!PopupNotifications.isPanelOpen, "Permission popup should not be visible");
+
+ await close_manager(manager);
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_reinstall.js b/toolkit/mozapps/extensions/test/browser/browser_reinstall.js
new file mode 100644
index 0000000000..c0eb7d139a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_reinstall.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that upgrading bootstrapped add-ons behaves correctly while the
+// manager is open
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const ID = "reinstall@tests.mozilla.org";
+const testIdSuffix = "@tests.mozilla.org";
+
+let gManagerWindow, xpi1, xpi2;
+
+function htmlDoc() {
+ return gManagerWindow.document;
+}
+
+function get_list_item_count() {
+ return htmlDoc().querySelectorAll(`addon-card[addon-id$="${testIdSuffix}"]`)
+ .length;
+}
+
+function removeItem(item) {
+ let button = item.querySelector('[action="remove"]');
+ button.click();
+}
+
+function hasPendingMessage(item, msg) {
+ let messageBar = htmlDoc().querySelector(
+ `moz-message-bar[addon-id="${item.addon.id}"`
+ );
+ is_element_visible(messageBar, msg);
+}
+
+async function install_addon(xpi) {
+ let install = await AddonManager.getInstallForFile(
+ xpi,
+ "application/x-xpinstall"
+ );
+ return install.install();
+}
+
+async function check_addon(aAddon, aVersion) {
+ is(get_list_item_count(), 1, "Should be one item in the list");
+ is(aAddon.version, aVersion, "Add-on should have the right version");
+
+ let item = getAddonCard(gManagerWindow, ID);
+ ok(!!item, "Should see the add-on in the list");
+
+ // Force XBL to apply
+ item.clientTop;
+
+ let { version } = await get_tooltip_info(item, gManagerWindow);
+ is(version, aVersion, "Version should be correct");
+
+ const l10nAttrs = item.ownerDocument.l10n.getAttributes(item.addonNameEl);
+ if (aAddon.userDisabled) {
+ Assert.deepEqual(
+ l10nAttrs,
+ { id: "addon-name-disabled", args: { name: aAddon.name } },
+ "localized addon name is marked as disabled"
+ );
+ } else {
+ Assert.deepEqual(
+ l10nAttrs,
+ { id: null, args: null },
+ "localized addon name is not marked as disabled"
+ );
+ }
+}
+
+async function wait_for_addon_item_added(addonId) {
+ await BrowserTestUtils.waitForEvent(
+ htmlDoc().querySelector("addon-list"),
+ "add"
+ );
+ const item = getAddonCard(gManagerWindow, addonId);
+ ok(item, `Found addon card for ${addonId}`);
+}
+
+async function wait_for_addon_item_removed(addonId) {
+ await BrowserTestUtils.waitForEvent(
+ htmlDoc().querySelector("addon-list"),
+ "remove"
+ );
+ const item = getAddonCard(gManagerWindow, addonId);
+ ok(!item, `There shouldn't be an addon card for ${addonId}`);
+}
+
+function wait_for_addon_item_updated(addonId) {
+ return BrowserTestUtils.waitForEvent(
+ getAddonCard(gManagerWindow, addonId),
+ "update"
+ );
+}
+
+// Install version 1 then upgrade to version 2 with the manager open
+async function test_upgrade_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let addon = await promiseAddonByID(ID);
+ await check_addon(addon, "1.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ await install_addon(xpi2);
+ await promiseItemUpdated;
+
+ addon = await promiseAddonByID(ID);
+ await check_addon(addon, "2.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+// Install version 1 mark it as disabled then upgrade to version 2 with the
+// manager open
+async function test_upgrade_disabled_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ let addon = await promiseAddonByID(ID);
+ await addon.disable();
+ await promiseItemUpdated;
+
+ await check_addon(addon, "1.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ promiseItemUpdated = wait_for_addon_item_updated(ID);
+ await install_addon(xpi2);
+ await promiseItemUpdated;
+
+ addon = await promiseAddonByID(ID);
+ await check_addon(addon, "2.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+// Install version 1 click the remove button and then upgrade to version 2 with
+// the manager open
+async function test_upgrade_pending_uninstall_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let addon = await promiseAddonByID(ID);
+ await check_addon(addon, "1.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ let item = getAddonCard(gManagerWindow, ID);
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ removeItem(item);
+
+ // Force XBL to apply
+ item.clientTop;
+
+ await promiseItemRemoved;
+
+ ok(
+ !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "Add-on should be pending uninstall"
+ );
+ hasPendingMessage(item, "Pending message should be visible");
+
+ promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi2);
+ await promiseItemAdded;
+
+ addon = await promiseAddonByID(ID);
+ await check_addon(addon, "2.0");
+ ok(!addon.userDisabled, "Add-on should not be disabled");
+
+ promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+// Install version 1, disable it, click the remove button and then upgrade to
+// version 2 with the manager open
+async function test_upgrade_pending_uninstall_disabled_v1_to_v2() {
+ let promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi1);
+ await promiseItemAdded;
+
+ let promiseItemUpdated = wait_for_addon_item_updated(ID);
+ let addon = await promiseAddonByID(ID);
+ await addon.disable();
+ await promiseItemUpdated;
+
+ await check_addon(addon, "1.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ let item = getAddonCard(gManagerWindow, ID);
+
+ let promiseItemRemoved = wait_for_addon_item_removed(ID);
+ removeItem(item);
+
+ // Force XBL to apply
+ item.clientTop;
+
+ await promiseItemRemoved;
+ ok(
+ !!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "Add-on should be pending uninstall"
+ );
+ hasPendingMessage(item, "Pending message should be visible");
+
+ promiseItemAdded = wait_for_addon_item_added(ID);
+ await install_addon(xpi2);
+ addon = await promiseAddonByID(ID);
+
+ await promiseItemAdded;
+ await check_addon(addon, "2.0");
+ ok(addon.userDisabled, "Add-on should be disabled");
+
+ promiseItemRemoved = wait_for_addon_item_removed(ID);
+ await addon.uninstall();
+ await promiseItemRemoved;
+
+ is(get_list_item_count(), 0, "Should be no items in the list");
+}
+
+add_setup(async function () {
+ xpi1 = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ xpi2 = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ // Accept all prompts.
+ mockPromptService()._response = 0;
+});
+
+add_task(async function test_upgrades() {
+ // Close existing about:addons tab if a test failure has
+ // prevented it from being closed.
+ if (gManagerWindow) {
+ await close_manager(gManagerWindow);
+ }
+
+ gManagerWindow = await open_manager("addons://list/extension");
+
+ await test_upgrade_v1_to_v2();
+ await test_upgrade_disabled_v1_to_v2();
+ await test_upgrade_pending_uninstall_v1_to_v2();
+ await test_upgrade_pending_uninstall_disabled_v1_to_v2();
+
+ await close_manager(gManagerWindow);
+ gManagerWindow = null;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js b/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js
new file mode 100644
index 0000000000..912ce8d62f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_shortcuts_duplicate_check.js
@@ -0,0 +1,262 @@
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+async function loadShortcutsView() {
+ let win = await loadInitialView("extension");
+
+ // There should be a manage shortcuts link.
+ let shortcutsLink = win.document.querySelector('[action="manage-shortcuts"]');
+
+ // Open the shortcuts view.
+ let loaded = waitForViewLoad(win);
+ shortcutsLink.click();
+ await loaded;
+
+ return win;
+}
+
+add_task(async function testDuplicateShortcutsWarnings() {
+ let duplicateCommands = {
+ commandOne: {
+ suggested_key: { default: "Shift+Alt+1" },
+ },
+ commandTwo: {
+ description: "Command Two!",
+ suggested_key: { default: "Shift+Alt+2" },
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands: duplicateCommands,
+ name: "Extension 1",
+ },
+ background() {
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest: {
+ commands: {
+ ...duplicateCommands,
+ commandThree: {
+ description: "Command Three!",
+ suggested_key: { default: "Shift+Alt+3" },
+ },
+ },
+ name: "Extension 2",
+ },
+ background() {
+ browser.test.sendMessage("ready");
+ },
+ useAddonManager: "temporary",
+ });
+
+ await extension2.startup();
+ await extension2.awaitMessage("ready");
+
+ let win = await loadShortcutsView();
+ let doc = win.document;
+
+ let warningBars = doc.querySelectorAll("moz-message-bar");
+ // Ensure warning messages are shown for each duplicate shorctut.
+ is(
+ warningBars.length,
+ Object.keys(duplicateCommands).length,
+ "There is a warning message bar for each duplicate shortcut"
+ );
+
+ // Ensure warning messages are correct with correct shortcuts.
+ let count = 1;
+ for (let warning of warningBars) {
+ let l10nAttrs = doc.l10n.getAttributes(warning);
+ await TestUtils.waitForCondition(() => warning.message !== "");
+ Assert.notStrictEqual(
+ warning.message,
+ "",
+ "Warning message attribute is set"
+ );
+ is(
+ l10nAttrs.id,
+ "shortcuts-duplicate-warning-message2",
+ "Warning message l10nId is correct"
+ );
+ Assert.deepEqual(
+ l10nAttrs.args,
+ { shortcut: `Shift+Alt+${count}` },
+ "Warning message shortcut is correct"
+ );
+ count++;
+ }
+
+ ["Shift+Alt+1", "Shift+Alt+2"].forEach((shortcut, index) => {
+ // Ensure warning messages are correct with correct shortcuts.
+ let warning = warningBars[index];
+ let l10nAttrs = doc.l10n.getAttributes(warning);
+ Assert.notStrictEqual(
+ warning.message,
+ "",
+ "Warning message attribute is set"
+ );
+ is(
+ l10nAttrs.id,
+ "shortcuts-duplicate-warning-message2",
+ "Warning message l10nId is correct"
+ );
+ Assert.deepEqual(
+ l10nAttrs.args,
+ { shortcut },
+ "Warning message shortcut is correct"
+ );
+
+ // Check if all inputs have warning style.
+ let inputs = doc.querySelectorAll(`input[shortcut="${shortcut}"]`);
+ for (let input of inputs) {
+ // Check if warning error message is shown on focus.
+ input.focus();
+ let error = doc.querySelector(".error-message");
+ let label = error.querySelector(".error-message-label");
+ is(error.style.visibility, "visible", "The error element is shown");
+ is(
+ error.getAttribute("type"),
+ "warning",
+ "Duplicate shortcut has warning class"
+ );
+ is(
+ label.dataset.l10nId,
+ "shortcuts-duplicate",
+ "Correct error message is shown"
+ );
+
+ // On keypress events wrning class should be removed.
+ EventUtils.synthesizeKey("A");
+ ok(
+ !error.classList.contains("warning"),
+ "Error element should not have warning class"
+ );
+
+ input.blur();
+ is(
+ error.style.visibility,
+ "hidden",
+ "The error element is hidden on blur"
+ );
+ }
+ });
+
+ await closeView(win);
+ await extension.unload();
+ await extension2.unload();
+});
+
+add_task(async function testDuplicateShortcutOnMacOSCtrlKey() {
+ if (AppConstants.platform !== "macosx") {
+ ok(
+ true,
+ `Skipping macos specific test on platform ${AppConstants.platform}`
+ );
+ return;
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension 1",
+ browser_specific_settings: {
+ gecko: { id: "extension1@mochi.test" },
+ },
+ commands: {
+ commandOne: {
+ // Cover expected mac normalized shortcut on default shortcut.
+ suggested_key: { default: "Ctrl+Shift+1" },
+ },
+ commandTwo: {
+ suggested_key: {
+ default: "Alt+Shift+2",
+ // Cover expected mac normalized shortcut on mac-specific shortcut.
+ mac: "Ctrl+Shift+2",
+ },
+ },
+ },
+ },
+ });
+
+ const extension2 = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension 2",
+ browser_specific_settings: {
+ gecko: { id: "extension2@mochi.test" },
+ },
+ commands: {
+ anotherCommand: {},
+ },
+ },
+ });
+
+ await extension.startup();
+ await extension2.startup();
+
+ const win = await loadShortcutsView();
+ const doc = win.document;
+ const errorEl = doc.querySelector("addon-shortcuts .error-message");
+ const errorLabel = errorEl.querySelector(".error-message-label");
+
+ ok(
+ BrowserTestUtils.isHidden(errorEl),
+ "Expect shortcut error element to be initially hidden"
+ );
+
+ const getShortcutInput = commandName =>
+ doc.querySelector(`input.shortcut-input[name="${commandName}"]`);
+
+ const assertDuplicateShortcutWarning = async msg => {
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.isVisible(errorEl),
+ `Wait for the shortcut-duplicate error to be visible on ${msg}`
+ );
+ Assert.deepEqual(
+ document.l10n.getAttributes(errorLabel),
+ {
+ id: "shortcuts-exists",
+ args: { addon: "Extension 1" },
+ },
+ `Got the expected warning message on duplicate shortcut on ${msg}`
+ );
+ };
+
+ const clearWarning = async inputEl => {
+ anotherCommandInput.blur();
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.isHidden(errorEl),
+ "Wait for the shortcut-duplicate error to be hidden"
+ );
+ };
+
+ const anotherCommandInput = getShortcutInput("anotherCommand");
+ anotherCommandInput.focus();
+ EventUtils.synthesizeKey("1", { metaKey: true, shiftKey: true });
+
+ await assertDuplicateShortcutWarning("shortcut conflict with commandOne");
+ await clearWarning(anotherCommandInput);
+
+ anotherCommandInput.focus();
+ EventUtils.synthesizeKey("2", { metaKey: true, shiftKey: true });
+
+ await assertDuplicateShortcutWarning("shortcut conflict with commandTwo");
+ await clearWarning(anotherCommandInput);
+
+ await closeView(win);
+ await extension.unload();
+ await extension2.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js b/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js
new file mode 100644
index 0000000000..f391edbf34
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_categories.js
@@ -0,0 +1,166 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const THEME_ID = "default-theme@mozilla.org";
+
+function assertViewHas(win, selector, msg) {
+ ok(win.document.querySelector(selector), msg);
+}
+function assertListView(win, type) {
+ assertViewHas(win, `addon-list[type="${type}"]`, `On ${type} list`);
+}
+
+add_task(async function testClickingSidebarEntriesChangesView() {
+ let win = await loadInitialView("extension");
+ let doc = win.document;
+ let themeCategory = doc.querySelector("#categories > [name=theme]");
+ let extensionCategory = doc.querySelector("#categories > [name=extension]");
+
+ assertListView(win, "extension");
+
+ let loaded = waitForViewLoad(win);
+ themeCategory.click();
+ await loaded;
+
+ assertListView(win, "theme");
+
+ loaded = waitForViewLoad(win);
+ getAddonCard(win, THEME_ID).querySelector(".addon-name-link").click();
+ await loaded;
+
+ ok(!doc.querySelector("addon-list"), "No more addon-list");
+ assertViewHas(
+ win,
+ `addon-card[addon-id="${THEME_ID}"][expanded]`,
+ "Detail view now"
+ );
+
+ loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(themeCategory, {}, win);
+ await loaded;
+
+ assertListView(win, "theme");
+
+ loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(extensionCategory, {}, win);
+ await loaded;
+
+ assertListView(win, "extension");
+
+ await closeView(win);
+});
+
+add_task(async function testClickingSidebarPaddingNoChange() {
+ let win = await loadInitialView("theme");
+ let categoryUtils = new CategoryUtilities(win);
+ let themeCategory = categoryUtils.get("theme");
+
+ let loadDetailView = async () => {
+ let loaded = waitForViewLoad(win);
+ getAddonCard(win, THEME_ID).querySelector(".addon-name-link").click();
+ await loaded;
+
+ is(
+ win.gViewController.currentViewId,
+ `addons://detail/${THEME_ID}`,
+ "The detail view loaded"
+ );
+ };
+
+ // Confirm that clicking the button directly works.
+ await loadDetailView();
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(themeCategory, {}, win);
+ await loaded;
+ is(
+ win.gViewController.currentViewId,
+ `addons://list/theme`,
+ "The detail view loaded"
+ );
+
+ // Confirm that clicking on the padding beside it does nothing.
+ await loadDetailView();
+ // We intentionally turn off this a11y check, because the following click
+ // is purposefully targeting a non-interactive padding of the container
+ // to confirm nothing happens, thus this rule check shall be ignored by
+ // a11y_checks suite.
+ AccessibilityUtils.setEnv({ mustHaveAccessibleRule: false });
+ EventUtils.synthesizeMouse(themeCategory, -5, -5, {}, win);
+ AccessibilityUtils.resetEnv();
+ ok(!win.gViewController.isLoading, "No view is loading");
+
+ await closeView(win);
+});
+
+add_task(async function testKeyboardUsage() {
+ let win = await loadInitialView("extension");
+ let categories = win.document.getElementById("categories");
+ let extensionCategory = categories.getButtonByName("extension");
+ let themeCategory = categories.getButtonByName("theme");
+ let pluginCategory = categories.getButtonByName("plugin");
+
+ let waitForAnimationFrame = () =>
+ new Promise(resolve => win.requestAnimationFrame(resolve));
+ let sendKey = (key, e = {}) => {
+ EventUtils.synthesizeKey(key, e, win);
+ return waitForAnimationFrame();
+ };
+ let sendTabKey = e => sendKey("VK_TAB", e);
+ let isFocusInCategories = () =>
+ categories.contains(win.document.activeElement);
+
+ ok(!isFocusInCategories(), "Focus is not in the category list");
+
+ // Tab to the first focusable element.
+ await sendTabKey();
+
+ ok(isFocusInCategories(), "Focus is in the category list");
+ is(
+ win.document.activeElement,
+ extensionCategory,
+ "The extension button is focused"
+ );
+
+ // Tab out of the categories list.
+ await sendTabKey();
+ ok(!isFocusInCategories(), "Focus is out of the category list");
+
+ // Tab back into the list.
+ await sendTabKey({ shiftKey: true });
+ is(win.document.activeElement, extensionCategory, "Back on Extensions");
+
+ // We're on the extension list.
+ assertListView(win, "extension");
+
+ // Switch to theme list.
+ let loaded = waitForViewLoad(win);
+ await sendKey("VK_DOWN");
+ is(win.document.activeElement, themeCategory, "Themes is focused");
+ await loaded;
+
+ assertListView(win, "theme");
+
+ loaded = waitForViewLoad(win);
+ await sendKey("VK_DOWN");
+ is(win.document.activeElement, pluginCategory, "Plugins is focused");
+ await loaded;
+
+ assertListView(win, "plugin");
+
+ await sendKey("VK_DOWN");
+ is(win.document.activeElement, pluginCategory, "Plugins is still focused");
+ ok(!win.gViewController.isLoading, "No view is loading");
+
+ loaded = waitForViewLoad(win);
+ await sendKey("VK_UP");
+ await loaded;
+ loaded = waitForViewLoad(win);
+ await sendKey("VK_UP");
+ await loaded;
+ is(win.document.activeElement, extensionCategory, "Extensions is focused");
+ assertListView(win, "extension");
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js b/toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js
new file mode 100644
index 0000000000..4cb641c2a0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_hidden_categories.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that the visible delay in showing the "Language" category occurs
+// very minimally
+
+let gProvider;
+let gInstall;
+let gInstallProperties = [
+ {
+ name: "Locale Category Test",
+ type: "locale",
+ },
+];
+
+function installLocale() {
+ return new Promise(resolve => {
+ gInstall = gProvider.createInstalls(gInstallProperties)[0];
+ gInstall.addTestListener({
+ onInstallEnded(aInstall) {
+ gInstall.removeTestListener(this);
+ resolve();
+ },
+ });
+ gInstall.install();
+ });
+}
+
+async function checkCategory(win, category, { expectHidden, expectSelected }) {
+ await win.customElements.whenDefined("categories-box");
+
+ let categoriesBox = win.document.getElementById("categories");
+ await categoriesBox.promiseRendered;
+
+ let button = categoriesBox.getButtonByName(category);
+ is(
+ button.hidden,
+ expectHidden,
+ `${category} button is ${expectHidden ? "" : "not "}hidden`
+ );
+ if (expectSelected !== undefined) {
+ is(
+ button.selected,
+ expectSelected,
+ `${category} button is ${expectSelected ? "" : "not "}selected`
+ );
+ }
+}
+
+add_setup(async function () {
+ gProvider = new MockProvider();
+});
+
+add_task(async function testLocalesHiddenByDefault() {
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", { expectHidden: true });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", { expectHidden: true });
+
+ await installLocale();
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesShownWhenInstalled() {
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesHiddenWhenUninstalled() {
+ gInstall.cancel();
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", { expectHidden: true });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesHiddenWithoutDelay() {
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", { expectHidden: true });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", { expectHidden: true });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesShownAfterDelay() {
+ await installLocale();
+
+ gProvider.blockQueryResponses();
+
+ let viewLoaded = loadInitialView("extension", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", { expectHidden: true });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: false,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesShownIfPreviousView() {
+ gProvider.blockQueryResponses();
+
+ // Passing "locale" will set the last view to locales and open the view.
+ let viewLoaded = loadInitialView("locale", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: true,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: true,
+ });
+
+ await closeView(win);
+});
+
+add_task(async function testLocalesHiddenIfPreviousViewAndNoLocales() {
+ gInstall.cancel();
+ gProvider.blockQueryResponses();
+
+ // Passing "locale" will set the last view to locales and open the view.
+ let viewLoaded = loadInitialView("locale", {
+ async loadCallback(win) {
+ await checkCategory(win, "locale", {
+ expectHidden: false,
+ expectSelected: true,
+ });
+ gProvider.unblockQueryResponses();
+ },
+ });
+ let win = await viewLoaded;
+
+ let categoryUtils = new CategoryUtilities(win);
+
+ await TestUtils.waitForCondition(
+ () => categoryUtils.selectedCategory != "locale"
+ );
+
+ await checkCategory(win, "locale", {
+ expectHidden: true,
+ expectSelected: false,
+ });
+
+ is(
+ categoryUtils.getSelectedViewId(),
+ win.gViewController.defaultViewId,
+ "default view is selected"
+ );
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js b/toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js
new file mode 100644
index 0000000000..4c5b1e25f0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_sidebar_restore_category.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that the selected category is persisted across loads of the manager
+
+add_task(async function testCategoryRestore() {
+ let win = await loadInitialView("extension");
+ let utils = new CategoryUtilities(win);
+
+ // Open the plugins category
+ await utils.openType("plugin");
+
+ // Re-open the manager
+ await closeView(win);
+ win = await loadInitialView();
+ utils = new CategoryUtilities(win);
+
+ is(
+ utils.selectedCategory,
+ "plugin",
+ "Should have shown the plugins category"
+ );
+
+ // Open the extensions category
+ await utils.openType("extension");
+
+ // Re-open the manager
+ await closeView(win);
+ win = await loadInitialView();
+ utils = new CategoryUtilities(win);
+
+ is(
+ utils.selectedCategory,
+ "extension",
+ "Should have shown the extensions category"
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testInvalidAddonType() {
+ let win = await loadInitialView("invalid");
+
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.getSelectedViewId(),
+ win.gViewController.defaultViewId,
+ "default view is selected"
+ );
+ is(
+ win.gViewController.currentViewId,
+ win.gViewController.defaultViewId,
+ "default view is shown"
+ );
+
+ await closeView(win);
+});
+
+add_task(async function testInvalidViewId() {
+ let win = await loadInitialView("addons://invalid/view");
+
+ let categoryUtils = new CategoryUtilities(win);
+ is(
+ categoryUtils.getSelectedViewId(),
+ win.gViewController.defaultViewId,
+ "default view is selected"
+ );
+ is(
+ win.gViewController.currentViewId,
+ win.gViewController.defaultViewId,
+ "default view is shown"
+ );
+
+ await closeView(win);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_subframe_install.js b/toolkit/mozapps/extensions/test/browser/browser_subframe_install.js
new file mode 100644
index 0000000000..e9e8c73728
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_subframe_install.js
@@ -0,0 +1,234 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+
+AddonTestUtils.initMochitest(this);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.install.requireBuiltInCerts", false]],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+function testSubframeInstallOnNavigation({
+ topFrameURL,
+ midFrameURL,
+ bottomFrameURL,
+ xpiURL,
+ assertFn,
+}) {
+ return BrowserTestUtils.withNewTab(topFrameURL, async browser => {
+ await SpecialPowers.pushPrefEnv({
+ // Relax the user input requirements while running this test.
+ set: [["xpinstall.userActivation.required", false]],
+ });
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: [`${midFrameURL}*`],
+ js: ["createFrame.js"],
+ all_frames: true,
+ },
+ {
+ matches: [`${bottomFrameURL}*`],
+ js: ["installByNavigatingToXPIURL.js"],
+ all_frames: true,
+ },
+ ],
+ },
+ files: {
+ "createFrame.js": `(function(frameURL) {
+ browser.test.log("Executing createFrame.js on " + window.location.href);
+ const frame = document.createElement("iframe");
+ frame.src = frameURL;
+ document.body.appendChild(frame);
+ })("${bottomFrameURL}")`,
+
+ "installByNavigatingToXPIURL.js": `
+ browser.test.log("Navigating to XPI url from " + window.location.href);
+ const link = document.createElement("a");
+ link.id = "xpi-link";
+ link.href = "${xpiURL}";
+ link.textContent = "Link to XPI file";
+ document.body.appendChild(link);
+ link.click();
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await SpecialPowers.spawn(browser, [midFrameURL], async frameURL => {
+ const frame = content.document.createElement("iframe");
+ frame.src = frameURL;
+ content.document.body.appendChild(frame);
+ });
+
+ await assertFn({ browser });
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+ });
+}
+
+add_task(async function testInstallBlockedOnNavigationFromCrossOriginFrame() {
+ const promiseOriginBlocked = TestUtils.topicObserved(
+ "addon-install-origin-blocked"
+ );
+
+ await testSubframeInstallOnNavigation({
+ topFrameURL: "https://test1.example.com/",
+ midFrameURL: "https://example.org/",
+ bottomFrameURL: "https://test1.example.com/installTrigger",
+ xpiURL: XPI_URL,
+ assertFn: async () => {
+ await promiseOriginBlocked;
+ Assert.deepEqual(
+ await AddonManager.getAllInstalls(),
+ [],
+ "Expects no pending addon install"
+ );
+ },
+ });
+});
+
+add_task(async function testInstallPromptedOnNavigationFromSameOriginFrame() {
+ const promisePromptedInstallFromThirdParty = TestUtils.topicObserved(
+ "addon-install-blocked"
+ );
+
+ await testSubframeInstallOnNavigation({
+ topFrameURL: "https://test2.example.com/",
+ midFrameURL: "https://test1.example.com/",
+ bottomFrameURL: "https://test2.example.com/installTrigger",
+ xpiURL: XPI_URL,
+ assertFn: async () => {
+ const [subject] = await promisePromptedInstallFromThirdParty;
+ let installInfo = subject.wrappedJSObject;
+ ok(installInfo, "Got a blocked addon install pending");
+ installInfo.cancel();
+ },
+ });
+});
+
+add_task(async function testInstallTriggerBlockedFromCrossOriginFrame() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ const promiseOriginBlocked = TestUtils.topicObserved(
+ "addon-install-origin-blocked"
+ );
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ content_scripts: [
+ {
+ matches: ["https://example.org/*"],
+ js: ["createFrame.js"],
+ all_frames: true,
+ },
+ {
+ matches: ["https://test1.example.com/installTrigger*"],
+ js: ["installTrigger.js"],
+ all_frames: true,
+ },
+ ],
+ },
+ files: {
+ "createFrame.js": function () {
+ const frame = document.createElement("iframe");
+ frame.src = "https://test1.example.com/installTrigger/";
+ document.body.appendChild(frame);
+ },
+ "installTrigger.js": `
+ window.InstallTrigger.install({extension: "${XPI_URL}"});
+ `,
+ },
+ });
+
+ await extension.startup();
+
+ await BrowserTestUtils.withNewTab(
+ "https://test1.example.com",
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ const frame = content.document.createElement("iframe");
+ frame.src = "https://example.org";
+ content.document.body.appendChild(frame);
+ });
+
+ await promiseOriginBlocked;
+ Assert.deepEqual(
+ await AddonManager.getAllInstalls(),
+ [],
+ "Expects no pending addon install"
+ );
+ }
+ );
+
+ await extension.unload();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function testInstallTriggerPromptedFromSameOriginFrame() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ const promisePromptedInstallFromThirdParty = TestUtils.topicObserved(
+ "addon-install-blocked"
+ );
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ await SpecialPowers.spawn(browser, [XPI_URL], async xpiURL => {
+ const frame = content.document.createElement("iframe");
+ frame.src = "https://example.com";
+ const frameLoaded = new Promise(resolve => {
+ frame.addEventListener("load", resolve, { once: true });
+ });
+ content.document.body.appendChild(frame);
+ await frameLoaded;
+ frame.contentWindow.InstallTrigger.install({ URL: xpiURL });
+ });
+
+ const [subject] = await promisePromptedInstallFromThirdParty;
+ let installInfo = subject.wrappedJSObject;
+ ok(installInfo, "Got a blocked addon install pending");
+ is(
+ installInfo?.installs?.[0]?.state,
+ Services.prefs.getBoolPref(
+ "extensions.postDownloadThirdPartyPrompt",
+ false
+ )
+ ? AddonManager.STATE_DOWNLOADED
+ : AddonManager.STATE_AVAILABLE,
+ "Got a pending addon install"
+ );
+ await installInfo.cancel();
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_task_next_test.js b/toolkit/mozapps/extensions/test/browser/browser_task_next_test.js
new file mode 100644
index 0000000000..f8e3293b82
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_task_next_test.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that we throw if a test created with add_task()
+// calls run_next_test
+
+add_task(async function run_next_throws() {
+ let err = null;
+ try {
+ run_next_test();
+ } catch (e) {
+ err = e;
+ info("run_next_test threw " + err);
+ }
+ ok(err, "run_next_test() should throw an error inside an add_task test");
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updateid.js b/toolkit/mozapps/extensions/test/browser/browser_updateid.js
new file mode 100644
index 0000000000..c6e6d3030f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updateid.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that updates that change an add-on's ID show up correctly in the UI
+
+var gProvider;
+var gManagerWindow;
+var gCategoryUtilities;
+
+function getName(item) {
+ return item.addonNameEl.textContent;
+}
+
+async function getUpdateButton(item) {
+ let button = item.querySelector('[action="install-update"]');
+ let panel = button.closest("panel-list");
+ let shown = BrowserTestUtils.waitForEvent(panel, "shown");
+ let moreOptionsButton = item.querySelector('[action="more-options"]');
+ EventUtils.synthesizeMouseAtCenter(moreOptionsButton, {}, item.ownerGlobal);
+ await shown;
+ return button;
+}
+
+add_task(async function test_updateid() {
+ // Close the existing about:addons tab and unrestier the existing MockProvider
+ // instance if a previous failed test has not been able to clear them.
+ if (gManagerWindow) {
+ await close_manager(gManagerWindow);
+ }
+ if (gProvider) {
+ gProvider.unregister();
+ }
+
+ gProvider = new MockProvider();
+
+ gProvider.createAddons([
+ {
+ id: "addon1@tests.mozilla.org",
+ name: "manually updating addon",
+ version: "1.0",
+ applyBackgroundUpdates: AddonManager.AUTOUPDATE_DISABLE,
+ },
+ ]);
+
+ gManagerWindow = await open_manager("addons://list/extension");
+ gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+ await gCategoryUtilities.openType("extension");
+
+ gProvider.createInstalls([
+ {
+ name: "updated add-on",
+ existingAddon: gProvider.addons[0],
+ version: "2.0",
+ },
+ ]);
+ var newAddon = new MockAddon("addon2@tests.mozilla.org");
+ newAddon.name = "updated add-on";
+ newAddon.version = "2.0";
+ newAddon.pendingOperations = AddonManager.PENDING_INSTALL;
+ gProvider.installs[0]._addonToInstall = newAddon;
+
+ var item = getAddonCard(gManagerWindow, "addon1@tests.mozilla.org");
+ is(
+ getName(item),
+ "manually updating addon",
+ "Should show the old name in the list"
+ );
+ const { name, version } = await get_tooltip_info(item, gManagerWindow);
+ is(
+ name,
+ "manually updating addon",
+ "Should show the old name in the tooltip"
+ );
+ is(version, "1.0", "Should still show the old version in the tooltip");
+
+ var update = await getUpdateButton(item);
+ is_element_visible(update, "Update button should be visible");
+
+ item = getAddonCard(gManagerWindow, "addon2@tests.mozilla.org");
+ is(item, null, "Should not show the new version in the list");
+
+ await close_manager(gManagerWindow);
+ gManagerWindow = null;
+ gProvider.unregister();
+ gProvider = null;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updatessl.js b/toolkit/mozapps/extensions/test/browser/browser_updatessl.js
new file mode 100644
index 0000000000..9dbeec4a84
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.js
@@ -0,0 +1,389 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+let { AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+);
+
+const updatejson = RELATIVE_DIR + "browser_updatessl.json";
+const redirect = RELATIVE_DIR + "redirect.sjs?";
+const SUCCESS = 0;
+const DOWNLOAD_ERROR = AddonManager.ERROR_DOWNLOAD_ERROR;
+
+const HTTP = "http://example.com/";
+const HTTPS = "https://example.com/";
+const NOCERT = "https://nocert.example.com/";
+const SELFSIGNED = "https://self-signed.example.com/";
+const UNTRUSTED = "https://untrusted.example.com/";
+const EXPIRED = "https://expired.example.com/";
+
+const PREF_UPDATE_REQUIREBUILTINCERTS = "extensions.update.requireBuiltInCerts";
+
+var gTests = [];
+var gStart = 0;
+var gLast = 0;
+
+var HTTPObserver = {
+ observeActivity(
+ aChannel,
+ aType,
+ aSubtype,
+ aTimestamp,
+ aSizeData,
+ aStringData
+ ) {
+ aChannel.QueryInterface(Ci.nsIChannel);
+
+ dump(
+ "*** HTTP Activity 0x" +
+ aType.toString(16) +
+ " 0x" +
+ aSubtype.toString(16) +
+ " " +
+ aChannel.URI.spec +
+ "\n"
+ );
+ },
+};
+
+function test() {
+ gStart = Date.now();
+ requestLongerTimeout(4);
+ waitForExplicitFinish();
+
+ let observerService = Cc[
+ "@mozilla.org/network/http-activity-distributor;1"
+ ].getService(Ci.nsIHttpActivityDistributor);
+ observerService.addObserver(HTTPObserver);
+
+ registerCleanupFunction(function () {
+ observerService.removeObserver(HTTPObserver);
+ });
+
+ run_next_test();
+}
+
+function end_test() {
+ var cos = Cc["@mozilla.org/security/certoverride;1"].getService(
+ Ci.nsICertOverrideService
+ );
+ cos.clearValidityOverride("nocert.example.com", -1, {});
+ cos.clearValidityOverride("self-signed.example.com", -1, {});
+ cos.clearValidityOverride("untrusted.example.com", -1, {});
+ cos.clearValidityOverride("expired.example.com", -1, {});
+
+ info("All tests completed in " + (Date.now() - gStart) + "ms");
+ finish();
+}
+
+function add_update_test(mainURL, redirectURL, expectedStatus) {
+ gTests.push([mainURL, redirectURL, expectedStatus]);
+}
+
+function run_update_tests(callback) {
+ function run_next_update_test() {
+ if (!gTests.length) {
+ callback();
+ return;
+ }
+ gLast = Date.now();
+
+ let [mainURL, redirectURL, expectedStatus] = gTests.shift();
+ if (redirectURL) {
+ var url = mainURL + redirect + redirectURL + updatejson;
+ var message =
+ "Should have seen the right result for an update check redirected from " +
+ mainURL +
+ " to " +
+ redirectURL;
+ } else {
+ url = mainURL + updatejson;
+ message =
+ "Should have seen the right result for an update check from " + mainURL;
+ }
+
+ AddonUpdateChecker.checkForUpdates("addon1@tests.mozilla.org", url, {
+ onUpdateCheckComplete(updates) {
+ is(updates.length, 1, "Should be the right number of results");
+ is(SUCCESS, expectedStatus, message);
+ info("Update test ran in " + (Date.now() - gLast) + "ms");
+ run_next_update_test();
+ },
+
+ onUpdateCheckError(status) {
+ is(status, expectedStatus, message);
+ info("Update test ran in " + (Date.now() - gLast) + "ms");
+ run_next_update_test();
+ },
+ });
+ }
+
+ run_next_update_test();
+}
+
+// Runs tests with built-in certificates required and no certificate exceptions.
+add_test(async function test_builtin_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, true]],
+ });
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, null, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, null, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, null, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, null, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTP, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, EXPIRED, DOWNLOAD_ERROR);
+
+ run_update_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates and no certificate
+// exceptions.
+add_test(async function test_builtin_not_required() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, SUCCESS);
+ add_update_test(NOCERT, null, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, null, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, null, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, null, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTP, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTP, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, SUCCESS);
+ add_update_test(HTTPS, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, EXPIRED, DOWNLOAD_ERROR);
+
+ run_update_tests(run_next_test);
+});
+
+// Set up overrides for the next test.
+add_test(() => {
+ addCertOverrides().then(run_next_test);
+});
+
+// Runs tests with built-in certificates required and all certificate exceptions.
+add_test(async function test_builtin_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, true]],
+ });
+
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, null, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, null, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, null, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, null, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, SUCCESS);
+ add_update_test(HTTP, SELFSIGNED, SUCCESS);
+ add_update_test(HTTP, UNTRUSTED, SUCCESS);
+ add_update_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, EXPIRED, DOWNLOAD_ERROR);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, NOCERT, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, SELFSIGNED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, UNTRUSTED, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, EXPIRED, DOWNLOAD_ERROR);
+
+ run_update_tests(run_next_test);
+});
+
+// Runs tests without requiring built-in certificates and all certificate
+// exceptions.
+add_test(async function test_builtin_not_required_overrides() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_UPDATE_REQUIREBUILTINCERTS, false]],
+ });
+
+ // Tests that a simple update.json retrieval works as expected.
+ add_update_test(HTTP, null, SUCCESS);
+ add_update_test(HTTPS, null, SUCCESS);
+ add_update_test(NOCERT, null, SUCCESS);
+ add_update_test(SELFSIGNED, null, SUCCESS);
+ add_update_test(UNTRUSTED, null, SUCCESS);
+ add_update_test(EXPIRED, null, SUCCESS);
+
+ // Tests that redirecting from http to other servers works as expected
+ add_update_test(HTTP, HTTP, SUCCESS);
+ add_update_test(HTTP, HTTPS, SUCCESS);
+ add_update_test(HTTP, NOCERT, SUCCESS);
+ add_update_test(HTTP, SELFSIGNED, SUCCESS);
+ add_update_test(HTTP, UNTRUSTED, SUCCESS);
+ add_update_test(HTTP, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from valid https to other servers works as expected
+ add_update_test(HTTPS, HTTP, DOWNLOAD_ERROR);
+ add_update_test(HTTPS, HTTPS, SUCCESS);
+ add_update_test(HTTPS, NOCERT, SUCCESS);
+ add_update_test(HTTPS, SELFSIGNED, SUCCESS);
+ add_update_test(HTTPS, UNTRUSTED, SUCCESS);
+ add_update_test(HTTPS, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from nocert https to other servers works as expected
+ add_update_test(NOCERT, HTTP, DOWNLOAD_ERROR);
+ add_update_test(NOCERT, HTTPS, SUCCESS);
+ add_update_test(NOCERT, NOCERT, SUCCESS);
+ add_update_test(NOCERT, SELFSIGNED, SUCCESS);
+ add_update_test(NOCERT, UNTRUSTED, SUCCESS);
+ add_update_test(NOCERT, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from self-signed https to other servers works as expected
+ add_update_test(SELFSIGNED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(SELFSIGNED, HTTPS, SUCCESS);
+ add_update_test(SELFSIGNED, NOCERT, SUCCESS);
+ add_update_test(SELFSIGNED, SELFSIGNED, SUCCESS);
+ add_update_test(SELFSIGNED, UNTRUSTED, SUCCESS);
+ add_update_test(SELFSIGNED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from untrusted https to other servers works as expected
+ add_update_test(UNTRUSTED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(UNTRUSTED, HTTPS, SUCCESS);
+ add_update_test(UNTRUSTED, NOCERT, SUCCESS);
+ add_update_test(UNTRUSTED, SELFSIGNED, SUCCESS);
+ add_update_test(UNTRUSTED, UNTRUSTED, SUCCESS);
+ add_update_test(UNTRUSTED, EXPIRED, SUCCESS);
+
+ // Tests that redirecting from expired https to other servers works as expected
+ add_update_test(EXPIRED, HTTP, DOWNLOAD_ERROR);
+ add_update_test(EXPIRED, HTTPS, SUCCESS);
+ add_update_test(EXPIRED, NOCERT, SUCCESS);
+ add_update_test(EXPIRED, SELFSIGNED, SUCCESS);
+ add_update_test(EXPIRED, UNTRUSTED, SUCCESS);
+ add_update_test(EXPIRED, EXPIRED, SUCCESS);
+
+ run_update_tests(run_next_test);
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updatessl.json b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json
new file mode 100644
index 0000000000..223d1ef2d3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json
@@ -0,0 +1,17 @@
+{
+ "addons": {
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "20"
+ }
+ },
+ "version": "2.0"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^
new file mode 100644
index 0000000000..2e4f8163bb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_updatessl.json^headers^
@@ -0,0 +1 @@
+Connection: close
diff --git a/toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js b/toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js
new file mode 100644
index 0000000000..e245e3a6e4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_verify_l10n_strings.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ BuiltInThemes: "resource:///modules/BuiltInThemes.sys.mjs",
+});
+
+// Maps add-on descriptors to updated Fluent IDs. Keep it in sync
+// with the list in XPIDatabase.sys.mjs.
+const updatedAddonFluentIds = new Map([
+ ["extension-default-theme-name", "extension-default-theme-name-auto"],
+]);
+
+add_task(async function test_ensure_bundled_addons_are_localized() {
+ let l10nReg = L10nRegistry.getInstance();
+ let bundles = l10nReg.generateBundlesSync(
+ ["en-US"],
+ ["browser/appExtensionFields.ftl"]
+ );
+ let addons = await AddonManager.getAllAddons();
+ let standardBuiltInThemes = addons.filter(
+ addon =>
+ addon.isBuiltin &&
+ addon.type === "theme" &&
+ !addon.id.endsWith("colorway@mozilla.org")
+ );
+ let bundle = bundles.next().value;
+
+ ok(!!standardBuiltInThemes.length, "Standard built-in themes should exist");
+
+ for (let standardTheme of standardBuiltInThemes) {
+ let l10nId = standardTheme.id.replace("@mozilla.org", "");
+ for (let prop of ["name", "description"]) {
+ let defaultFluentId = `extension-${l10nId}-${prop}`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ ok(
+ bundle.hasMessage(fluentId),
+ `l10n id for ${standardTheme.id} \"${prop}\" attribute should exist`
+ );
+ }
+ }
+
+ let colorwayThemes = Array.from(BuiltInThemes.builtInThemeMap.keys()).filter(
+ id => id.endsWith("colorway@mozilla.org")
+ );
+ ok(!!colorwayThemes.length, "Colorway themes should exist");
+ for (let id of colorwayThemes) {
+ let l10nId = id.replace("@mozilla.org", "");
+ let [, variantName] = l10nId.split("-", 2);
+ if (variantName != "colorway") {
+ let defaultFluentId = `extension-colorways-${variantName}-name`;
+ let fluentId =
+ updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId;
+ ok(
+ bundle.hasMessage(fluentId),
+ `l10n id for ${id} \"name\" attribute should exist`
+ );
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi.js b/toolkit/mozapps/extensions/test/browser/browser_webapi.js
new file mode 100644
index 0000000000..853cd3902a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+function testWithAPI(task) {
+ return async function () {
+ await BrowserTestUtils.withNewTab(TESTPAGE, task);
+ };
+}
+
+let gProvider = new MockProvider();
+
+let addons = gProvider.createAddons([
+ {
+ id: "addon1@tests.mozilla.org",
+ name: "Test add-on 1",
+ version: "2.1",
+ description: "Short description",
+ type: "extension",
+ userDisabled: false,
+ isActive: true,
+ },
+ {
+ id: "addon2@tests.mozilla.org",
+ name: "Test add-on 2",
+ version: "5.3.7ab",
+ description: null,
+ type: "theme",
+ userDisabled: false,
+ isActive: false,
+ },
+ {
+ id: "addon3@tests.mozilla.org",
+ name: "Test add-on 3",
+ version: "1",
+ description: "Longer description",
+ type: "extension",
+ userDisabled: true,
+ isActive: false,
+ },
+ {
+ id: "addon4@tests.mozilla.org",
+ name: "Test add-on 4",
+ version: "1",
+ description: "Longer description",
+ type: "extension",
+ userDisabled: false,
+ isActive: true,
+ },
+]);
+
+addons[3].permissions &= ~AddonManager.PERM_CAN_UNINSTALL;
+
+function API_getAddonByID(browser, id) {
+ return SpecialPowers.spawn(browser, [id], async function (id) {
+ let addon = await content.navigator.mozAddonManager.getAddonByID(id);
+ let addonDetails = {};
+ for (let prop in addon) {
+ addonDetails[prop] = addon[prop];
+ }
+ // We can't send native objects back so clone its properties.
+ return JSON.parse(JSON.stringify(addonDetails));
+ });
+}
+
+add_task(
+ testWithAPI(async function (browser) {
+ function compareObjects(web, real) {
+ ok(
+ !!Object.keys(web).length,
+ "Got a valid mozAddonManager addon object dump"
+ );
+
+ for (let prop of Object.keys(web)) {
+ let webVal = web[prop];
+ let realVal = real[prop];
+
+ switch (prop) {
+ case "isEnabled":
+ realVal = !real.userDisabled;
+ break;
+
+ case "canUninstall":
+ realVal = Boolean(
+ real.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ break;
+ }
+
+ // null and undefined don't compare well so stringify them first
+ if (realVal === null || realVal === undefined) {
+ realVal = `${realVal}`;
+ webVal = `${webVal}`;
+ }
+
+ is(
+ webVal,
+ realVal,
+ `Property ${prop} should have the right value in add-on ${real.id}`
+ );
+ }
+ }
+
+ let [a1, a2, a3] = await promiseAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ "addon3@tests.mozilla.org",
+ ]);
+ let w1 = await API_getAddonByID(browser, "addon1@tests.mozilla.org");
+ let w2 = await API_getAddonByID(browser, "addon2@tests.mozilla.org");
+ let w3 = await API_getAddonByID(browser, "addon3@tests.mozilla.org");
+
+ compareObjects(w1, a1);
+ compareObjects(w2, a2);
+ compareObjects(w3, a3);
+ })
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js
new file mode 100644
index 0000000000..b9ea0f6a93
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_abuse_report.js
@@ -0,0 +1,375 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+loadTestSubscript("head_abuse_report.js");
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+const TELEMETRY_EVENTS_FILTERS = {
+ category: "addonsManager",
+ method: "report",
+};
+const REPORT_PROP_NAMES = [
+ "addon",
+ "addon_signature",
+ "reason",
+ "message",
+ "report_entry_point",
+];
+
+function getObjectProps(obj, propNames) {
+ const res = {};
+ for (const k of propNames) {
+ res[k] = obj[k];
+ }
+ return res;
+}
+
+async function assertSubmittedReport(expectedReportProps) {
+ let reportSubmitted;
+ const onReportSubmitted = AbuseReportTestUtils.promiseReportSubmitHandled(
+ ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ }
+ );
+
+ let panelEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseWinClosed = waitClosedWindow();
+ let promisePanelUpdated = AbuseReportTestUtils.promiseReportUpdated(
+ panelEl,
+ "submit"
+ );
+ panelEl._form.elements.reason.value = expectedReportProps.reason;
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnNext);
+ await promisePanelUpdated;
+
+ panelEl._form.elements.message.value = expectedReportProps.message;
+ // Reset the timestamp of the last report between tests.
+ AbuseReporter._lastReportTimestamp = null;
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnSubmit);
+ await Promise.all([onReportSubmitted, promiseWinClosed]);
+
+ ok(!panelEl.ownerGlobal, "Report dialog window is closed");
+ Assert.deepEqual(
+ getObjectProps(reportSubmitted, REPORT_PROP_NAMES),
+ expectedReportProps,
+ "Got the expected report data submitted"
+ );
+}
+
+add_setup(async function () {
+ await AbuseReportTestUtils.setup();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.abuseReport.amWebAPI.enabled", true],
+ // Make sure the integrated abuse report panel is the one enabled
+ // while this test file runs (instead of the AMO hosted form).
+ // NOTE: behaviors expected when amoFormEnabled is true are tested
+ // in the separate browser_amo_abuse_report.js test file.
+ ["extensions.abuseReport.amoFormEnabled", false],
+ ],
+ });
+});
+
+add_task(async function test_report_installed_addon_cancelled() {
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const extension = await installTestExtension(ADDON_ID);
+
+ let reportEnabled = await SpecialPowers.spawn(browser, [], () => {
+ return content.navigator.mozAddonManager.abuseReportPanelEnabled;
+ });
+
+ is(reportEnabled, true, "Expect abuseReportPanelEnabled to be true");
+
+ info("Test reportAbuse result on user cancelled report");
+
+ let promiseNewWindow = waitForNewWindow();
+ let promiseWebAPIResult = SpecialPowers.spawn(
+ browser,
+ [ADDON_ID],
+ addonId => content.navigator.mozAddonManager.reportAbuse(addonId)
+ );
+
+ let win = await promiseNewWindow;
+ is(win, AbuseReportTestUtils.getReportDialog(), "Got the report dialog");
+
+ let panelEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ let promiseWinClosed = waitClosedWindow();
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnCancel);
+ let reportResult = await promiseWebAPIResult;
+ is(
+ reportResult,
+ false,
+ "Expect reportAbuse to resolve to false on user cancelled report"
+ );
+ await promiseWinClosed;
+ ok(!panelEl.ownerGlobal, "Report dialog window is closed");
+
+ await extension.unload();
+ });
+
+ // Expect no telemetry events collected for user cancelled reports.
+ TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTERS);
+});
+
+add_task(async function test_report_installed_addon_submitted() {
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const extension = await installTestExtension(ADDON_ID);
+
+ let promiseNewWindow = waitForNewWindow();
+ let promiseWebAPIResult = SpecialPowers.spawn(browser, [ADDON_ID], id =>
+ content.navigator.mozAddonManager.reportAbuse(id)
+ );
+ let win = await promiseNewWindow;
+ is(win, AbuseReportTestUtils.getReportDialog(), "Got the report dialog");
+
+ await assertSubmittedReport({
+ addon: ADDON_ID,
+ addon_signature: "missing",
+ message: "fake report message",
+ reason: "unwanted",
+ report_entry_point: "amo",
+ });
+
+ let reportResult = await promiseWebAPIResult;
+ is(
+ reportResult,
+ true,
+ "Expect reportAbuse to resolve to false on user cancelled report"
+ );
+
+ await extension.unload();
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "amo",
+ value: ADDON_ID,
+ extra: { addon_type: "extension" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+});
+
+add_task(async function test_report_unknown_not_installed_addon() {
+ const addonId = "unknown-addon@mochi.test";
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ let promiseWebAPIResult = SpecialPowers.spawn(browser, [addonId], id =>
+ content.navigator.mozAddonManager.reportAbuse(id).catch(err => {
+ return { name: err.name, message: err.message };
+ })
+ );
+
+ await Assert.deepEqual(
+ await promiseWebAPIResult,
+ { name: "Error", message: "Error creating abuse report" },
+ "Got the expected rejected error on reporting unknown addon"
+ );
+
+ ok(!AbuseReportTestUtils.getReportDialog(), "No report dialog is open");
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "amo",
+ value: addonId,
+ extra: { error_type: "ERROR_AMODETAILS_NOTFOUND" },
+ },
+ {
+ object: "amo",
+ value: addonId,
+ extra: { error_type: "ERROR_ADDON_NOTFOUND" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+});
+
+add_task(async function test_report_not_installed_addon() {
+ const addonId = "not-installed-addon@mochi.test";
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const fakeAMODetails = {
+ name: "fake name",
+ current_version: { version: "1.0" },
+ type: "extension",
+ icon_url: "http://test.addons.org/asserts/fake-icon-url.png",
+ homepage: "http://fake.url/homepage",
+ authors: [{ name: "author1", url: "http://fake.url/author1" }],
+ is_recommended: false,
+ };
+
+ AbuseReportTestUtils.amoAddonDetailsMap.set(addonId, fakeAMODetails);
+ registerCleanupFunction(() =>
+ AbuseReportTestUtils.amoAddonDetailsMap.clear()
+ );
+
+ let promiseNewWindow = waitForNewWindow();
+
+ let promiseWebAPIResult = SpecialPowers.spawn(browser, [addonId], id =>
+ content.navigator.mozAddonManager.reportAbuse(id)
+ );
+ let win = await promiseNewWindow;
+ is(win, AbuseReportTestUtils.getReportDialog(), "Got the report dialog");
+
+ await assertSubmittedReport({
+ addon: addonId,
+ addon_signature: "unknown",
+ message: "fake report message",
+ reason: "other",
+ report_entry_point: "amo",
+ });
+
+ let reportResult = await promiseWebAPIResult;
+ is(
+ reportResult,
+ true,
+ "Expect reportAbuse to resolve to true on submitted report"
+ );
+ });
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "amo",
+ value: addonId,
+ extra: { addon_type: "extension" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+});
+
+add_task(async function test_amo_report_on_report_already_inprogress() {
+ const extension = await installTestExtension(ADDON_ID);
+ const reportDialog = await AbuseReporter.openDialog(
+ ADDON_ID,
+ "menu",
+ gBrowser.selectedBrowser
+ );
+ await AbuseReportTestUtils.promiseReportDialogRendered();
+ ok(reportDialog.window, "Got an open report dialog");
+
+ let promiseWinClosed = waitClosedWindow();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const promiseAMOResult = SpecialPowers.spawn(browser, [ADDON_ID], id =>
+ content.navigator.mozAddonManager.reportAbuse(id)
+ );
+
+ await promiseWinClosed;
+ ok(reportDialog.window.closed, "previous report dialog should be closed");
+
+ is(
+ await reportDialog.promiseAMOResult,
+ undefined,
+ "old report cancelled after AMO called mozAddonManager.reportAbuse"
+ );
+
+ const panelEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ const { report } = AbuseReportTestUtils.getReportDialogParams();
+ Assert.deepEqual(
+ {
+ reportEntryPoint: report.reportEntryPoint,
+ addonId: report.addon.id,
+ },
+ {
+ reportEntryPoint: "amo",
+ addonId: ADDON_ID,
+ },
+ "Got the expected report from the opened report dialog"
+ );
+
+ promiseWinClosed = waitClosedWindow();
+ AbuseReportTestUtils.clickPanelButton(panelEl._btnCancel);
+ await promiseWinClosed;
+
+ is(
+ await promiseAMOResult,
+ false,
+ "AMO report request resolved to false on cancel button clicked"
+ );
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_reject_on_unsupported_addon_types() {
+ const addonId = "not-supported-addon-type@mochi.test";
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ const fakeAMODetails = {
+ name: "fake name",
+ current_version: { version: "1.0" },
+ type: "fake-unsupported-addon-type",
+ };
+
+ AbuseReportTestUtils.amoAddonDetailsMap.set(addonId, fakeAMODetails);
+ registerCleanupFunction(() =>
+ AbuseReportTestUtils.amoAddonDetailsMap.clear()
+ );
+
+ let webAPIResult = await SpecialPowers.spawn(browser, [addonId], id =>
+ content.navigator.mozAddonManager.reportAbuse(id).then(
+ res => ({ gotRejection: false, result: res }),
+ err => ({ gotRejection: true, message: err.message })
+ )
+ );
+
+ Assert.deepEqual(
+ webAPIResult,
+ { gotRejection: true, message: "Error creating abuse report" },
+ "Got the expected rejection from mozAddonManager.reportAbuse"
+ );
+ });
+});
+
+add_task(async function test_report_on_disabled_webapi() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.abuseReport.amWebAPI.enabled", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ let reportEnabled = await SpecialPowers.spawn(browser, [], () => {
+ return content.navigator.mozAddonManager.abuseReportPanelEnabled;
+ });
+
+ is(reportEnabled, false, "Expect abuseReportPanelEnabled to be false");
+
+ info("Test reportAbuse result on report webAPI disabled");
+
+ let promiseWebAPIResult = SpecialPowers.spawn(
+ browser,
+ ["an-addon@mochi.test"],
+ addonId =>
+ content.navigator.mozAddonManager.reportAbuse(addonId).catch(err => {
+ return { name: err.name, message: err.message };
+ })
+ );
+
+ Assert.deepEqual(
+ await promiseWebAPIResult,
+ { name: "Error", message: "amWebAPI reportAbuse not supported" },
+ "Got the expected rejected error"
+ );
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_access.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_access.js
new file mode 100644
index 0000000000..aec6ddedca
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_access.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function check_frame_availability(browser) {
+ return check_availability(browser.browsingContext.children[0]);
+}
+
+function check_availability(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ return content.document.getElementById("result").textContent == "true";
+ });
+}
+
+// Test that initially the API isn't available in the test domain
+add_task(async function test_not_available() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that with testing on the API is available in the test domain
+add_task(async function test_available() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(available, "API should be available.");
+ }
+ );
+});
+
+// Test that the API is not available in a bad domain
+add_task(async function test_bad_domain() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT2}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that the API is only available in https sites
+add_task(async function test_not_available_http() {
+ await BrowserTestUtils.withNewTab(
+ `${TESTROOT}webapi_checkavailable.html`,
+ async function test_not_available(browser) {
+ let available = await check_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that the API is available when in a frame of the test domain
+add_task(async function test_available_framed() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT}webapi_checkframed.html`,
+ async function test_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(available, "API should be available.");
+ }
+ );
+});
+
+// Test that if the external frame is http then the inner frame doesn't have
+// the API
+add_task(async function test_not_available_http_framed() {
+ await BrowserTestUtils.withNewTab(
+ `${TESTROOT}webapi_checkframed.html`,
+ async function test_not_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that if the external frame is a bad domain then the inner frame doesn't
+// have the API
+add_task(async function test_not_available_framed() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT2}webapi_checkframed.html`,
+ async function test_not_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(!available, "API should not be available.");
+ }
+ );
+});
+
+// Test that a window navigated to a bad domain doesn't allow access to the API
+add_task(async function test_navigated_window() {
+ await BrowserTestUtils.withNewTab(
+ `${SECURE_TESTROOT2}webapi_checknavigatedwindow.html`,
+ async function test_available(browser) {
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ await content.wrappedJSObject.openWindow();
+ });
+
+ // Should be a new tab open
+ let tab = await tabPromise;
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.getBrowserForTab(tab)
+ );
+
+ SpecialPowers.spawn(browser, [], async function () {
+ content.wrappedJSObject.navigate();
+ });
+
+ await loadPromise;
+
+ let available = await SpecialPowers.spawn(browser, [], async function () {
+ return content.wrappedJSObject.check();
+ });
+
+ ok(!available, "API should not be available.");
+
+ gBrowser.removeTab(tab);
+ }
+ );
+});
+
+// Check that if a page is embedded in a chrome content UI that it can still
+// access the API.
+add_task(async function test_chrome_frame() {
+ SpecialPowers.pushPrefEnv({
+ set: [["security.allow_unsafe_parent_loads", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ `${CHROMEROOT}webapi_checkchromeframe.xhtml`,
+ async function test_available(browser) {
+ let available = await check_frame_availability(browser);
+ ok(available, "API should be available.");
+ }
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js
new file mode 100644
index 0000000000..3692644714
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_addon_listener.js
@@ -0,0 +1,124 @@
+const TESTPAGE = `${SECURE_TESTROOT}webapi_addon_listener.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+async function getListenerEvents(browser) {
+ let result = await SpecialPowers.spawn(browser, [], async function () {
+ return content.document.getElementById("result").textContent;
+ });
+
+ return result.split("\n").map(JSON.parse);
+}
+
+const RESTARTLESS_ID = "restartless@tests.mozilla.org";
+const INSTALL_ID = "install@tests.mozilla.org";
+const CANCEL_ID = "cancel@tests.mozilla.org";
+
+let provider = new MockProvider();
+provider.createAddons([
+ {
+ id: RESTARTLESS_ID,
+ name: "Restartless add-on",
+ operationsRequiringRestart: AddonManager.OP_NEED_RESTART_NONE,
+ },
+ {
+ id: CANCEL_ID,
+ name: "Add-on for uninstall cancel",
+ },
+]);
+
+// Test enable/disable events for restartless
+add_task(async function test_restartless() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(RESTARTLESS_ID);
+ is(addon.userDisabled, false, "addon is enabled");
+
+ // disable it
+ await addon.disable();
+ is(addon.userDisabled, true, "addon was disabled successfully");
+
+ // re-enable it
+ await addon.enable();
+ is(addon.userDisabled, false, "addon was re-enabled successfuly");
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: RESTARTLESS_ID, event: "onDisabling" },
+ { id: RESTARTLESS_ID, event: "onDisabled" },
+ { id: RESTARTLESS_ID, event: "onEnabling" },
+ { id: RESTARTLESS_ID, event: "onEnabled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected disable/enable events");
+ });
+});
+
+// Test install events
+add_task(async function test_restartless() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = new MockAddon(
+ INSTALL_ID,
+ "installme",
+ null,
+ AddonManager.OP_NEED_RESTART_NONE
+ );
+ let install = new MockInstall(null, null, addon);
+
+ let installPromise = new Promise(resolve => {
+ install.addTestListener({
+ onInstallEnded: resolve,
+ });
+ });
+
+ provider.addInstall(install);
+ install.install();
+
+ await installPromise;
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: INSTALL_ID, event: "onInstalling" },
+ { id: INSTALL_ID, event: "onInstalled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected install events");
+ });
+});
+
+// Test uninstall
+add_task(async function test_uninstall() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(RESTARTLESS_ID);
+ isnot(addon, null, "Found add-on for uninstall");
+
+ addon.uninstall();
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: RESTARTLESS_ID, event: "onUninstalling" },
+ { id: RESTARTLESS_ID, event: "onUninstalled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected uninstall events");
+ });
+});
+
+// Test cancel of uninstall.
+add_task(async function test_cancel() {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(CANCEL_ID);
+ isnot(addon, null, "Found add-on for cancelling uninstall");
+
+ addon.uninstall();
+
+ let events = await getListenerEvents(browser);
+ let expected = [{ id: CANCEL_ID, event: "onUninstalling" }];
+ Assert.deepEqual(events, expected, "Got expected uninstalling event");
+
+ addon.cancelUninstall();
+ events = await getListenerEvents(browser);
+ expected.push({ id: CANCEL_ID, event: "onOperationCancelled" });
+ Assert.deepEqual(events, expected, "Got expected cancel event");
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js
new file mode 100644
index 0000000000..25989bf797
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_enable.js
@@ -0,0 +1,63 @@
+const TESTPAGE = `${SECURE_TESTROOT}webapi_addon_listener.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+async function getListenerEvents(browser) {
+ let result = await SpecialPowers.spawn(browser, [], async function () {
+ return content.document.getElementById("result").textContent;
+ });
+
+ return result.split("\n").map(JSON.parse);
+}
+
+const ID = "test@tests.mozilla.org";
+
+let provider = new MockProvider();
+provider.createAddons([
+ {
+ id: ID,
+ name: "Test add-on",
+ operationsRequiringRestart: AddonManager.OP_NEED_RESTART_NONE,
+ },
+]);
+
+// Test disable and enable from content
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ let addon = await promiseAddonByID(ID);
+ isnot(addon, null, "Test addon exists");
+ is(addon.userDisabled, false, "addon is enabled");
+
+ // Disable the addon from content.
+ await SpecialPowers.spawn(browser, [], async function () {
+ return content.navigator.mozAddonManager
+ .getAddonByID("test@tests.mozilla.org")
+ .then(addon => addon.setEnabled(false));
+ });
+
+ let events = await getListenerEvents(browser);
+ let expected = [
+ { id: ID, event: "onDisabling" },
+ { id: ID, event: "onDisabled" },
+ ];
+ Assert.deepEqual(events, expected, "Got expected disable events");
+
+ // Enable the addon from content.
+ await SpecialPowers.spawn(browser, [], async function () {
+ return content.navigator.mozAddonManager
+ .getAddonByID("test@tests.mozilla.org")
+ .then(addon => addon.setEnabled(true));
+ });
+
+ events = await getListenerEvents(browser);
+ expected = expected.concat([
+ { id: ID, event: "onEnabling" },
+ { id: ID, event: "onEnabled" },
+ ]);
+ Assert.deepEqual(events, expected, "Got expected enable events");
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js
new file mode 100644
index 0000000000..24d34c3f4d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_install.js
@@ -0,0 +1,652 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const TESTPATH = "webapi_checkavailable.html";
+const TESTPAGE = `${SECURE_TESTROOT}${TESTPATH}`;
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+const XPI_ADDON_ID = "amosigned-xpi@tests.mozilla.org";
+
+const XPI_SHA =
+ "sha256:91121ed2c27f670f2307b9aebdd30979f147318c7fb9111c254c14ddbb84e4b0";
+
+const ID = "amosigned-xpi@tests.mozilla.org";
+// eh, would be good to just stat the real file instead of this...
+const XPI_LEN = 4287;
+
+AddonTestUtils.initMochitest(this);
+
+function waitForClear() {
+ const MSG = "WebAPICleanup";
+ return new Promise(resolve => {
+ let listener = {
+ receiveMessage(msg) {
+ if (msg.name == MSG) {
+ Services.mm.removeMessageListener(MSG, listener);
+ resolve();
+ }
+ },
+ };
+
+ Services.mm.addMessageListener(MSG, listener, true);
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+ info("added preferences");
+});
+
+// Wrapper around a common task to run in the content process to test
+// the mozAddonManager API. Takes a URL for the XPI to install and an
+// array of steps, each of which can either be an action to take
+// (i.e., start or cancel the install) or an install event to wait for.
+// Steps that look for a specific event may also include a "props" property
+// with properties that the AddonInstall object is expected to have when
+// that event is triggered.
+async function testInstall(browser, args, steps, description) {
+ let success = await SpecialPowers.spawn(
+ browser,
+ [{ args, steps }],
+ async function (opts) {
+ let { args, steps } = opts;
+ let install = await content.navigator.mozAddonManager.createInstall(args);
+ if (!install) {
+ await Promise.reject(
+ "createInstall() did not return an install object"
+ );
+ }
+
+ // Check that the initial state of the AddonInstall is sane.
+ if (install.state != "STATE_AVAILABLE") {
+ await Promise.reject("new install should be in STATE_AVAILABLE");
+ }
+ if (install.error != null) {
+ await Promise.reject("new install should have null error");
+ }
+
+ const events = [
+ "onDownloadStarted",
+ "onDownloadProgress",
+ "onDownloadEnded",
+ "onDownloadCancelled",
+ "onDownloadFailed",
+ "onInstallStarted",
+ "onInstallEnded",
+ "onInstallCancelled",
+ "onInstallFailed",
+ ];
+ let eventWaiter = null;
+ let receivedEvents = [];
+ let prevEvent = null;
+ events.forEach(event => {
+ install.addEventListener(event, e => {
+ receivedEvents.push({
+ event,
+ state: install.state,
+ error: install.error,
+ progress: install.progress,
+ maxProgress: install.maxProgress,
+ });
+ if (eventWaiter) {
+ eventWaiter();
+ }
+ });
+ });
+
+ // Returns a promise that is resolved when the given event occurs
+ // or rejects if a different event comes first or if props is supplied
+ // and properties on the AddonInstall don't match those in props.
+ function expectEvent(event, props) {
+ return new Promise((resolve, reject) => {
+ function check() {
+ let received = receivedEvents.shift();
+ // Skip any repeated onDownloadProgress events.
+ while (
+ received &&
+ received.event == prevEvent &&
+ prevEvent == "onDownloadProgress"
+ ) {
+ received = receivedEvents.shift();
+ }
+ // Wait for more events if we skipped all there were.
+ if (!received) {
+ eventWaiter = () => {
+ eventWaiter = null;
+ check();
+ };
+ return;
+ }
+ prevEvent = received.event;
+ if (received.event != event) {
+ let err = new Error(
+ `expected ${event} but got ${received.event}`
+ );
+ reject(err);
+ }
+ if (props) {
+ for (let key of Object.keys(props)) {
+ if (received[key] != props[key]) {
+ throw new Error(
+ `AddonInstall property ${key} was ${received[key]} but expected ${props[key]}`
+ );
+ }
+ }
+ }
+ resolve();
+ }
+ check();
+ });
+ }
+
+ while (steps.length) {
+ let nextStep = steps.shift();
+ if (nextStep.action) {
+ if (nextStep.action == "install") {
+ try {
+ await install.install();
+ if (nextStep.expectError) {
+ throw new Error("Expected install to fail but it did not");
+ }
+ } catch (err) {
+ if (!nextStep.expectError) {
+ throw new Error("Install failed unexpectedly");
+ }
+ }
+ } else if (nextStep.action == "cancel") {
+ await install.cancel();
+ } else {
+ throw new Error(`unknown action ${nextStep.action}`);
+ }
+ } else {
+ await expectEvent(nextStep.event, nextStep.props);
+ }
+ }
+
+ return true;
+ }
+ );
+
+ is(success, true, description);
+}
+
+function makeInstallTest(task) {
+ return async function () {
+ // withNewTab() will close the test tab before returning, at which point
+ // the cleanup event will come from the content process. We need to see
+ // that event but don't want to race to install a listener for it after
+ // the tab is closed. So set up the listener now but don't yield the
+ // listening promise until below.
+ let clearPromise = waitForClear();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, task);
+
+ await clearPromise;
+ is(AddonManager.webAPI.installs.size, 0, "AddonInstall was cleaned up");
+ };
+}
+
+function makeRegularTest(options, what) {
+ return makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "install" },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ {
+ event: "onDownloadProgress",
+ props: { maxProgress: XPI_LEN },
+ },
+ {
+ event: "onDownloadEnded",
+ props: {
+ state: "STATE_DOWNLOADED",
+ progress: XPI_LEN,
+ maxProgress: XPI_LEN,
+ },
+ },
+ {
+ event: "onInstallStarted",
+ props: { state: "STATE_INSTALLING" },
+ },
+ {
+ event: "onInstallEnded",
+ props: { state: "STATE_INSTALLED" },
+ },
+ ];
+
+ let installPromptPromise = promisePopupNotificationShown(
+ "addon-webext-permissions"
+ ).then(panel => {
+ panel.button.click();
+ });
+
+ let promptPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ options.addonId
+ );
+
+ await testInstall(browser, options, steps, what);
+
+ await installPromptPromise;
+
+ await promptPromise;
+
+ // Sanity check to ensure that the test in makeInstallTest() that
+ // installs.size == 0 means we actually did clean up.
+ Assert.greater(
+ AddonManager.webAPI.installs.size,
+ 0,
+ "webAPI is tracking the AddonInstall"
+ );
+
+ let addon = await promiseAddonByID(ID);
+ isnot(addon, null, "Found the addon");
+
+ // Check that the expected installTelemetryInfo has been stored in the addon details.
+ AddonTestUtils.checkInstallInfo(addon, {
+ method: "amWebAPI",
+ source: "test-host",
+ sourceURL: /https:\/\/example.com\/.*\/webapi_checkavailable.html/,
+ });
+
+ await addon.uninstall();
+
+ addon = await promiseAddonByID(ID);
+ is(addon, null, "Addon was uninstalled");
+ });
+}
+
+let addonId = XPI_ADDON_ID;
+add_task(makeRegularTest({ url: XPI_URL, addonId }, "a basic install works"));
+add_task(
+ makeRegularTest(
+ { url: XPI_URL, addonId, hash: null },
+ "install with hash=null works"
+ )
+);
+add_task(
+ makeRegularTest(
+ { url: XPI_URL, addonId, hash: "" },
+ "install with empty string for hash works"
+ )
+);
+add_task(
+ makeRegularTest(
+ { url: XPI_URL, addonId, hash: XPI_SHA },
+ "install with hash works"
+ )
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "cancel" },
+ {
+ event: "onDownloadCancelled",
+ props: {
+ state: "STATE_CANCELLED",
+ error: null,
+ },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL },
+ steps,
+ "canceling an install works"
+ );
+
+ let addons = await promiseAddonsByIDs([ID]);
+ is(addons[0], null, "The addon was not installed");
+
+ Assert.greater(
+ AddonManager.webAPI.installs.size,
+ 0,
+ "webAPI is tracking the AddonInstall"
+ );
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "install", expectError: true },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ { event: "onDownloadProgress" },
+ {
+ event: "onDownloadFailed",
+ props: {
+ state: "STATE_DOWNLOAD_FAILED",
+ error: "ERROR_NETWORK_FAILURE",
+ },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL + "bogus" },
+ steps,
+ "install of a bad url fails"
+ );
+
+ let addons = await promiseAddonsByIDs([ID]);
+ is(addons[0], null, "The addon was not installed");
+
+ Assert.greater(
+ AddonManager.webAPI.installs.size,
+ 0,
+ "webAPI is tracking the AddonInstall"
+ );
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let steps = [
+ { action: "install", expectError: true },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ { event: "onDownloadProgress" },
+ {
+ event: "onDownloadFailed",
+ props: {
+ state: "STATE_DOWNLOAD_FAILED",
+ error: "ERROR_INCORRECT_HASH",
+ },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL, hash: "sha256:bogus" },
+ steps,
+ "install with bad hash fails"
+ );
+
+ let addons = await promiseAddonsByIDs([ID]);
+ is(addons[0], null, "The addon was not installed");
+
+ Assert.greater(
+ AddonManager.webAPI.installs.size,
+ 0,
+ "webAPI is tracking the AddonInstall"
+ );
+ })
+);
+
+add_task(async function test_permissions_and_policy() {
+ async function testBadUrl(url, pattern, successMessage) {
+ gBrowser.selectedTab = await BrowserTestUtils.addTab(gBrowser, TESTPAGE);
+ let browser = gBrowser.getBrowserForTab(gBrowser.selectedTab);
+ await BrowserTestUtils.browserLoaded(browser);
+ let result = await SpecialPowers.spawn(
+ browser,
+ [{ url, pattern }],
+ function (opts) {
+ return new Promise(resolve => {
+ content.navigator.mozAddonManager
+ .createInstall({ url: opts.url })
+ .then(
+ () => {
+ resolve({
+ success: false,
+ message: "createInstall should not have succeeded",
+ });
+ },
+ err => {
+ if (err.message.match(new RegExp(opts.pattern))) {
+ resolve({ success: true });
+ }
+ resolve({
+ success: false,
+ message: `Wrong error message: ${err.message}`,
+ });
+ }
+ );
+ });
+ }
+ );
+ is(result.success, true, result.message || successMessage);
+ }
+
+ await testBadUrl(
+ "i am not a url",
+ "NS_ERROR_MALFORMED_URI",
+ "Installing from an unparseable URL fails"
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ let popupPromise = promisePopupNotificationShown(
+ "addon-install-webapi-blocked"
+ );
+ await Promise.all([
+ testBadUrl(
+ "https://addons.not-really-mozilla.org/impostor.xpi",
+ "not permitted",
+ "Installing from non-approved URL fails"
+ ),
+ popupPromise,
+ ]);
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ const blocked_install_message = "Custom Policy Block Message";
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: [],
+ blocked_install_message,
+ },
+ },
+ },
+ });
+
+ popupPromise = promisePopupNotificationShown("addon-install-policy-blocked");
+
+ await testBadUrl(
+ XPI_URL,
+ "not permitted by policy",
+ "Installing from policy blocked origin fails"
+ );
+
+ const panel = await popupPromise;
+ const description = panel.querySelector(
+ ".popup-notification-description"
+ ).textContent;
+ ok(
+ description.startsWith("Your organization"),
+ "Policy specific error is shown."
+ );
+ ok(
+ description.endsWith(` ${blocked_install_message}`),
+ `Found the expected custom blocked message in "${description}"`
+ );
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ ExtensionSettings: {
+ "*": {
+ install_sources: ["<all_urls>"],
+ },
+ },
+ },
+ });
+});
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let xpiURL = `${SECURE_TESTROOT}../xpinstall/incompatible.xpi`;
+ let id = "incompatible-xpi@tests.mozilla.org";
+
+ let steps = [
+ { action: "install", expectError: true },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ { event: "onDownloadProgress" },
+ { event: "onDownloadEnded" },
+ { event: "onDownloadCancelled", error: "ERROR_INCOMPATIBLE" },
+ ];
+
+ await testInstall(
+ browser,
+ { url: xpiURL },
+ steps,
+ "install of an incompatible XPI fails"
+ );
+
+ let addons = await promiseAddonsByIDs([id]);
+ is(addons[0], null, "The addon was not installed");
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ let id = "amosigned-xpi@tests.mozilla.org";
+ let version = "2.1";
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {
+ stash: { blocked: [`${id}:${version}`], unblocked: [] },
+ stash_time: 0,
+ },
+ ],
+ });
+
+ let steps = [
+ { action: "install", expectError: true },
+ { event: "onDownloadStarted" },
+ { event: "onDownloadProgress" },
+ { event: "onDownloadEnded" },
+ {
+ event: "onDownloadCancelled",
+ props: { state: "STATE_CANCELLED", error: "ERROR_BLOCKLISTED" },
+ },
+ ];
+
+ await testInstall(
+ browser,
+ { url: XPI_URL },
+ steps,
+ "install of a blocked XPI fails"
+ );
+
+ let addons = await promiseAddonsByIDs([id]);
+ is(addons[0], null, "The addon was not installed");
+
+ // Clear the blocklist.
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {
+ stash: { blocked: [], unblocked: [] },
+ stash_time: 0,
+ },
+ ],
+ });
+ })
+);
+
+add_task(
+ makeInstallTest(async function (browser) {
+ const options = { url: XPI_URL, addonId };
+ let steps = [
+ { action: "install" },
+ {
+ event: "onDownloadStarted",
+ props: { state: "STATE_DOWNLOADING" },
+ },
+ {
+ event: "onDownloadProgress",
+ props: { maxProgress: XPI_LEN },
+ },
+ {
+ event: "onDownloadEnded",
+ props: {
+ state: "STATE_DOWNLOADED",
+ progress: XPI_LEN,
+ maxProgress: XPI_LEN,
+ },
+ },
+ {
+ event: "onInstallStarted",
+ props: { state: "STATE_INSTALLING" },
+ },
+ {
+ event: "onInstallEnded",
+ props: { state: "STATE_INSTALLED" },
+ },
+ ];
+
+ await SpecialPowers.spawn(browser, [TESTPATH], testPath => {
+ // `sourceURL` should match the exact location, even after a location
+ // update using the history API. In this case, we update the URL with
+ // query parameters and expect `sourceURL` to contain those parameters.
+ content.history.pushState(
+ {}, // state
+ "", // title
+ `/${testPath}?some=query&par=am`
+ );
+ });
+
+ let installPromptPromise = promisePopupNotificationShown(
+ "addon-webext-permissions"
+ ).then(panel => {
+ panel.button.click();
+ });
+
+ let promptPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ options.addonId
+ );
+
+ await Promise.all([
+ testInstall(browser, options, steps, "install to check source URL"),
+ installPromptPromise,
+ promptPromise,
+ ]);
+
+ let addon = await promiseAddonByID(ID);
+
+ registerCleanupFunction(async () => {
+ await addon.uninstall();
+ });
+
+ // Check that the expected installTelemetryInfo has been stored in the
+ // addon details.
+ AddonTestUtils.checkInstallInfo(addon, {
+ method: "amWebAPI",
+ source: "test-host",
+ sourceURL:
+ "https://example.com/webapi_checkavailable.html?some=query&par=am",
+ });
+ })
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js
new file mode 100644
index 0000000000..5bc291fe7a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_install_disabled.js
@@ -0,0 +1,60 @@
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+const XPI_URL = `${SECURE_TESTROOT}../xpinstall/amosigned.xpi`;
+
+function waitForClear() {
+ const MSG = "WebAPICleanup";
+ return new Promise(resolve => {
+ let listener = {
+ receiveMessage(msg) {
+ if (msg.name == MSG) {
+ Services.mm.removeMessageListener(MSG, listener);
+ resolve();
+ }
+ },
+ };
+
+ Services.mm.addMessageListener(MSG, listener, true);
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["xpinstall.enabled", false],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+ info("added preferences");
+});
+
+async function testInstall(browser, args) {
+ let success = await SpecialPowers.spawn(
+ browser,
+ [{ args }],
+ async function (opts) {
+ let { args } = opts;
+ let install;
+ try {
+ install = await content.navigator.mozAddonManager.createInstall(args);
+ } catch (e) {}
+ return !!install;
+ }
+ );
+ is(success, false, "Install was blocked");
+}
+
+add_task(async function () {
+ // withNewTab() will close the test tab before returning, at which point
+ // the cleanup event will come from the content process. We need to see
+ // that event but don't want to race to install a listener for it after
+ // the tab is closed. So set up the listener now but don't yield the
+ // listening promise until below.
+ let clearPromise = waitForClear();
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async function (browser) {
+ await testInstall(browser, { url: XPI_URL });
+ });
+
+ await clearPromise;
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js
new file mode 100644
index 0000000000..dd1df90907
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_theme.js
@@ -0,0 +1,79 @@
+"use strict";
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+const URL = `${SECURE_TESTROOT}addons/browser_theme.xpi`;
+
+add_task(async function test_theme_install() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(TESTPAGE, async browser => {
+ let updates = [];
+ function observer(subject, topic, data) {
+ updates.push(JSON.stringify(subject.wrappedJSObject));
+ }
+ Services.obs.addObserver(observer, "lightweight-theme-styling-update");
+ registerCleanupFunction(() => {
+ Services.obs.removeObserver(observer, "lightweight-theme-styling-update");
+ });
+
+ let sawConfirm = false;
+ promisePopupNotificationShown("addon-install-confirmation").then(panel => {
+ sawConfirm = true;
+ panel.button.click();
+ });
+
+ let prompt1 = waitAppMenuNotificationShown(
+ "addon-installed",
+ "theme@tests.mozilla.org",
+ false
+ );
+ let installPromise = SpecialPowers.spawn(browser, [URL], async url => {
+ let install = await content.navigator.mozAddonManager.createInstall({
+ url,
+ });
+ return install.install();
+ });
+ await prompt1;
+
+ ok(sawConfirm, "Confirm notification was displayed before installation");
+
+ // Open a new window and test the app menu panel from there. This verifies the
+ // incognito checkbox as well as finishing install in this case.
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ await waitAppMenuNotificationShown(
+ "addon-installed",
+ "theme@tests.mozilla.org",
+ true,
+ newWin
+ );
+ await installPromise;
+ ok(true, "Theme install completed");
+
+ await BrowserTestUtils.closeWindow(newWin);
+
+ Assert.equal(updates.length, 1, "Got a single theme update");
+ let parsed = JSON.parse(updates[0]);
+ ok(
+ parsed.theme.headerURL.endsWith("/testImage.png"),
+ "Theme update has the expected headerURL"
+ );
+ is(
+ parsed.theme.id,
+ "theme@tests.mozilla.org",
+ "Theme update includes the theme ID"
+ );
+ is(
+ parsed.theme.version,
+ "1.0",
+ "Theme update includes the theme's version"
+ );
+
+ let addon = await AddonManager.getAddonByID(parsed.theme.id);
+ await addon.uninstall();
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js b/toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js
new file mode 100644
index 0000000000..ad4afe0fa7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webapi_uninstall.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TESTPAGE = `${SECURE_TESTROOT}webapi_checkavailable.html`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.webapi.testing", true]],
+ });
+});
+
+function testWithAPI(task) {
+ return async function () {
+ await BrowserTestUtils.withNewTab(TESTPAGE, task);
+ };
+}
+
+function API_uninstallByID(browser, id) {
+ return SpecialPowers.spawn(browser, [id], async function (id) {
+ let addon = await content.navigator.mozAddonManager.getAddonByID(id);
+
+ let result = await addon.uninstall();
+ return result;
+ });
+}
+
+add_task(
+ testWithAPI(async function (browser) {
+ const ID1 = "addon1@tests.mozilla.org";
+ const ID2 = "addon2@tests.mozilla.org";
+ const ID3 = "addon3@tests.mozilla.org";
+
+ let provider = new MockProvider();
+
+ provider.addAddon(new MockAddon(ID1, "Test add-on 1", "extension", 0));
+ provider.addAddon(new MockAddon(ID2, "Test add-on 2", "extension", 0));
+
+ let addon = new MockAddon(ID3, "Test add-on 3", "extension", 0);
+ addon.permissions &= ~AddonManager.PERM_CAN_UNINSTALL;
+ provider.addAddon(addon);
+
+ let [a1, a2, a3] = await promiseAddonsByIDs([ID1, ID2, ID3]);
+ isnot(a1, null, "addon1 is installed");
+ isnot(a2, null, "addon2 is installed");
+ isnot(a3, null, "addon3 is installed");
+
+ let result = await API_uninstallByID(browser, ID1);
+ is(result, true, "uninstall of addon1 succeeded");
+
+ [a1, a2, a3] = await promiseAddonsByIDs([ID1, ID2, ID3]);
+ is(a1, null, "addon1 is uninstalled");
+ isnot(a2, null, "addon2 is still installed");
+
+ result = await API_uninstallByID(browser, ID2);
+ is(result, true, "uninstall of addon2 succeeded");
+ [a2] = await promiseAddonsByIDs([ID2]);
+ is(a2, null, "addon2 is uninstalled");
+
+ await Assert.rejects(
+ API_uninstallByID(browser, ID3),
+ /Addon cannot be uninstalled/,
+ "Unable to uninstall addon"
+ );
+
+ // Cleanup addon3
+ a3.permissions |= AddonManager.PERM_CAN_UNINSTALL;
+ await a3.uninstall();
+ [a3] = await promiseAddonsByIDs([ID3]);
+ is(a3, null, "addon3 is uninstalled");
+ })
+);
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webext_icon.js b/toolkit/mozapps/extensions/test/browser/browser_webext_icon.js
new file mode 100644
index 0000000000..123fe0c665
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webext_icon.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";
+
+function pngArrayBuffer(size) {
+ const canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.height = canvas.width = size;
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = "blue";
+ ctx.fillRect(0, 0, size, size);
+ return new Promise(resolve => {
+ canvas.toBlob(blob => {
+ const fileReader = new FileReader();
+ fileReader.onload = () => {
+ resolve(fileReader.result);
+ };
+ fileReader.readAsArrayBuffer(blob);
+ });
+ });
+}
+
+async function checkIconInView(view, name, findIcon) {
+ const manager = await open_manager(view);
+ const icon = findIcon(manager.document);
+ const size = Number(icon.src.match(/icon(\d+)\.png/)[1]);
+ is(
+ icon.clientWidth,
+ icon.clientHeight,
+ `The icon should be square in ${name}`
+ );
+ is(
+ size,
+ icon.clientWidth * window.devicePixelRatio,
+ `The correct icon size should have been chosen in ${name}`
+ );
+ await close_manager(manager);
+}
+
+add_task(async function test_addon_icon() {
+ // This test loads an extension with a variety of icon sizes, and checks that the
+ // fitting one is chosen. If this fails it's because you changed the icon size in
+ // about:addons but didn't update some AddonManager.getPreferredIconURL call.
+ const id = "@test-addon-icon";
+ const icons = {};
+ const files = {};
+ const file = await pngArrayBuffer(256);
+ for (let size = 1; size <= 256; ++size) {
+ let fileName = `icon${size}.png`;
+ icons[size] = fileName;
+ files[fileName] = file;
+ }
+ const extensionDefinition = {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ icons,
+ },
+ files,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extensionDefinition);
+ await extension.startup();
+
+ await checkIconInView("addons://list/extension", "list", doc => {
+ return getAddonCard(doc.defaultView, id).querySelector(".addon-icon");
+ });
+
+ await checkIconInView(
+ "addons://detail/" + encodeURIComponent(id),
+ "details",
+ doc => {
+ return getAddonCard(doc.defaultView, id).querySelector(".addon-icon");
+ }
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js b/toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js
new file mode 100644
index 0000000000..9180bbcf91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_webext_incognito.js
@@ -0,0 +1,593 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+var gManagerWindow;
+
+AddonTestUtils.initMochitest(this);
+
+function get_test_items() {
+ var items = {};
+
+ for (let item of gManagerWindow.document.querySelectorAll("addon-card")) {
+ items[item.getAttribute("addon-id")] = item;
+ }
+
+ return items;
+}
+
+function getHtmlElem(selector) {
+ return gManagerWindow.document.querySelector(selector);
+}
+
+function getPrivateBrowsingBadge(card) {
+ return card.querySelector(".addon-badge-private-browsing-allowed");
+}
+
+function getPreferencesButtonAtListView(card) {
+ return card.querySelector("panel-item[action='preferences']");
+}
+
+function getPreferencesButtonAtDetailsView() {
+ return getHtmlElem("panel-item[action='preferences']");
+}
+
+function isInlineOptionsVisible() {
+ // The following button is used to open the inline options browser.
+ return !getHtmlElem(".tab-button[name='preferences']").hidden;
+}
+
+function getPrivateBrowsingValue() {
+ return getHtmlElem("input[type='radio'][name='private-browsing']:checked")
+ .value;
+}
+
+async function setPrivateBrowsingValue(value, id) {
+ let changePromise = new Promise(resolve => {
+ const listener = (type, { extensionId, added, removed }) => {
+ if (extensionId == id) {
+ // Let's make sure we received the right message
+ let { permissions } = value == "0" ? removed : added;
+ ok(permissions.includes("internal:privateBrowsingAllowed"));
+ Management.off("change-permissions", listener);
+ resolve();
+ }
+ };
+ Management.on("change-permissions", listener);
+ });
+ let radio = getHtmlElem(
+ `input[type="radio"][name="private-browsing"][value="${value}"]`
+ );
+ // NOTE: not using EventUtils.synthesizeMouseAtCenter here because it
+ // does make this test to fail intermittently in some jobs (e.g. TV jobs)
+ radio.click();
+ // Let's make sure we wait until the change has peristed in the database
+ return changePromise;
+}
+
+// Check whether the private browsing inputs are visible in the details view.
+function checkIsModifiable(expected) {
+ if (expected) {
+ is_element_visible(
+ getHtmlElem(".addon-detail-row-private-browsing"),
+ "Private browsing should be visible"
+ );
+ } else {
+ is_element_hidden(
+ getHtmlElem(".addon-detail-row-private-browsing"),
+ "Private browsing should be hidden"
+ );
+ }
+ checkHelpRow(".addon-detail-row-private-browsing", expected);
+}
+
+// Check whether the details view shows that private browsing is forcibly disallowed.
+function checkIsDisallowed(expected) {
+ if (expected) {
+ is_element_visible(
+ getHtmlElem(".addon-detail-row-private-browsing-disallowed"),
+ "Private browsing should be disallowed"
+ );
+ } else {
+ is_element_hidden(
+ getHtmlElem(".addon-detail-row-private-browsing-disallowed"),
+ "Private browsing should not be disallowed"
+ );
+ }
+ checkHelpRow(".addon-detail-row-private-browsing-disallowed", expected);
+}
+
+// Check whether the details view shows that private browsing is forcibly allowed.
+function checkIsRequired(expected) {
+ if (expected) {
+ is_element_visible(
+ getHtmlElem(".addon-detail-row-private-browsing-required"),
+ "Private browsing should be required"
+ );
+ } else {
+ is_element_hidden(
+ getHtmlElem(".addon-detail-row-private-browsing-required"),
+ "Private browsing should not be required"
+ );
+ }
+ checkHelpRow(".addon-detail-row-private-browsing-required", expected);
+}
+
+function checkHelpRow(selector, expected) {
+ let helpRow = getHtmlElem(`${selector} + .addon-detail-help-row`);
+ if (expected) {
+ is_element_visible(helpRow, `Help row should be shown: ${selector}`);
+ is_element_visible(helpRow.querySelector("a"), "Expected learn more link");
+ } else {
+ is_element_hidden(helpRow, `Help row should be hidden: ${selector}`);
+ }
+}
+
+async function hasPrivateAllowed(id) {
+ let perms = await ExtensionPermissions.get(id);
+ return perms.permissions.includes("internal:privateBrowsingAllowed");
+}
+
+add_task(async function test_badge_and_toggle_incognito() {
+ let addons = new Map([
+ [
+ "@test-default",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-default" },
+ },
+ },
+ },
+ ],
+ [
+ "@test-override",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-override" },
+ },
+ },
+ incognitoOverride: "spanning",
+ },
+ ],
+ [
+ "@test-override-permanent",
+ {
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-override-permanent" },
+ },
+ },
+ incognitoOverride: "spanning",
+ },
+ ],
+ [
+ "@test-not-allowed",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "@test-not-allowed" },
+ },
+ incognito: "not_allowed",
+ },
+ },
+ ],
+ ]);
+ let extensions = [];
+ for (let definition of addons.values()) {
+ let extension = ExtensionTestUtils.loadExtension(definition);
+ extensions.push(extension);
+ await extension.startup();
+ }
+
+ gManagerWindow = await open_manager("addons://list/extension");
+ let items = get_test_items();
+ for (let [id, definition] of addons.entries()) {
+ ok(items[id], `${id} listed`);
+ let badge = getPrivateBrowsingBadge(items[id]);
+ if (definition.incognitoOverride == "spanning") {
+ is_element_visible(badge, `private browsing badge is visible`);
+ } else {
+ is_element_hidden(badge, `private browsing badge is hidden`);
+ }
+ }
+ await close_manager(gManagerWindow);
+
+ for (let [id, definition] of addons.entries()) {
+ gManagerWindow = await open_manager(
+ "addons://detail/" + encodeURIComponent(id)
+ );
+ ok(true, `==== ${id} detail opened`);
+ if (definition.manifest.incognito == "not_allowed") {
+ checkIsModifiable(false);
+ ok(!(await hasPrivateAllowed(id)), "Private browsing permission not set");
+ checkIsDisallowed(true);
+ } else {
+ // This assumes PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS, we test other options in a later test in this file.
+ checkIsModifiable(true);
+ if (definition.incognitoOverride == "spanning") {
+ is(getPrivateBrowsingValue(), "1", "Private browsing should be on");
+ ok(await hasPrivateAllowed(id), "Private browsing permission set");
+ await setPrivateBrowsingValue("0", id);
+ is(getPrivateBrowsingValue(), "0", "Private browsing should be off");
+ ok(
+ !(await hasPrivateAllowed(id)),
+ "Private browsing permission removed"
+ );
+ } else {
+ is(getPrivateBrowsingValue(), "0", "Private browsing should be off");
+ ok(
+ !(await hasPrivateAllowed(id)),
+ "Private browsing permission not set"
+ );
+ await setPrivateBrowsingValue("1", id);
+ is(getPrivateBrowsingValue(), "1", "Private browsing should be on");
+ ok(await hasPrivateAllowed(id), "Private browsing permission set");
+ }
+ }
+ await close_manager(gManagerWindow);
+ }
+
+ for (let extension of extensions) {
+ await extension.unload();
+ }
+});
+
+add_task(async function test_addon_preferences_button() {
+ let addons = new Map([
+ [
+ "test-inline-options@mozilla.com",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension with inline options",
+ browser_specific_settings: {
+ gecko: { id: "test-inline-options@mozilla.com" },
+ },
+ options_ui: { page: "options.html", open_in_tab: false },
+ },
+ },
+ ],
+ [
+ "test-newtab-options@mozilla.com",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension with options page in a new tab",
+ browser_specific_settings: {
+ gecko: { id: "test-newtab-options@mozilla.com" },
+ },
+ options_ui: { page: "options.html", open_in_tab: true },
+ },
+ },
+ ],
+ [
+ "test-not-allowed@mozilla.com",
+ {
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Extension not allowed in PB windows",
+ incognito: "not_allowed",
+ browser_specific_settings: {
+ gecko: { id: "test-not-allowed@mozilla.com" },
+ },
+ options_ui: { page: "options.html", open_in_tab: true },
+ },
+ },
+ ],
+ ]);
+
+ async function runTest(openInPrivateWin) {
+ const win = await BrowserTestUtils.openNewBrowserWindow({
+ private: openInPrivateWin,
+ });
+
+ gManagerWindow = await open_manager(
+ "addons://list/extension",
+ undefined,
+ undefined,
+ undefined,
+ win
+ );
+
+ const checkPrefsVisibility = (id, hasInlinePrefs, expectVisible) => {
+ if (!hasInlinePrefs) {
+ const detailsPrefBtn = getPreferencesButtonAtDetailsView();
+ is(
+ !detailsPrefBtn.hidden,
+ expectVisible,
+ `The ${id} prefs button in the addon details has the expected visibility`
+ );
+ } else {
+ is(
+ isInlineOptionsVisible(),
+ expectVisible,
+ `The ${id} inline prefs in the addon details has the expected visibility`
+ );
+ }
+ };
+
+ const setAddonPrivateBrowsingAccess = async (id, allowPrivateBrowsing) => {
+ const cardUpdatedPromise = BrowserTestUtils.waitForEvent(
+ getHtmlElem("addon-card"),
+ "update"
+ );
+ is(
+ getPrivateBrowsingValue(),
+ allowPrivateBrowsing ? "0" : "1",
+ `Private browsing should be initially ${
+ allowPrivateBrowsing ? "off" : "on"
+ }`
+ );
+
+ // Get the DOM element we want to click on (to allow or disallow the
+ // addon on private browsing windows).
+ await setPrivateBrowsingValue(allowPrivateBrowsing ? "1" : "0", id);
+
+ info(`Waiting for details view of ${id} to be reloaded`);
+ await cardUpdatedPromise;
+
+ is(
+ getPrivateBrowsingValue(),
+ allowPrivateBrowsing ? "1" : "0",
+ `Private browsing should be initially ${
+ allowPrivateBrowsing ? "on" : "off"
+ }`
+ );
+
+ is(
+ await hasPrivateAllowed(id),
+ allowPrivateBrowsing,
+ `Private browsing permission ${
+ allowPrivateBrowsing ? "added" : "removed"
+ }`
+ );
+ let badge = getPrivateBrowsingBadge(getHtmlElem("addon-card"));
+ is(
+ !badge.hidden,
+ allowPrivateBrowsing,
+ `Expected private browsing badge at ${id}`
+ );
+ };
+
+ const extensions = [];
+ for (const definition of addons.values()) {
+ const extension = ExtensionTestUtils.loadExtension(definition);
+ extensions.push(extension);
+ await extension.startup();
+ }
+
+ const items = get_test_items();
+
+ for (const id of addons.keys()) {
+ // Check the preferences button in the addon list page.
+ is(
+ getPreferencesButtonAtListView(items[id]).hidden,
+ openInPrivateWin,
+ `The ${id} prefs button in the addon list has the expected visibility`
+ );
+ }
+
+ for (const [id, definition] of addons.entries()) {
+ // Check the preferences button or inline frame in the addon
+ // details page.
+ info(`Opening addon details for ${id}`);
+ const hasInlinePrefs = !definition.manifest.options_ui.open_in_tab;
+ const onceViewChanged = wait_for_view_load(gManagerWindow, null, true);
+ gManagerWindow.loadView(`addons://detail/${encodeURIComponent(id)}`);
+ await onceViewChanged;
+
+ checkPrefsVisibility(id, hasInlinePrefs, !openInPrivateWin);
+
+ // While testing in a private window, also check that the preferences
+ // are going to be visible when we toggle the PB access for the addon.
+ if (openInPrivateWin && definition.manifest.incognito !== "not_allowed") {
+ await setAddonPrivateBrowsingAccess(id, true);
+ checkPrefsVisibility(id, hasInlinePrefs, true);
+
+ await setAddonPrivateBrowsingAccess(id, false);
+ checkPrefsVisibility(id, hasInlinePrefs, false);
+ }
+ }
+
+ for (const extension of extensions) {
+ await extension.unload();
+ }
+
+ await close_manager(gManagerWindow);
+ await BrowserTestUtils.closeWindow(win);
+ }
+
+ // run tests in private and non-private windows.
+ await runTest(true);
+ await runTest(false);
+});
+
+add_task(async function test_addon_postinstall_incognito_hidden_checkbox() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.langpacks.signatures.required", false]],
+ });
+
+ const TEST_ADDONS = [
+ {
+ manifest: {
+ name: "Extension incognito default opt-in",
+ browser_specific_settings: {
+ gecko: { id: "ext-incognito-default-opt-in@mozilla.com" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "Extension incognito not_allowed",
+ browser_specific_settings: {
+ gecko: { id: "ext-incognito-not-allowed@mozilla.com" },
+ },
+ incognito: "not_allowed",
+ },
+ },
+ {
+ manifest: {
+ name: "Static Theme",
+ browser_specific_settings: {
+ gecko: { id: "static-theme@mozilla.com" },
+ },
+ theme: {
+ colors: {
+ frame: "#FFFFFF",
+ tab_background_text: "#000",
+ },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "Dictionary",
+ browser_specific_settings: { gecko: { id: "dictionary@mozilla.com" } },
+ dictionaries: {
+ und: "dictionaries/und.dic",
+ },
+ },
+ files: {
+ "dictionaries/und.dic": "",
+ "dictionaries/und.aff": "",
+ },
+ },
+ {
+ manifest: {
+ name: "Langpack",
+ browser_specific_settings: { gecko: { id: "langpack@mozilla.com" } },
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global",
+ },
+ version: "20190326174300",
+ },
+ },
+ },
+ },
+ ];
+
+ for (let definition of TEST_ADDONS) {
+ let { id } = definition.manifest.browser_specific_settings.gecko;
+ info(
+ `Testing incognito checkbox visibility on ${id} post install notification`
+ );
+
+ const xpi = AddonTestUtils.createTempWebExtensionFile(definition);
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ await Promise.all([
+ waitAppMenuNotificationShown("addon-installed", id, true),
+ install.install().then(() => {
+ Services.obs.notifyObservers(
+ {
+ addon: install.addon,
+ target: gBrowser.selectedBrowser,
+ },
+ "webextension-install-notify"
+ );
+ }),
+ ]);
+
+ const { addon } = install;
+ const { permissions } = addon;
+ const canChangePBAccess = Boolean(
+ permissions & AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ );
+
+ if (id === "ext-incognito-default-opt-in@mozilla.com") {
+ ok(
+ canChangePBAccess,
+ `${id} should have the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission`
+ );
+ } else {
+ ok(
+ !canChangePBAccess,
+ `${id} should not have the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission`
+ );
+ }
+
+ // This tests the visibility of various private detail rows.
+ gManagerWindow = await open_manager(
+ "addons://detail/" + encodeURIComponent(id)
+ );
+ info(`addon ${id} detail opened`);
+ if (addon.type === "extension") {
+ checkIsModifiable(canChangePBAccess);
+ let required = addon.incognito === "spanning";
+ checkIsRequired(!canChangePBAccess && required);
+ checkIsDisallowed(!canChangePBAccess && !required);
+ } else {
+ checkIsModifiable(false);
+ checkIsRequired(false);
+ checkIsDisallowed(false);
+ }
+ await close_manager(gManagerWindow);
+
+ await addon.uninstall();
+ }
+
+ // It is not possible to create a privileged add-on and install it, so just
+ // simulate an installed privileged add-on and check the UI.
+ await test_incognito_of_privileged_addons();
+});
+
+// Checks that the private browsing flag of privileged add-ons cannot be modified.
+async function test_incognito_of_privileged_addons() {
+ // In mochitests it is not possible to create and install a privileged add-on
+ // or a system add-on, so create a mock provider that simulates privileged
+ // add-ons (which lack the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission).
+ let provider = new MockProvider();
+ provider.createAddons([
+ {
+ name: "default incognito",
+ id: "default-incognito@mock",
+ incognito: "spanning", // This is the default.
+ // Anything without the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission.
+ permissions: 0,
+ },
+ {
+ name: "not_allowed incognito",
+ id: "not-allowed-incognito@mock",
+ incognito: "not_allowed",
+ // Anything without the PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS permission.
+ permissions: 0,
+ },
+ ]);
+
+ gManagerWindow = await open_manager(
+ "addons://detail/default-incognito%40mock"
+ );
+ checkIsModifiable(false);
+ checkIsRequired(true);
+ checkIsDisallowed(false);
+ await close_manager(gManagerWindow);
+
+ gManagerWindow = await open_manager(
+ "addons://detail/not-allowed-incognito%40mock"
+ );
+ checkIsModifiable(false);
+ checkIsRequired(false);
+ checkIsDisallowed(true);
+ await close_manager(gManagerWindow);
+
+ provider.unregister();
+}
diff --git a/toolkit/mozapps/extensions/test/browser/discovery/api_response.json b/toolkit/mozapps/extensions/test/browser/discovery/api_response.json
new file mode 100644
index 0000000000..b36d3c1f02
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/api_response.json
@@ -0,0 +1,679 @@
+{
+ "results": [
+ {
+ "description_text": "",
+ "addon": {
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{e0d2e13b-2e07-49d5-9574-eb0227482320}",
+ "authors": [
+ {
+ "id": 7804538,
+ "name": "Sondergaard",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/7/7804/7804538.png?modified=1392125542",
+ "username": "EatingStick",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/7804538/"
+ }
+ ],
+ "previews": [
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183758.png?modified=1555593109",
+ "image_size": [680, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183758.png?modified=1555593109",
+ "id": 183758,
+ "thumbnail_size": [473, 64],
+ "caption": null
+ },
+ {
+ "id": 183768,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183768.png?modified=1555593111",
+ "image_size": [760, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183768.png?modified=1555593111",
+ "caption": null,
+ "thumbnail_size": [529, 64]
+ },
+ {
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183777.png?modified=1555593112",
+ "id": 183777,
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183777.png?modified=1555593112",
+ "image_size": [720, 92],
+ "caption": null,
+ "thumbnail_size": [501, 64]
+ }
+ ],
+ "name": "Tigers Matter ** DON'T DELTE ME**",
+ "id": 496012,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/tigers-matter/",
+ "type": "statictheme",
+ "ratings": {
+ "average": 4.7636,
+ "text_count": 55,
+ "count": 55,
+ "bayesian_average": 4.75672
+ },
+ "slug": "tigers-matter",
+ "average_daily_users": 1,
+ "current_version": {
+ "compatibility": {
+ "firefox": {
+ "max": "*",
+ "min": "53.0"
+ },
+ "android": {
+ "max": "*",
+ "min": "65.0"
+ }
+ },
+ "is_strict_compatibility_enabled": false,
+ "id": 1655900,
+ "files": [
+ {
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/376561/tigers_matter_dont_delte_me-2.0-an+fx.xpi?src=",
+ "created": "2019-04-18T13:11:48Z",
+ "size": 86337,
+ "status": "public",
+ "is_webextension": true,
+ "is_mozilla_signed_extension": false,
+ "permissions": [],
+ "hash": "sha256:ebeb6e4f40ceafbc4affc5bc9a182ed44ae410d71b8c5f9c547f8d45863e0c37",
+ "platform": "all",
+ "id": 376561
+ }
+ ]
+ }
+ },
+ "is_recommendation": false
+ },
+ {
+ "is_recommendation": false,
+ "addon": {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/awesome-screenshot-plus-/",
+ "type": "extension",
+ "ratings": {
+ "count": 848,
+ "bayesian_average": 3.87925,
+ "average": 3.8797,
+ "text_count": 842
+ },
+ "slug": "awesome-screenshot-plus-",
+ "average_daily_users": 1,
+ "current_version": {
+ "is_strict_compatibility_enabled": false,
+ "id": 1532816,
+ "files": [
+ {
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/253549/awesome_screenshot_plus-7-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "size": 4196,
+ "created": "2017-09-01T13:31:17Z",
+ "is_webextension": true,
+ "status": "public",
+ "is_mozilla_signed_extension": false,
+ "permissions": [],
+ "hash": "sha256:4cd8e9b7e89f61e6855d01c73c5c05920c1e0e91f3ae0f45adbb4bd9919f59d7",
+ "platform": "all",
+ "id": 253549
+ }
+ ],
+ "compatibility": {
+ "android": {
+ "min": "48.0",
+ "max": "*"
+ },
+ "firefox": {
+ "max": "*",
+ "min": "48.0"
+ }
+ }
+ },
+ "authors": [
+ {
+ "username": "diigo-inc",
+ "name": "Diigo Inc.",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/0/6/6724.png?modified=1554393597",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/6724/",
+ "id": 6724
+ }
+ ],
+ "icon_url": "https://addons-dev-cdn.allizom.org/user-media/addon_icons/287/287841-64.png?modified=mcrushed",
+ "guid": "jid0-GXjLLfbCoAx0LcltEdFrEkQdQPI@jetpack",
+ "previews": [
+ {
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54638.png?modified=1543388383",
+ "id": 54638,
+ "image_size": [625, 525],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54638.png?modified=1543388383",
+ "caption": "Capture and annotate a page",
+ "thumbnail_size": [571, 480]
+ },
+ {
+ "caption": "Crop selected area",
+ "thumbnail_size": [571, 480],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54639.png?modified=1543388385",
+ "image_size": [625, 525],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54639.png?modified=1543388385",
+ "id": 54639
+ },
+ {
+ "caption": "Save as a local file or upload to get a sharable link",
+ "thumbnail_size": [640, 234],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/previews/full/54/54641.png?modified=1543388385",
+ "image_size": [700, 256],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/previews/thumbs/54/54641.png?modified=1543388385",
+ "id": 54641
+ }
+ ],
+ "name": "Awesome Screenshot Plus - Capture, Annotate & More",
+ "id": 287841
+ },
+ "description_text": "Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines and text, blur sensitive info, one-click upload to share. And more! Capture the whole page or any portion, annotate it with rectangles, circles, arrows, lines"
+ },
+ {
+ "description_text": "Help Admins in their daily work",
+ "addon": {
+ "slug": "amo-admin-assistant-test",
+ "average_daily_users": 0,
+ "current_version": {
+ "files": [
+ {
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/255370/amo_admin_assistant-4.2-fx.xpi?src=",
+ "size": 16016,
+ "created": "2018-08-21T16:49:21Z",
+ "is_webextension": true,
+ "status": "public",
+ "is_mozilla_signed_extension": false,
+ "permissions": [
+ "tabs",
+ "https://addons-internal.prod.mozaws.net/*"
+ ],
+ "hash": "sha256:cd28c841a6daf8a2e3c94b0773b373fec0213404b70074309326cfc75e6725d3",
+ "platform": "all",
+ "id": 255370
+ }
+ ],
+ "is_strict_compatibility_enabled": false,
+ "id": 1534709,
+ "compatibility": {
+ "firefox": {
+ "min": "45.0",
+ "max": "*"
+ }
+ }
+ },
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/amo-admin-assistant-test/",
+ "ratings": {
+ "bayesian_average": 0,
+ "count": 0,
+ "text_count": 0,
+ "average": 0
+ },
+ "type": "extension",
+ "id": 496168,
+ "guid": "aaa-test-icon@xulforge.com",
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "authors": [
+ {
+ "id": 4230,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/4230/",
+ "username": "jorge-villalobos",
+ "name": "Jorge Villalobos",
+ "picture_url": null
+ }
+ ],
+ "previews": [],
+ "name": "AMO Admin Assistant Test"
+ },
+ "is_recommendation": false
+ },
+ {
+ "addon": {
+ "authors": [
+ {
+ "name": "LexaDev",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10640/10640485.png?modified=1554812253",
+ "username": "LexaSV",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10640485/",
+ "id": 10640485
+ }
+ ],
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{f9b9cdd3-91ae-476e-9c21-d5ecfce9889f}",
+ "previews": [
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183694.png?modified=1555593096",
+ "image_size": [680, 92],
+ "id": 183694,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183694.png?modified=1555593096",
+ "thumbnail_size": [473, 64],
+ "caption": null
+ },
+ {
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183699.png?modified=1555593097",
+ "id": 183699,
+ "image_size": [760, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183699.png?modified=1555593097",
+ "caption": null,
+ "thumbnail_size": [529, 64]
+ },
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/183/183703.png?modified=1555593098",
+ "image_size": [720, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/183/183703.png?modified=1555593098",
+ "id": 183703,
+ "caption": null,
+ "thumbnail_size": [501, 64]
+ }
+ ],
+ "name": "iarba",
+ "id": 495969,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/iarba/",
+ "ratings": {
+ "bayesian_average": 4.86128,
+ "count": 10,
+ "text_count": 10,
+ "average": 4.9
+ },
+ "type": "statictheme",
+ "slug": "iarba",
+ "current_version": {
+ "files": [
+ {
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/376535/iarba-2.0-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "size": 895804,
+ "created": "2019-04-18T13:11:35Z",
+ "is_mozilla_signed_extension": false,
+ "status": "public",
+ "is_webextension": true,
+ "id": 376535,
+ "permissions": [],
+ "platform": "all",
+ "hash": "sha256:d7ecbdfa8ba56c5d08129c867e68b02ffc8c6000a7f7f85d85d2a558045babfa"
+ }
+ ],
+ "is_strict_compatibility_enabled": false,
+ "id": 1655874,
+ "compatibility": {
+ "android": {
+ "min": "65.0",
+ "max": "*"
+ },
+ "firefox": {
+ "min": "53.0",
+ "max": "*"
+ }
+ }
+ },
+ "average_daily_users": 1
+ },
+ "description_text": "",
+ "is_recommendation": false
+ },
+ {
+ "description_text": "Get international weather forecasts",
+ "addon": {
+ "id": 502855,
+ "authors": [
+ {
+ "id": 10641527,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10641527/",
+ "name": "Amoga-dev",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641527.png?modified=1555333028",
+ "username": "Amoga_dev_REST"
+ }
+ ],
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "forecastfox@s3_fix_version",
+ "previews": [],
+ "name": "Forecastfox (fix version)",
+ "slug": "forecastfox-fix-version",
+ "current_version": {
+ "id": 1541667,
+ "is_strict_compatibility_enabled": false,
+ "files": [
+ {
+ "permissions": [
+ "activeTab",
+ "tabs",
+ "background",
+ "storage",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ "http://www.s3blog.org/geolocation.html*",
+ "https://embed.windy.com/embed2.html*"
+ ],
+ "platform": "all",
+ "hash": "sha256:89e4de4ce86005c57b0197f671e86936aaf843ebd5751caae02cad4991ccbf0a",
+ "id": 262328,
+ "is_webextension": true,
+ "status": "public",
+ "is_mozilla_signed_extension": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262328/forecastfox_fix_version-4.20-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "created": "2019-01-16T07:54:26Z",
+ "size": 1331686
+ }
+ ],
+ "compatibility": {
+ "android": {
+ "min": "51.0",
+ "max": "*"
+ },
+ "firefox": {
+ "min": "51.0",
+ "max": "*"
+ }
+ }
+ },
+ "average_daily_users": 0,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/forecastfox-fix-version/",
+ "type": "extension",
+ "ratings": {
+ "count": 0,
+ "bayesian_average": 0,
+ "average": 0,
+ "text_count": 0
+ }
+ },
+ "is_recommendation": false
+ },
+ {
+ "description_text": "A test extension from webext-generator.",
+ "addon": {
+ "name": "tabby cat",
+ "previews": [],
+ "guid": "{1ed4b641-bac7-4492-b304-6ddc01f538ae}",
+ "icon_url": "https://addons-dev-cdn.allizom.org/user-media/addon_icons/502/502774-64.png?modified=f289a992",
+ "authors": [
+ {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10641572/",
+ "username": "AdminUserTestDev1",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641572.png?modified=1555675110",
+ "name": "úþÿ Ψ Φ ֎",
+ "id": 10641572
+ }
+ ],
+ "id": 502774,
+ "ratings": {
+ "bayesian_average": 0,
+ "count": 0,
+ "text_count": 0,
+ "average": 0
+ },
+ "type": "extension",
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/tabby-catextension/",
+ "current_version": {
+ "compatibility": {
+ "firefox": {
+ "max": "*",
+ "min": "48.0"
+ },
+ "android": {
+ "max": "*",
+ "min": "48.0"
+ }
+ },
+ "is_strict_compatibility_enabled": false,
+ "id": 1541570,
+ "files": [
+ {
+ "created": "2018-12-04T09:54:24Z",
+ "size": 4374,
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262231/tabby_cat-1.0-an+fx.xpi?src=",
+ "is_mozilla_signed_extension": false,
+ "status": "public",
+ "is_webextension": true,
+ "id": 262231,
+ "hash": "sha256:f12c8a8b71e7d4c48e38db6b6a374ca8dcde42d6cb13fb1f2a8299bb51116615",
+ "platform": "all",
+ "permissions": []
+ }
+ ]
+ },
+ "average_daily_users": 1,
+ "slug": "tabby-catextension"
+ },
+ "is_recommendation": false
+ },
+ {
+ "addon": {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/the-moon-cat/",
+ "ratings": {
+ "average": 4.8182,
+ "text_count": 11,
+ "count": 11,
+ "bayesian_average": 4.78325
+ },
+ "type": "statictheme",
+ "slug": "the-moon-cat",
+ "average_daily_users": 2,
+ "current_version": {
+ "files": [
+ {
+ "is_mozilla_signed_extension": false,
+ "status": "public",
+ "is_webextension": true,
+ "id": 262333,
+ "permissions": [],
+ "hash": "sha256:d159190add69c739b0fe07b19ad3ff48045c5ded502a8df0f892b8feb645c5ae",
+ "platform": "all",
+ "is_restart_required": false,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262333/the_moon_cat-1.0-an+fx.xpi?src=",
+ "size": 102889,
+ "created": "2019-01-16T08:31:21Z"
+ }
+ ],
+ "is_strict_compatibility_enabled": false,
+ "id": 1541672,
+ "compatibility": {
+ "firefox": {
+ "max": "*",
+ "min": "53.0"
+ },
+ "android": {
+ "min": "65.0",
+ "max": "*"
+ }
+ }
+ },
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "authors": [
+ {
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/5822165/",
+ "username": "Rallara",
+ "name": "Rallara",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/5/5822/5822165.png?modified=1391855104",
+ "id": 5822165
+ }
+ ],
+ "guid": "{db4f6548-da04-43fb-a03e-249bf70ef5a1}",
+ "previews": [
+ {
+ "thumbnail_size": [473, 64],
+ "caption": null,
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14307.png?modified=1547627485",
+ "image_size": [680, 92],
+ "id": 14307,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14307.png?modified=1547627485"
+ },
+ {
+ "thumbnail_size": [529, 64],
+ "caption": null,
+ "id": 14308,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14308.png?modified=1547627486",
+ "image_size": [760, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14308.png?modified=1547627486"
+ },
+ {
+ "thumbnail_size": [501, 64],
+ "caption": null,
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14309.png?modified=1547627487",
+ "image_size": [720, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14309.png?modified=1547627487",
+ "id": 14309
+ }
+ ],
+ "name": "the Moon Cat",
+ "id": 502859
+ },
+ "description_text": "",
+ "is_recommendation": false
+ },
+ {
+ "is_recommendation": false,
+ "addon": {
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{2e5ff8c8-32fe-46d0-9fc8-6b8986621f3c}",
+ "authors": [
+ {
+ "id": 10641570,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/10641570/",
+ "name": "BobsDisplayName",
+ "picture_url": "https://addons-dev-cdn.allizom.org/user-media/userpics/10/10641/10641570.png?modified=1536063975",
+ "username": "BobsUserName"
+ }
+ ],
+ "previews": [],
+ "name": "SI",
+ "id": 495710,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/search_by_image/",
+ "ratings": {
+ "average": 3.8333,
+ "text_count": 5,
+ "count": 6,
+ "bayesian_average": 3.77144
+ },
+ "type": "extension",
+ "slug": "search_by_image",
+ "current_version": {
+ "files": [
+ {
+ "id": 262271,
+ "permissions": [
+ "contextMenus",
+ "storage",
+ "tabs",
+ "activeTab",
+ "notifications",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>",
+ "http://*/*",
+ "https://*/*",
+ "ftp://*/*",
+ "file:///*"
+ ],
+ "platform": "all",
+ "hash": "sha256:f358b24d0b950f5acf035342dec64c99ee2e22a5cf369e7c787ebb00013127a8",
+ "is_mozilla_signed_extension": false,
+ "is_webextension": true,
+ "status": "public",
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262271/search_by_image_reverse_image_search-1.12.6-fx.xpi?src=",
+ "is_restart_required": false,
+ "size": 372225,
+ "created": "2018-12-14T13:48:23Z"
+ }
+ ],
+ "id": 1541610,
+ "is_strict_compatibility_enabled": false,
+ "compatibility": {
+ "firefox": {
+ "min": "57.0",
+ "max": "*"
+ }
+ }
+ },
+ "average_daily_users": 374
+ },
+ "description_text": "AAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGGAAAGGGG"
+ },
+ {
+ "addon": {
+ "icon_url": "https://addons-dev-cdn.allizom.org/static/img/addon-icons/default-64.png",
+ "guid": "{f5e7a6ee-ebe0-4add-8f75-b5e4015feca1}",
+ "authors": [
+ {
+ "id": 8733220,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/user/8733220/",
+ "username": "michellet-2",
+ "name": "michellet",
+ "picture_url": null
+ }
+ ],
+ "previews": [
+ {
+ "caption": null,
+ "thumbnail_size": [473, 64],
+ "id": 14304,
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14304.png?modified=1547627480",
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14304.png?modified=1547627480",
+ "image_size": [680, 92]
+ },
+ {
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14305.png?modified=1547627481",
+ "image_size": [760, 92],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14305.png?modified=1547627481",
+ "id": 14305,
+ "thumbnail_size": [529, 64],
+ "caption": null
+ },
+ {
+ "caption": null,
+ "thumbnail_size": [501, 64],
+ "thumbnail_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/thumbs/14/14306.png?modified=1547627482",
+ "id": 14306,
+ "image_size": [720, 92],
+ "image_url": "https://addons-dev-cdn.allizom.org/user-media/version-previews/full/14/14306.png?modified=1547627482"
+ }
+ ],
+ "name": "Purple Sparkles",
+ "id": 502858,
+ "url": "https://addons-dev.allizom.org/en-US/firefox/addon/purple-sparkles/",
+ "type": "statictheme",
+ "ratings": {
+ "count": 4,
+ "bayesian_average": 4.1476,
+ "average": 4.25,
+ "text_count": 3
+ },
+ "slug": "purple-sparkles",
+ "average_daily_users": 445,
+ "current_version": {
+ "compatibility": {
+ "firefox": {
+ "min": "53.0",
+ "max": "*"
+ },
+ "android": {
+ "max": "*",
+ "min": "65.0"
+ }
+ },
+ "id": 1541671,
+ "is_strict_compatibility_enabled": false,
+ "files": [
+ {
+ "created": "2019-01-16T08:31:18Z",
+ "size": 237348,
+ "url": "https://addons-dev.allizom.org/firefox/downloads/file/262332/purple_sparkles-1.0-an+fx.xpi?src=",
+ "is_restart_required": false,
+ "is_mozilla_signed_extension": false,
+ "is_webextension": true,
+ "status": "public",
+ "id": 262332,
+ "hash": "sha256:5a3d311b7c1be2ee32446dbcf1422c5d7c786c5a237aa3d4e2939074ab50ad30",
+ "platform": "all",
+ "permissions": []
+ }
+ ]
+ }
+ },
+ "description_text": "",
+ "is_recommendation": false
+ }
+ ],
+ "count": 9
+}
diff --git a/toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json b/toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json
new file mode 100644
index 0000000000..a5a3af7835
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/api_response_empty.json
@@ -0,0 +1 @@
+{ "results": [] }
diff --git a/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png b/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png
new file mode 100644
index 0000000000..862d1dd10c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/discovery/small-1x1.png
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/browser/head.js b/toolkit/mozapps/extensions/test/browser/head.js
new file mode 100644
index 0000000000..482429177c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/head.js
@@ -0,0 +1,1714 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+/* globals end_test */
+
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+let { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+var pathParts = gTestPath.split("/");
+// Drop the test filename
+pathParts.splice(pathParts.length - 1, pathParts.length);
+
+const RELATIVE_DIR = pathParts.slice(4).join("/") + "/";
+
+const TESTROOT = "http://example.com/" + RELATIVE_DIR;
+const SECURE_TESTROOT = "https://example.com/" + RELATIVE_DIR;
+const TESTROOT2 = "http://example.org/" + RELATIVE_DIR;
+const SECURE_TESTROOT2 = "https://example.org/" + RELATIVE_DIR;
+const CHROMEROOT = pathParts.join("/") + "/";
+const PREF_DISCOVER_ENABLED = "extensions.getAddons.showPane";
+const PREF_XPI_ENABLED = "xpinstall.enabled";
+const PREF_UPDATEURL = "extensions.update.url";
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_UI_LASTCATEGORY = "extensions.ui.lastCategory";
+
+const MANAGER_URI = "about:addons";
+const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
+const PREF_STRICT_COMPAT = "extensions.strictCompatibility";
+
+var PREF_CHECK_COMPATIBILITY;
+(function () {
+ var channel = Services.prefs.getCharPref("app.update.channel", "default");
+ if (
+ channel != "aurora" &&
+ channel != "beta" &&
+ channel != "release" &&
+ channel != "esr"
+ ) {
+ var version = "nightly";
+ } else {
+ version = Services.appinfo.version.replace(
+ /^([^\.]+\.[0-9]+[a-z]*).*/gi,
+ "$1"
+ );
+ }
+ PREF_CHECK_COMPATIBILITY = "extensions.checkCompatibility." + version;
+})();
+
+var gPendingTests = [];
+var gTestsRun = 0;
+var gTestStart = null;
+
+var gRestorePrefs = [
+ { name: PREF_LOGGING_ENABLED },
+ { name: "extensions.webservice.discoverURL" },
+ { name: "extensions.update.url" },
+ { name: "extensions.update.background.url" },
+ { name: "extensions.update.enabled" },
+ { name: "extensions.update.autoUpdateDefault" },
+ { name: "extensions.getAddons.get.url" },
+ { name: "extensions.getAddons.getWithPerformance.url" },
+ { name: "extensions.getAddons.cache.enabled" },
+ { name: "devtools.chrome.enabled" },
+ { name: PREF_STRICT_COMPAT },
+ { name: PREF_CHECK_COMPATIBILITY },
+];
+
+for (let pref of gRestorePrefs) {
+ if (!Services.prefs.prefHasUserValue(pref.name)) {
+ pref.type = "clear";
+ continue;
+ }
+ pref.type = Services.prefs.getPrefType(pref.name);
+ if (pref.type == Services.prefs.PREF_BOOL) {
+ pref.value = Services.prefs.getBoolPref(pref.name);
+ } else if (pref.type == Services.prefs.PREF_INT) {
+ pref.value = Services.prefs.getIntPref(pref.name);
+ } else if (pref.type == Services.prefs.PREF_STRING) {
+ pref.value = Services.prefs.getCharPref(pref.name);
+ }
+}
+
+// Turn logging on for all tests
+Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
+
+function promiseFocus(window) {
+ return new Promise(resolve => waitForFocus(resolve, window));
+}
+
+// Tools to disable and re-enable the background update and blocklist timers
+// so that tests can protect themselves from unwanted timer events.
+var gCatMan = Services.catMan;
+// Default value from toolkit/mozapps/extensions/extensions.manifest, but disable*UpdateTimer()
+// records the actual value so we can put it back in enable*UpdateTimer()
+var backgroundUpdateConfig =
+ "@mozilla.org/addons/integration;1,getService,addon-background-update-timer,extensions.update.interval,86400";
+
+var UTIMER = "update-timer";
+var AMANAGER = "addonManager";
+var BLOCKLIST = "nsBlocklistService";
+
+function disableBackgroundUpdateTimer() {
+ info("Disabling " + UTIMER + " " + AMANAGER);
+ backgroundUpdateConfig = gCatMan.getCategoryEntry(UTIMER, AMANAGER);
+ gCatMan.deleteCategoryEntry(UTIMER, AMANAGER, true);
+}
+
+function enableBackgroundUpdateTimer() {
+ info("Enabling " + UTIMER + " " + AMANAGER);
+ gCatMan.addCategoryEntry(
+ UTIMER,
+ AMANAGER,
+ backgroundUpdateConfig,
+ false,
+ true
+ );
+}
+
+registerCleanupFunction(function () {
+ // Restore prefs
+ for (let pref of gRestorePrefs) {
+ if (pref.type == "clear") {
+ Services.prefs.clearUserPref(pref.name);
+ } else if (pref.type == Services.prefs.PREF_BOOL) {
+ Services.prefs.setBoolPref(pref.name, pref.value);
+ } else if (pref.type == Services.prefs.PREF_INT) {
+ Services.prefs.setIntPref(pref.name, pref.value);
+ } else if (pref.type == Services.prefs.PREF_STRING) {
+ Services.prefs.setCharPref(pref.name, pref.value);
+ }
+ }
+
+ return AddonManager.getAllInstalls().then(aInstalls => {
+ for (let install of aInstalls) {
+ if (install instanceof MockInstall) {
+ continue;
+ }
+
+ ok(
+ false,
+ "Should not have seen an install of " +
+ install.sourceURI.spec +
+ " in state " +
+ install.state
+ );
+ install.cancel();
+ }
+ });
+});
+
+function log_exceptions(aCallback, ...aArgs) {
+ try {
+ return aCallback.apply(null, aArgs);
+ } catch (e) {
+ info("Exception thrown: " + e);
+ throw e;
+ }
+}
+
+function log_callback(aPromise, aCallback) {
+ aPromise.then(aCallback).catch(e => info("Exception thrown: " + e));
+ return aPromise;
+}
+
+function add_test(test) {
+ gPendingTests.push(test);
+}
+
+function run_next_test() {
+ // Make sure we're not calling run_next_test from inside an add_task() test
+ // We're inside the browser_test.js 'testScope' here
+ if (this.__tasks) {
+ throw new Error(
+ "run_next_test() called from an add_task() test function. " +
+ "run_next_test() should not be called from inside add_task() " +
+ "under any circumstances!"
+ );
+ }
+ if (gTestsRun > 0) {
+ info("Test " + gTestsRun + " took " + (Date.now() - gTestStart) + "ms");
+ }
+
+ if (!gPendingTests.length) {
+ executeSoon(end_test);
+ return;
+ }
+
+ gTestsRun++;
+ var test = gPendingTests.shift();
+ if (test.name) {
+ info("Running test " + gTestsRun + " (" + test.name + ")");
+ } else {
+ info("Running test " + gTestsRun);
+ }
+
+ gTestStart = Date.now();
+ executeSoon(() => log_exceptions(test));
+}
+
+var get_tooltip_info = async function (addonEl, managerWindow) {
+ // Extract from title attribute.
+ const { addon } = addonEl;
+ const name = addon.name;
+
+ let nameWithVersion = addonEl.addonNameEl.title;
+ if (addonEl.addon.userDisabled) {
+ // TODO - Bug 1558077: Currently Fluent is clearing the addon title
+ // when the addon is disabled, fixing it requires changes to the
+ // HTML about:addons localized strings, and then remove this
+ // workaround.
+ nameWithVersion = `${name} ${addon.version}`;
+ }
+
+ return {
+ name,
+ version: nameWithVersion.substring(name.length + 1),
+ };
+};
+
+function get_addon_file_url(aFilename) {
+ try {
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ var fileurl = cr.convertChromeURL(
+ makeURI(CHROMEROOT + "addons/" + aFilename)
+ );
+ return fileurl.QueryInterface(Ci.nsIFileURL);
+ } catch (ex) {
+ var jar = getJar(CHROMEROOT + "addons/" + aFilename);
+ var tmpDir = extractJarToTmp(jar);
+ tmpDir.append(aFilename);
+
+ return Services.io.newFileURI(tmpDir).QueryInterface(Ci.nsIFileURL);
+ }
+}
+
+function check_all_in_list(aManager, aIds, aIgnoreExtras) {
+ var doc = aManager.document;
+ var list = doc.getElementById("addon-list");
+
+ var inlist = [];
+ var node = list.firstChild;
+ while (node) {
+ if (node.value) {
+ inlist.push(node.value);
+ }
+ node = node.nextSibling;
+ }
+
+ for (let id of aIds) {
+ if (!inlist.includes(id)) {
+ ok(false, "Should find " + id + " in the list");
+ }
+ }
+
+ if (aIgnoreExtras) {
+ return;
+ }
+
+ for (let inlistItem of inlist) {
+ if (!aIds.includes(inlistItem)) {
+ ok(false, "Shouldn't have seen " + inlistItem + " in the list");
+ }
+ }
+}
+
+function getAddonCard(win, id) {
+ return win.document.querySelector(`addon-card[addon-id="${id}"]`);
+}
+
+async function wait_for_view_load(
+ aManagerWindow,
+ aCallback,
+ aForceWait,
+ aLongerTimeout
+) {
+ // Wait one tick to make sure that the microtask related to an
+ // async loadView call originated from outsite about:addons
+ // is already executing (otherwise isLoading would be still false
+ // and we wouldn't be waiting for that load before resolving
+ // the promise returned by this test helper function).
+ await Promise.resolve();
+
+ let p = new Promise(resolve => {
+ requestLongerTimeout(aLongerTimeout ? aLongerTimeout : 2);
+
+ if (!aForceWait && !aManagerWindow.gViewController.isLoading) {
+ resolve(aManagerWindow);
+ return;
+ }
+
+ aManagerWindow.document.addEventListener(
+ "view-loaded",
+ function () {
+ resolve(aManagerWindow);
+ },
+ { once: true }
+ );
+ });
+
+ return log_callback(p, aCallback);
+}
+
+function wait_for_manager_load(aManagerWindow, aCallback) {
+ info("Waiting for initialization");
+ return log_callback(
+ aManagerWindow.promiseInitialized.then(() => aManagerWindow),
+ aCallback
+ );
+}
+
+function open_manager(
+ aView,
+ aCallback,
+ aLoadCallback,
+ aLongerTimeout,
+ aWin = window
+) {
+ let p = new Promise((resolve, reject) => {
+ async function setup_manager(aManagerWindow) {
+ if (aLoadCallback) {
+ log_exceptions(aLoadCallback, aManagerWindow);
+ }
+
+ if (aView) {
+ aManagerWindow.loadView(aView);
+ }
+
+ Assert.notEqual(
+ aManagerWindow,
+ null,
+ "Should have an add-ons manager window"
+ );
+ is(
+ aManagerWindow.location.href,
+ MANAGER_URI,
+ "Should be displaying the correct UI"
+ );
+
+ await promiseFocus(aManagerWindow);
+ info("window has focus, waiting for manager load");
+ await wait_for_manager_load(aManagerWindow);
+ info("Manager waiting for view load");
+ await wait_for_view_load(aManagerWindow, null, null, aLongerTimeout);
+ resolve(aManagerWindow);
+ }
+
+ info("Loading manager window in tab");
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ if (aSubject.location.href != MANAGER_URI) {
+ info("Ignoring load event for " + aSubject.location.href);
+ return;
+ }
+ setup_manager(aSubject);
+ }, "EM-loaded");
+
+ aWin.gBrowser.selectedTab = BrowserTestUtils.addTab(aWin.gBrowser);
+ aWin.switchToTabHavingURI(MANAGER_URI, true, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ });
+
+ // The promise resolves with the manager window, so it is passed to the callback
+ return log_callback(p, aCallback);
+}
+
+function close_manager(aManagerWindow, aCallback, aLongerTimeout) {
+ let p = new Promise((resolve, reject) => {
+ requestLongerTimeout(aLongerTimeout ? aLongerTimeout : 2);
+
+ Assert.notEqual(
+ aManagerWindow,
+ null,
+ "Should have an add-ons manager window to close"
+ );
+ is(
+ aManagerWindow.location.href,
+ MANAGER_URI,
+ "Should be closing window with correct URI"
+ );
+
+ aManagerWindow.addEventListener("unload", function listener() {
+ try {
+ dump("Manager window unload handler\n");
+ this.removeEventListener("unload", listener);
+ resolve();
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+
+ info("Telling manager window to close");
+ aManagerWindow.close();
+ info("Manager window close() call returned");
+
+ return log_callback(p, aCallback);
+}
+
+function restart_manager(aManagerWindow, aView, aCallback, aLoadCallback) {
+ if (!aManagerWindow) {
+ return open_manager(aView, aCallback, aLoadCallback);
+ }
+
+ return close_manager(aManagerWindow).then(() =>
+ open_manager(aView, aCallback, aLoadCallback)
+ );
+}
+
+function wait_for_window_open(aCallback) {
+ let p = new Promise(resolve => {
+ Services.wm.addListener({
+ onOpenWindow(aXulWin) {
+ Services.wm.removeListener(this);
+
+ let domwindow = aXulWin.docShell.domWindow;
+ domwindow.addEventListener(
+ "load",
+ function () {
+ executeSoon(function () {
+ resolve(domwindow);
+ });
+ },
+ { once: true }
+ );
+ },
+
+ onCloseWindow(aWindow) {},
+ });
+ });
+
+ return log_callback(p, aCallback);
+}
+
+function formatDate(aDate) {
+ const dtOptions = { year: "numeric", month: "long", day: "numeric" };
+ return aDate.toLocaleDateString(undefined, dtOptions);
+}
+
+function is_hidden(aElement) {
+ var style = aElement.ownerGlobal.getComputedStyle(aElement);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+
+ // Hiding a parent element will hide all its children
+ if (aElement.parentNode != aElement.ownerDocument) {
+ return is_hidden(aElement.parentNode);
+ }
+
+ return false;
+}
+
+function is_element_visible(aElement, aMsg) {
+ isnot(aElement, null, "Element should not be null, when checking visibility");
+ ok(!is_hidden(aElement), aMsg || aElement + " should be visible");
+}
+
+function is_element_hidden(aElement, aMsg) {
+ isnot(aElement, null, "Element should not be null, when checking visibility");
+ ok(is_hidden(aElement), aMsg || aElement + " should be hidden");
+}
+
+function promiseAddonByID(aId) {
+ return AddonManager.getAddonByID(aId);
+}
+
+function promiseAddonsByIDs(aIDs) {
+ return AddonManager.getAddonsByIDs(aIDs);
+}
+/**
+ * Install an add-on and call a callback when complete.
+ *
+ * The callback will receive the Addon for the installed add-on.
+ */
+async function install_addon(path, cb, pathPrefix = TESTROOT) {
+ let install = await AddonManager.getInstallForURL(pathPrefix + path);
+ let p = new Promise((resolve, reject) => {
+ install.addListener({
+ onInstallEnded: () => resolve(install.addon),
+ });
+
+ install.install();
+ });
+
+ return log_callback(p, cb);
+}
+
+function CategoryUtilities(aManagerWindow) {
+ this.window = aManagerWindow;
+ this.window.addEventListener("unload", () => (this.window = null), {
+ once: true,
+ });
+}
+
+CategoryUtilities.prototype = {
+ window: null,
+
+ get _categoriesBox() {
+ return this.window.document.querySelector("categories-box");
+ },
+
+ getSelectedViewId() {
+ let selectedItem = this._categoriesBox.querySelector("[selected]");
+ isnot(selectedItem, null, "A category should be selected");
+ return selectedItem.getAttribute("viewid");
+ },
+
+ get selectedCategory() {
+ isnot(
+ this.window,
+ null,
+ "Should not get selected category when manager window is not loaded"
+ );
+ let viewId = this.getSelectedViewId();
+ let view = this.window.gViewController.parseViewId(viewId);
+ return view.type == "list" ? view.param : view.type;
+ },
+
+ get(categoryType) {
+ isnot(
+ this.window,
+ null,
+ "Should not get category when manager window is not loaded"
+ );
+
+ let button = this._categoriesBox.querySelector(`[name="${categoryType}"]`);
+ if (button) {
+ return button;
+ }
+
+ ok(false, "Should have found a category with type " + categoryType);
+ return null;
+ },
+
+ isVisible(categoryButton) {
+ isnot(
+ this.window,
+ null,
+ "Should not check visible state when manager window is not loaded"
+ );
+
+ // There are some tests checking this before the categories have loaded.
+ if (!categoryButton) {
+ return false;
+ }
+
+ if (categoryButton.disabled || categoryButton.hidden) {
+ return false;
+ }
+
+ return !is_hidden(categoryButton);
+ },
+
+ isTypeVisible(categoryType) {
+ return this.isVisible(this.get(categoryType));
+ },
+
+ open(categoryButton) {
+ isnot(
+ this.window,
+ null,
+ "Should not open category when manager window is not loaded"
+ );
+ ok(
+ this.isVisible(categoryButton),
+ "Category should be visible if attempting to open it"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(categoryButton, {}, this.window);
+
+ // Use wait_for_view_load until all open_manager calls are gone.
+ return wait_for_view_load(this.window);
+ },
+
+ openType(categoryType) {
+ return this.open(this.get(categoryType));
+ },
+};
+
+// Returns a promise that will resolve when the certificate error override has been added, or reject
+// if there is some failure.
+function addCertOverride(host) {
+ return new Promise((resolve, reject) => {
+ let req = new XMLHttpRequest();
+ req.open("GET", "https://" + host + "/");
+ req.onload = reject;
+ req.onerror = () => {
+ if (req.channel && req.channel.securityInfo) {
+ let securityInfo = req.channel.securityInfo;
+ if (securityInfo.serverCert) {
+ let cos = Cc["@mozilla.org/security/certoverride;1"].getService(
+ Ci.nsICertOverrideService
+ );
+ cos.rememberValidityOverride(
+ host,
+ -1,
+ {},
+ securityInfo.serverCert,
+ false
+ );
+ resolve();
+ return;
+ }
+ }
+ reject();
+ };
+ req.send(null);
+ });
+}
+
+// Returns a promise that will resolve when the necessary certificate overrides have been added.
+function addCertOverrides() {
+ return Promise.all([
+ addCertOverride("nocert.example.com"),
+ addCertOverride("self-signed.example.com"),
+ addCertOverride("untrusted.example.com"),
+ addCertOverride("expired.example.com"),
+ ]);
+}
+
+/** *** Mock Provider *****/
+
+function MockProvider(addonTypes) {
+ this.addons = [];
+ this.installs = [];
+ this.addonTypes = addonTypes ?? ["extension"];
+
+ var self = this;
+ registerCleanupFunction(function () {
+ if (self.started) {
+ self.unregister();
+ }
+ });
+
+ this.register();
+}
+
+MockProvider.prototype = {
+ addons: null,
+ installs: null,
+ addonTypes: null,
+ started: null,
+ queryDelayPromise: Promise.resolve(),
+
+ blockQueryResponses() {
+ this.queryDelayPromise = new Promise(resolve => {
+ this._unblockQueries = resolve;
+ });
+ },
+
+ unblockQueryResponses() {
+ if (this._unblockQueries) {
+ this._unblockQueries();
+ this._unblockQueries = null;
+ } else {
+ throw new Error("Queries are not blocked");
+ }
+ },
+
+ /** *** Utility functions *****/
+
+ /**
+ * Register this provider with the AddonManager
+ */
+ register: function MP_register() {
+ info("Registering mock add-on provider");
+ // addonTypes is supposedly the full set of types supported by the provider.
+ // The current list is not complete (there are tests that mock add-on types
+ // other than "extension"), but it doesn't affect tests since addonTypes is
+ // mainly used to determine whether any of the AddonManager's providers
+ // support a type, and XPIProvider already defines the types of interest.
+ AddonManagerPrivate.registerProvider(this, this.addonTypes);
+ },
+
+ /**
+ * Unregister this provider with the AddonManager
+ */
+ unregister: function MP_unregister() {
+ info("Unregistering mock add-on provider");
+ AddonManagerPrivate.unregisterProvider(this);
+ },
+
+ /**
+ * Adds an add-on to the list of add-ons that this provider exposes to the
+ * AddonManager, dispatching appropriate events in the process.
+ *
+ * @param aAddon
+ * The add-on to add
+ */
+ addAddon: function MP_addAddon(aAddon) {
+ var oldAddons = this.addons.filter(aOldAddon => aOldAddon.id == aAddon.id);
+ var oldAddon = oldAddons.length ? oldAddons[0] : null;
+
+ this.addons = this.addons.filter(aOldAddon => aOldAddon.id != aAddon.id);
+
+ this.addons.push(aAddon);
+ aAddon._provider = this;
+
+ if (!this.started) {
+ return;
+ }
+
+ let requiresRestart =
+ (aAddon.operationsRequiringRestart &
+ AddonManager.OP_NEEDS_RESTART_INSTALL) !=
+ 0;
+ AddonManagerPrivate.callInstallListeners(
+ "onExternalInstall",
+ null,
+ aAddon,
+ oldAddon,
+ requiresRestart
+ );
+ },
+
+ /**
+ * Removes an add-on from the list of add-ons that this provider exposes to
+ * the AddonManager, dispatching the onUninstalled event in the process.
+ *
+ * @param aAddon
+ * The add-on to add
+ */
+ removeAddon: function MP_removeAddon(aAddon) {
+ var pos = this.addons.indexOf(aAddon);
+ if (pos == -1) {
+ ok(
+ false,
+ "Tried to remove an add-on that wasn't registered with the mock provider"
+ );
+ return;
+ }
+
+ this.addons.splice(pos, 1);
+
+ if (!this.started) {
+ return;
+ }
+
+ AddonManagerPrivate.callAddonListeners("onUninstalled", aAddon);
+ },
+
+ /**
+ * Adds an add-on install to the list of installs that this provider exposes
+ * to the AddonManager, dispatching appropriate events in the process.
+ *
+ * @param aInstall
+ * The add-on install to add
+ */
+ addInstall: function MP_addInstall(aInstall) {
+ this.installs.push(aInstall);
+ aInstall._provider = this;
+
+ if (!this.started) {
+ return;
+ }
+
+ aInstall.callListeners("onNewInstall");
+ },
+
+ removeInstall: function MP_removeInstall(aInstall) {
+ var pos = this.installs.indexOf(aInstall);
+ if (pos == -1) {
+ ok(
+ false,
+ "Tried to remove an install that wasn't registered with the mock provider"
+ );
+ return;
+ }
+
+ this.installs.splice(pos, 1);
+ },
+
+ /**
+ * Creates a set of mock add-on objects and adds them to the list of add-ons
+ * managed by this provider.
+ *
+ * @param aAddonProperties
+ * An array of objects containing properties describing the add-ons
+ * @return Array of the new MockAddons
+ */
+ createAddons: function MP_createAddons(aAddonProperties) {
+ var newAddons = [];
+ for (let addonProp of aAddonProperties) {
+ let addon = new MockAddon(addonProp.id);
+ for (let prop in addonProp) {
+ if (prop == "id") {
+ continue;
+ }
+ if (prop == "applyBackgroundUpdates") {
+ addon._applyBackgroundUpdates = addonProp[prop];
+ } else if (prop == "appDisabled") {
+ addon._appDisabled = addonProp[prop];
+ } else if (prop == "userDisabled") {
+ addon.setUserDisabled(addonProp[prop]);
+ } else {
+ addon[prop] = addonProp[prop];
+ }
+ }
+ if (!addon.optionsType && !!addon.optionsURL) {
+ addon.optionsType = AddonManager.OPTIONS_TYPE_DIALOG;
+ }
+
+ // Make sure the active state matches the passed in properties
+ addon.isActive = addon.shouldBeActive;
+
+ this.addAddon(addon);
+ newAddons.push(addon);
+ }
+
+ return newAddons;
+ },
+
+ /**
+ * Creates a set of mock add-on install objects and adds them to the list
+ * of installs managed by this provider.
+ *
+ * @param aInstallProperties
+ * An array of objects containing properties describing the installs
+ * @return Array of the new MockInstalls
+ */
+ createInstalls: function MP_createInstalls(aInstallProperties) {
+ var newInstalls = [];
+ for (let installProp of aInstallProperties) {
+ let install = new MockInstall(
+ installProp.name || null,
+ installProp.type || null,
+ null
+ );
+ for (let prop in installProp) {
+ switch (prop) {
+ case "name":
+ case "type":
+ break;
+ case "sourceURI":
+ install[prop] = NetUtil.newURI(installProp[prop]);
+ break;
+ default:
+ install[prop] = installProp[prop];
+ }
+ }
+ this.addInstall(install);
+ newInstalls.push(install);
+ }
+
+ return newInstalls;
+ },
+
+ /** *** AddonProvider implementation *****/
+
+ /**
+ * Called to initialize the provider.
+ */
+ startup: function MP_startup() {
+ this.started = true;
+ },
+
+ /**
+ * Called when the provider should shutdown.
+ */
+ shutdown: function MP_shutdown() {
+ this.started = false;
+ },
+
+ /**
+ * Called to get an Addon with a particular ID.
+ *
+ * @param aId
+ * The ID of the add-on to retrieve
+ */
+ async getAddonByID(aId) {
+ await this.queryDelayPromise;
+
+ for (let addon of this.addons) {
+ if (addon.id == aId) {
+ return addon;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Called to get Addons of a particular type.
+ *
+ * @param aTypes
+ * An array of types to fetch. Can be null to get all types.
+ */
+ async getAddonsByTypes(aTypes) {
+ await this.queryDelayPromise;
+
+ var addons = this.addons.filter(function (aAddon) {
+ if (aTypes && !!aTypes.length && !aTypes.includes(aAddon.type)) {
+ return false;
+ }
+ return true;
+ });
+ return addons;
+ },
+
+ /**
+ * Called to get the current AddonInstalls, optionally restricting by type.
+ *
+ * @param aTypes
+ * An array of types or null to get all types
+ */
+ async getInstallsByTypes(aTypes) {
+ await this.queryDelayPromise;
+
+ var installs = this.installs.filter(function (aInstall) {
+ // Appear to have actually removed cancelled installs from the provider
+ if (aInstall.state == AddonManager.STATE_CANCELLED) {
+ return false;
+ }
+
+ if (aTypes && !!aTypes.length && !aTypes.includes(aInstall.type)) {
+ return false;
+ }
+
+ return true;
+ });
+ return installs;
+ },
+
+ /**
+ * Called when a new add-on has been enabled when only one add-on of that type
+ * can be enabled.
+ *
+ * @param aId
+ * The ID of the newly enabled add-on
+ * @param aType
+ * The type of the newly enabled add-on
+ * @param aPendingRestart
+ * true if the newly enabled add-on will only become enabled after a
+ * restart
+ */
+ addonChanged: function MP_addonChanged(aId, aType, aPendingRestart) {
+ // Not implemented
+ },
+
+ /**
+ * Update the appDisabled property for all add-ons.
+ */
+ updateAddonAppDisabledStates: function MP_updateAddonAppDisabledStates() {
+ // Not needed
+ },
+
+ /**
+ * Called to get an AddonInstall to download and install an add-on from a URL.
+ *
+ * @param {string} aUrl
+ * The URL to be installed
+ * @param {object} aOptions
+ * Options for the install
+ */
+ getInstallForURL: function MP_getInstallForURL(aUrl, aOptions) {
+ // Not yet implemented
+ },
+
+ /**
+ * Called to get an AddonInstall to install an add-on from a local file.
+ *
+ * @param aFile
+ * The file to be installed
+ */
+ getInstallForFile: function MP_getInstallForFile(aFile) {
+ // Not yet implemented
+ },
+
+ /**
+ * Called to test whether installing add-ons is enabled.
+ *
+ * @return true if installing is enabled
+ */
+ isInstallEnabled: function MP_isInstallEnabled() {
+ return false;
+ },
+
+ /**
+ * Called to test whether this provider supports installing a particular
+ * mimetype.
+ *
+ * @param aMimetype
+ * The mimetype to check for
+ * @return true if the mimetype is supported
+ */
+ supportsMimetype: function MP_supportsMimetype(aMimetype) {
+ return false;
+ },
+
+ /**
+ * Called to test whether installing add-ons from a URI is allowed.
+ *
+ * @param aUri
+ * The URI being installed from
+ * @return true if installing is allowed
+ */
+ isInstallAllowed: function MP_isInstallAllowed(aUri) {
+ return false;
+ },
+};
+
+/** *** Mock Addon object for the Mock Provider *****/
+
+function MockAddon(aId, aName, aType, aOperationsRequiringRestart) {
+ // Only set required attributes.
+ this.id = aId || "";
+ this.name = aName || "";
+ this.type = aType || "extension";
+ this.version = "";
+ this.isCompatible = true;
+ this.providesUpdatesSecurely = true;
+ this.blocklistState = 0;
+ this._appDisabled = false;
+ this._userDisabled = false;
+ this._applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+ this.scope = AddonManager.SCOPE_PROFILE;
+ this.isActive = true;
+ this.creator = "";
+ this.pendingOperations = 0;
+ this._permissions =
+ AddonManager.PERM_CAN_UNINSTALL |
+ AddonManager.PERM_CAN_ENABLE |
+ AddonManager.PERM_CAN_DISABLE |
+ AddonManager.PERM_CAN_UPGRADE |
+ AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS;
+ this.operationsRequiringRestart =
+ aOperationsRequiringRestart != undefined
+ ? aOperationsRequiringRestart
+ : AddonManager.OP_NEEDS_RESTART_INSTALL |
+ AddonManager.OP_NEEDS_RESTART_UNINSTALL |
+ AddonManager.OP_NEEDS_RESTART_ENABLE |
+ AddonManager.OP_NEEDS_RESTART_DISABLE;
+}
+
+MockAddon.prototype = {
+ get isCorrectlySigned() {
+ if (this.signedState === AddonManager.SIGNEDSTATE_NOT_REQUIRED) {
+ return true;
+ }
+ return this.signedState > AddonManager.SIGNEDSTATE_MISSING;
+ },
+
+ get shouldBeActive() {
+ return (
+ !this.appDisabled &&
+ !this._userDisabled &&
+ !(this.pendingOperations & AddonManager.PENDING_UNINSTALL)
+ );
+ },
+
+ get appDisabled() {
+ return this._appDisabled;
+ },
+
+ set appDisabled(val) {
+ if (val == this._appDisabled) {
+ return;
+ }
+
+ AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "appDisabled",
+ ]);
+
+ var currentActive = this.shouldBeActive;
+ this._appDisabled = val;
+ var newActive = this.shouldBeActive;
+ this._updateActiveState(currentActive, newActive);
+ },
+
+ get userDisabled() {
+ return this._userDisabled;
+ },
+
+ set userDisabled(val) {
+ throw new Error("No. Bad.");
+ },
+
+ setUserDisabled(val) {
+ if (val == this._userDisabled) {
+ return;
+ }
+
+ var currentActive = this.shouldBeActive;
+ this._userDisabled = val;
+ var newActive = this.shouldBeActive;
+ this._updateActiveState(currentActive, newActive);
+ },
+
+ async enable() {
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this.setUserDisabled(false);
+ },
+ async disable() {
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ this.setUserDisabled(true);
+ },
+
+ get permissions() {
+ let permissions = this._permissions;
+ if (this.appDisabled || !this._userDisabled) {
+ permissions &= ~AddonManager.PERM_CAN_ENABLE;
+ }
+ if (this.appDisabled || this._userDisabled) {
+ permissions &= ~AddonManager.PERM_CAN_DISABLE;
+ }
+ return permissions;
+ },
+
+ set permissions(val) {
+ this._permissions = val;
+ },
+
+ get applyBackgroundUpdates() {
+ return this._applyBackgroundUpdates;
+ },
+
+ set applyBackgroundUpdates(val) {
+ if (
+ val != AddonManager.AUTOUPDATE_DEFAULT &&
+ val != AddonManager.AUTOUPDATE_DISABLE &&
+ val != AddonManager.AUTOUPDATE_ENABLE
+ ) {
+ ok(false, "addon.applyBackgroundUpdates set to an invalid value: " + val);
+ }
+ this._applyBackgroundUpdates = val;
+ AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [
+ "applyBackgroundUpdates",
+ ]);
+ },
+
+ isCompatibleWith(aAppVersion, aPlatformVersion) {
+ return true;
+ },
+
+ findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
+ // Tests can implement this if they need to
+ },
+
+ async getBlocklistURL() {
+ return this.blocklistURL;
+ },
+
+ uninstall(aAlwaysAllowUndo = false) {
+ if (
+ this.operationsRequiringRestart &
+ AddonManager.OP_NEED_RESTART_UNINSTALL &&
+ this.pendingOperations & AddonManager.PENDING_UNINSTALL
+ ) {
+ throw Components.Exception("Add-on is already pending uninstall");
+ }
+
+ var needsRestart =
+ aAlwaysAllowUndo ||
+ !!(
+ this.operationsRequiringRestart &
+ AddonManager.OP_NEEDS_RESTART_UNINSTALL
+ );
+ this.pendingOperations |= AddonManager.PENDING_UNINSTALL;
+ AddonManagerPrivate.callAddonListeners(
+ "onUninstalling",
+ this,
+ needsRestart
+ );
+ if (!needsRestart) {
+ this.pendingOperations -= AddonManager.PENDING_UNINSTALL;
+ this._provider.removeAddon(this);
+ } else if (
+ !(this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_DISABLE)
+ ) {
+ this.isActive = false;
+ }
+ },
+
+ cancelUninstall() {
+ if (!(this.pendingOperations & AddonManager.PENDING_UNINSTALL)) {
+ throw Components.Exception("Add-on is not pending uninstall");
+ }
+
+ this.pendingOperations -= AddonManager.PENDING_UNINSTALL;
+ this.isActive = this.shouldBeActive;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
+ },
+
+ markAsSeen() {
+ this.seen = true;
+ },
+
+ _updateActiveState(currentActive, newActive) {
+ if (currentActive == newActive) {
+ return;
+ }
+
+ if (newActive == this.isActive) {
+ this.pendingOperations -= newActive
+ ? AddonManager.PENDING_DISABLE
+ : AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onOperationCancelled", this);
+ } else if (newActive) {
+ let needsRestart = !!(
+ this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_ENABLE
+ );
+ this.pendingOperations |= AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onEnabling", this, needsRestart);
+ if (!needsRestart) {
+ this.isActive = newActive;
+ this.pendingOperations -= AddonManager.PENDING_ENABLE;
+ AddonManagerPrivate.callAddonListeners("onEnabled", this);
+ }
+ } else {
+ let needsRestart = !!(
+ this.operationsRequiringRestart & AddonManager.OP_NEEDS_RESTART_DISABLE
+ );
+ this.pendingOperations |= AddonManager.PENDING_DISABLE;
+ AddonManagerPrivate.callAddonListeners("onDisabling", this, needsRestart);
+ if (!needsRestart) {
+ this.isActive = newActive;
+ this.pendingOperations -= AddonManager.PENDING_DISABLE;
+ AddonManagerPrivate.callAddonListeners("onDisabled", this);
+ }
+ }
+ },
+};
+
+/** *** Mock AddonInstall object for the Mock Provider *****/
+
+function MockInstall(aName, aType, aAddonToInstall) {
+ this.name = aName || "";
+ // Don't expose type until download completed
+ this._type = aType || "extension";
+ this.type = null;
+ this.version = "1.0";
+ this.iconURL = "";
+ this.infoURL = "";
+ this.state = AddonManager.STATE_AVAILABLE;
+ this.error = 0;
+ this.sourceURI = null;
+ this.file = null;
+ this.progress = 0;
+ this.maxProgress = -1;
+ this.certificate = null;
+ this.certName = "";
+ this.existingAddon = null;
+ this.addon = null;
+ this._addonToInstall = aAddonToInstall;
+ this.listeners = [];
+
+ // Another type of install listener for tests that want to check the results
+ // of code run from standard install listeners
+ this.testListeners = [];
+}
+
+MockInstall.prototype = {
+ install() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.state = AddonManager.STATE_DOWNLOADING;
+ if (!this.callListeners("onDownloadStarted")) {
+ this.state = AddonManager.STATE_CANCELLED;
+ this.callListeners("onDownloadCancelled");
+ return;
+ }
+
+ this.type = this._type;
+
+ // Adding addon to MockProvider to be implemented when needed
+ if (this._addonToInstall) {
+ this.addon = this._addonToInstall;
+ } else {
+ this.addon = new MockAddon("", this.name, this.type);
+ this.addon.version = this.version;
+ this.addon.pendingOperations = AddonManager.PENDING_INSTALL;
+ }
+ this.addon.install = this;
+ if (this.existingAddon) {
+ if (!this.addon.id) {
+ this.addon.id = this.existingAddon.id;
+ }
+ this.existingAddon.pendingUpgrade = this.addon;
+ this.existingAddon.pendingOperations |= AddonManager.PENDING_UPGRADE;
+ }
+
+ this.state = AddonManager.STATE_DOWNLOADED;
+ this.callListeners("onDownloadEnded");
+ // fall through
+ case AddonManager.STATE_DOWNLOADED:
+ this.state = AddonManager.STATE_INSTALLING;
+ if (!this.callListeners("onInstallStarted")) {
+ this.state = AddonManager.STATE_CANCELLED;
+ this.callListeners("onInstallCancelled");
+ return;
+ }
+
+ let needsRestart =
+ this.operationsRequiringRestart &
+ AddonManager.OP_NEEDS_RESTART_INSTALL;
+ AddonManagerPrivate.callAddonListeners(
+ "onInstalling",
+ this.addon,
+ needsRestart
+ );
+ if (!needsRestart) {
+ AddonManagerPrivate.callAddonListeners("onInstalled", this.addon);
+ }
+
+ this.state = AddonManager.STATE_INSTALLED;
+ this.callListeners("onInstallEnded");
+ break;
+ case AddonManager.STATE_DOWNLOADING:
+ case AddonManager.STATE_CHECKING_UPDATE:
+ case AddonManager.STATE_INSTALLING:
+ // Installation is already running
+ return;
+ default:
+ ok(false, "Cannot start installing when state = " + this.state);
+ }
+ },
+
+ cancel() {
+ switch (this.state) {
+ case AddonManager.STATE_AVAILABLE:
+ this.state = AddonManager.STATE_CANCELLED;
+ break;
+ case AddonManager.STATE_INSTALLED:
+ this.state = AddonManager.STATE_CANCELLED;
+ this._provider.removeInstall(this);
+ this.callListeners("onInstallCancelled");
+ break;
+ default:
+ // Handling cancelling when downloading to be implemented when needed
+ ok(false, "Cannot cancel when state = " + this.state);
+ }
+ },
+
+ addListener(aListener) {
+ if (!this.listeners.some(i => i == aListener)) {
+ this.listeners.push(aListener);
+ }
+ },
+
+ removeListener(aListener) {
+ this.listeners = this.listeners.filter(i => i != aListener);
+ },
+
+ addTestListener(aListener) {
+ if (!this.testListeners.some(i => i == aListener)) {
+ this.testListeners.push(aListener);
+ }
+ },
+
+ removeTestListener(aListener) {
+ this.testListeners = this.testListeners.filter(i => i != aListener);
+ },
+
+ callListeners(aMethod) {
+ var result = AddonManagerPrivate.callInstallListeners(
+ aMethod,
+ this.listeners,
+ this,
+ this.addon
+ );
+
+ // Call test listeners after standard listeners to remove race condition
+ // between standard and test listeners
+ for (let listener of this.testListeners) {
+ try {
+ if (aMethod in listener) {
+ if (listener[aMethod](this, this.addon) === false) {
+ result = false;
+ }
+ }
+ } catch (e) {
+ ok(false, "Test listener threw exception: " + e);
+ }
+ }
+
+ return result;
+ },
+};
+
+function waitForCondition(condition, nextTest, errorMsg) {
+ let tries = 0;
+ let interval = setInterval(function () {
+ if (tries >= 30) {
+ ok(false, errorMsg);
+ moveOn();
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ ok(false, e + "\n" + e.stack);
+ conditionPassed = false;
+ }
+ if (conditionPassed) {
+ moveOn();
+ }
+ tries++;
+ }, 100);
+ let moveOn = function () {
+ clearInterval(interval);
+ nextTest();
+ };
+}
+
+// Wait for and then acknowledge (by pressing the primary button) the
+// given notification.
+function promiseNotification(id = "addon-webext-permissions") {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(id);
+ if (notification) {
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ PopupNotifications.panel.firstElementChild.button.click();
+ resolve();
+ }
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name = "addon-webext-permissions") {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstChild);
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function waitAppMenuNotificationShown(
+ id,
+ addonId,
+ accept = false,
+ win = window
+) {
+ const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+ );
+ return new Promise(resolve => {
+ let { document, PanelUI } = win;
+
+ async function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification) {
+ return;
+ }
+
+ is(notification.id, id, `${id} notification shown`);
+ ok(PanelUI.isNotificationPanelOpen, "notification panel open");
+
+ PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);
+
+ if (id == "addon-installed" && addonId) {
+ let addon = await AddonManager.getAddonByID(addonId);
+ if (!addon) {
+ ok(false, `Addon with id "${addonId}" not found`);
+ }
+ let hidden = !(
+ addon.permissions &
+ AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ );
+ let checkbox = document.getElementById("addon-incognito-checkbox");
+ is(checkbox.hidden, hidden, "checkbox visibility is correct");
+ }
+ if (accept) {
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ let popupnotification = document.getElementById(popupnotificationID);
+ popupnotification.button.click();
+ }
+
+ resolve();
+ }
+ // If it's already open just run the test.
+ let notification = AppMenuNotifications.activeNotification;
+ if (notification && PanelUI.isNotificationPanelOpen) {
+ popupshown();
+ return;
+ }
+ PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function acceptAppMenuNotificationWhenShown(id, addonId) {
+ return waitAppMenuNotificationShown(id, addonId, true);
+}
+
+/* HTML view helpers */
+async function loadInitialView(type, opts) {
+ if (type) {
+ // Force the first page load to be the view we want.
+ let viewId;
+ if (type.startsWith("addons://")) {
+ viewId = type;
+ } else {
+ viewId =
+ type == "discover" ? "addons://discover/" : `addons://list/${type}`;
+ }
+ Services.prefs.setCharPref(PREF_UI_LASTCATEGORY, viewId);
+ }
+
+ let loadCallback;
+ let loadCallbackDone = Promise.resolve();
+
+ if (opts && opts.loadCallback) {
+ loadCallback = win => {
+ loadCallbackDone = (async () => {
+ // Wait for the test code to finish running before proceeding.
+ await opts.loadCallback(win);
+ })();
+ };
+ }
+
+ let win = await open_manager(null, null, loadCallback);
+ if (!opts || !opts.withAnimations) {
+ win.document.body.setAttribute("skip-animations", "");
+ }
+
+ // Let any load callback code to run before the rest of the test continues.
+ await loadCallbackDone;
+
+ return win;
+}
+
+function getSection(doc, className) {
+ return doc.querySelector(`section.${className}`);
+}
+
+function waitForViewLoad(win) {
+ return wait_for_view_load(win, undefined, true);
+}
+
+function closeView(win) {
+ return close_manager(win);
+}
+
+function switchView(win, type) {
+ return new CategoryUtilities(win).openType(type);
+}
+
+function isCategoryVisible(win, type) {
+ return new CategoryUtilities(win).isTypeVisible(type);
+}
+
+function mockPromptService() {
+ let { prompt } = Services;
+ let promptService = {
+ // The prompt returns 1 for cancelled and 0 for accepted.
+ _response: 1,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: () => promptService._response,
+ };
+ Services.prompt = promptService;
+ registerCleanupFunction(() => {
+ Services.prompt = prompt;
+ });
+ return promptService;
+}
+
+function assertHasPendingUninstalls(addonList, expectedPendingUninstallsCount) {
+ const pendingUninstalls = addonList.querySelector(
+ "message-bar-stack.pending-uninstall"
+ );
+ ok(pendingUninstalls, "Got a pending-uninstall message-bar-stack");
+ is(
+ pendingUninstalls.childElementCount,
+ expectedPendingUninstallsCount,
+ "Got a message bar in the pending-uninstall message-bar-stack"
+ );
+}
+
+function assertHasPendingUninstallAddon(addonList, addon) {
+ const pendingUninstalls = addonList.querySelector(
+ "message-bar-stack.pending-uninstall"
+ );
+ const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
+ ok(
+ addonPendingUninstall,
+ "Got expected message-bar for the pending uninstall test extension"
+ );
+ is(
+ addonPendingUninstall.parentNode,
+ pendingUninstalls,
+ "pending uninstall bar should be part of the message-bar-stack"
+ );
+ is(
+ addonPendingUninstall.getAttribute("addon-id"),
+ addon.id,
+ "Got expected addon-id attribute on the pending uninstall message-bar"
+ );
+}
+
+async function testUndoPendingUninstall(addonList, addon) {
+ const addonPendingUninstall = addonList.getPendingUninstallBar(addon);
+ const undoButton = addonPendingUninstall.querySelector("button[action=undo]");
+ ok(undoButton, "Got undo action button in the pending uninstall message-bar");
+
+ info(
+ "Clicking the pending uninstall undo button and wait for addon card rendered"
+ );
+ const updated = BrowserTestUtils.waitForEvent(addonList, "add");
+ undoButton.click();
+ await updated;
+
+ ok(
+ addon && !(addon.pendingOperations & AddonManager.PENDING_UNINSTALL),
+ "The addon pending uninstall cancelled"
+ );
+}
+
+function loadTestSubscript(filePath) {
+ Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this);
+}
+
+function cleanupPendingNotifications() {
+ const { ExtensionsUI } = ChromeUtils.importESModule(
+ "resource:///modules/ExtensionsUI.sys.mjs"
+ );
+ info("Cleanup any pending notification before exiting the test");
+ const keys = ChromeUtils.nondeterministicGetWeakSetKeys(
+ ExtensionsUI.pendingNotifications
+ );
+ if (keys) {
+ keys.forEach(key => ExtensionsUI.pendingNotifications.delete(key));
+ }
+}
+
+function promisePermissionPrompt(addonId) {
+ return BrowserUtils.promiseObserved(
+ "webextension-permission-prompt",
+ subject => {
+ const { info } = subject.wrappedJSObject || {};
+ return !addonId || (info.addon && info.addon.id === addonId);
+ }
+ ).then(({ subject }) => {
+ return subject.wrappedJSObject.info;
+ });
+}
+
+async function handlePermissionPrompt({
+ addonId,
+ reject = false,
+ assertIcon = true,
+} = {}) {
+ const info = await promisePermissionPrompt(addonId);
+ // Assert that info.addon and info.icon are defined as expected.
+ is(
+ info.addon && info.addon.id,
+ addonId,
+ "Got the AddonWrapper in the permission prompt info"
+ );
+
+ if (assertIcon) {
+ Assert.notEqual(
+ info.icon,
+ null,
+ "Got an addon icon in the permission prompt info"
+ );
+ }
+
+ if (reject) {
+ info.reject();
+ } else {
+ info.resolve();
+ }
+}
+
+async function switchToDetailView({ id, win }) {
+ let card = getAddonCard(win, id);
+ ok(card, `Addon card found for ${id}`);
+ ok(!card.querySelector("addon-details"), "The card doesn't have details");
+ let loaded = waitForViewLoad(win);
+ EventUtils.synthesizeMouseAtCenter(
+ card.querySelector(".addon-name-link"),
+ { clickCount: 1 },
+ win
+ );
+ await loaded;
+ card = getAddonCard(win, id);
+ ok(card.querySelector("addon-details"), "The card does have details");
+ return card;
+}
diff --git a/toolkit/mozapps/extensions/test/browser/head_abuse_report.js b/toolkit/mozapps/extensions/test/browser/head_abuse_report.js
new file mode 100644
index 0000000000..f3a683e8d5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/head_abuse_report.js
@@ -0,0 +1,615 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 max-len: ["error", 80] */
+
+/* exported installTestExtension, addCommonAbuseReportTestTasks,
+ * createPromptConfirmEx, DEFAULT_BUILTIN_THEME_ID,
+ * gManagerWindow, handleSubmitRequest, makeWidgetId,
+ * waitForNewWindow, waitClosedWindow, AbuseReporter,
+ * AbuseReporterTestUtils, AddonTestUtils
+ */
+
+/* global MockProvider, loadInitialView, closeView */
+
+const { AbuseReporter } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+);
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionCommon } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionCommon.sys.mjs"
+);
+
+const { makeWidgetId } = ExtensionCommon;
+
+const ADDON_ID = "test-extension-to-report@mochi.test";
+const REPORT_ENTRY_POINT = "menu";
+const BASE_TEST_MANIFEST = {
+ name: "Fake extension to report",
+ author: "Fake author",
+ homepage_url: "https://fake.extension.url/",
+};
+const DEFAULT_BUILTIN_THEME_ID = "default-theme@mozilla.org";
+const EXT_DICTIONARY_ADDON_ID = "fake-dictionary@mochi.test";
+const EXT_LANGPACK_ADDON_ID = "fake-langpack@mochi.test";
+const EXT_WITH_PRIVILEGED_URL_ID = "ext-with-privileged-url@mochi.test";
+const EXT_SYSTEM_ADDON_ID = "test-system-addon@mochi.test";
+const EXT_UNSUPPORTED_TYPE_ADDON_ID = "report-unsupported-type@mochi.test";
+const THEME_NO_UNINSTALL_ID = "theme-without-perm-can-uninstall@mochi.test";
+
+let gManagerWindow;
+
+AddonTestUtils.initMochitest(this);
+
+async function openAboutAddons(type = "extension") {
+ gManagerWindow = await loadInitialView(type);
+}
+
+async function closeAboutAddons() {
+ if (gManagerWindow) {
+ await closeView(gManagerWindow);
+ gManagerWindow = null;
+ }
+}
+
+function waitForNewWindow() {
+ return new Promise(resolve => {
+ let listener = win => {
+ Services.obs.removeObserver(listener, "toplevel-window-ready");
+ resolve(win);
+ };
+
+ Services.obs.addObserver(listener, "toplevel-window-ready");
+ });
+}
+
+function waitClosedWindow(win) {
+ return new Promise((resolve, reject) => {
+ function onWindowClosed() {
+ if (win && !win.closed) {
+ // If a specific window reference has been passed, then check
+ // that the window is closed before resolving the promise.
+ return;
+ }
+ Services.obs.removeObserver(onWindowClosed, "xul-window-destroyed");
+ resolve();
+ }
+ Services.obs.addObserver(onWindowClosed, "xul-window-destroyed");
+ });
+}
+
+async function installTestExtension(
+ id = ADDON_ID,
+ type = "extension",
+ manifest = {}
+) {
+ let additionalProps = {
+ icons: {
+ 32: "test-icon.png",
+ },
+ };
+
+ switch (type) {
+ case "theme":
+ additionalProps = {
+ ...additionalProps,
+ theme: {
+ colors: {
+ frame: "#a14040",
+ tab_background_text: "#fac96e",
+ },
+ },
+ };
+ break;
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ case "sitepermission-deprecated":
+ additionalProps = {
+ name: "WebMIDI test addon for https://mochi.test",
+ install_origins: ["https://mochi.test"],
+ site_permissions: ["midi"],
+ };
+ break;
+ case "extension":
+ break;
+ default:
+ throw new Error(`Unexpected addon type: ${type}`);
+ }
+
+ const extensionOpts = {
+ manifest: {
+ ...BASE_TEST_MANIFEST,
+ ...additionalProps,
+ ...manifest,
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "temporary",
+ };
+
+ // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based
+ // implementation is also removed.
+ if (type === "sitepermission-deprecated") {
+ const xpi = AddonTestUtils.createTempWebExtensionFile(extensionOpts);
+ const addon = await AddonManager.installTemporaryAddon(xpi);
+ // The extension object that ExtensionTestUtils.loadExtension returns for
+ // mochitest is pretty tight to the Extension class, and so for now this
+ // returns a more minimal `extension` test object which only provides the
+ // `unload` method.
+ //
+ // For the purpose of the abuse reports tests that are using this helper
+ // this should be already enough.
+ return {
+ addon,
+ unload: () => addon.uninstall(),
+ };
+ }
+
+ const extension = ExtensionTestUtils.loadExtension(extensionOpts);
+ await extension.startup();
+ return extension;
+}
+
+function handleSubmitRequest({ request, response }) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json", false);
+ response.write("{}");
+}
+
+const AbuseReportTestUtils = {
+ _mockProvider: null,
+ _mockServer: null,
+ _abuseRequestHandlers: [],
+
+ // Mock addon details API endpoint.
+ amoAddonDetailsMap: new Map(),
+
+ // Setup the test environment by setting the expected prefs and
+ // initializing MockProvider and the mock AMO server.
+ async setup() {
+ // Enable html about:addons and the abuse reporting and
+ // set the api endpoints url to the mock service.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.abuseReport.enabled", true],
+ ["extensions.abuseReport.url", "http://test.addons.org/api/report/"],
+ [
+ "extensions.abuseReport.amoDetailsURL",
+ "http://test.addons.org/api/addons/addon/",
+ ],
+ ],
+ });
+
+ this._setupMockProvider();
+ this._setupMockServer();
+ },
+
+ // Returns the currently open abuse report dialog window (if any).
+ getReportDialog() {
+ return Services.ww.getWindowByName("addons-abuse-report-dialog");
+ },
+
+ // Returns the parameters related to the report dialog (if any).
+ getReportDialogParams() {
+ const win = this.getReportDialog();
+ return win && win.arguments[0] && win.arguments[0].wrappedJSObject;
+ },
+
+ // Returns a reference to the addon-abuse-report element from the currently
+ // open abuse report.
+ getReportPanel() {
+ const win = this.getReportDialog();
+ ok(win, "Got an abuse report dialog open");
+ return win && win.document.querySelector("addon-abuse-report");
+ },
+
+ // Returns the list of abuse report reasons.
+ getReasons(abuseReportEl) {
+ return Object.keys(abuseReportEl.ownerGlobal.ABUSE_REPORT_REASONS);
+ },
+
+ // Returns the info related to a given abuse report reason.
+ getReasonInfo(abuseReportEl, reason) {
+ return abuseReportEl.ownerGlobal.ABUSE_REPORT_REASONS[reason];
+ },
+
+ async promiseReportOpened({ addonId, reportEntryPoint, managerWindow }) {
+ let abuseReportEl;
+
+ if (!this.getReportDialog()) {
+ info("Wait for the report dialog window");
+ const dialog = await waitForNewWindow();
+ is(dialog, this.getReportDialog(), "Report dialog opened");
+ }
+
+ info("Wait for the abuse report panel render");
+ abuseReportEl = await AbuseReportTestUtils.promiseReportDialogRendered();
+
+ ok(abuseReportEl, "Got an abuse report panel");
+ is(
+ abuseReportEl.addon && abuseReportEl.addon.id,
+ addonId,
+ "Abuse Report panel rendered for the expected addonId"
+ );
+ is(
+ abuseReportEl._report && abuseReportEl._report.reportEntryPoint,
+ reportEntryPoint,
+ "Abuse Report panel rendered for the expected reportEntryPoint"
+ );
+
+ return abuseReportEl;
+ },
+
+ // Return a promise resolved when the currently open report panel
+ // is closed.
+ // Also asserts that a specific report panel element has been closed,
+ // if one has been provided through the optional panel parameter.
+ async promiseReportClosed(panel) {
+ const win = panel ? panel.ownerGlobal : this.getReportDialog();
+ if (!win || win.closed) {
+ throw Error("Expected report dialog not found or already closed");
+ }
+
+ await waitClosedWindow(win);
+ // Assert that the panel has been closed (if the caller has passed it).
+ if (panel) {
+ ok(!panel.ownerGlobal, "abuse report dialog closed");
+ }
+ },
+
+ // Returns a promise resolved when the report panel has been rendered
+ // (rejects is there is no dialog currently open).
+ async promiseReportDialogRendered() {
+ const params = this.getReportDialogParams();
+ if (!params) {
+ throw new Error("abuse report dialog not found");
+ }
+ return params.promiseReportPanel;
+ },
+
+ // Given a `requestHandler` function, an HTTP server handler function
+ // to use to handle a report submit request received by the mock AMO server),
+ // returns a promise resolved when the mock AMO server has received and
+ // handled the report submit request.
+ async promiseReportSubmitHandled(requestHandler) {
+ if (typeof requestHandler !== "function") {
+ throw new Error("requestHandler should be a function");
+ }
+ return new Promise((resolve, reject) => {
+ this._abuseRequestHandlers.unshift({ resolve, reject, requestHandler });
+ });
+ },
+
+ // Return a promise resolved to the abuse report panel element,
+ // once its rendering is completed.
+ // If abuseReportEl is undefined, it looks for the currently opened
+ // report panel.
+ async promiseReportRendered(abuseReportEl) {
+ let el = abuseReportEl;
+
+ if (!el) {
+ const win = this.getReportDialog();
+ if (!win) {
+ await waitForNewWindow();
+ }
+
+ el = await this.promiseReportDialogRendered();
+ ok(el, "Got an abuse report panel");
+ }
+
+ return el._radioCheckedReason
+ ? el
+ : BrowserTestUtils.waitForEvent(
+ el,
+ "abuse-report:updated",
+ "Wait the abuse report panel to be rendered"
+ ).then(() => el);
+ },
+
+ // A promise resolved when the given abuse report panel element
+ // has been rendered. If a panel name ("reasons" or "submit") is
+ // passed as a second parameter, it also asserts that the panel is
+ // updated to the expected view mode.
+ async promiseReportUpdated(abuseReportEl, panel) {
+ const evt = await BrowserTestUtils.waitForEvent(
+ abuseReportEl,
+ "abuse-report:updated",
+ "Wait abuse report panel update"
+ );
+
+ if (panel) {
+ is(evt.detail.panel, panel, `Got a "${panel}" update event`);
+
+ const el = abuseReportEl;
+ switch (evt.detail.panel) {
+ case "reasons":
+ ok(!el._reasonsPanel.hidden, "Reasons panel should be visible");
+ ok(el._submitPanel.hidden, "Submit panel should be hidden");
+ break;
+ case "submit":
+ ok(el._reasonsPanel.hidden, "Reasons panel should be hidden");
+ ok(!el._submitPanel.hidden, "Submit panel should be visible");
+ break;
+ }
+ }
+ },
+
+ // Returns a promise resolved once the expected number of abuse report
+ // message bars have been created.
+ promiseMessageBars(expectedMessageBarCount) {
+ return new Promise(resolve => {
+ const details = [];
+ function listener(evt) {
+ details.push(evt.detail);
+ if (details.length >= expectedMessageBarCount) {
+ cleanup();
+ resolve(details);
+ }
+ }
+ function cleanup() {
+ if (gManagerWindow) {
+ gManagerWindow.document.removeEventListener(
+ "abuse-report:new-message-bar",
+ listener
+ );
+ }
+ }
+ gManagerWindow.document.addEventListener(
+ "abuse-report:new-message-bar",
+ listener
+ );
+ });
+ },
+
+ async assertFluentStrings(containerEl) {
+ // Make sure all localized elements have defined Fluent strings.
+ let localizedEls = Array.from(
+ containerEl.querySelectorAll("[data-l10n-id]")
+ );
+ if (containerEl.getAttribute("data-l10n-id")) {
+ localizedEls.push(containerEl);
+ }
+ ok(localizedEls.length, "Got localized elements");
+ for (let el of localizedEls) {
+ const l10nId = el.getAttribute("data-l10n-id");
+ const l10nAttrs = el.getAttribute("data-l10n-attrs");
+ if (!l10nAttrs) {
+ await TestUtils.waitForCondition(
+ () => el.textContent !== "",
+ `Element with Fluent id '${l10nId}' should not be empty`
+ );
+ } else {
+ await TestUtils.waitForCondition(
+ () => el.message !== "",
+ `Message attribute of the element with Fluent id '${l10nId}'
+ should not be empty`
+ );
+ }
+ }
+ },
+
+ // Assert that the report action visibility on the addon card
+ // for the given about:addons windows and extension id.
+ async assertReportActionVisibility(gManagerWindow, extId, expectShown) {
+ let addonCard = gManagerWindow.document.querySelector(
+ `addon-list addon-card[addon-id="${extId}"]`
+ );
+ ok(addonCard, `Got the addon-card for the ${extId} test extension`);
+
+ let reportButton = addonCard.querySelector("[action=report]");
+ ok(reportButton, `Got the report action for ${extId}`);
+ Assert.equal(
+ reportButton.hidden,
+ !expectShown,
+ `${extId} report action should be ${expectShown ? "shown" : "hidden"}`
+ );
+ },
+
+ // Assert that the report action is hidden on the addon card
+ // for the given about:addons windows and extension id.
+ assertReportActionHidden(gManagerWindow, extId) {
+ return this.assertReportActionVisibility(gManagerWindow, extId, false);
+ },
+
+ // Assert that the report action is shown on the addon card
+ // for the given about:addons windows and extension id.
+ assertReportActionShown(gManagerWindow, extId) {
+ return this.assertReportActionVisibility(gManagerWindow, extId, true);
+ },
+
+ // Assert that the report panel is hidden (or closed if the report
+ // panel is opened in its own dialog window).
+ async assertReportPanelHidden() {
+ const win = this.getReportDialog();
+ ok(!win, "Abuse Report dialog should be initially hidden");
+ },
+
+ createMockAddons(mockProviderAddons) {
+ this._mockProvider.createAddons(mockProviderAddons);
+ },
+
+ async clickPanelButton(buttonEl, { label = undefined } = {}) {
+ info(`Clicking the '${buttonEl.textContent.trim() || label}' button`);
+ // NOTE: ideally this should synthesize the mouse event,
+ // we call the click method to prevent intermittent timeouts
+ // due to the mouse event not received by the target element.
+ buttonEl.click();
+ },
+
+ triggerNewReport(addonId, reportEntryPoint) {
+ gManagerWindow.openAbuseReport({ addonId, reportEntryPoint });
+ },
+
+ triggerSubmit(reason, message) {
+ const reportEl =
+ this.getReportDialog().document.querySelector("addon-abuse-report");
+ reportEl._form.elements.message.value = message;
+ reportEl._form.elements.reason.value = reason;
+ reportEl.submit();
+ },
+
+ async openReport(addonId, reportEntryPoint = REPORT_ENTRY_POINT) {
+ // Close the current about:addons window if it has been leaved open from
+ // a previous test case failure.
+ if (gManagerWindow) {
+ await closeAboutAddons();
+ }
+
+ await openAboutAddons();
+
+ let promiseReportPanel = waitForNewWindow().then(() =>
+ this.promiseReportDialogRendered()
+ );
+
+ this.triggerNewReport(addonId, reportEntryPoint);
+
+ const panelEl = await promiseReportPanel;
+ await this.promiseReportRendered(panelEl);
+ is(panelEl.addonId, addonId, `Got Abuse Report panel for ${addonId}`);
+
+ return panelEl;
+ },
+
+ async closeReportPanel(panelEl) {
+ const onceReportClosed = AbuseReportTestUtils.promiseReportClosed(panelEl);
+
+ info("Cancel report and wait the dialog to be closed");
+ panelEl.dispatchEvent(new CustomEvent("abuse-report:cancel"));
+
+ await onceReportClosed;
+ },
+
+ // Internal helper methods.
+
+ _setupMockProvider() {
+ this._mockProvider = new MockProvider();
+ this._mockProvider.createAddons([
+ {
+ id: THEME_NO_UNINSTALL_ID,
+ name: "This theme cannot be uninstalled",
+ version: "1.1",
+ creator: { name: "Theme creator", url: "http://example.com/creator" },
+ type: "theme",
+ permissions: 0,
+ },
+ {
+ id: EXT_WITH_PRIVILEGED_URL_ID,
+ name: "This extension has an unexpected privileged creator URL",
+ version: "1.1",
+ creator: { name: "creator", url: "about:config" },
+ type: "extension",
+ },
+ {
+ id: EXT_SYSTEM_ADDON_ID,
+ name: "This is a system addon",
+ version: "1.1",
+ creator: { name: "creator", url: "http://example.com/creator" },
+ type: "extension",
+ isSystem: true,
+ },
+ {
+ id: EXT_UNSUPPORTED_TYPE_ADDON_ID,
+ name: "This is a fake unsupported addon type",
+ version: "1.1",
+ type: "unsupported_addon_type",
+ },
+ {
+ id: EXT_LANGPACK_ADDON_ID,
+ name: "This is a fake langpack",
+ version: "1.1",
+ type: "locale",
+ },
+ {
+ id: EXT_DICTIONARY_ADDON_ID,
+ name: "This is a fake dictionary",
+ version: "1.1",
+ type: "dictionary",
+ },
+ ]);
+ },
+
+ _setupMockServer() {
+ if (this._mockServer) {
+ return;
+ }
+
+ // Init test report api server.
+ const server = AddonTestUtils.createHttpServer({
+ hosts: ["test.addons.org"],
+ });
+ this._mockServer = server;
+
+ server.registerPathHandler("/api/report/", (request, response) => {
+ const stream = request.bodyInputStream;
+ const buffer = NetUtil.readInputStream(stream, stream.available());
+ const data = new TextDecoder().decode(buffer);
+ const promisedHandler = this._abuseRequestHandlers.pop();
+ if (promisedHandler) {
+ const { requestHandler, resolve, reject } = promisedHandler;
+ try {
+ requestHandler({ data, request, response });
+ resolve();
+ } catch (err) {
+ ok(false, `Unexpected requestHandler error ${err} ${err.stack}\n`);
+ reject(err);
+ }
+ } else {
+ ok(false, `Unexpected request: ${request.path} ${data}`);
+ }
+ });
+
+ server.registerPrefixHandler("/api/addons/addon/", (request, response) => {
+ const addonId = request.path.split("/").pop();
+ if (!this.amoAddonDetailsMap.has(addonId)) {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ response.write(JSON.stringify({ detail: "Not found." }));
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "Success");
+ response.write(JSON.stringify(this.amoAddonDetailsMap.get(addonId)));
+ }
+ });
+ server.registerPathHandler(
+ "/assets/fake-icon-url.png",
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "Success");
+ response.write("");
+ response.finish();
+ }
+ );
+ },
+};
+
+function createPromptConfirmEx({
+ remove = false,
+ report = false,
+ expectCheckboxHidden = false,
+} = {}) {
+ return (...args) => {
+ const checkboxState = args.pop();
+ const checkboxMessage = args.pop();
+ is(
+ checkboxState && checkboxState.value,
+ false,
+ "checkboxState should be initially false"
+ );
+ if (expectCheckboxHidden) {
+ ok(
+ !checkboxMessage,
+ "Should not have a checkboxMessage in promptService.confirmEx call"
+ );
+ } else {
+ ok(
+ checkboxMessage,
+ "Got a checkboxMessage in promptService.confirmEx call"
+ );
+ }
+
+ // Report checkbox selected.
+ checkboxState.value = report;
+
+ // Remove accepted.
+ return remove ? 0 : 1;
+ };
+}
diff --git a/toolkit/mozapps/extensions/test/browser/head_disco.js b/toolkit/mozapps/extensions/test/browser/head_disco.js
new file mode 100644
index 0000000000..64c346f3dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/head_disco.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint max-len: ["error", 80] */
+
+/* exported DISCOAPI_DEFAULT_FIXTURE, getCardContainer,
+ getDiscoveryElement, promiseAddonInstall, promiseDiscopaneUpdate,
+ promiseEvent, promiseObserved, readAPIResponseFixture */
+
+/* globals RELATIVE_DIR, promisePopupNotificationShown,
+ waitAppMenuNotificationShown */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const {
+ ExtensionUtils: { promiseEvent, promiseObserved },
+} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs");
+
+AddonTestUtils.initMochitest(this);
+
+// The response to the discovery API, as documented at:
+// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
+//
+// The tests using this fixure are meant to verify that the discopane works
+// with the latest AMO API.
+// The following fixure file should be kept in sync with the content of
+// latest AMO API response, e.g. from
+//
+// https://addons.allizom.org/api/v4/discovery/?lang=en-US
+//
+// The response must contain at least one theme, and one extension.
+const DISCOAPI_DEFAULT_FIXTURE = PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ ...RELATIVE_DIR.split("/"),
+ "discovery",
+ "api_response.json"
+);
+
+// Read the content of API_RESPONSE_FILE, and replaces any embedded URLs with
+// URLs that point to the `amoServer` test server.
+async function readAPIResponseFixture(
+ amoTestHost,
+ fixtureFilePath = DISCOAPI_DEFAULT_FIXTURE
+) {
+ let apiText = await IOUtils.readUTF8(fixtureFilePath);
+ apiText = apiText.replace(/\bhttps?:\/\/[^"]+(?=")/g, url => {
+ try {
+ url = new URL(url);
+ } catch (e) {
+ // Responses may contain "http://*/*"; ignore it.
+ return url;
+ }
+ // In this test, we only need to distinguish between different file types,
+ // so just use the file extension as path name for amoServer.
+ let ext = url.pathname.split(".").pop();
+ return `http://${amoTestHost}/${ext}?${url.pathname}${url.search}`;
+ });
+
+ return apiText;
+}
+
+// Wait until the current `<discovery-pane>` element has finished loading its
+// cards. This can be used after the cards have been loaded.
+function promiseDiscopaneUpdate(win) {
+ let { cardsReady } = getCardContainer(win);
+ ok(cardsReady, "Discovery cards should have started to initialize");
+ return cardsReady;
+}
+
+function getCardContainer(win) {
+ return getDiscoveryElement(win).querySelector("recommended-addon-list");
+}
+
+function getDiscoveryElement(win) {
+ return win.document.querySelector("discovery-pane");
+}
+
+// A helper that waits until an installation has been requested from `amoServer`
+// and proceeds with approving the installation.
+async function promiseAddonInstall(
+ amoServer,
+ extensionData,
+ expectedTelemetryInfo = { source: "disco", taarRecommended: false }
+) {
+ let description = extensionData.manifest.description;
+ let xpiFile = AddonTestUtils.createTempWebExtensionFile(extensionData);
+ amoServer.registerFile("/xpi", xpiFile);
+
+ let addonId =
+ extensionData.manifest?.browser_specific_settings?.gecko?.id ||
+ extensionData.manifest?.applications?.gecko?.id;
+ let installedPromise = waitAppMenuNotificationShown(
+ "addon-installed",
+ addonId,
+ true
+ );
+
+ if (!extensionData.manifest.theme) {
+ info(`${description}: Waiting for permission prompt`);
+ // Extensions have install prompts.
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ panel.button.click();
+ } else {
+ info(`${description}: Waiting for install prompt`);
+ let panel = await promisePopupNotificationShown(
+ "addon-install-confirmation"
+ );
+ panel.button.click();
+ }
+
+ info("Waiting for post-install doorhanger");
+ await installedPromise;
+
+ let addon = await AddonManager.getAddonByID(addonId);
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ expectedTelemetryInfo,
+ "The installed add-on should have the expected telemetry info"
+ );
+}
diff --git a/toolkit/mozapps/extensions/test/browser/moz.build b/toolkit/mozapps/extensions/test/browser/moz.build
new file mode 100644
index 0000000000..4cc6314d0e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/moz.build
@@ -0,0 +1,31 @@
+# -*- 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser.toml",
+]
+
+addons = [
+ "browser_dragdrop1",
+ "browser_dragdrop2",
+ "browser_dragdrop_incompat",
+ "browser_installssl",
+ "browser_theme",
+ "options_signed",
+]
+
+output_dir = (
+ OBJDIR_FILES._tests.testing.mochitest.browser.toolkit.mozapps.extensions.test.browser.addons
+)
+
+for addon in addons:
+ for file_type in ["xpi", "zip"]:
+ indir = "addons/%s" % addon
+ path = "%s.%s" % (indir, file_type)
+
+ GeneratedFile(path, script="../create_xpi.py", inputs=[indir])
+
+ output_dir += ["!%s" % path]
diff --git a/toolkit/mozapps/extensions/test/browser/redirect.sjs b/toolkit/mozapps/extensions/test/browser/redirect.sjs
new file mode 100644
index 0000000000..8f9d1c08af
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/redirect.sjs
@@ -0,0 +1,5 @@
+function handleRequest(request, response) {
+ dump("*** Received redirect for " + request.queryString + "\n");
+ response.setStatusLine(request.httpVersion, 301, "Moved Permanently");
+ response.setHeader("Location", request.queryString, false);
+}
diff --git a/toolkit/mozapps/extensions/test/browser/sandboxed.html b/toolkit/mozapps/extensions/test/browser/sandboxed.html
new file mode 100644
index 0000000000..219426f0a9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/sandboxed.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ Sandboxed page
+ </body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^ b/toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^
new file mode 100644
index 0000000000..4705ce9ded
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/sandboxed.html^headers^
@@ -0,0 +1 @@
+Content-Security-Policy: sandbox allow-scripts;
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html b/toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html
new file mode 100644
index 0000000000..383d2a0986
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_addon_listener.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<p id="result"></p>
+<script type="text/javascript">
+let events = [];
+let resultEl = document.getElementById("result");
+[ "onEnabling",
+ "onEnabled",
+ "onDisabling",
+ "onDisabled",
+ "onInstalling",
+ "onInstalled",
+ "onUninstalling",
+ "onUninstalled",
+ "onOperationCancelled",
+].forEach(event => {
+ navigator.mozAddonManager.addEventListener(event, data => {
+ let obj = {event, id: data.id, needsRestart: data.needsRestart};
+ events.push(JSON.stringify(obj));
+ resultEl.textContent = events.join("\n");
+ });
+});
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html b/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html
new file mode 100644
index 0000000000..141f09cc61
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<p id="result"></p>
+<script type="text/javascript">
+document.getElementById("result").textContent = ("mozAddonManager" in window.navigator);
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml b/toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml
new file mode 100644
index 0000000000..6e3ba328ec
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checkchromeframe.xhtml
@@ -0,0 +1,6 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <browser id="frame" disablehistory="true" flex="1" type="content"
+ src="https://example.com/browser/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html"/>
+</window>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checkframed.html b/toolkit/mozapps/extensions/test/browser/webapi_checkframed.html
new file mode 100644
index 0000000000..1467699789
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checkframed.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<iframe id="frame" height="200" width="200" src="https://example.com/browser/toolkit/mozapps/extensions/test/browser/webapi_checkavailable.html">
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html b/toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html
new file mode 100644
index 0000000000..e1f96a0b0c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/webapi_checknavigatedwindow.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<script type="text/javascript">
+/* exported openWindow, navigate, check */
+var nav, win;
+
+function openWindow() {
+ return new Promise(resolve => {
+ win = window.open(window.location);
+
+ win.addEventListener("load", function listener() {
+ nav = win.navigator;
+ resolve();
+ });
+ });
+}
+
+function navigate() {
+ win.location = "http://example.com/";
+}
+
+function check() {
+ return "mozAddonManager" in nav;
+}
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/create_xpi.py b/toolkit/mozapps/extensions/test/create_xpi.py
new file mode 100644
index 0000000000..fcd6756e44
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/create_xpi.py
@@ -0,0 +1,21 @@
+# -*- 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/.
+
+from os.path import abspath, relpath
+
+from mozbuild.action.zip import main as create_zip
+
+
+def main(output, input_dir, *files):
+ output.close()
+
+ if files:
+ # The zip builder doesn't handle the files being an absolute path.
+ in_files = [relpath(file, input_dir) for file in files]
+
+ return create_zip(["-C", input_dir, abspath(output.name)] + in_files)
+ else:
+ return create_zip(["-C", input_dir, abspath(output.name), "**"])
diff --git a/toolkit/mozapps/extensions/test/mochitest/chrome.toml b/toolkit/mozapps/extensions/test/mochitest/chrome.toml
new file mode 100644
index 0000000000..607667ee57
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/chrome.toml
@@ -0,0 +1,3 @@
+[DEFAULT]
+
+["test_default_theme.html"]
diff --git a/toolkit/mozapps/extensions/test/mochitest/file_empty.html b/toolkit/mozapps/extensions/test/mochitest/file_empty.html
new file mode 100644
index 0000000000..b6c8a53b41
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/file_empty.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<html><head></head><body><span id="text">Nothing to see here</span></body></html>
diff --git a/toolkit/mozapps/extensions/test/mochitest/mochitest.toml b/toolkit/mozapps/extensions/test/mochitest/mochitest.toml
new file mode 100644
index 0000000000..9567d51802
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/mochitest.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+support-files = ["file_empty.html"]
+
+["test_blocklist_gfx_initialized.html"]
+
+["test_bug887098.html"]
diff --git a/toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html b/toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html
new file mode 100644
index 0000000000..3800df3f69
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/test_blocklist_gfx_initialized.html
@@ -0,0 +1,31 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test gfx blocklist is initialized</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script type="application/javascript">
+
+add_task(async function check_GfxBlocklistRS_initialized() {
+ // We have extensive xpcshell tests to ensure the blocklist works
+ // correctly. Here we just want to ensure the blocklist is indeed
+ // initialized in a regular browser setup.
+ // In fact, calling GfxBlocklistRS.checkForEntries() in order to test
+ // specific functionality would lazily initialize the blocklist, negating
+ // the value of this test.
+ let initialized = await SpecialPowers.spawnChrome([], async () => {
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+
+ return BlocklistPrivate.GfxBlocklistRS._initialized;
+ });
+
+ ok(initialized, "Gfx Blocklist was initialized")
+});
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/mochitest/test_bug887098.html b/toolkit/mozapps/extensions/test/mochitest/test_bug887098.html
new file mode 100644
index 0000000000..acf646bd3c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/test_bug887098.html
@@ -0,0 +1,70 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=887098
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 887098</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+ <script type="application/javascript">
+ /* exported loaded */
+
+ /** Test for Bug 887098 **/
+ SimpleTest.waitForExplicitFinish();
+ /* globals $,evalRef */
+
+ async function loaded() {
+ if (!SpecialPowers.Services.prefs.getBoolPref("extensions.InstallTrigger.enabled") ||
+ !SpecialPowers.Services.prefs.getBoolPref("extensions.InstallTriggerImpl.enabled")) {
+ ok(true, "InstallTrigger is not enabled");
+ SimpleTest.finish();
+ return;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Bug 1703215: Using SpecialPowers causes about:mozilla to be loaded in the wrong
+ // process, hence we have to flip the pref and don't enforce IPC based Principal Vetting.
+ ["dom.security.enforceIPCBasedPrincipalVetting", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ var iwin = $("ifr").contentWindow;
+ var href = SpecialPowers.wrap(iwin).location.href;
+ if (/file_empty/.test(href)) {
+ window.evalRef = iwin.eval;
+ window.installTriggerRef = iwin.InstallTrigger; // Force lazy instantiation.
+ // about:mozilla is privileged, so we need to be privileged to load it.
+ SpecialPowers.wrap(iwin).location.href = "about:mozilla";
+ } else {
+ is(href, "about:mozilla", "Successfully navigated to about:mozilla");
+ try {
+ evalRef('InstallTrigger.install({URL: "chrome://global/skin/global.css"});');
+ ok(false, "Should have thrown when trying to install restricted URI from InstallTrigger");
+ } catch (e) {
+ // XXXgijs this test broke because of the switch to webidl. I'm told
+ // it has to do with compartments and the fact that we eval in "about:mozilla".
+ // Tracking in bug 1007671
+ todo(/permission/.test(e), "We should throw a security exception. Got: " + e);
+ }
+ SimpleTest.finish();
+ }
+ }
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=887098">Mozilla Bug 887098</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<iframe onload="loaded();" id="ifr" src="file_empty.html"></iframe>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/mochitest/test_default_theme.html b/toolkit/mozapps/extensions/test/mochitest/test_default_theme.html
new file mode 100644
index 0000000000..9b48c7136c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/mochitest/test_default_theme.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for correct installation of default theme</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css">
+</head>
+<body>
+
+<script>
+"use strict";
+
+const {AddonManager} = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const {AppConstants} = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+add_task(async function() {
+ let addon = await AddonManager.getAddonByID("default-theme@mozilla.org");
+
+ // Dev edition uses a different default theme on desktop.
+ const expectActive = (!AppConstants.MOZ_DEV_EDITION ||
+ AppConstants.MOZ_BUILD_APP !== "browser");
+
+ ok(addon != null, "Default theme exists");
+ is(addon.type, "theme", "Add-on type is correct");
+ is(addon.isActive, expectActive, "Add-on is active?");
+ is(addon.hidden, false, "Add-on is not hidden");
+});
+
+</script>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/moz.build b/toolkit/mozapps/extensions/test/moz.build
new file mode 100644
index 0000000000..00ecaac1bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/moz.build
@@ -0,0 +1,20 @@
+# -*- 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/.
+
+DIRS += ["browser"]
+
+BROWSER_CHROME_MANIFESTS += ["xpinstall/browser.toml"]
+MOCHITEST_MANIFESTS += ["mochitest/mochitest.toml"]
+MOCHITEST_CHROME_MANIFESTS += ["mochitest/chrome.toml"]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "xpcshell/rs-blocklist/xpcshell.toml",
+ "xpcshell/xpcshell-unpack.toml",
+ "xpcshell/xpcshell.toml",
+]
+
+with Files("xpcshell/rs-blocklist/**"):
+ BUG_COMPONENT = ("Toolkit", "Blocklist Implementation")
diff --git a/toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js b/toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..8e3971b385
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/.eslintrc.js
@@ -0,0 +1,24 @@
+"use strict";
+
+module.exports = {
+ rules: {
+ "no-unused-vars": [
+ "error",
+ { args: "none", varsIgnorePattern: "^end_test$" },
+ ],
+ },
+ overrides: [
+ {
+ files: "head*.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml
new file mode 100644
index 0000000000..1f673ef2fb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_block.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug455906_1@tests.mozilla.org" blockID="test_bug455906_1@tests.mozilla.org"/>
+ <emItem id="test_bug455906_2@tests.mozilla.org" blockID="test_bug455906_2@tests.mozilla.org"/>
+ <emItem id="test_bug455906_3@tests.mozilla.org" blockID="test_bug455906_3@tests.mozilla.org"/>
+ <emItem id="test_bug455906_4@tests.mozilla.org" blockID="test_bug455906_4@tests.mozilla.org"/>
+ <emItem id="test_bug455906_5@tests.mozilla.org" blockID="test_bug455906_5@tests.mozilla.org"/>
+ <emItem id="test_bug455906_6@tests.mozilla.org" blockID="test_bug455906_6@tests.mozilla.org"/>
+ <emItem id="test_bug455906_7@tests.mozilla.org" blockID="test_bug455906_7@tests.mozilla.org"/>
+ </emItems>
+ <pluginItems>
+ <pluginItem blockID="test_bug455906_plugin">
+ <match name="name" exp="^test_bug455906"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml
new file mode 100644
index 0000000000..88d22f281f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_empty.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="dummy_bug455906_2@tests.mozilla.org"/>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml
new file mode 100644
index 0000000000..daba6f4c1c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_start.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug455906_4@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_5@tests.mozilla.org">
+ <versionRange severity="1"/>
+ </emItem>
+ <emItem id="test_bug455906_6@tests.mozilla.org">
+ <versionRange severity="2"/>
+ </emItem>
+ <emItem id="dummy_bug455906_1@tests.mozilla.org"/>
+ </emItems>
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906_4$"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906_5$"/>
+ <versionRange severity="1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906_6$"/>
+ <versionRange severity="2"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml
new file mode 100644
index 0000000000..232fd0d079
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/bug455906_warn.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug455906_1@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_2@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_3@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_4@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_5@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_6@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ <emItem id="test_bug455906_7@tests.mozilla.org">
+ <versionRange severity="-1"/>
+ </emItem>
+ </emItems>
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug455906"/>
+ <versionRange severity="-1"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi
new file mode 100644
index 0000000000..35d7bd5e5d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/corrupt.xpi
@@ -0,0 +1 @@
+This is a corrupt zip file
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpi
new file mode 100644
index 0000000000..0c30989aa5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/corruptfile.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/empty.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/empty.xpi
new file mode 100644
index 0000000000..74ed2b8174
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/empty.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.bin b/toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.bin
new file mode 100644
index 0000000000..fe8e08fa68
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/mlbf-blocked1-unblocked2.bin
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml b/toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml
new file mode 100644
index 0000000000..75e252a46b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/pluginInfoURL_block.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ </emItems>
+ <pluginItems>
+ <pluginItem blockID="test_plugin_wInfoURL">
+ <match name="name" exp="^test_with_infoURL"/>
+ <match name="version" exp="^5"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ <infoURL>http://test.url.com/</infoURL>
+ </pluginItem>
+ <pluginItem blockID="test_plugin_wAltInfoURL">
+ <match name="name" exp="^test_with_altInfoURL"/>
+ <match name="version" exp="^5"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ <infoURL>http://alt.test.url.com/</infoURL>
+ </pluginItem>
+ <pluginItem blockID="test_plugin_noInfoURL">
+ <match name="name" exp="^test_no_infoURL"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem blockID="test_plugin_newVersion">
+ <match name="name" exp="^test_newVersion"/>
+ <infoURL>http://test.url2.com/</infoURL>
+ <versionRange minVersion="1" maxVersion="2">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="*"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt
new file mode 100644
index 0000000000..f17f98b15b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt
@@ -0,0 +1 @@
+Not an xml file! \ No newline at end of file
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml
new file mode 100644
index 0000000000..0e3d415c44
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<foobar></barfoo> \ No newline at end of file
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml
new file mode 100644
index 0000000000..55ad1c7d55
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<test></test>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem
new file mode 100644
index 0000000000..e7933cc864
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICRzCCAS+gAwIBAgIUbctVfUWXUmfxzCRUBeXixFrQuOEwDQYJKoZIhvcNAQEL
+BQAwETEPMA0GA1UEAwwGaW50LUNBMCIYDzIwMjIxMTI3MDAwMDAwWhgPMjAyNTAy
+MDQwMDAwMDBaMA0xCzAJBgNVBAMMAmVlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE
+oWhyQzYrXHsYifN5FUYVocc/tI3uhj4CKRXbYI4lLeS3Ey2ozpjoMVNOapwMCwnI
+1jmt6DIG5bqBNHOhH6Mw4F2oyW5Dg/4nhz2pcQO+KIjP8ALwWvcaH93Mg3SqbqnO
+o0UwQzATBgNVHSUEDDAKBggrBgEFBQcDAzAsBgNVHREEJTAjgiFhdXMuY29udGVu
+dC1zaWduYXR1cmUubW96aWxsYS5vcmcwDQYJKoZIhvcNAQELBQADggEBAF5IT9HZ
+1ej+FAXbs2e/LOojJAulc2sxbeaa5V3rJWIiSq8iMj/ZV8dRaa96x3M6azdPiJjD
+/VT4mNF9/KBC8YoEwfJe4A9MR8SmEIe/2EMIzmZVdTv1LYsKqRuuwvbGFssBj7lW
+U9+V5KzjxtKU/RQfak5Iz+vnl6s4LIt92SLdOooPqDGj2K3FI9dg2Fqwm6vF+6zi
+8yZ7/zg4PQcY6t2C6l0e9iAFM+wzhtTPq1AvFdq5hdOil6AS8Ivb0elMwBzsLjr5
+COLcKmCeRQ/8JFhJ48C+/MQkp3gbgXvVR3fcufufSC2YaLmMb7MIhaJwNCT0nO87
+ItpI1owSYrJOnQ0=
+-----END CERTIFICATE-----
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec
new file mode 100644
index 0000000000..ee9fea9110
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_aus_ee.pem.certspec
@@ -0,0 +1,5 @@
+issuer:int-CA
+subject:ee
+subjectKey:secp384r1
+extension:extKeyUsage:codeSigning
+extension:subjectAlternativeName:aus.content-signature.mozilla.org
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem
new file mode 100644
index 0000000000..6c80b1be43
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC8TCCAdmgAwIBAgIUGU8IXEaU5Al531xp9aITCfLjy/cwDQYJKoZIhvcNAQEL
+BQAwKTEnMCUGA1UEAwweeHBjc2hlbGwgc2lnbmVkIGFwcHMgdGVzdCByb290MCIY
+DzIwMjIxMTI3MDAwMDAwWhgPMjAyNTAyMDQwMDAwMDBaMBExDzANBgNVBAMMBmlu
+dC1DQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALqIUahEjhbWQf1u
+togGNhA9PBPZ6uQ1SrTs9WhXbCR7wcclqODYH72xnAabbhqG8mvir1p1a2pkcQh6
+pVqnRYf3HNUknAJ+zUP8HmnQOCApk6sgw0nk27lMwmtsDu0Vgg/xfq1pGrHTAjqL
+KkHup3DgDw2N/WYLK7AkkqR9uYhheZCxV5A90jvF4LhIH6g304hD7ycW2FW3Zlqq
+fgKQLzp7EIAGJMwcbJetlmFbt+KWEsB1MaMMkd20yvf8rR0l0wnvuRcOp2jhs3sv
+Im9p47SKlWEd7ibWJZ2rkQhONsscJAQsvxaLL+Xxj5kXMbiz/kkj+nJRxDHVA6za
+GAo17Y0CAwEAAaMlMCMwDAYDVR0TBAUwAwEB/zATBgNVHSUEDDAKBggrBgEFBQcD
+AzANBgkqhkiG9w0BAQsFAAOCAQEAQw8azGUnMeiHd6BYf8LZDK2dqsbVpWuDT/td
+LNQcYStX4jgPSfSxm9Mg6osXBnEKF83qXoNeP6Zt84WSJDotEf0WlC5JfNZFCMry
+vfd7odumxp/00LYaMbVK8Wz2LXXXwjsYF8xoZz6zq1DYviXIMluhcvCMepnCUnbP
+hY12tcznmHiHCOoEB1qurCfW8MkIz/GkLa409i7wFE9rsAeuAKgtdTStY5g8qp5j
+2KpmTzgfCeDgKwOSEUyW4YZXrvHYpPSnLiFsWvdxG3/D9aZExw1fipvzhpvqZYv9
+u2e7Qpt98Cd+Kitom/uDNmX9hv6E3eBThQI8QpTf43z6w/KD4A==
+-----END CERTIFICATE-----
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec
new file mode 100644
index 0000000000..fc9dfd47ae
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/content_signing_int.pem.certspec
@@ -0,0 +1,4 @@
+issuer:xpcshell signed apps test root
+subject:int-CA
+extension:basicConstraints:cA,
+extension:extKeyUsage:codeSigning
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml
new file mode 100644
index 0000000000..42cb20bd01
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<updates>
+ <addons></addons>
+</updates>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml
new file mode 100644
index 0000000000..e1da86fa54
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<updates>
+ <addons>
+ <addon id="test1" URL="http://example.com/test1.xpi"/>
+ <addon id="test2" URL="http://example.com/test2.xpi" hashFunction="md5" hashValue="djhfgsjdhf"/>
+ <addon id="test3" URL="http://example.com/test3.xpi" version="1.0" size="45"/>
+ <addon id="test4"/>
+ <addon URL="http://example.com/test5.xpi"/>
+ </addons>
+</updates>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml
new file mode 100644
index 0000000000..8c9501478e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<updates></updates>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi
new file mode 100644
index 0000000000..51b00475a9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpi
new file mode 100644
index 0000000000..f60d00348e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_signed.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpi
new file mode 100644
index 0000000000..89de7f4409
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/langpack_unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpi
new file mode 100644
index 0000000000..f95f3df91e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/long.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpi
new file mode 100644
index 0000000000..c22acaacd2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/privileged.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpi
new file mode 100644
index 0000000000..e2ba7d6fd8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed1.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpi
new file mode 100644
index 0000000000..ccb20796f2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/signed2.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpi
new file mode 100644
index 0000000000..9e10be5db3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/signing_checks/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json
new file mode 100644
index 0000000000..a9fdcf1782
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_cache.json
@@ -0,0 +1,134 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 4,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "Repo Add-on 1",
+ "type": "extension",
+ "guid": "test_AddonRepository_1@tests.mozilla.org",
+ "current_version": {
+ "version": "2.1",
+ "files": [
+ {
+ "platform": "all",
+ "size": 9,
+ "url": "http://example.com/repo/1/install.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Repo Add-on 1 - Creator",
+ "url": "http://example.com/repo/1/creator.html"
+ },
+ {
+ "name": "Repo Add-on 1 - First Developer",
+ "url": "http://example.com/repo/1/firstDeveloper.html"
+ },
+ {
+ "name": "Repo Add-on 1 - Second Developer",
+ "url": "http://example.com/repo/1/secondDeveloper.html"
+ }
+ ],
+ "summary": "Repo Add-on 1 - Description<br>Second line",
+ "description": "<p>Repo Add-on 1 - Full Description &amp; some extra</p>",
+ "icons": {
+ "32": "http://example.com/repo/1/icon.png"
+ },
+ "ratings": {
+ "count": 1234,
+ "text_count": 1111,
+ "average": 1
+ },
+ "homepage": "http://example.com/repo/1/homepage.html",
+ "support_url": "http://example.com/repo/1/support.html",
+ "contributions_url": "http://example.com/repo/1/meetDevelopers.html",
+ "ratings_url": "http://example.com/repo/1/review.html",
+ "weekly_downloads": 3331,
+ "last_updated": "1970-01-01T00:00:09Z"
+ },
+ {
+ "name": "Repo Add-on 2",
+ "type": "theme",
+ "guid": "test_AddonRepository_2@tests.mozilla.org",
+ "current_version": {
+ "version": "2.2",
+ "files": [
+ {
+ "platform": "all",
+ "size": 9,
+ "url": "http://example.com/repo/2/install.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Repo Add-on 2 - Creator",
+ "url": "http://example.com/repo/2/creator.html"
+ },
+ {
+ "name": "Repo Add-on 2 - First Developer",
+ "url": "http://example.com/repo/2/firstDeveloper.html"
+ },
+ {
+ "name": "Repo Add-on 2 - Second Developer",
+ "url": "http://example.com/repo/2/secondDeveloper.html"
+ }
+ ],
+ "summary": "Repo Add-on 2 - Description",
+ "description": "Repo Add-on 2 - Full Description",
+ "icons": {
+ "32": "http://example.com/repo/2/icon.png"
+ },
+ "previews": [
+ {
+ "image_url": "http://example.com/repo/2/firstFull.png",
+ "thumbnail_url": "http://example.com/repo/2/firstThumbnail.png",
+ "caption": "Repo Add-on 2 - First Caption"
+ },
+ {
+ "image_url": "http://example.com/repo/2/secondFull.png",
+ "thumbnail_url": "http://example.com/repo/2/secondThumbnail.png",
+ "caption": "Repo Add-on 2 - Second Caption"
+ }
+ ],
+ "ratings": {
+ "count": 2223,
+ "text_count": 1112,
+ "average": 2
+ },
+ "homepage": "http://example.com/repo/2/homepage.html",
+ "support_url": "http://example.com/repo/2/support.html",
+ "contributions_url": "http://example.com/repo/2/meetDevelopers.html",
+ "ratings_url": "http://example.com/repo/2/review.html",
+ "weekly_downloads": 3332,
+ "last_updated": "1970-01-01T00:00:09Z"
+ },
+ {
+ "name": "Repo Add-on 3",
+ "type": "theme",
+ "guid": "test_AddonRepository_3@tests.mozilla.org",
+ "current_version": {
+ "version": "2.3"
+ },
+ "icons": {
+ "32": "http://example.com/repo/3/icon.png"
+ },
+ "previews": [
+ {
+ "image_url": "http://example.com/repo/3/firstFull.png",
+ "thumbnail_url": "http://example.com/repo/3/firstThumbnail.png",
+ "caption": "Repo Add-on 3 - First Caption"
+ },
+ {
+ "image_url": "http://example.com/repo/3/secondFull.png",
+ "thumbnail_url": "http://example.com/repo/3/secondThumbnail.png",
+ "caption": "Repo Add-on 3 - Second Caption"
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json
new file mode 100644
index 0000000000..c6c09cdf92
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_empty.json
@@ -0,0 +1,7 @@
+{
+ "page_size": 25,
+ "count": 0,
+ "next": null,
+ "previous": null,
+ "results": []
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json
new file mode 100644
index 0000000000..d29d525a81
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_fail.json
@@ -0,0 +1 @@
+this should yield a json parse error
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json
new file mode 100644
index 0000000000..cfd9fcb74a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getAddonsByIDs.json
@@ -0,0 +1,117 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 4,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "PASS",
+ "type": "extension",
+ "guid": "test1@tests.mozilla.org",
+ "current_version": {
+ "version": "1.1",
+ "files": [
+ {
+ "platform": "all",
+ "url": "http://example.com/addons/test_AddonRepository_2.xpi",
+ "size": 5555
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Test Creator 1",
+ "url": "http://example.com/creator1.html"
+ },
+ {
+ "name": "Test Developer 1",
+ "url": "http://example.com/developer1.html"
+ }
+ ],
+ "summary": "Test Summary 1",
+ "description": "Test Description 1",
+ "icons": {
+ "32": "http://example.com/icon1.png"
+ },
+ "previews": [
+ {
+ "caption": "Caption 1 - 1",
+ "image_size": [400, 300],
+ "image_url": "http://example.com/full1-1.png",
+ "thumbnail_size": [200, 150],
+ "thumbnail_url": "http://example.com/thumbnail1-1.png"
+ },
+ {
+ "caption": "Caption 2 - 1",
+ "image_url": "http://example.com/full2-1.png",
+ "thumbnail_url": "http://example.com/thumbnail2-1.png"
+ }
+ ],
+ "ratings": {
+ "count": 1234,
+ "text_count": 1111,
+ "average": 4
+ },
+ "ratings_url": "http://example.com/review1.html",
+ "support_url": "http://example.com/support1.html",
+ "contributions_url": "http://example.com/contribution1.html",
+ "weekly_downloads": 3333,
+ "last_updated": "2010-02-01T14:04:05Z",
+ "url": "https://addons.mozilla.org/en-US/firefox/addon/test1@tests.mozilla.org/"
+ },
+ {
+ "name": "PASS",
+ "type": "extension",
+ "guid": "test2@tests.mozilla.org",
+ "current_version": {
+ "version": "2.0",
+ "files": [
+ {
+ "platform": "XPCShell",
+ "url": "http://example.com/addons/bleah.xpi",
+ "size": 1000
+ }
+ ]
+ }
+ },
+ {
+ "name": "FAIL",
+ "type": "extension",
+ "guid": "notRequested@tests.mozilla.org",
+ "current_version": {
+ "version": "1.3",
+ "files": [
+ {
+ "platform": "all",
+ "url": "http://example.com/test3.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Test Creator 3"
+ }
+ ],
+ "summary": "Add-on with a guid that wasn't requested should be ignored."
+ },
+ {
+ "name": "PASS",
+ "type": "theme",
+ "guid": "test_AddonRepository_1@tests.mozilla.org",
+ "current_version": {
+ "version": "1.4",
+ "files": [
+ {
+ "platform": "UNKNOWN1",
+ "url": "http://example.com/test4.xpi"
+ },
+ {
+ "platform": "UNKNOWN2",
+ "url": "http://example.com/test4.xpi"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons.json
new file mode 100644
index 0000000000..a392673717
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons.json
@@ -0,0 +1,25 @@
+{
+ "count": 4,
+ "next": null,
+ "page_count": 1,
+ "page_size": 100,
+ "previous": null,
+ "results": [
+ {
+ "addon_guid": "test1@tests.mozilla.org",
+ "extension_id": "browser-extension-test-1"
+ },
+ {
+ "addon_guid": "test2@tests.mozilla.org",
+ "extension_id": "browser-extension-test-2"
+ },
+ {
+ "addon_guid": "{00000000-1111-2222-3333-444444444444}",
+ "extension_id": "browser-extension-test-3"
+ },
+ {
+ "addon_guid": "test_AddonRepository_1@tests.mozilla.org",
+ "extension_id": "browser-extension-test-4"
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons_empty.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons_empty.json
new file mode 100644
index 0000000000..add773a29d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_AddonRepository_getMappedAddons_empty.json
@@ -0,0 +1,8 @@
+{
+ "count": 0,
+ "next": null,
+ "page_count": 1,
+ "page_size": 100,
+ "previous": null,
+ "results": []
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json
new file mode 100644
index 0000000000..b83e0b04ba
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_backgroundupdate.json
@@ -0,0 +1,46 @@
+{
+ "addons": {
+ "addon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2",
+ "update_link": "http://example.com/broken.xpi"
+ }
+ ]
+ },
+ "addon3@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2",
+ "update_link": "http://example.com/broken.xpi"
+ }
+ ]
+ },
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2",
+ "update_link": "http://example.com/broken.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml
new file mode 100644
index 0000000000..b092418bbb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_metadata_filters_1.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem name="/^Mozilla Corp\.$/">
+ <versionRange severity="1">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="/block2/" name="/^Moz/"
+ homepageURL="/\.dangerous\.com/" updateURL="/\.dangerous\.com/">
+ <versionRange severity="3">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml
new file mode 100644
index 0000000000..41df457b05
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_blocklist_prefs_1.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="block1@tests.mozilla.org">
+ <prefs>
+ <pref>test.blocklist.pref1</pref>
+ <pref>test.blocklist.pref2</pref>
+ </prefs>
+ <versionRange severity="1">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="block2@tests.mozilla.org">
+ <prefs>
+ <pref>test.blocklist.pref3</pref>
+ <pref>test.blocklist.pref4</pref>
+ </prefs>
+ <versionRange severity="3">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="2.*"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml
new file mode 100644
index 0000000000..1767b4332f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug393285.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="test_bug393285_2@tests.mozilla.org"/>
+ <emItem id="test_bug393285_3a@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0"/>
+ </emItem>
+ <emItem id="test_bug393285_3b@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0"/>
+ </emItem>
+ <emItem id="test_bug393285_4@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0">
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1.0" maxVersion="1.0"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug393285_5@tests.mozilla.org" os="Darwin"/>
+ <emItem id="test_bug393285_6@tests.mozilla.org" os="XPCShell"/>
+ <emItem id="test_bug393285_7@tests.mozilla.org" os="Darwin,XPCShell,WINNT"/>
+ <emItem id="test_bug393285_8@tests.mozilla.org" xpcomabi="x86-msvc"/>
+ <emItem id="test_bug393285_9@tests.mozilla.org" xpcomabi="noarch-spidermonkey"/>
+ <emItem id="test_bug393285_10@tests.mozilla.org" xpcomabi="ppc-gcc3,noarch-spidermonkey,x86-msvc"/>
+ <emItem id="test_bug393285_11@tests.mozilla.org" os="Darwin" xpcomabi="ppc-gcc3,x86-msvc"/>
+ <emItem id="test_bug393285_12@tests.mozilla.org" os="Darwin" xpcomabi="ppc-gcc3,noarch-spidermonkey,x86-msvc"/>
+ <emItem id="test_bug393285_13@tests.mozilla.org" os="XPCShell" xpcomabi="ppc-gcc3,x86-msvc"/>
+ <emItem id="test_bug393285_14@tests.mozilla.org" os="XPCShell,WINNT" xpcomabi="ppc-gcc3,x86-msvc,noarch-spidermonkey"/>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json
new file mode 100644
index 0000000000..2c1fff10c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-extensions.json
@@ -0,0 +1,332 @@
+[
+ {
+ "_comment": "Always blocked",
+ "guid": "test_bug449027_2@tests.mozilla.org",
+ "versionRange": []
+ },
+ {
+ "_comment": "Always blocked",
+ "guid": "test_bug449027_3@tests.mozilla.org",
+ "versionRange": [{}]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "guid": "test_bug449027_4@tests.mozilla.org",
+ "versionRange": [
+ {
+ "minVersion": "6"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "guid": "test_bug449027_5@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "guid": "test_bug449027_6@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_7@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "4"
+ },
+ {
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "7",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_8@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "2",
+ "minVersion": "2"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_9@tests.mozilla.org",
+ "versionRange": [
+ {
+ "minVersion": "4"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_10@tests.mozilla.org",
+ "versionRange": [
+ {
+ "minVersion": "5"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_11@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "6"
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_12@tests.mozilla.org",
+ "versionRange": [
+ {
+ "maxVersion": "5"
+ }
+ ]
+ },
+ {
+ "_comment": "This should block all versions for any application",
+ "guid": "test_bug449027_13@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [{}]
+ }
+ ]
+ },
+ {
+ "_comment": "Shouldn't block",
+ "guid": "test_bug449027_14@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block for any version of the app",
+ "guid": "test_bug449027_15@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "guid": "test_bug449027_16@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "guid": "test_bug449027_17@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "4"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "guid": "test_bug449027_18@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "guid": "test_bug449027_19@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "3"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_20@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_21@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "1",
+ "minVersion": "1"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_22@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_23@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "2"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_24@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_25@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json
new file mode 100644
index 0000000000..c88088c9b3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app-plugins.json
@@ -0,0 +1,332 @@
+[
+ {
+ "_comment": "Always blocked",
+ "matchName": "^test_bug449027_2$",
+ "versionRange": []
+ },
+ {
+ "_comment": "Always blocked",
+ "matchName": "^test_bug449027_3$",
+ "versionRange": [{}]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "matchName": "^test_bug449027_4$",
+ "versionRange": [
+ {
+ "minVersion": "6"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "matchName": "^test_bug449027_5$",
+ "versionRange": [
+ {
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "matchName": "^test_bug449027_6$",
+ "versionRange": [
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "maxVersion": "4"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_7$",
+ "versionRange": [
+ {
+ "maxVersion": "4"
+ },
+ {
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "7",
+ "minVersion": "6"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_8$",
+ "versionRange": [
+ {
+ "maxVersion": "2",
+ "minVersion": "2"
+ },
+ {
+ "maxVersion": "6",
+ "minVersion": "4"
+ },
+ {
+ "maxVersion": "8",
+ "minVersion": "7"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_9$",
+ "versionRange": [
+ {
+ "minVersion": "4"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_10$",
+ "versionRange": [
+ {
+ "minVersion": "5"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_11$",
+ "versionRange": [
+ {
+ "maxVersion": "6"
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_12$",
+ "versionRange": [
+ {
+ "maxVersion": "5"
+ }
+ ]
+ },
+ {
+ "_comment": "This should block all versions for any application",
+ "matchName": "^test_bug449027_13$",
+ "versionRange": [
+ {
+ "targetApplication": [{}]
+ }
+ ]
+ },
+ {
+ "_comment": "Shouldn't block",
+ "matchName": "^test_bug449027_14$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block for any version of the app",
+ "matchName": "^test_bug449027_15$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "matchName": "^test_bug449027_16$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "matchName": "^test_bug449027_17$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "4"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "matchName": "^test_bug449027_18$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "6"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of these",
+ "matchName": "^test_bug449027_19$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "3"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_20$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "5",
+ "minVersion": "4"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_21$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "1",
+ "minVersion": "1"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4",
+ "minVersion": "2"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "6",
+ "minVersion": "5"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_22$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_23$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "minVersion": "2"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_24$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_25$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "xpcshell@tests.mozilla.org",
+ "maxVersion": "4"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml
new file mode 100644
index 0000000000..f12ca1fa6d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_app.xml
@@ -0,0 +1,333 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <!-- All extensions are version 5 and tests run against appVersion 3 -->
+
+ <!-- Test 1 not listed, should never get blocked -->
+ <!-- Always blocked -->
+ <emItem id="test_bug449027_2@tests.mozilla.org"/>
+ <!-- Always blocked -->
+ <emItem id="test_bug449027_3@tests.mozilla.org">
+ <versionRange/>
+ </emItem>
+ <!-- Not blocked since neither version range matches -->
+ <emItem id="test_bug449027_4@tests.mozilla.org">
+ <versionRange minVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </emItem>
+ <!-- Invalid version range, should not block -->
+ <emItem id="test_bug449027_5@tests.mozilla.org">
+ <versionRange minVersion="6" maxVersion="4"/>
+ </emItem>
+ <!-- Should block all of these -->
+ <emItem id="test_bug449027_6@tests.mozilla.org">
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </emItem>
+ <emItem id="test_bug449027_7@tests.mozilla.org">
+ <versionRange maxVersion="4"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ <versionRange minVersion="6" maxVersion="7"/>
+ </emItem>
+ <emItem id="test_bug449027_8@tests.mozilla.org">
+ <versionRange minVersion="2" maxVersion="2"/>
+ <versionRange minVersion="4" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ </emItem>
+ <emItem id="test_bug449027_9@tests.mozilla.org">
+ <versionRange minVersion="4"/>
+ </emItem>
+ <emItem id="test_bug449027_10@tests.mozilla.org">
+ <versionRange minVersion="5"/>
+ </emItem>
+ <emItem id="test_bug449027_11@tests.mozilla.org">
+ <versionRange maxVersion="6"/>
+ </emItem>
+ <emItem id="test_bug449027_12@tests.mozilla.org">
+ <versionRange maxVersion="5"/>
+ </emItem>
+
+ <!-- This should block all versions for any application -->
+ <emItem id="test_bug449027_13@tests.mozilla.org">
+ <versionRange>
+ <targetApplication/>
+ </versionRange>
+ </emItem>
+ <!-- Shouldn't block -->
+ <emItem id="test_bug449027_14@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <!-- Should block for any version of the app -->
+ <emItem id="test_bug449027_15@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org"/>
+ </versionRange>
+ </emItem>
+ <!-- Should still block -->
+ <emItem id="test_bug449027_16@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Not blocked since neither version range matches -->
+ <emItem id="test_bug449027_17@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Invalid version range, should not block -->
+ <emItem id="test_bug449027_18@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="6" maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Should block all of these -->
+ <emItem id="test_bug449027_19@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange minVersion="3" maxVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_20@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="2"/>
+ <versionRange minVersion="2" maxVersion="3"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_21@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="1"/>
+ <versionRange minVersion="2" maxVersion="4"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_22@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_23@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="2"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_24@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_25@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+ <pluginItems>
+ <!-- All plugins are version 5 and tests run against appVersion 3 -->
+
+ <!-- Test 1 not listed, should never get blocked -->
+ <!-- Always blocked -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_2$"/>
+ </pluginItem>
+ <!-- Always blocked -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_3$"/>
+ <versionRange/>
+ </pluginItem>
+ <!-- Not blocked since neither version range matches -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_4$"/>
+ <versionRange minVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </pluginItem>
+ <!-- Invalid version range, should not block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_5$"/>
+ <versionRange minVersion="6" maxVersion="4"/>
+ </pluginItem>
+ <!-- Should block all of these -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_6$"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange maxVersion="4"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_7$"/>
+ <versionRange maxVersion="4"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ <versionRange minVersion="6" maxVersion="7"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_8$"/>
+ <versionRange minVersion="2" maxVersion="2"/>
+ <versionRange minVersion="4" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_9$"/>
+ <versionRange minVersion="4"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_10$"/>
+ <versionRange minVersion="5"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_11$"/>
+ <versionRange maxVersion="6"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_12$"/>
+ <versionRange maxVersion="5"/>
+ </pluginItem>
+
+ <!-- This should block all versions for any application -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_13$"/>
+ <versionRange>
+ <targetApplication/>
+ </versionRange>
+ </pluginItem>
+ <!-- Shouldn't block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_14$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <!-- Should block for any version of the app -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_15$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org"/>
+ </versionRange>
+ </pluginItem>
+ <!-- Should still block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_16$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Not blocked since neither version range matches -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_17$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Invalid version range, should not block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_18$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="6" maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Should block all of these -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_19$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="5" maxVersion="6"/>
+ <versionRange minVersion="3" maxVersion="4"/>
+ <versionRange maxVersion="2"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_20$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="2"/>
+ <versionRange minVersion="2" maxVersion="3"/>
+ <versionRange minVersion="4" maxVersion="5"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_21$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="1" maxVersion="1"/>
+ <versionRange minVersion="2" maxVersion="4"/>
+ <versionRange minVersion="5" maxVersion="6"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_22$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_23$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange minVersion="2"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_24$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="3"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_25$"/>
+ <versionRange>
+ <targetApplication id="xpcshell@tests.mozilla.org">
+ <versionRange maxVersion="4"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json
new file mode 100644
index 0000000000..107079fd41
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-extensions.json
@@ -0,0 +1,189 @@
+[
+ {
+ "_general_comment": "All extensions are version 5 and tests run against toolkitVersion 8",
+ "_general_comment2": "Test 1-14 not listed, should never get blocked",
+
+ "_comment": "Should block for any version of the app",
+ "guid": "test_bug449027_15@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "guid": "test_bug449027_16@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "guid": "test_bug449027_17@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ },
+ {
+ "maxVersion": "7",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "guid": "test_bug449027_18@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "11",
+ "maxVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of the following",
+ "guid": "test_bug449027_19@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ },
+ {
+ "minVersion": "8",
+ "maxVersion": "9"
+ },
+ {
+ "maxVersion": "7"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_20@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "7"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "8"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "9",
+ "maxVersion": "10"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_21@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "6",
+ "maxVersion": "6"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "9"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_22@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_23@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_24@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "maxVersion": "8",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "guid": "test_bug449027_25@tests.mozilla.org",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "9"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json
new file mode 100644
index 0000000000..c3565d2073
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit-plugins.json
@@ -0,0 +1,189 @@
+[
+ {
+ "_general_comment": "All plugins are version 5 and tests run against appVersion 3",
+ "_general_comment2": "Test 1-14 not listed, should never get blocked",
+
+ "_comment": "Should block for any version of the app",
+ "matchName": "^test_bug449027_15$",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Should still block",
+ "matchName": "^test_bug449027_16$",
+ "versionRange": [
+ {
+ "targetApplication": [{ "guid": "toolkit@mozilla.org" }]
+ }
+ ]
+ },
+ {
+ "_comment": "Not blocked since neither version range matches",
+ "matchName": "^test_bug449027_17$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ },
+ {
+ "maxVersion": "7",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Invalid version range, should not block",
+ "matchName": "^test_bug449027_18$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "minVersion": "11",
+ "maxVersion": "9",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "_comment": "Should block all of the following",
+ "matchName": "^test_bug449027_19$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ },
+ {
+ "minVersion": "8",
+ "maxVersion": "9"
+ },
+ {
+ "maxVersion": "7"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_20$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "7"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "8"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "9",
+ "maxVersion": "10"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_21$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "6",
+ "maxVersion": "6"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7",
+ "maxVersion": "9"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "10",
+ "maxVersion": "11"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_22$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "foo@bar.com"
+ },
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_23$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "minVersion": "7"
+ },
+ {
+ "guid": "foo@bar.com"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_24$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "maxVersion": "8",
+ "guid": "toolkit@mozilla.org"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "matchName": "^test_bug449027_25$",
+ "versionRange": [
+ {
+ "targetApplication": [
+ {
+ "guid": "toolkit@mozilla.org",
+ "maxVersion": "9"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml
new file mode 100644
index 0000000000..ad8ec5ed9d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug449027_toolkit.xml
@@ -0,0 +1,208 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <!-- All extensions are version 5 and tests run against toolkitVersion 8 -->
+
+ <!-- Test 1-14 not listed, should never get blocked -->
+
+ <!-- Should block for any version of the app -->
+ <emItem id="test_bug449027_15@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org"/>
+ </versionRange>
+ </emItem>
+ <!-- Should still block -->
+ <emItem id="test_bug449027_16@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Not blocked since neither version range matches -->
+ <emItem id="test_bug449027_17@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Invalid version range, should not block -->
+ <emItem id="test_bug449027_18@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="11" maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <!-- Should block all of these -->
+ <emItem id="test_bug449027_19@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="10" maxVersion="11"/>
+ <versionRange minVersion="8" maxVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_20@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="7"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="9" maxVersion="10"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_21@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="6" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="9"/>
+ <versionRange minVersion="10" maxVersion="11"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_22@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_23@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="7"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_24@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ <emItem id="test_bug449027_25@tests.mozilla.org">
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </emItem>
+ </emItems>
+ <pluginItems>
+ <!-- All plugins are version 5 and tests run against appVersion 3 -->
+
+ <!-- Test 1-14 not listed, should never get blocked -->
+ <!-- Should block for any version of the app -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_15$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org"/>
+ </versionRange>
+ </pluginItem>
+ <!-- Should still block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_16$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Not blocked since neither version range matches -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_17$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Invalid version range, should not block -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_18$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="11" maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <!-- Should block all of these -->
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_19$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="10" maxVersion="11"/>
+ <versionRange minVersion="8" maxVersion="9"/>
+ <versionRange maxVersion="7"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_20$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="7"/>
+ <versionRange minVersion="7" maxVersion="8"/>
+ <versionRange minVersion="9" maxVersion="10"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_21$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="6" maxVersion="6"/>
+ <versionRange minVersion="7" maxVersion="9"/>
+ <versionRange minVersion="10" maxVersion="11"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_22$"/>
+ <versionRange>
+ <targetApplication id="foo@bar.com"/>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_23$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange minVersion="7"/>
+ </targetApplication>
+ <targetApplication id="foo@bar.com"/>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_24$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="8"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug449027_25$"/>
+ <versionRange>
+ <targetApplication id="toolkit@mozilla.org">
+ <versionRange maxVersion="9"/>
+ </targetApplication>
+ </versionRange>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml
new file mode 100644
index 0000000000..85f0da57ce
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug468528.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug468528_1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug468528_2["/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug468528_3"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml
new file mode 100644
index 0000000000..c4cc2fe37a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_1.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_bug514327_1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug514327_2"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_bug514327_3"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml
new file mode 100644
index 0000000000..cc0a0c69df
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_2.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="Test Plug-in"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml
new file mode 100644
index 0000000000..0261794f8a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_empty.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml
new file mode 100644
index 0000000000..d651f87996
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_1.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_1"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_outdated"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml
new file mode 100644
index 0000000000..208444681e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug514327_3_outdated_2.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_2"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="test_bug514327_outdated"/>
+ <versionRange severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json
new file mode 100644
index 0000000000..3b1dd81dab
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_bug655254.json
@@ -0,0 +1,17 @@
+{
+ "addons": {
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "2"
+ }
+ },
+ "version": "1"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json
new file mode 100644
index 0000000000..7cb48d4798
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_corrupt.json
@@ -0,0 +1,30 @@
+{
+ "addons": {
+ "addon3@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "2"
+ }
+ },
+ "version": "1.0"
+ }
+ ]
+ },
+ "addon4@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "2"
+ }
+ },
+ "version": "1.0"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json
new file mode 100644
index 0000000000..b79dc236c3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete.json
@@ -0,0 +1,12 @@
+{
+ "addons": {
+ "test_delay_update_complete_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_complete_webextension_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json
new file mode 100644
index 0000000000..125d1b1a91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_complete_legacy.json
@@ -0,0 +1,18 @@
+{
+ "addons": {
+ "test_delay_update_complete@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_complete_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json
new file mode 100644
index 0000000000..c2ea01e8c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer.json
@@ -0,0 +1,12 @@
+{
+ "addons": {
+ "test_delay_update_defer_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_defer_webextension_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json
new file mode 100644
index 0000000000..d434fe2e17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_defer_legacy.json
@@ -0,0 +1,18 @@
+{
+ "addons": {
+ "test_delay_update_defer@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_defer_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json
new file mode 100644
index 0000000000..5d5dc262cb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore.json
@@ -0,0 +1,12 @@
+{
+ "addons": {
+ "test_delay_update_ignore_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_ignore_webextension_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json
new file mode 100644
index 0000000000..bc46fab8fd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_ignore_legacy.json
@@ -0,0 +1,18 @@
+{
+ "addons": {
+ "test_delay_update_ignore@tests.mozilla.org": {
+ "updates": [
+ {
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ },
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_ignore_v2.xpi"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json
new file mode 100644
index 0000000000..e0611edb35
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_delay_updates_staged.json
@@ -0,0 +1,32 @@
+{
+ "addons": {
+ "test_delay_update_staged_webext@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_staged_webextension_v2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "43"
+ }
+ }
+ }
+ ]
+ },
+ "test_delay_update_staged_webext_no_update_url@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_delay_update_staged_webextension_no_update_url_v2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "43"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json
new file mode 100644
index 0000000000..6f5d61288d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist.json
@@ -0,0 +1,377 @@
+[
+ {
+ "blockID": "g35",
+ "os": "WINNT 6.1",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ },
+ {
+ "os": "WINNT 6.0",
+ "vendor": "0xdcba",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ },
+ {
+ "blockID": "g36",
+ "os": "WINNT 6.1",
+ "vendor": "0xabab",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " GREATER_THAN_OR_EQUAL "
+ },
+ {
+ "os": "WINNT 6.1",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1111 ",
+ "driverVersionComparator": " EQUAL "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+ {
+ "os": "Android",
+ "vendor": "abcd",
+ "devices": ["wxyz", "asdf", "erty"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionComparator": " LESS_THAN_OR_EQUAL "
+ },
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop", "vbnm", "hjkl"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionComparator": " EQUAL "
+ },
+ {
+ "os": "Android",
+ "vendor": "abab",
+ "devices": ["ghjk", "cvbn"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 7 ",
+ "driverVersionComparator": " GREATER_THAN_OR_EQUAL "
+ },
+ {
+ "os": "WINNT 6.1",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "WINNT 6.1",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x6666"],
+ "feature": " WEBRENDER ",
+ "featureStatus": " BLOCKED_DEVICE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1112 ",
+ "driverVersionMax": " 8.52.323.1000 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.50.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 9.52.322.1000 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 7.82.322.1000 ",
+ "driverVersionMax": " 9.25.322.1001 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1112 ",
+ "driverVersionMax": " 9.52.322.1300 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1112 ",
+ "driverVersionMax": " 8.52.322.1200 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1200 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 8.52.322.1112 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xdcdc",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " CANVAS2D_ACCELERATION ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.1000 ",
+ "driverVersionMax": " 9.52.322.1000 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 6 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 6 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 6 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 6 ",
+ "driverVersionComparator": " BETWEEN_INCLUSIVE_START "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "dcdc",
+ "devices": ["uiop"],
+ "feature": " CANVAS2D_ACCELERATION ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 5 ",
+ "driverVersionMax": " 7 ",
+ "driverVersionComparator": " BETWEEN_EXCLUSIVE "
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json
new file mode 100644
index 0000000000..3f44eb330f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_AllOS.json
@@ -0,0 +1,581 @@
+[
+ {
+ "blockID": "g1",
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "All",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g1",
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g1",
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Linux",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g1",
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g2",
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "22.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_9_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_LAYERS",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "16.0a1", "maxVersion": "22.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_10_1_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "blockID": "g11",
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "14.0b2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_OPENGL ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL2 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "12.0", "maxVersion": "16.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBGL_MSAA ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " STAGEFRIGHT ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_H264 ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_ENCODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "42.0", "maxVersion": "13.0b2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " WEBRTC_HW_ACCELERATION_DECODE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "17.2a2", "maxVersion": "15.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "15.0", "maxVersion": "13.2" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " HARDWARE_VIDEO_DECODING ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ },
+
+ {
+ "os": "Android",
+ "vendor": "0xabcd",
+ "versionRange": { "minVersion": "10.5", "maxVersion": "13.0" },
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT3D_11_ANGLE ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION "
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json
new file mode 100644
index 0000000000..c80bf3eedd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_gfxBlacklist_OSVersion.json
@@ -0,0 +1,20 @@
+[
+ {
+ "os": "WINNT 6.2",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " DIRECT2D ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ },
+ {
+ "os": "Darwin 13",
+ "vendor": "0xabcd",
+ "devices": ["0x2783", "0x1234", "0x2782"],
+ "feature": " OPENGL_LAYERS ",
+ "featureStatus": " BLOCKED_DRIVER_VERSION ",
+ "driverVersion": " 8.52.322.2202 ",
+ "driverVersionComparator": " LESS_THAN "
+ }
+]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json
new file mode 100644
index 0000000000..d7307831af
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_addons.json
@@ -0,0 +1,31 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "Real Test 2",
+ "type": "extension",
+ "guid": "addon2@tests.mozilla.org",
+ "current_version": {
+ "version": "1.0",
+ "files": [
+ {
+ "size": 2,
+ "url": "http://example.com/browser/toolkit/mozapps/extensions/test/browser/addons/browser_install1_2.xpi"
+ }
+ ]
+ },
+ "authors": [
+ {
+ "name": "Test Creator",
+ "url": "http://example.com/creator.html"
+ }
+ ],
+ "summary": "Repository summary",
+ "description": "Repository description"
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json
new file mode 100644
index 0000000000..93d0cf3d3d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_install_compat.json
@@ -0,0 +1,27 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "addon_guid": "addon6@tests.mozilla.org",
+ "name": "Addon Test 6",
+ "version_ranges": [
+ {
+ "addon_min_version": "1.0",
+ "addon_max_version": "1.0",
+ "applications": [
+ {
+ "name": "XPCShell",
+ "guid": "xpcshell@tests.mozilla.org",
+ "min_version": "1.0",
+ "max_version": "1.0"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json
new file mode 100644
index 0000000000..2773c7f98f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_no_update.json
@@ -0,0 +1,7 @@
+{
+ "addons": {
+ "test_no_update_webext@tests.mozilla.org": {
+ "updates": []
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml
new file mode 100644
index 0000000000..699257f87e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/ancient.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem blockID="i454" id="ancient@tests.mozilla.org">
+ <versionRange minVersion="0" maxVersion="*" severity="3"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml
new file mode 100644
index 0000000000..8cbfb5d6a0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/new.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist" lastupdate="1396046918000">
+ <emItems>
+ <emItem blockID="i454" id="new@tests.mozilla.org">
+ <versionRange minVersion="0" maxVersion="*" severity="3"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml
new file mode 100644
index 0000000000..75bd6e934c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_overrideblocklist/old.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0"?>
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist" lastupdate="1296046918000">
+ <emItems>
+ <emItem blockID="i454" id="old@tests.mozilla.org">
+ <versionRange minVersion="0" maxVersion="*" severity="3"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml
new file mode 100644
index 0000000000..937d8a5901
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtp.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_0"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_1"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_2"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_3"/>
+ <versionRange minVersion="0" maxVersion="*"/>
+ </pluginItem>
+ <pluginItem>
+ <match name="name" exp="^test_plugin_4"/>
+ <versionRange minVersion="0" maxVersion="*" severity="1"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml
new file mode 100644
index 0000000000..162876230e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_pluginBlocklistCtpUndo.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <pluginItems>
+ <pluginItem>
+ <match name="name" exp="^Test Plug-in"/>
+ <versionRange minVersion="0" maxVersion="*" severity="0"/>
+ </pluginItem>
+ </pluginItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml b/toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml
new file mode 100644
index 0000000000..a1d18470c8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_softblocked1.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<blocklist xmlns="http://www.mozilla.org/2006/addons-blocklist">
+ <emItems>
+ <emItem id="softblock1@tests.mozilla.org">
+ <versionRange severity="1"/>
+ </emItem>
+ </emItems>
+</blocklist>
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js b/toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js
new file mode 100644
index 0000000000..9814d5bc96
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_trash_directory.worker.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/. */
+
+/* import-globals-from /toolkit/components/workerloader/require.js */
+importScripts("resource://gre/modules/workers/require.js");
+
+const PromiseWorker = require("resource://gre/modules/workers/PromiseWorker.js");
+
+class OpenFileWorker extends PromiseWorker.AbstractWorker {
+ constructor() {
+ super();
+
+ this._file = null;
+ }
+
+ postMessage(message, ...transfers) {
+ self.postMessage(message, transfers);
+ }
+
+ dispatch(method, args) {
+ return this[method](...args);
+ }
+
+ open(path) {
+ this._file = IOUtils.openFileForSyncReading(path);
+ }
+
+ close() {
+ if (this._file) {
+ this._file.close();
+ }
+ }
+}
+
+const worker = new OpenFileWorker();
+
+self.addEventListener("message", msg => worker.handleMessage(msg));
+self.addEventListener("unhandledrejection", err => {
+ throw err.reason;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_update.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_update.json
new file mode 100644
index 0000000000..930ed44e5d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_update.json
@@ -0,0 +1,120 @@
+{
+ "addons": {
+ "addon1@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ },
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2"
+ }
+ }
+ },
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_update.xpi",
+ "update_info_url": "http://example.com/updateInfo.xhtml",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon3@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "3",
+ "advisory_max_version": "3"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon4@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "5.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "0"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon7@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon8@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_update8.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "addon12@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/addons/test_update12.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "1"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json
new file mode 100644
index 0000000000..d9777335a6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_addons.json
@@ -0,0 +1,14 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "name": "Ttest Addon 9",
+ "type": "extension",
+ "guid": "addon9@tests.mozilla.org"
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json
new file mode 100644
index 0000000000..cc2cc15ad5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_update_compat.json
@@ -0,0 +1,28 @@
+{
+ "page_size": 25,
+ "page_count": 1,
+ "count": 1,
+ "next": null,
+ "previous": null,
+ "results": [
+ {
+ "addon_guid": "addon9@tests.mozilla.org",
+ "name": "Test Addon 9",
+ "version_ranges": [
+ {
+ "addon_min_version": "4",
+ "addon_max_version": "4",
+ "applications": [
+ {
+ "name": "XPCShell",
+ "id": "XPCShell",
+ "guid": "xpcshell@tests.mozilla.org",
+ "min_version": "1",
+ "max_version": "1"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json b/toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json
new file mode 100644
index 0000000000..f61bfeacd3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/test_updatecheck.json
@@ -0,0 +1,269 @@
+{
+ "addons": {
+ "updatecheck1@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ },
+ {
+ "_comment_": "This update is incompatible and so should not be considered a valid update",
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "strict_max_version": "2"
+ }
+ }
+ },
+ {
+ "version": "3.0",
+ "update_link": "https://example.com/addons/test3.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ },
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "2"
+ }
+ }
+ },
+ {
+ "_comment_": "This update is incompatible and so should not be considered a valid update",
+ "version": "4.0",
+ "update_link": "https://example.com/addons/test4.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "strict_max_version": "2"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_8@tests.mozilla.org": {
+ "_comment_": "The updateLink will be ignored since it is not secure and there is no updateHash.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/broken.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_9@tests.mozilla.org": {
+ "_comment_": "The updateLink will used since there is an updateHash to verify it.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/broken.xpi",
+ "update_hash": "sha256:78fc1d2887eda35b4ad2e3a0b60120ca271ce6e6",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_10@tests.mozilla.org": {
+ "_comment_": "The updateLink will used since it is a secure URL.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/broken.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_11@tests.mozilla.org": {
+ "_comment_": "The updateLink will used since it is a secure URL.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/broken.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_12@tests.mozilla.org": {
+ "_comment_": "The updateLink will not be used since the updateHash verifying it is not strong enough.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "http://example.com/broken.xpi",
+ "update_hash": "sha1:78fc1d2887eda35b4ad2e3a0b60120ca271ce6e6",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "test_bug378216_13@tests.mozilla.org": {
+ "_comment_": "An update with a weak hash. The updateLink will used since it is a secure URL.",
+
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/broken.xpi",
+ "update_hash": "sha1:78fc1d2887eda35b4ad2e3a0b60120ca271ce6e6",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "strict_max_version": "1"
+ }
+ }
+ }
+ ]
+ },
+
+ "_comment_": "There should be no information present for test_bug378216_14",
+
+ "test_bug378216_15@tests.mozilla.org": {
+ "_comment_": "Invalid update JSON",
+
+ "updates": "foo"
+ },
+
+ "ignore-compat@tests.mozilla.org": {
+ "_comment_": "Various updates available - one is not compatible, but compatibility checking is disabled",
+
+ "updates": [
+ {
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.1",
+ "advisory_max_version": "0.2"
+ }
+ }
+ },
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.5",
+ "advisory_max_version": "0.6"
+ }
+ }
+ },
+ {
+ "_comment_": "Update for future app versions - should never be compatible",
+ "version": "3.0",
+ "update_link": "https://example.com/addons/test3.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "advisory_max_version": "3"
+ }
+ }
+ }
+ ]
+ },
+
+ "compat-override@tests.mozilla.org": {
+ "_comment_": "Various updates available - one is not compatible, but compatibility checking is disabled",
+
+ "updates": [
+ {
+ "_comment_": "Has compatibility override, but it doesn't match this app version",
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.1",
+ "advisory_max_version": "0.2"
+ }
+ }
+ },
+ {
+ "_comment_": "Has compatibility override, so is incompaible",
+ "version": "2.0",
+ "update_link": "https://example.com/addons/test2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.5",
+ "advisory_max_version": "0.6"
+ }
+ }
+ },
+ {
+ "_comment_": "Update for future app versions - should never be compatible",
+ "version": "3.0",
+ "update_link": "https://example.com/addons/test3.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "2",
+ "advisory_max_version": "3"
+ }
+ }
+ }
+ ]
+ },
+
+ "compat-strict-optin@tests.mozilla.org": {
+ "_comment_": "Opt-in to strict compatibility checking",
+
+ "updates": [
+ {
+ "version": "1.0",
+ "update_link": "https://example.com/addons/test1.xpi",
+ "_comment_": "strictCompatibility: true",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "0.1",
+ "strict_max_version": "0.2"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpi
new file mode 100644
index 0000000000..12a13f139b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpi
new file mode 100644
index 0000000000..6b4abaa691
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/data/webext-implicit-id.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
new file mode 100644
index 0000000000..23614cdb2a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -0,0 +1,1223 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+if (!_TEST_NAME.includes("toolkit/mozapps/extensions/test/xpcshell/")) {
+ Assert.ok(
+ false,
+ "head_addons.js may not be loaded by tests outside of " +
+ "the add-on manager component."
+ );
+}
+
+const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
+const PREF_EM_STRICT_COMPATIBILITY = "extensions.strictCompatibility";
+const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url";
+const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
+
+// Maximum error in file modification times. Some file systems don't store
+// modification times exactly. As long as we are closer than this then it
+// still passes.
+const MAX_TIME_DIFFERENCE = 3000;
+
+// Time to reset file modified time relative to Date.now() so we can test that
+// times are modified (10 hours old).
+const MAKE_FILE_OLD_DIFFERENCE = 10 * 3600 * 1000;
+
+const { AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AddonRepository } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonRepository.sys.mjs"
+);
+
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Blocklist: "resource://gre/modules/Blocklist.sys.mjs",
+ Extension: "resource://gre/modules/Extension.sys.mjs",
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+ ExtensionTestUtils:
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
+ HttpServer: "resource://testing-common/httpd.sys.mjs",
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+ MockRegistry: "resource://testing-common/MockRegistry.sys.mjs",
+ PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "aomStartup",
+ "@mozilla.org/addons/addon-manager-startup;1",
+ "amIAddonManagerStartup"
+);
+
+const {
+ createAppInfo,
+ createHttpServer,
+ createTempWebExtensionFile,
+ getFileForAddon,
+ manuallyInstall,
+ manuallyUninstall,
+ overrideBuiltIns,
+ promiseAddonEvent,
+ promiseCompleteAllInstalls,
+ promiseCompleteInstall,
+ promiseConsoleOutput,
+ promiseFindAddonUpdates,
+ promiseInstallAllFiles,
+ promiseInstallFile,
+ promiseRestartManager,
+ promiseSetExtensionModifiedTime,
+ promiseShutdownManager,
+ promiseStartupManager,
+ promiseWebExtensionStartup,
+ promiseWriteProxyFileToDir,
+ registerDirectory,
+ setExtensionModifiedTime,
+ writeFilesToZip,
+} = AddonTestUtils;
+
+// WebExtension wrapper for ease of testing
+ExtensionTestUtils.init(this);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+ChromeUtils.defineLazyGetter(
+ this,
+ "BOOTSTRAP_REASONS",
+ () => AddonManagerPrivate.BOOTSTRAP_REASONS
+);
+
+function getReasonName(reason) {
+ for (let key of Object.keys(BOOTSTRAP_REASONS)) {
+ if (BOOTSTRAP_REASONS[key] == reason) {
+ return key;
+ }
+ }
+ throw new Error("This shouldn't happen.");
+}
+
+Object.defineProperty(this, "gAppInfo", {
+ get() {
+ return AddonTestUtils.appInfo;
+ },
+});
+
+Object.defineProperty(this, "gAddonStartup", {
+ get() {
+ return AddonTestUtils.addonStartup.clone();
+ },
+});
+
+Object.defineProperty(this, "gInternalManager", {
+ get() {
+ return AddonTestUtils.addonIntegrationService.QueryInterface(
+ Ci.nsITimerCallback
+ );
+ },
+});
+
+Object.defineProperty(this, "gProfD", {
+ get() {
+ return AddonTestUtils.profileDir.clone();
+ },
+});
+
+Object.defineProperty(this, "gTmpD", {
+ get() {
+ return AddonTestUtils.tempDir.clone();
+ },
+});
+
+Object.defineProperty(this, "gUseRealCertChecks", {
+ get() {
+ return AddonTestUtils.useRealCertChecks;
+ },
+ set(val) {
+ AddonTestUtils.useRealCertChecks = val;
+ },
+});
+
+Object.defineProperty(this, "TEST_UNPACKED", {
+ get() {
+ return AddonTestUtils.testUnpacked;
+ },
+ set(val) {
+ AddonTestUtils.testUnpacked = val;
+ },
+});
+
+const promiseAddonByID = AddonManager.getAddonByID;
+const promiseAddonsByIDs = AddonManager.getAddonsByIDs;
+const promiseAddonsByTypes = AddonManager.getAddonsByTypes;
+
+var gPort = null;
+
+var BootstrapMonitor = {
+ started: new Map(),
+ stopped: new Map(),
+ installed: new Map(),
+ uninstalled: new Map(),
+
+ init() {
+ this.onEvent = this.onEvent.bind(this);
+
+ AddonTestUtils.on("addon-manager-shutdown", this.onEvent);
+ AddonTestUtils.on("bootstrap-method", this.onEvent);
+ },
+
+ shutdownCheck() {
+ equal(
+ this.started.size,
+ 0,
+ "Should have no add-ons that were started but not shutdown"
+ );
+ },
+
+ onEvent(msg, data) {
+ switch (msg) {
+ case "addon-manager-shutdown":
+ this.shutdownCheck();
+ break;
+ case "bootstrap-method":
+ this.onBootstrapMethod(data.method, data.params, data.reason);
+ break;
+ }
+ },
+
+ onBootstrapMethod(method, params, reason) {
+ let { id } = params;
+
+ info(
+ `Bootstrap method ${method} ${reason} for ${params.id} version ${params.version}`
+ );
+
+ if (method !== "install") {
+ this.checkInstalled(id);
+ }
+
+ switch (method) {
+ case "install":
+ this.checkNotInstalled(id);
+ this.installed.set(id, { reason, params });
+ this.uninstalled.delete(id);
+ break;
+ case "startup":
+ this.checkNotStarted(id);
+ this.started.set(id, { reason, params });
+ this.stopped.delete(id);
+ break;
+ case "shutdown":
+ this.checkMatches("shutdown", "startup", params, this.started.get(id));
+ this.checkStarted(id);
+ this.stopped.set(id, { reason, params });
+ this.started.delete(id);
+ break;
+ case "uninstall":
+ this.checkMatches(
+ "uninstall",
+ "install",
+ params,
+ this.installed.get(id)
+ );
+ this.uninstalled.set(id, { reason, params });
+ this.installed.delete(id);
+ break;
+ case "update":
+ this.checkMatches("update", "install", params, this.installed.get(id));
+ this.installed.set(id, { reason, params, method });
+ break;
+ }
+ },
+
+ clear(id) {
+ this.installed.delete(id);
+ this.started.delete(id);
+ this.stopped.delete(id);
+ this.uninstalled.delete(id);
+ },
+
+ checkMatches(method, lastMethod, params, { params: lastParams } = {}) {
+ ok(
+ lastParams,
+ `Expecting matching ${lastMethod} call for add-on ${params.id} ${method} call`
+ );
+
+ if (method == "update") {
+ equal(
+ params.oldVersion,
+ lastParams.version,
+ "params.oldVersion should match last call"
+ );
+ } else {
+ equal(
+ params.version,
+ lastParams.version,
+ "params.version should match last call"
+ );
+ }
+
+ if (method !== "update" && method !== "uninstall") {
+ equal(
+ params.resourceURI.spec,
+ lastParams.resourceURI.spec,
+ `params.resourceURI should match last call`
+ );
+
+ ok(
+ params.resourceURI.equals(lastParams.resourceURI),
+ `params.resourceURI should match: "${params.resourceURI.spec}" == "${lastParams.resourceURI.spec}"`
+ );
+ }
+ },
+
+ checkStarted(id, version = undefined) {
+ let started = this.started.get(id);
+ ok(started, `Should have seen startup method call for ${id}`);
+
+ if (version !== undefined) {
+ equal(started.params.version, version, "Expected version number");
+ }
+ return started;
+ },
+
+ checkNotStarted(id) {
+ ok(
+ !this.started.has(id),
+ `Should not have seen startup method call for ${id}`
+ );
+ },
+
+ checkInstalled(id, version = undefined) {
+ const installed = this.installed.get(id);
+ ok(installed, `Should have seen install call for ${id}`);
+
+ if (version !== undefined) {
+ equal(installed.params.version, version, "Expected version number");
+ }
+
+ return installed;
+ },
+
+ checkUpdated(id, version = undefined) {
+ const installed = this.installed.get(id);
+ equal(installed.method, "update", `Should have seen update call for ${id}`);
+
+ if (version !== undefined) {
+ equal(installed.params.version, version, "Expected version number");
+ }
+
+ return installed;
+ },
+
+ checkNotInstalled(id) {
+ ok(
+ !this.installed.has(id),
+ `Should not have seen install method call for ${id}`
+ );
+ },
+};
+
+function isNightlyChannel() {
+ var channel = Services.prefs.getCharPref("app.update.channel", "default");
+
+ return (
+ channel != "aurora" &&
+ channel != "beta" &&
+ channel != "release" &&
+ channel != "esr"
+ );
+}
+
+async function restartWithLocales(locales) {
+ Services.locale.requestedLocales = locales;
+ await promiseRestartManager();
+}
+
+function delay(msec) {
+ return new Promise(resolve => {
+ setTimeout(resolve, msec);
+ });
+}
+
+/**
+ * Returns a map of Addon objects for installed add-ons with the given
+ * IDs. The returned map contains a key for the ID of each add-on that
+ * is found. IDs for add-ons which do not exist are not present in the
+ * map.
+ *
+ * @param {sequence<string>} ids
+ * The list of add-on IDs to get.
+ * @returns {Promise<string, Addon>}
+ * Map of add-ons that were found.
+ */
+async function getAddons(ids) {
+ let addons = new Map();
+ for (let addon of await AddonManager.getAddonsByIDs(ids)) {
+ if (addon) {
+ addons.set(addon.id, addon);
+ }
+ }
+ return addons;
+}
+
+/**
+ * Checks that the given add-on has the given expected properties.
+ *
+ * @param {string} id
+ * The id of the add-on.
+ * @param {Addon?} addon
+ * The add-on object, or null if the add-on does not exist.
+ * @param {object?} expected
+ * An object containing the expected values for properties of the
+ * add-on, or null if the add-on is expected not to exist.
+ */
+function checkAddon(id, addon, expected) {
+ info(`Checking state of addon ${id}`);
+
+ if (expected === null) {
+ ok(!addon, `Addon ${id} should not exist`);
+ } else {
+ ok(addon, `Addon ${id} should exist`);
+ for (let [key, value] of Object.entries(expected)) {
+ if (value instanceof Ci.nsIURI) {
+ equal(
+ addon[key] && addon[key].spec,
+ value.spec,
+ `Expected value of addon.${key}`
+ );
+ } else {
+ deepEqual(addon[key], value, `Expected value of addon.${key}`);
+ }
+ }
+ }
+}
+
+/**
+ * Tests that an add-on does appear in the crash report annotations, if
+ * crash reporting is enabled. The test will fail if the add-on is not in the
+ * annotation.
+ * @param aId
+ * The ID of the add-on
+ * @param aVersion
+ * The version of the add-on
+ */
+function do_check_in_crash_annotation(aId, aVersion) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ if (!("Add-ons" in gAppInfo.annotations)) {
+ Assert.ok(false, "Cannot find Add-ons entry in crash annotations");
+ return;
+ }
+
+ let addons = gAppInfo.annotations["Add-ons"].split(",");
+ Assert.ok(
+ addons.includes(
+ `${encodeURIComponent(aId)}:${encodeURIComponent(aVersion)}`
+ )
+ );
+}
+
+/**
+ * Tests that an add-on does not appear in the crash report annotations, if
+ * crash reporting is enabled. The test will fail if the add-on is in the
+ * annotation.
+ * @param aId
+ * The ID of the add-on
+ * @param aVersion
+ * The version of the add-on
+ */
+function do_check_not_in_crash_annotation(aId, aVersion) {
+ if (!AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ if (!("Add-ons" in gAppInfo.annotations)) {
+ Assert.ok(true);
+ return;
+ }
+
+ let addons = gAppInfo.annotations["Add-ons"].split(",");
+ Assert.ok(
+ !addons.includes(
+ `${encodeURIComponent(aId)}:${encodeURIComponent(aVersion)}`
+ )
+ );
+}
+
+function do_get_file_hash(aFile, aAlgorithm) {
+ if (!aAlgorithm) {
+ aAlgorithm = "sha256";
+ }
+
+ let crypto = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ crypto.initWithString(aAlgorithm);
+ let fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fis.init(aFile, -1, -1, false);
+ crypto.updateFromStream(fis, aFile.fileSize);
+
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ let binary = crypto.finish(false);
+ let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+ return aAlgorithm + ":" + hash.join("");
+}
+
+/**
+ * Returns an extension uri spec
+ *
+ * @param aProfileDir
+ * The extension install directory
+ * @return a uri spec pointing to the root of the extension
+ */
+function do_get_addon_root_uri(aProfileDir, aId) {
+ let path = aProfileDir.clone();
+ path.append(aId);
+ if (!path.exists()) {
+ path.leafName += ".xpi";
+ return "jar:" + Services.io.newFileURI(path).spec + "!/";
+ }
+ return Services.io.newFileURI(path).spec;
+}
+
+function do_get_expected_addon_name(aId) {
+ if (TEST_UNPACKED) {
+ return aId;
+ }
+ return aId + ".xpi";
+}
+
+/**
+ * Returns the file containing the add-on. For packed add-ons, this is
+ * an XPI file. For unpacked add-ons, it is the add-on's root directory.
+ *
+ * @param {Addon} addon
+ * @returns {nsIFile}
+ */
+function getAddonFile(addon) {
+ let uri = addon.getResourceURI("");
+ if (uri instanceof Ci.nsIJARURI) {
+ uri = uri.JARFile;
+ }
+ return uri.QueryInterface(Ci.nsIFileURL).file;
+}
+
+/**
+ * Check that an array of actual add-ons is the same as an array of
+ * expected add-ons.
+ *
+ * @param aActualAddons
+ * The array of actual add-ons to check.
+ * @param aExpectedAddons
+ * The array of expected add-ons to check against.
+ * @param aProperties
+ * An array of properties to check.
+ */
+function do_check_addons(aActualAddons, aExpectedAddons, aProperties) {
+ Assert.notEqual(aActualAddons, null);
+ Assert.equal(aActualAddons.length, aExpectedAddons.length);
+ for (let i = 0; i < aActualAddons.length; i++) {
+ do_check_addon(aActualAddons[i], aExpectedAddons[i], aProperties);
+ }
+}
+
+/**
+ * Check that the actual add-on is the same as the expected add-on.
+ *
+ * @param aActualAddon
+ * The actual add-on to check.
+ * @param aExpectedAddon
+ * The expected add-on to check against.
+ * @param aProperties
+ * An array of properties to check.
+ */
+function do_check_addon(aActualAddon, aExpectedAddon, aProperties) {
+ Assert.notEqual(aActualAddon, null);
+
+ aProperties.forEach(function (aProperty) {
+ let actualValue = aActualAddon[aProperty];
+ let expectedValue = aExpectedAddon[aProperty];
+
+ // Check that all undefined expected properties are null on actual add-on
+ if (!(aProperty in aExpectedAddon)) {
+ if (actualValue !== undefined && actualValue !== null) {
+ do_throw(
+ "Unexpected defined/non-null property for add-on " +
+ aExpectedAddon.id +
+ " (addon[" +
+ aProperty +
+ "] = " +
+ actualValue.toSource() +
+ ")"
+ );
+ }
+
+ return;
+ } else if (expectedValue && !actualValue) {
+ do_throw(
+ "Missing property for add-on " +
+ aExpectedAddon.id +
+ ": expected addon[" +
+ aProperty +
+ "] = " +
+ expectedValue
+ );
+ return;
+ }
+
+ switch (aProperty) {
+ case "creator":
+ do_check_author(actualValue, expectedValue);
+ break;
+
+ case "developers":
+ Assert.equal(actualValue.length, expectedValue.length);
+ for (let i = 0; i < actualValue.length; i++) {
+ do_check_author(actualValue[i], expectedValue[i]);
+ }
+ break;
+
+ case "screenshots":
+ Assert.equal(actualValue.length, expectedValue.length);
+ for (let i = 0; i < actualValue.length; i++) {
+ do_check_screenshot(actualValue[i], expectedValue[i]);
+ }
+ break;
+
+ case "sourceURI":
+ Assert.equal(actualValue.spec, expectedValue);
+ break;
+
+ case "updateDate":
+ Assert.equal(actualValue.getTime(), expectedValue.getTime());
+ break;
+
+ case "compatibilityOverrides":
+ Assert.equal(actualValue.length, expectedValue.length);
+ for (let i = 0; i < actualValue.length; i++) {
+ do_check_compatibilityoverride(actualValue[i], expectedValue[i]);
+ }
+ break;
+
+ case "icons":
+ do_check_icons(actualValue, expectedValue);
+ break;
+
+ default:
+ if (actualValue !== expectedValue) {
+ do_throw(
+ "Failed for " +
+ aProperty +
+ " for add-on " +
+ aExpectedAddon.id +
+ " (" +
+ actualValue +
+ " === " +
+ expectedValue +
+ ")"
+ );
+ }
+ }
+ });
+}
+
+/**
+ * Check that the actual author is the same as the expected author.
+ *
+ * @param aActual
+ * The actual author to check.
+ * @param aExpected
+ * The expected author to check against.
+ */
+function do_check_author(aActual, aExpected) {
+ Assert.equal(aActual.toString(), aExpected.name);
+ Assert.equal(aActual.name, aExpected.name);
+ Assert.equal(aActual.url, aExpected.url);
+}
+
+/**
+ * Check that the actual screenshot is the same as the expected screenshot.
+ *
+ * @param aActual
+ * The actual screenshot to check.
+ * @param aExpected
+ * The expected screenshot to check against.
+ */
+function do_check_screenshot(aActual, aExpected) {
+ Assert.equal(aActual.toString(), aExpected.url);
+ Assert.equal(aActual.url, aExpected.url);
+ Assert.equal(aActual.width, aExpected.width);
+ Assert.equal(aActual.height, aExpected.height);
+ Assert.equal(aActual.thumbnailURL, aExpected.thumbnailURL);
+ Assert.equal(aActual.thumbnailWidth, aExpected.thumbnailWidth);
+ Assert.equal(aActual.thumbnailHeight, aExpected.thumbnailHeight);
+ Assert.equal(aActual.caption, aExpected.caption);
+}
+
+/**
+ * Check that the actual compatibility override is the same as the expected
+ * compatibility override.
+ *
+ * @param aAction
+ * The actual compatibility override to check.
+ * @param aExpected
+ * The expected compatibility override to check against.
+ */
+function do_check_compatibilityoverride(aActual, aExpected) {
+ Assert.equal(aActual.type, aExpected.type);
+ Assert.equal(aActual.minVersion, aExpected.minVersion);
+ Assert.equal(aActual.maxVersion, aExpected.maxVersion);
+ Assert.equal(aActual.appID, aExpected.appID);
+ Assert.equal(aActual.appMinVersion, aExpected.appMinVersion);
+ Assert.equal(aActual.appMaxVersion, aExpected.appMaxVersion);
+}
+
+function do_check_icons(aActual, aExpected) {
+ for (var size in aExpected) {
+ Assert.equal(aActual[size], aExpected[size]);
+ }
+}
+
+function isThemeInAddonsList(aDir, aId) {
+ return AddonTestUtils.addonsList.hasTheme(aDir, aId);
+}
+
+function isExtensionInBootstrappedList(aDir, aId) {
+ return AddonTestUtils.addonsList.hasExtension(aDir, aId);
+}
+
+/**
+ * Writes a manifest.json manifest into an extension using the properties passed
+ * in a JS object.
+ *
+ * @param aManifest
+ * The data to write
+ * @param aDir
+ * The install directory to add the extension to
+ * @param aId
+ * An optional string to override the default installation aId
+ * @return A file pointing to where the extension was installed
+ */
+function promiseWriteWebManifestForExtension(aData, aDir, aId) {
+ let files = {
+ "manifest.json": JSON.stringify(aData),
+ };
+ if (!aId) {
+ aId =
+ aData?.browser_specific_settings?.gecko?.id ||
+ aData?.applications?.gecko?.id;
+ }
+ return AddonTestUtils.promiseWriteFilesToExtension(aDir.path, aId, files);
+}
+
+function hasFlag(aBits, aFlag) {
+ return (aBits & aFlag) != 0;
+}
+
+class EventChecker {
+ constructor(options) {
+ this.expectedEvents = options.addonEvents || {};
+ this.expectedInstalls = options.installEvents || null;
+ this.ignorePlugins = options.ignorePlugins || false;
+
+ this.finished = new Promise(resolve => {
+ this.resolveFinished = resolve;
+ });
+
+ AddonManager.addAddonListener(this);
+ if (this.expectedInstalls) {
+ AddonManager.addInstallListener(this);
+ }
+ }
+
+ cleanup() {
+ AddonManager.removeAddonListener(this);
+ if (this.expectedInstalls) {
+ AddonManager.removeInstallListener(this);
+ }
+ }
+
+ checkValue(prop, value, flagName) {
+ if (Array.isArray(flagName)) {
+ let names = flagName.map(name => `AddonManager.${name}`);
+
+ Assert.ok(
+ flagName.map(name => AddonManager[name]).includes(value),
+ `${prop} value \`${value}\` should be one of [${names.join(", ")}`
+ );
+ } else {
+ Assert.equal(
+ value,
+ AddonManager[flagName],
+ `${prop} should have value AddonManager.${flagName}`
+ );
+ }
+ }
+
+ checkFlag(prop, value, flagName) {
+ Assert.equal(
+ value & AddonManager[flagName],
+ AddonManager[flagName],
+ `${prop} should have flag AddonManager.${flagName}`
+ );
+ }
+
+ checkNoFlag(prop, value, flagName) {
+ Assert.ok(
+ !(value & AddonManager[flagName]),
+ `${prop} should not have flag AddonManager.${flagName}`
+ );
+ }
+
+ checkComplete() {
+ if (this.expectedInstalls && this.expectedInstalls.length) {
+ return;
+ }
+
+ if (Object.values(this.expectedEvents).some(events => events.length)) {
+ return;
+ }
+
+ info("Test complete");
+ this.cleanup();
+ this.resolveFinished();
+ }
+
+ ensureComplete() {
+ this.cleanup();
+
+ for (let [id, events] of Object.entries(this.expectedEvents)) {
+ Assert.equal(
+ events.length,
+ 0,
+ `Should have no remaining events for ${id}`
+ );
+ }
+ if (this.expectedInstalls) {
+ Assert.deepEqual(
+ this.expectedInstalls,
+ [],
+ "Should have no remaining install events"
+ );
+ }
+ }
+
+ // Add-on listener events
+ getExpectedEvent(aId) {
+ if (!(aId in this.expectedEvents)) {
+ return null;
+ }
+
+ let events = this.expectedEvents[aId];
+ Assert.ok(!!events.length, `Should be expecting events for ${aId}`);
+
+ return events.shift();
+ }
+
+ checkAddonEvent(event, addon, details = {}) {
+ info(`Got event "${event}" for add-on ${addon.id}`);
+
+ if ("requiresRestart" in details) {
+ Assert.equal(
+ details.requiresRestart,
+ false,
+ "requiresRestart should always be false"
+ );
+ }
+
+ let expected = this.getExpectedEvent(addon.id);
+ if (!expected) {
+ return undefined;
+ }
+
+ Assert.equal(
+ expected.event,
+ event,
+ `Expecting event "${expected.event}" got "${event}"`
+ );
+
+ for (let prop of ["properties"]) {
+ if (prop in expected) {
+ Assert.deepEqual(
+ expected[prop],
+ details[prop],
+ `Expected value for ${prop}`
+ );
+ }
+ }
+
+ this.checkComplete();
+
+ if ("returnValue" in expected) {
+ return expected.returnValue;
+ }
+ return undefined;
+ }
+
+ onPropertyChanged(addon, properties) {
+ return this.checkAddonEvent("onPropertyChanged", addon, { properties });
+ }
+
+ onEnabling(addon, requiresRestart) {
+ let result = this.checkAddonEvent("onEnabling", addon, { requiresRestart });
+
+ this.checkNoFlag("addon.permissions", addon.permissions, "PERM_CAN_ENABLE");
+
+ return result;
+ }
+
+ onEnabled(addon) {
+ let result = this.checkAddonEvent("onEnabled", addon);
+
+ this.checkNoFlag("addon.permissions", addon.permissions, "PERM_CAN_ENABLE");
+
+ return result;
+ }
+
+ onDisabling(addon, requiresRestart) {
+ let result = this.checkAddonEvent("onDisabling", addon, {
+ requiresRestart,
+ });
+
+ this.checkNoFlag(
+ "addon.permissions",
+ addon.permissions,
+ "PERM_CAN_DISABLE"
+ );
+ return result;
+ }
+
+ onDisabled(addon) {
+ let result = this.checkAddonEvent("onDisabled", addon);
+
+ this.checkNoFlag(
+ "addon.permissions",
+ addon.permissions,
+ "PERM_CAN_DISABLE"
+ );
+
+ return result;
+ }
+
+ onInstalling(addon, requiresRestart) {
+ return this.checkAddonEvent("onInstalling", addon, { requiresRestart });
+ }
+
+ onInstalled(addon) {
+ return this.checkAddonEvent("onInstalled", addon);
+ }
+
+ onUninstalling(addon, requiresRestart) {
+ return this.checkAddonEvent("onUninstalling", addon);
+ }
+
+ onUninstalled(addon) {
+ return this.checkAddonEvent("onUninstalled", addon);
+ }
+
+ onOperationCancelled(addon) {
+ return this.checkAddonEvent("onOperationCancelled", addon);
+ }
+
+ // Install listener events.
+ checkInstall(event, install, details = {}) {
+ // Lazy initialization of the plugin host means we can get spurious
+ // install events for plugins. If we're not looking for plugin
+ // installs, ignore them completely. If we *are* looking for plugin
+ // installs, the onus is on the individual test to ensure it waits
+ // for the plugin host to have done its initial work.
+ if (this.ignorePlugins && install.type == "plugin") {
+ info(`Ignoring install event for plugin ${install.id}`);
+ return undefined;
+ }
+ info(`Got install event "${event}"`);
+
+ let expected = this.expectedInstalls.shift();
+ Assert.ok(expected, "Should be expecting install event");
+
+ Assert.equal(
+ expected.event,
+ event,
+ "Should be expecting onExternalInstall event"
+ );
+
+ if ("state" in details) {
+ this.checkValue("install.state", install.state, details.state);
+ }
+
+ this.checkComplete();
+
+ if ("callback" in expected) {
+ expected.callback(install);
+ }
+
+ if ("returnValue" in expected) {
+ return expected.returnValue;
+ }
+ return undefined;
+ }
+
+ onNewInstall(install) {
+ let result = this.checkInstall("onNewInstall", install, {
+ state: ["STATE_DOWNLOADED", "STATE_DOWNLOAD_FAILED", "STATE_AVAILABLE"],
+ });
+
+ if (install.state != AddonManager.STATE_DOWNLOAD_FAILED) {
+ Assert.equal(install.error, 0, "Should have no error");
+ } else {
+ Assert.notEqual(install.error, 0, "Should have error");
+ }
+
+ return result;
+ }
+
+ onDownloadStarted(install) {
+ return this.checkInstall("onDownloadStarted", install, {
+ state: "STATE_DOWNLOADING",
+ error: 0,
+ });
+ }
+
+ onDownloadEnded(install) {
+ return this.checkInstall("onDownloadEnded", install, {
+ state: "STATE_DOWNLOADED",
+ error: 0,
+ });
+ }
+
+ onDownloadFailed(install) {
+ return this.checkInstall("onDownloadFailed", install, {
+ state: "STATE_FAILED",
+ });
+ }
+
+ onDownloadCancelled(install) {
+ return this.checkInstall("onDownloadCancelled", install, {
+ state: "STATE_CANCELLED",
+ error: 0,
+ });
+ }
+
+ onInstallStarted(install) {
+ return this.checkInstall("onInstallStarted", install, {
+ state: "STATE_INSTALLING",
+ error: 0,
+ });
+ }
+
+ onInstallEnded(install, newAddon) {
+ return this.checkInstall("onInstallEnded", install, {
+ state: "STATE_INSTALLED",
+ error: 0,
+ });
+ }
+
+ onInstallFailed(install) {
+ return this.checkInstall("onInstallFailed", install, {
+ state: "STATE_FAILED",
+ });
+ }
+
+ onInstallCancelled(install) {
+ // If the install was cancelled by a listener returning false from
+ // onInstallStarted, then the state will revert to STATE_DOWNLOADED.
+ return this.checkInstall("onInstallCancelled", install, {
+ state: ["STATE_CANCELED", "STATE_DOWNLOADED"],
+ error: 0,
+ });
+ }
+
+ onExternalInstall(addon, existingAddon, requiresRestart) {
+ if (this.ignorePlugins && addon.type == "plugin") {
+ info(`Ignoring install event for plugin ${addon.id}`);
+ return undefined;
+ }
+ let expected = this.expectedInstalls.shift();
+ Assert.ok(expected, "Should be expecting install event");
+
+ Assert.equal(
+ expected.event,
+ "onExternalInstall",
+ "Should be expecting onExternalInstall event"
+ );
+ Assert.ok(!requiresRestart, "Should never require restart");
+
+ this.checkComplete();
+ if ("returnValue" in expected) {
+ return expected.returnValue;
+ }
+ return undefined;
+ }
+}
+
+/**
+ * Run the giving callback function, and expect the given set of add-on
+ * and install listener events to be emitted, and returns a promise
+ * which resolves when they have all been observed.
+ *
+ * If `callback` returns a promise, all events are expected to be
+ * observed by the time the promise resolves. If not, simply waits for
+ * all events to be observed before resolving the returned promise.
+ *
+ * @param {object} details
+ * @param {function} callback
+ * @returns {Promise}
+ */
+/* exported expectEvents */
+async function expectEvents(details, callback) {
+ let checker = new EventChecker(details);
+
+ try {
+ let result = callback();
+
+ if (
+ result &&
+ typeof result === "object" &&
+ typeof result.then === "function"
+ ) {
+ result = await result;
+ checker.ensureComplete();
+ } else {
+ await checker.finished;
+ }
+
+ return result;
+ } catch (e) {
+ do_throw(e);
+ return undefined;
+ }
+}
+
+const EXTENSIONS_DB = "extensions.json";
+var gExtensionsJSON = gProfD.clone();
+gExtensionsJSON.append(EXTENSIONS_DB);
+
+async function promiseInstallWebExtension(aData) {
+ let addonFile = createTempWebExtensionFile(aData);
+
+ let { addon } = await promiseInstallFile(addonFile);
+ return addon;
+}
+
+// By default use strict compatibility
+Services.prefs.setBoolPref("extensions.strictCompatibility", true);
+
+// Ensure signature checks are enabled by default
+Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, true);
+
+Services.prefs.setBoolPref("extensions.experiments.enabled", true);
+
+// Copies blocklistFile (an nsIFile) to gProfD/blocklist.xml.
+function copyBlocklistToProfile(blocklistFile) {
+ var dest = gProfD.clone();
+ dest.append("blocklist.xml");
+ if (dest.exists()) {
+ dest.remove(false);
+ }
+ blocklistFile.copyTo(gProfD, "blocklist.xml");
+ dest.lastModifiedTime = Date.now();
+}
+
+async function mockGfxBlocklistItemsFromDisk(path) {
+ let response = await fetch(Services.io.newFileURI(do_get_file(path)).spec);
+ let json = await response.json();
+ return mockGfxBlocklistItems(json);
+}
+
+async function mockGfxBlocklistItems(items) {
+ const { generateUUID } = Services.uuid;
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ const client = RemoteSettings("gfx", {
+ bucketName: "blocklists",
+ });
+ const records = items.map(item => {
+ if (item.id && item.last_modified) {
+ return item;
+ }
+ return {
+ id: generateUUID().toString().replace(/[{}]/g, ""),
+ last_modified: Date.now(),
+ ...item,
+ };
+ });
+ const collectionTimestamp = Math.max(...records.map(r => r.last_modified));
+ await client.db.importChanges({}, collectionTimestamp, records, {
+ clear: true,
+ });
+ let rv = await BlocklistPrivate.GfxBlocklistRS.checkForEntries();
+ return rv;
+}
+
+/**
+ * Change the schema version of the JSON extensions database
+ */
+async function changeXPIDBVersion(aNewVersion) {
+ let json = await IOUtils.readJSON(gExtensionsJSON.path);
+ json.schemaVersion = aNewVersion;
+ await IOUtils.writeJSON(gExtensionsJSON.path, json);
+}
+
+async function setInitialState(addon, initialState) {
+ if (initialState.userDisabled) {
+ await addon.disable();
+ } else if (initialState.userDisabled === false) {
+ await addon.enable();
+ }
+}
+
+async function setupBuiltinExtension(extensionData, location = "ext-test") {
+ 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(location, base);
+}
+
+async function installBuiltinExtension(extensionData, waitForStartup = true) {
+ await setupBuiltinExtension(extensionData);
+
+ let id =
+ extensionData.manifest?.browser_specific_settings?.gecko?.id ||
+ extensionData.manifest?.applications?.gecko?.id;
+ let wrapper = ExtensionTestUtils.expectExtension(id);
+ await AddonManager.installBuiltinAddon("resource://ext-test/");
+ if (waitForStartup) {
+ await wrapper.awaitStartup();
+ }
+ return wrapper;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js b/toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js
new file mode 100644
index 0000000000..36741736fa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_amremotesettings.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+});
+
+async function setAndEmitFakeRemoteSettingsData(
+ data,
+ expectClientInitialized = true
+) {
+ const { AMRemoteSettings } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+ let client;
+ if (expectClientInitialized) {
+ ok(AMRemoteSettings.client, "Got a remote settings client");
+ ok(AMRemoteSettings.onSync, "Got a remote settings 'sync' event handler");
+ client = AMRemoteSettings.client;
+ } else {
+ // No client is expected to exist, and so we create one to inject the expected data
+ // into the RemoteSettings db.
+ client = new RemoteSettings(AMRemoteSettings.RS_COLLECTION);
+ }
+
+ await client.db.clear();
+ if (data.length) {
+ await client.db.importChanges({}, Date.now(), data);
+ }
+ await client.emit("sync", { data: {} });
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js b/toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js
new file mode 100644
index 0000000000..08c41a8c7e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_cert_handling.js
@@ -0,0 +1,33 @@
+// Helpers for handling certs.
+// These are taken from
+// https://searchfox.org/mozilla-central/rev/36aa22c7ea92bd3cf7910774004fff7e63341cf5/security/manager/ssl/tests/unit/head_psm.js
+// but we don't want to drag that file in here because
+// - it conflicts with `head_addons.js`.
+// - it has a lot of extra code we don't need.
+// So dupe relevant code here.
+
+// This file will be included along with head_addons.js, use its globals.
+/* import-globals-from head_addons.js */
+
+"use strict";
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let available = fstream.available();
+ let data =
+ available > 0 ? NetUtil.readInputStreamToString(fstream, available) : "";
+ fstream.close();
+ return data;
+}
+
+function loadCertChain(prefix, names) {
+ let chain = [];
+ for (let name of names) {
+ let filename = `${prefix}_${name}.pem`;
+ chain.push(readFile(do_get_file(filename)));
+ }
+ return chain;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_compat.js b/toolkit/mozapps/extensions/test/xpcshell/head_compat.js
new file mode 100644
index 0000000000..79ddb8dd3f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_compat.js
@@ -0,0 +1,47 @@
+//
+// This file provides helpers for tests of addons that use strictCompatibility.
+// Since WebExtensions cannot opt out of strictCompatibility, we add a
+// simple extension loader that lets tests directly set AddonInternal
+// properties (including strictCompatibility)
+//
+
+/* import-globals-from head_addons.js */
+
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+const MANIFEST = "compat_manifest.json";
+
+AddonManager.addExternalExtensionLoader({
+ name: "compat-test",
+ manifestFile: MANIFEST,
+ async loadManifest(pkg) {
+ let addon = new XPIExports.AddonInternal();
+ let manifest = JSON.parse(await pkg.readString(MANIFEST));
+ Object.assign(addon, manifest);
+ return addon;
+ },
+ loadScope(addon, file) {
+ return {
+ install() {},
+ uninstall() {},
+ startup() {},
+ shutdonw() {},
+ };
+ },
+});
+
+const DEFAULTS = {
+ defaultLocale: {},
+ locales: [],
+ targetPlatforms: [],
+ type: "extension",
+ version: "1.0",
+};
+
+function createAddon(manifest) {
+ return AddonTestUtils.createTempXPIFile({
+ [MANIFEST]: Object.assign({}, DEFAULTS, manifest),
+ });
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_sideload.js b/toolkit/mozapps/extensions/test/xpcshell/head_sideload.js
new file mode 100644
index 0000000000..8ff3f2f072
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_sideload.js
@@ -0,0 +1,76 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/publicdomain/zero/1.0/
+
+/* import-globals-from head_addons.js */
+
+// Enable all scopes.
+Services.prefs.setIntPref("extensions.enabledScopes", AddonManager.SCOPE_ALL);
+// Setting this to all enables the same behavior as before disabling sideloading.
+// We reset this later after doing some legacy sideloading.
+Services.prefs.setIntPref("extensions.sideloadScopes", AddonManager.SCOPE_ALL);
+// AddonTestUtils sets this to zero, we need the default value.
+Services.prefs.clearUserPref("extensions.autoDisableScopes");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+function getID(n) {
+ return `${n}@tests.mozilla.org`;
+}
+function initialVersion(n) {
+ return `${n}.0`;
+}
+
+// Setup some common extension locations, one in each scope.
+
+// SCOPE_SYSTEM
+const globalDir = gProfD.clone();
+globalDir.append("app-system-share");
+globalDir.append(gAppInfo.ID);
+registerDirectory("XRESysSExtPD", globalDir.parent);
+
+// SCOPE_USER
+const userDir = gProfD.clone();
+userDir.append("app-system-user");
+userDir.append(gAppInfo.ID);
+registerDirectory("XREUSysExt", userDir.parent);
+
+// SCOPE_APPLICATION
+const addonAppDir = gProfD.clone();
+addonAppDir.append("app-global");
+addonAppDir.append("extensions");
+registerDirectory("XREAddonAppDir", addonAppDir.parent);
+
+// SCOPE_PROFILE
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const scopeDirectories = {
+ global: globalDir,
+ user: userDir,
+ app: addonAppDir,
+ profile: profileDir,
+};
+
+const scopeToDir = new Map([
+ [AddonManager.SCOPE_SYSTEM, globalDir],
+ [AddonManager.SCOPE_USER, userDir],
+ [AddonManager.SCOPE_APPLICATION, addonAppDir],
+ [AddonManager.SCOPE_PROFILE, profileDir],
+]);
+
+async function createWebExtension(id, version, dir) {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ await AddonTestUtils.manuallyInstall(xpi, dir);
+}
+
+function check_startup_changes(aType, aIds) {
+ let changes = AddonManager.getStartupChanges(aType);
+ changes = changes.filter(aEl => /@tests.mozilla.org$/.test(aEl));
+
+ Assert.deepEqual([...aIds].sort(), changes.sort());
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js b/toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js
new file mode 100644
index 0000000000..2c77aa8019
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_system_addons.js
@@ -0,0 +1,486 @@
+/* import-globals-from head_addons.js */
+
+const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet";
+const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url";
+const PREF_SYSTEM_ADDON_UPDATE_ENABLED =
+ "extensions.systemAddon.update.enabled";
+
+// See bug 1507255
+Services.prefs.setBoolPref("media.gmp-manager.updateEnabled", true);
+
+function root(server) {
+ let { primaryScheme, primaryHost, primaryPort } = server.identity;
+ return `${primaryScheme}://${primaryHost}:${primaryPort}/data`;
+}
+
+ChromeUtils.defineLazyGetter(this, "testserver", () => {
+ let server = new HttpServer();
+ server.start();
+ Services.prefs.setCharPref(
+ PREF_SYSTEM_ADDON_UPDATE_URL,
+ `${root(server)}/update.xml`
+ );
+ return server;
+});
+
+async function serveSystemUpdate(xml, perform_update) {
+ testserver.registerPathHandler("/data/update.xml", (request, response) => {
+ response.write(xml);
+ });
+
+ try {
+ await perform_update();
+ } finally {
+ testserver.registerPathHandler("/data/update.xml", null);
+ }
+}
+
+// Runs an update check making it use the passed in xml string. Uses the direct
+// call to the update function so we get rejections on failure.
+async function installSystemAddons(xml, waitIDs = []) {
+ info("Triggering system add-on update check.");
+
+ await serveSystemUpdate(
+ xml,
+ async function () {
+ let { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ await Promise.all([
+ XPIExports.XPIProvider.updateSystemAddons(),
+ ...waitIDs.map(id => promiseWebExtensionStartup(id)),
+ ]);
+ },
+ testserver
+ );
+}
+
+// Runs a full add-on update check which will in some cases do a system add-on
+// update check. Always succeeds.
+async function updateAllSystemAddons(xml) {
+ info("Triggering full add-on update check.");
+
+ await serveSystemUpdate(
+ xml,
+ function () {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(
+ observer,
+ "addons-background-update-complete"
+ );
+
+ resolve();
+ }, "addons-background-update-complete");
+
+ // Trigger the background update timer handler
+ gInternalManager.notify(null);
+ });
+ },
+ testserver
+ );
+}
+
+// Builds an update.xml file for an update check based on the data passed.
+function buildSystemAddonUpdates(addons) {
+ let xml = `<?xml version="1.0" encoding="UTF-8"?>\n\n<updates>\n`;
+ if (addons) {
+ xml += ` <addons>\n`;
+ for (let addon of addons) {
+ if (addon.xpi) {
+ testserver.registerFile(`/data/${addon.path}`, addon.xpi);
+ }
+
+ xml += ` <addon id="${addon.id}" URL="${root(testserver)}/${
+ addon.path
+ }" version="${addon.version}"`;
+ if (addon.hashFunction) {
+ xml += ` hashFunction="${addon.hashFunction}"`;
+ }
+ if (addon.hashValue) {
+ xml += ` hashValue="${addon.hashValue}"`;
+ }
+ xml += `/>\n`;
+ }
+ xml += ` </addons>\n`;
+ }
+ xml += `</updates>\n`;
+
+ return xml;
+}
+
+let _systemXPIs = new Map();
+function getSystemAddonXPI(num, version) {
+ let key = `${num}:${version}`;
+ if (!_systemXPIs.has(key)) {
+ _systemXPIs.set(
+ key,
+ AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: `System Add-on ${num}`,
+ version,
+ browser_specific_settings: {
+ gecko: {
+ id: `system${num}@tests.mozilla.org`,
+ },
+ },
+ },
+ })
+ );
+ }
+ return _systemXPIs.get(key);
+}
+
+async function initSystemAddonDirs() {
+ let hiddenSystemAddonDir = FileUtils.getDir("ProfD", [
+ "sysfeatures",
+ "hidden",
+ ]);
+ hiddenSystemAddonDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ let system1_1 = await getSystemAddonXPI(1, "1.0");
+ system1_1.copyTo(hiddenSystemAddonDir, "system1@tests.mozilla.org.xpi");
+
+ let system2_1 = await getSystemAddonXPI(2, "1.0");
+ system2_1.copyTo(hiddenSystemAddonDir, "system2@tests.mozilla.org.xpi");
+
+ let prefilledSystemAddonDir = FileUtils.getDir("ProfD", [
+ "sysfeatures",
+ "prefilled",
+ ]);
+ prefilledSystemAddonDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ let system2_2 = await getSystemAddonXPI(2, "2.0");
+ system2_2.copyTo(prefilledSystemAddonDir, "system2@tests.mozilla.org.xpi");
+ let system3_2 = await getSystemAddonXPI(3, "2.0");
+ system3_2.copyTo(prefilledSystemAddonDir, "system3@tests.mozilla.org.xpi");
+}
+
+/**
+ * Returns current system add-on update directory (stored in pref).
+ */
+function getCurrentSystemAddonUpdatesDir() {
+ const updatesDir = FileUtils.getDir("ProfD", ["features"]);
+ let dir = updatesDir.clone();
+ let set = JSON.parse(Services.prefs.getCharPref(PREF_SYSTEM_ADDON_SET));
+ dir.append(set.directory);
+ return dir;
+}
+
+/**
+ * Removes all files from system add-on update directory.
+ */
+function clearSystemAddonUpdatesDir() {
+ const updatesDir = FileUtils.getDir("ProfD", ["features"]);
+ // Delete any existing directories
+ if (updatesDir.exists()) {
+ updatesDir.remove(true);
+ }
+
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_SET);
+}
+
+registerCleanupFunction(() => {
+ clearSystemAddonUpdatesDir();
+});
+
+/**
+ * Installs a known set of add-ons into the system add-on update directory.
+ */
+async function buildPrefilledUpdatesDir() {
+ clearSystemAddonUpdatesDir();
+
+ // Build the test set
+ let dir = FileUtils.getDir("ProfD", ["features", "prefilled"]);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let xpi = await getSystemAddonXPI(2, "2.0");
+ xpi.copyTo(dir, "system2@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "2.0");
+ xpi.copyTo(dir, "system3@tests.mozilla.org.xpi");
+
+ // Mark these in the past so the startup file scan notices when files have changed properly
+ {
+ let toModify = await IOUtils.getFile(
+ PathUtils.profileDir,
+ "features",
+ "prefilled",
+ "system2@tests.mozilla.org.xpi"
+ );
+ toModify.lastModifiedTime -= 10000;
+
+ toModify = await IOUtils.getFile(
+ PathUtils.profileDir,
+ "features",
+ "prefilled",
+ "system3@tests.mozilla.org.xpi"
+ );
+ toModify.lastModifiedTime -= 10000;
+ }
+
+ Services.prefs.setCharPref(
+ PREF_SYSTEM_ADDON_SET,
+ JSON.stringify({
+ schema: 1,
+ directory: dir.leafName,
+ addons: {
+ "system2@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system3@tests.mozilla.org": {
+ version: "2.0",
+ },
+ },
+ })
+ );
+}
+
+/**
+ * Check currently installed ssystem add-ons against a set of conditions.
+ *
+ * @param {Array<Object>} conditions - an array of objects of the form { isUpgrade: false, version: null}
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+async function checkInstalledSystemAddons(conditions, distroDir) {
+ for (let i = 0; i < conditions.length; i++) {
+ let condition = conditions[i];
+ let id = "system" + (i + 1) + "@tests.mozilla.org";
+ let addon = await promiseAddonByID(id);
+
+ if (!("isUpgrade" in condition) || !("version" in condition)) {
+ throw Error("condition must contain isUpgrade and version");
+ }
+ let isUpgrade = conditions[i].isUpgrade;
+ let version = conditions[i].version;
+
+ let expectedDir = isUpgrade ? getCurrentSystemAddonUpdatesDir() : distroDir;
+
+ if (version) {
+ info(`Checking state of add-on ${id}, expecting version ${version}`);
+
+ // Add-on should be installed
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, version);
+ Assert.ok(addon.isActive);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.hidden);
+ Assert.ok(addon.isSystem);
+
+ // Verify the add-ons file is in the right place
+ let file = expectedDir.clone();
+ file.append(id + ".xpi");
+ Assert.ok(file.exists());
+ Assert.ok(file.isFile());
+
+ let uri = addon.getResourceURI();
+ if (uri instanceof Ci.nsIJARURI) {
+ uri = uri.JARFile;
+ }
+
+ Assert.ok(uri instanceof Ci.nsIFileURL);
+ Assert.equal(uri.file.path, file.path);
+
+ if (isUpgrade) {
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SYSTEM);
+ }
+ } else {
+ info(`Checking state of add-on ${id}, expecting it to be missing`);
+
+ if (isUpgrade) {
+ // Add-on should not be installed
+ Assert.equal(addon, null);
+ }
+ }
+ }
+}
+
+/**
+ * Returns all system add-on updates directories.
+ */
+async function getSystemAddonDirectories() {
+ const updatesDir = FileUtils.getDir("ProfD", ["features"]);
+ let subdirs = [];
+
+ if (await IOUtils.exists(updatesDir.path)) {
+ for (const child of await IOUtils.getChildren(updatesDir.path)) {
+ const stat = await IOUtils.stat(child);
+ if (stat.type === "directory") {
+ subdirs.push(child);
+ }
+ }
+ }
+
+ return subdirs;
+}
+
+/**
+ * Sets up initial system add-on update conditions.
+ *
+ * @param {Object<function, Array<Object>} setup - an object containing a setup function and an array of objects
+ * of the form {isUpgrade: false, version: null}
+ *
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+async function setupSystemAddonConditions(setup, distroDir) {
+ info("Clearing existing database.");
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_SET);
+ distroDir.leafName = "empty";
+
+ let updateList = [];
+ await overrideBuiltIns({ system: updateList });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ info("Setting up conditions.");
+ await setup.setup();
+
+ if (distroDir) {
+ if (distroDir.path.endsWith("hidden")) {
+ updateList = ["system1@tests.mozilla.org", "system2@tests.mozilla.org"];
+ } else if (distroDir.path.endsWith("prefilled")) {
+ updateList = ["system2@tests.mozilla.org", "system3@tests.mozilla.org"];
+ }
+ }
+ await overrideBuiltIns({ system: updateList });
+
+ let startupPromises = setup.initialState.map((item, i) =>
+ item.version
+ ? promiseWebExtensionStartup(`system${i + 1}@tests.mozilla.org`)
+ : null
+ );
+ await Promise.all([promiseStartupManager(), ...startupPromises]);
+
+ // Make sure the initial state is correct
+ info("Checking initial state.");
+ await checkInstalledSystemAddons(setup.initialState, distroDir);
+}
+
+/**
+ * Verify state of system add-ons after installation.
+ *
+ * @param {Array<Object>} initialState - an array of objects of the form {isUpgrade: false, version: null}
+ * @param {Array<Object>} finalState - an array of objects of the form {isUpgrade: false, version: null}
+ * @param {Boolean} alreadyUpgraded - whether a restartless upgrade has already been performed.
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+async function verifySystemAddonState(
+ initialState,
+ finalState = undefined,
+ alreadyUpgraded = false,
+ distroDir
+) {
+ let expectedDirs = 0;
+
+ // If the initial state was using the profile set then that directory will
+ // still exist.
+
+ if (initialState.some(a => a.isUpgrade)) {
+ expectedDirs++;
+ }
+
+ if (finalState == undefined) {
+ finalState = initialState;
+ } else if (finalState.some(a => a.isUpgrade)) {
+ // If the new state is using the profile then that directory will exist.
+ expectedDirs++;
+ }
+
+ // Since upgrades are restartless now, the previous update dir hasn't been removed.
+ if (alreadyUpgraded) {
+ expectedDirs++;
+ }
+
+ info("Checking final state.");
+
+ let dirs = await getSystemAddonDirectories();
+ Assert.equal(dirs.length, expectedDirs);
+
+ await checkInstalledSystemAddons(...finalState, distroDir);
+
+ // Check that the new state is active after a restart
+ await promiseShutdownManager();
+
+ let updateList = [];
+
+ if (distroDir) {
+ if (distroDir.path.endsWith("hidden")) {
+ updateList = ["system1@tests.mozilla.org", "system2@tests.mozilla.org"];
+ } else if (distroDir.path.endsWith("prefilled")) {
+ updateList = ["system2@tests.mozilla.org", "system3@tests.mozilla.org"];
+ }
+ }
+ await overrideBuiltIns({ system: updateList });
+ await promiseStartupManager();
+ await checkInstalledSystemAddons(finalState, distroDir);
+}
+
+/**
+ * Run system add-on tests and compare the results against a set of expected conditions.
+ *
+ * @param {String} setupName - name of the current setup conditions.
+ * @param {Object<function, Array<Object>} setup - Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ * @param {Array<Object>} test - The test to run. Each test must define an updateList or test. The following
+ * properties are used:
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional regex property, if present the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ * @param {nsIFile} distroDir - the system add-on distribution directory (the "features" dir in the app directory)
+ */
+
+async function execSystemAddonTest(setupName, setup, test, distroDir) {
+ // Initial system addon conditions need system signature
+ AddonTestUtils.usePrivilegedSignatures = "system";
+ await setupSystemAddonConditions(setup, distroDir);
+
+ // The test may define what signature to use when running the test
+ if (test.usePrivilegedSignatures != undefined) {
+ AddonTestUtils.usePrivilegedSignatures = test.usePrivilegedSignatures;
+ }
+
+ function runTest() {
+ if ("test" in test) {
+ return test.test();
+ }
+ let xml = buildSystemAddonUpdates(test.updateList);
+ let ids = (test.updateList || []).map(item => item.id);
+ return installSystemAddons(xml, ids);
+ }
+
+ if (test.fails) {
+ await Assert.rejects(runTest(), test.fails);
+ } else {
+ await runTest();
+ }
+
+ // some tests have a different expected combination of default
+ // and updated add-ons.
+ if (test.finalState && setupName in test.finalState) {
+ await verifySystemAddonState(
+ setup.initialState,
+ test.finalState[setupName],
+ false,
+ distroDir
+ );
+ } else {
+ await verifySystemAddonState(
+ setup.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+ }
+
+ await promiseShutdownManager();
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/head_unpack.js b/toolkit/mozapps/extensions/test/xpcshell/head_unpack.js
new file mode 100644
index 0000000000..909dc1da2f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_unpack.js
@@ -0,0 +1,3 @@
+/* globals Services, TEST_UNPACKED: true */
+/* exported TEST_UNPACKED */
+TEST_UNPACKED = true;
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js
new file mode 100644
index 0000000000..9075126196
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/head.js
@@ -0,0 +1,57 @@
+// Appease eslint.
+/* import-globals-from ../head_addons.js */
+
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const IS_ANDROID_BUILD = AppConstants.platform === "android";
+
+const MLBF_RECORD = {
+ id: "A blocklist entry that refers to a MLBF file",
+ // Higher than any last_modified in addons-bloomfilters.json:
+ last_modified: Date.now(),
+ attachment: {
+ size: 32,
+ hash: "6af648a5d6ce6dbee99b0aab1780d24d204977a6606ad670d5372ef22fac1052",
+ filename: "does-not-matter.bin",
+ },
+ attachment_type: "bloomfilter-base",
+ generation_time: 1577833200000,
+};
+
+function enable_blocklist_v2_instead_of_useMLBF() {
+ Blocklist.allowDeprecatedBlocklistV2 = true;
+ Services.prefs.setBoolPref("extensions.blocklist.useMLBF", false);
+ // Sanity check: blocklist v2 has been enabled.
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ Assert.equal(
+ Blocklist.ExtensionBlocklist,
+ BlocklistPrivate.ExtensionBlocklistRS,
+ "ExtensionBlocklistRS should have been enabled"
+ );
+}
+
+async function load_mlbf_record_as_blob() {
+ const url = Services.io.newFileURI(
+ do_get_file("../data/mlbf-blocked1-unblocked2.bin")
+ ).spec;
+ return (await fetch(url)).blob();
+}
+
+function getExtensionBlocklistMLBF() {
+ // ExtensionBlocklist.Blocklist is an ExtensionBlocklistMLBF if the useMLBF
+ // pref is set to true.
+ const {
+ BlocklistPrivate: { ExtensionBlocklistMLBF },
+ } = ChromeUtils.importESModule("resource://gre/modules/Blocklist.sys.mjs");
+ if (Blocklist.allowDeprecatedBlocklistV2) {
+ Assert.ok(
+ Services.prefs.getBoolPref("extensions.blocklist.useMLBF", false),
+ "blocklist.useMLBF should be true"
+ );
+ }
+ return ExtensionBlocklistMLBF;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js
new file mode 100644
index 0000000000..d37e1c3c64
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_android_blocklist_dump.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// A known blocked version from bug 1626602.
+// Same as in test_blocklist_mlbf_dump.js.
+const blockedAddon = {
+ id: "{6f62927a-e380-401a-8c9e-c485b7d87f0d}",
+ version: "9.2.0",
+ signedDate: new Date(1588098908496), // 2020-04-28 (dummy date)
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+// A known add-on that is not blocked, as of writing. It is likely not going
+// to be blocked because it does not have any executable code.
+// Same as in test_blocklist_mlbf_dump.js.
+const nonBlockedAddon = {
+ id: "disable-ctrl-q-and-cmd-q@robwu.nl",
+ version: "1",
+ signedDate: new Date(1482430349000), // 2016-12-22 (actual signing time).
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+add_task(
+ async function verify_a_known_blocked_add_on_is_not_detected_as_blocked_at_first_run() {
+ const MLBF_LOAD_RESULTS = [];
+ const MLBF_LOAD_ATTEMPTS = [];
+ const onLoadAttempts = record => MLBF_LOAD_ATTEMPTS.push(record);
+ const onLoadResult = promise => MLBF_LOAD_RESULTS.push(promise);
+ spyOnExtensionBlocklistMLBF(onLoadAttempts, onLoadResult);
+
+ // The addons blocklist data is not packaged and will be downloaded after install
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "A known blocked add-on should not be blocked at first"
+ );
+
+ await Assert.rejects(
+ MLBF_LOAD_RESULTS[0],
+ /DownloadError: Could not download addons-mlbf.bin/,
+ "Should not find any packaged attachment"
+ );
+
+ MLBF_LOAD_ATTEMPTS.length = 0;
+ MLBF_LOAD_RESULTS.length = 0;
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(nonBlockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "A known non-blocked add-on should not be blocked"
+ );
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Blocklist is still not populated"
+ );
+ Assert.deepEqual(
+ MLBF_LOAD_ATTEMPTS,
+ [],
+ "MLBF is not fetched again after the first lookup"
+ );
+ }
+);
+
+function spyOnExtensionBlocklistMLBF(onLoadAttempts, onLoadResult) {
+ const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+ // Tapping into the internals of ExtensionBlocklistMLBF._fetchMLBF to observe
+ const originalFetchMLBF = ExtensionBlocklistMLBF._fetchMLBF;
+ ExtensionBlocklistMLBF._fetchMLBF = async function (record) {
+ onLoadAttempts(record);
+ let promise = originalFetchMLBF.apply(this, arguments);
+ onLoadResult(promise);
+ return promise;
+ };
+
+ registerCleanupFunction(
+ () => (ExtensionBlocklistMLBF._fetchMLBF = originalFetchMLBF)
+ );
+
+ return ExtensionBlocklistMLBF;
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js
new file mode 100644
index 0000000000..b11d1329cd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_addonBlockURL.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// useMLBF=true case is covered by test_blocklist_mlbf.js
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = [
+ {
+ id: "foo",
+ guid: "myfoo",
+ versionRange: [
+ {
+ severity: "3",
+ },
+ ],
+ },
+ {
+ blockID: "bar",
+ // we'll get a uuid as an `id` property from loadBlocklistRawData
+ guid: "mybar",
+ versionRange: [
+ {
+ severity: "3",
+ },
+ ],
+ },
+];
+
+const BASE_BLOCKLIST_INFOURL = Services.prefs.getStringPref(
+ "extensions.blocklist.detailsURL"
+);
+
+/*
+ * Check that add-on blocklist URLs are correctly exposed
+ * based on either blockID or id properties on the entries
+ * in remote settings.
+ */
+add_task(async function blocklistURL_check() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+ await AddonTestUtils.loadBlocklistRawData({ extensions: BLOCKLIST_DATA });
+
+ let entry = await Blocklist.getAddonBlocklistEntry({
+ id: "myfoo",
+ version: "1.0",
+ });
+ Assert.equal(entry.url, BASE_BLOCKLIST_INFOURL + "foo.html");
+
+ entry = await Blocklist.getAddonBlocklistEntry({
+ id: "mybar",
+ version: "1.0",
+ });
+ Assert.equal(entry.url, BASE_BLOCKLIST_INFOURL + "bar.html");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js
new file mode 100644
index 0000000000..e8d03f088b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_appversion.js
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// useMLBF=true does not offer special support for filtering by application ID.
+// The same functionality is offered through filter_expression, which is tested
+// by services/settings/test/unit/test_remote_settings_jexl_filters.js and
+// test_blocklistchange.js.
+enable_blocklist_v2_instead_of_useMLBF();
+
+var ADDONS = [
+ {
+ id: "test_bug449027_1@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 1",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_2@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 2",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_3@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 3",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_4@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 4",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_5@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 5",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_6@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 6",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_7@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 7",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_8@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 8",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_9@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 9",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_10@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 10",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_11@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 11",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_12@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 12",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_13@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 13",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_14@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 14",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_15@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 15",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_16@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 16",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_17@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 17",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_18@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 18",
+ version: "5",
+ start: false,
+ appBlocks: false,
+ toolkitBlocks: false,
+ },
+ {
+ id: "test_bug449027_19@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 19",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_20@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 20",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_21@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 21",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_22@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 22",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_23@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 23",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_24@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 24",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+ {
+ id: "test_bug449027_25@tests.mozilla.org",
+ name: "Bug 449027 Addon Test 25",
+ version: "5",
+ start: false,
+ appBlocks: true,
+ toolkitBlocks: true,
+ },
+];
+
+function createAddon(addon) {
+ return promiseInstallWebExtension({
+ manifest: {
+ name: addon.name,
+ version: addon.version,
+ browser_specific_settings: { gecko: { id: addon.id } },
+ },
+ });
+}
+
+/**
+ * Checks that items are blocklisted correctly according to the current test.
+ * If a lastTest is provided checks that the notification dialog got passed
+ * the newly blocked items compared to the previous test.
+ */
+async function checkState(test, lastTest, callback) {
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ const bls = Ci.nsIBlocklistService;
+
+ await TestUtils.waitForCondition(() =>
+ ADDONS.every(
+ (addon, i) =>
+ addon[test] == (addons[i].blocklistState == bls.STATE_BLOCKED)
+ )
+ ).catch(() => {
+ /* ignore exceptions; the following test will fail anyway. */
+ });
+
+ for (let [i, addon] of ADDONS.entries()) {
+ var blocked =
+ addons[i].blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED;
+ equal(
+ blocked,
+ addon[test],
+ `Blocklist state should match expected for extension ${addon.id}, test ${test}`
+ );
+ }
+}
+
+add_task(async function test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ for (let addon of ADDONS) {
+ await createAddon(addon);
+ }
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ for (var i = 0; i < ADDONS.length; i++) {
+ ok(addons[i], `Addon ${i + 1} should have been correctly installed`);
+ }
+
+ await checkState("start");
+});
+
+/**
+ * Load the toolkit based blocks
+ */
+add_task(async function test_pt2() {
+ await AddonTestUtils.loadBlocklistData(
+ do_get_file("../data/"),
+ "test_bug449027_toolkit"
+ );
+
+ await checkState("toolkitBlocks", "start");
+});
+
+/**
+ * Load the application based blocks
+ */
+add_task(async function test_pt3() {
+ await AddonTestUtils.loadBlocklistData(
+ do_get_file("../data/"),
+ "test_bug449027_app"
+ );
+
+ await checkState("appBlocks", "toolkitBlocks");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js
new file mode 100644
index 0000000000..52d297cbf7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_clients.js
@@ -0,0 +1,225 @@
+const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+);
+const { Utils: RemoteSettingsUtils } = ChromeUtils.importESModule(
+ "resource://services-settings/Utils.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+let gBlocklistClients;
+
+async function clear_state() {
+ RemoteSettings.enablePreviewMode(undefined);
+
+ for (let { client } of gBlocklistClients) {
+ // Remove last server times.
+ Services.prefs.clearUserPref(client.lastCheckTimePref);
+
+ // Clear local DB.
+ await client.db.clear();
+ }
+}
+
+add_task(async function setup() {
+ AddonTestUtils.createAppInfo(
+ "XPCShell",
+ "xpcshell@tests.mozilla.org",
+ "1",
+ ""
+ );
+
+ // This will initialize the remote settings clients for blocklists.
+ BlocklistPrivate.ExtensionBlocklistRS.ensureInitialized();
+ BlocklistPrivate.GfxBlocklistRS._ensureInitialized();
+
+ // ExtensionBlocklistMLBF is covered by test_blocklist_mlbf_dump.js.
+ gBlocklistClients = [
+ {
+ client: BlocklistPrivate.ExtensionBlocklistRS._client,
+ expectHasDump: false,
+ },
+ {
+ client: BlocklistPrivate.GfxBlocklistRS._client,
+ expectHasDump: true,
+ },
+ ];
+
+ await promiseStartupManager();
+});
+
+add_task(
+ async function test_initial_dump_is_loaded_as_synced_when_collection_is_empty() {
+ for (let { client, expectHasDump } of gBlocklistClients) {
+ Assert.equal(
+ await RemoteSettingsUtils.hasLocalDump(
+ client.bucketName,
+ client.collectionName
+ ),
+ expectHasDump,
+ `Expected initial remote settings dump for ${client.collectionName}`
+ );
+ }
+ }
+);
+add_task(clear_state);
+
+add_task(async function test_data_is_filtered_for_target() {
+ const initial = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [],
+ maxVersion: "*",
+ minVersion: "0",
+ severity: "1",
+ },
+ ],
+ },
+ ];
+ const noMatchingTarget = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [{ guid: "Foo" }],
+ maxVersion: "*",
+ minVersion: "0",
+ severity: "3",
+ },
+ ],
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [{ guid: "XPCShell", maxVersion: "0.1" }],
+ maxVersion: "*",
+ minVersion: "0",
+ severity: "1",
+ },
+ ],
+ },
+ ];
+ const oneMatch = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "XPCShell",
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const records = initial.concat(noMatchingTarget).concat(oneMatch);
+
+ for (let { client } of gBlocklistClients) {
+ // Initialize the collection with some data
+ for (const record of records) {
+ await client.db.create(record);
+ }
+
+ const internalData = await client.db.list();
+ Assert.equal(internalData.length, records.length);
+ let filtered = await client.get({ syncIfEmpty: false });
+ Assert.equal(filtered.length, 2); // only two matches.
+ }
+});
+add_task(clear_state);
+
+add_task(
+ async function test_entries_are_filtered_when_jexl_filter_expression_is_present() {
+ const records = [
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ filter_expression: null,
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ filter_expression: "1 == 1",
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: false,
+ filter_expression: "1 == 2",
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: true,
+ filter_expression: "1 == 1",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "some-guid",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: "foo",
+ matchName: "foo",
+ willMatch: false, // jexl prevails over versionRange.
+ filter_expression: "1 == 2",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ minVersion: "0",
+ maxVersion: "*",
+ },
+ ],
+ },
+ ],
+ },
+ ];
+ for (let { client } of gBlocklistClients) {
+ for (const record of records) {
+ await client.db.create(record);
+ }
+ const list = await client.get({
+ loadDumpIfNewer: false,
+ syncIfEmpty: false,
+ });
+ equal(list.length, 4);
+ ok(list.every(e => e.willMatch));
+ }
+ }
+);
+add_task(clear_state);
+
+add_task(async function test_bucketname_changes_when_preview_mode_is_enabled() {
+ for (const { client } of gBlocklistClients) {
+ equal(client.bucketName, "blocklists");
+ }
+
+ RemoteSettings.enablePreviewMode(true);
+
+ for (const { client } of gBlocklistClients) {
+ equal(client.bucketName, "blocklists-preview", client.identifier);
+ }
+});
+add_task(clear_state);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js
new file mode 100644
index 0000000000..2b243ec650
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_gfx.js
@@ -0,0 +1,113 @@
+const EVENT_NAME = "blocklist-data-gfxItems";
+
+const SAMPLE_GFX_RECORD = {
+ driverVersionComparator: "LESS_THAN_OR_EQUAL",
+ driverVersion: "8.17.12.5896",
+ vendor: "0x10de",
+ blockID: "g36",
+ feature: "DIRECT3D_9_LAYERS",
+ devices: ["0x0a6c", "geforce"],
+ featureStatus: "BLOCKED_DRIVER_VERSION",
+ last_modified: 9999999999999, // High timestamp to prevent load of dump
+ os: "WINNT 6.1",
+ id: "3f947f16-37c2-4e96-d356-78b26363729b",
+ versionRange: { minVersion: 0, maxVersion: "*" },
+};
+
+add_task(async function test_sends_serialized_data() {
+ const expected =
+ "blockID:g36\tdevices:0x0a6c,geforce\tdriverVersion:8.17.12.5896\t" +
+ "driverVersionComparator:LESS_THAN_OR_EQUAL\tfeature:DIRECT3D_9_LAYERS\t" +
+ "featureStatus:BLOCKED_DRIVER_VERSION\tos:WINNT 6.1\tvendor:0x10de\t" +
+ "versionRange:0,*";
+ let received;
+ const observe = (subject, topic, data) => {
+ received = data;
+ };
+ Services.obs.addObserver(observe, EVENT_NAME);
+ await mockGfxBlocklistItems([SAMPLE_GFX_RECORD]);
+ Services.obs.removeObserver(observe, EVENT_NAME);
+
+ equal(received, expected);
+});
+
+add_task(async function test_parsing_skips_devices_with_comma() {
+ let clonedItem = Cu.cloneInto(SAMPLE_GFX_RECORD, this);
+ clonedItem.devices[0] = "0x2,582";
+ let rv = await mockGfxBlocklistItems([clonedItem]);
+ equal(rv[0].devices.length, 1);
+ equal(rv[0].devices[0], "geforce");
+});
+
+add_task(async function test_empty_values_are_ignored() {
+ let received;
+ const observe = (subject, topic, data) => {
+ received = data;
+ };
+ Services.obs.addObserver(observe, EVENT_NAME);
+ let clonedItem = Cu.cloneInto(SAMPLE_GFX_RECORD, this);
+ clonedItem.os = "";
+ await mockGfxBlocklistItems([clonedItem]);
+ ok(!received.includes("os"), "Shouldn't send empty values");
+ Services.obs.removeObserver(observe, EVENT_NAME);
+});
+
+add_task(async function test_empty_devices_are_ignored() {
+ let received;
+ const observe = (subject, topic, data) => {
+ received = data;
+ };
+ Services.obs.addObserver(observe, EVENT_NAME);
+ let clonedItem = Cu.cloneInto(SAMPLE_GFX_RECORD, this);
+ clonedItem.devices = [];
+ await mockGfxBlocklistItems([clonedItem]);
+ ok(!received.includes("devices"), "Shouldn't send empty values");
+ Services.obs.removeObserver(observe, EVENT_NAME);
+});
+
+add_task(async function test_version_range_default_values() {
+ const kTests = [
+ {
+ input: { minVersion: "13.0b2", maxVersion: "42.0" },
+ output: { minVersion: "13.0b2", maxVersion: "42.0" },
+ },
+ {
+ input: { maxVersion: "2.0" },
+ output: { minVersion: "0", maxVersion: "2.0" },
+ },
+ {
+ input: { minVersion: "1.0" },
+ output: { minVersion: "1.0", maxVersion: "*" },
+ },
+ {
+ input: { minVersion: " " },
+ output: { minVersion: "0", maxVersion: "*" },
+ },
+ {
+ input: {},
+ output: { minVersion: "0", maxVersion: "*" },
+ },
+ ];
+ for (let test of kTests) {
+ let parsedEntries = await mockGfxBlocklistItems([
+ { versionRange: test.input },
+ ]);
+ equal(parsedEntries[0].versionRange.minVersion, test.output.minVersion);
+ equal(parsedEntries[0].versionRange.maxVersion, test.output.maxVersion);
+ }
+});
+
+add_task(async function test_blockid_attribute() {
+ const kTests = [
+ { blockID: "g60", vendor: " 0x10de " },
+ { feature: " DIRECT3D_9_LAYERS " },
+ ];
+ for (let test of kTests) {
+ let [rv] = await mockGfxBlocklistItems([test]);
+ if (test.blockID) {
+ equal(rv.blockID, test.blockID);
+ } else {
+ ok(!rv.hasOwnProperty("blockID"), "not expecting a blockID");
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js
new file mode 100644
index 0000000000..8f7ecbdf29
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_metadata_filters.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests blocking of extensions by ID, name, creator, homepageURL, updateURL
+// and RegExps for each. See bug 897735.
+
+// useMLBF=true only supports blocking by version+ID, not by other fields.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = {
+ extensions: [
+ {
+ guid: null,
+ name: "/^Mozilla Corp\\.$/",
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: "/block2/",
+ name: "/^Moz/",
+ homepageURL: "/\\.dangerous\\.com/",
+ updateURL: "/\\.dangerous\\.com/",
+ versionRange: [
+ {
+ severity: "3",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ await promiseStartupManager();
+
+ // Should get blocked by name
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Mozilla Corp.",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "block1@tests.mozilla.org" } },
+ },
+ });
+
+ // Should get blocked by all the attributes.
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Moz-addon",
+ version: "1.0",
+ homepage_url: "https://www.extension.dangerous.com/",
+ browser_specific_settings: {
+ gecko: {
+ id: "block2@tests.mozilla.org",
+ update_url: "https://www.extension.dangerous.com/update.json",
+ },
+ },
+ },
+ });
+
+ // Fails to get blocked because of a different ID even though other
+ // attributes match against a blocklist entry.
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Moz-addon",
+ version: "1.0",
+ homepage_url: "https://www.extension.dangerous.com/",
+ browser_specific_settings: {
+ gecko: {
+ id: "block3@tests.mozilla.org",
+ update_url: "https://www.extension.dangerous.com/update.json",
+ },
+ },
+ },
+ });
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ "block3@tests.mozilla.org",
+ ]);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(a3.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function test_blocks() {
+ await AddonTestUtils.loadBlocklistRawData(BLOCKLIST_DATA);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ "block3@tests.mozilla.org",
+ ]);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ Assert.equal(a3.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js
new file mode 100644
index 0000000000..1f6cb3db05
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf.js
@@ -0,0 +1,290 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+AddonTestUtils.useRealCertChecks = true;
+
+// A real, signed XPI for use in the test.
+const SIGNED_ADDON_XPI_FILE = do_get_file("../data/webext-implicit-id.xpi");
+const SIGNED_ADDON_ID = "webext_implicit_id@tests.mozilla.org";
+const SIGNED_ADDON_VERSION = "1.0";
+const SIGNED_ADDON_KEY = `${SIGNED_ADDON_ID}:${SIGNED_ADDON_VERSION}`;
+const SIGNED_ADDON_SIGN_TIME = 1459980789000; // notBefore of certificate.
+
+// A real, signed sitepermission XPI for use in the test.
+const SIGNED_SITEPERM_XPI_FILE = do_get_file("webmidi_permission.xpi");
+const SIGNED_SITEPERM_ADDON_ID = "webmidi@test.mozilla.org";
+const SIGNED_SITEPERM_ADDON_VERSION = "1.0.2";
+const SIGNED_SITEPERM_KEY = `${SIGNED_SITEPERM_ADDON_ID}:${SIGNED_SITEPERM_ADDON_VERSION}`;
+const SIGNED_SITEPERM_SIGN_TIME = 1637606460000; // notBefore of certificate.
+
+function mockMLBF({ blocked = [], notblocked = [], generationTime }) {
+ // Mock _fetchMLBF to be able to have a deterministic cascade filter.
+ ExtensionBlocklistMLBF._fetchMLBF = async () => {
+ return {
+ cascadeFilter: {
+ has(blockKey) {
+ if (blocked.includes(blockKey)) {
+ return true;
+ }
+ if (notblocked.includes(blockKey)) {
+ return false;
+ }
+ throw new Error(`Block entry must explicitly be listed: ${blockKey}`);
+ },
+ },
+ generationTime,
+ };
+ };
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ mockMLBF({});
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [MLBF_RECORD],
+ });
+});
+
+// Checks: Initially unblocked, then blocked, then unblocked again.
+add_task(async function signed_xpi_initially_unblocked() {
+ mockMLBF({
+ blocked: [],
+ notblocked: [SIGNED_ADDON_KEY],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ const install = await promiseInstallFile(SIGNED_ADDON_XPI_FILE);
+ Assert.equal(install.error, 0, "Install should not have an error");
+
+ let addon = await promiseAddonByID(SIGNED_ADDON_ID);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ Assert.deepEqual(
+ await Blocklist.getAddonBlocklistEntry(addon),
+ {
+ state: Ci.nsIBlocklistService.STATE_BLOCKED,
+ url: "https://addons.mozilla.org/en-US/xpcshell/blocked-addon/webext_implicit_id@tests.mozilla.org/1.0/",
+ },
+ "Blocked addon should have blocked entry"
+ );
+
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ // MLBF generationTime is older, so "blocked" entry should not apply.
+ generationTime: SIGNED_ADDON_SIGN_TIME - 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await addon.uninstall();
+});
+
+// Checks: Initially blocked on install, then unblocked.
+add_task(async function signed_xpi_blocked_on_install() {
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ const install = await promiseInstallFile(SIGNED_ADDON_XPI_FILE);
+ Assert.equal(
+ install.error,
+ AddonManager.ERROR_BLOCKLISTED,
+ "Install should have an error"
+ );
+
+ let addon = await promiseAddonByID(SIGNED_ADDON_ID);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ Assert.ok(addon.appDisabled, "Blocked add-on is disabled on install");
+
+ mockMLBF({
+ blocked: [],
+ notblocked: [SIGNED_ADDON_KEY],
+ generationTime: SIGNED_ADDON_SIGN_TIME - 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.ok(!addon.appDisabled, "Re-enabled after unblock");
+
+ await addon.uninstall();
+});
+
+// An unsigned add-on cannot be blocked.
+add_task(async function unsigned_not_blocked() {
+ const UNSIGNED_ADDON_ID = "not-signed@tests.mozilla.org";
+ const UNSIGNED_ADDON_VERSION = "1.0";
+ const UNSIGNED_ADDON_KEY = `${UNSIGNED_ADDON_ID}:${UNSIGNED_ADDON_VERSION}`;
+ mockMLBF({
+ blocked: [UNSIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ let unsignedAddonFile = createTempWebExtensionFile({
+ manifest: {
+ version: UNSIGNED_ADDON_VERSION,
+ browser_specific_settings: { gecko: { id: UNSIGNED_ADDON_ID } },
+ },
+ });
+
+ // Unsigned add-ons can generally only be loaded as a temporary install.
+ let [addon] = await Promise.all([
+ AddonManager.installTemporaryAddon(unsignedAddonFile),
+ promiseWebExtensionStartup(UNSIGNED_ADDON_ID),
+ ]);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+ Assert.equal(addon.signedDate, null);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(addon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Unsigned temporary add-on is not blocked"
+ );
+ await addon.uninstall();
+});
+
+// To make sure that unsigned_not_blocked did not trivially pass, we also check
+// that add-ons can actually be blocked when installed as a temporary add-on.
+add_task(async function signed_temporary() {
+ mockMLBF({
+ blocked: [SIGNED_ADDON_KEY],
+ notblocked: [],
+ generationTime: SIGNED_ADDON_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await Assert.rejects(
+ AddonManager.installTemporaryAddon(SIGNED_ADDON_XPI_FILE),
+ /Add-on webext_implicit_id@tests.mozilla.org is not compatible with application version/,
+ "Blocklisted add-on cannot be installed"
+ );
+});
+
+// A privileged add-on cannot be blocked by the MLBF.
+// It can still be blocked by a stash, which is tested in
+// privileged_addon_blocked_by_stash in test_blocklist_mlbf_stashes.js.
+add_task(async function privileged_xpi_not_blocked() {
+ mockMLBF({
+ blocked: ["test@tests.mozilla.org:2.0"],
+ notblocked: [],
+ generationTime: 1546297200000, // 1 jan 2019 = after the cert's notBefore
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ const install = await promiseInstallFile(
+ do_get_file("../data/signing_checks/privileged.xpi")
+ );
+ Assert.equal(install.error, 0, "Install should not have an error");
+
+ let addon = await promiseAddonByID("test@tests.mozilla.org");
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_PRIVILEGED);
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ await addon.uninstall();
+});
+
+// Langpacks cannot be blocked via the MLBF on Nightly.
+// It can still be blocked by a stash, which is tested in
+// langpack_blocked_by_stash in test_blocklist_mlbf_stashes.js.
+add_task(
+ // We do not support langpacks on Android.
+ { skip_if: () => AppConstants.platform == "android" },
+ async function langpack_not_blocked_on_Nightly() {
+ mockMLBF({
+ blocked: ["langpack-klingon@firefox.mozilla.org:1.0"],
+ notblocked: [],
+ generationTime: 1546297200000, // 1 jan 2019 = after the cert's notBefore
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ await promiseInstallFile(
+ do_get_file("../data/signing_checks/langpack_signed.xpi")
+ );
+ let addon = await promiseAddonByID("langpack-klingon@firefox.mozilla.org");
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+ if (AppConstants.NIGHTLY_BUILD) {
+ // Langpacks built for Nightly are currently signed by releng and not
+ // submitted to AMO, so we have to ignore the blocks of the MLBF.
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Langpacks cannot be blocked via the MLBF"
+ );
+ } else {
+ // On non-Nightly, langpacks are submitted through AMO so we will enforce
+ // the MLBF blocklist for them.
+ Assert.equal(addon.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+ }
+ await addon.uninstall();
+ }
+);
+
+// Checks: Signed sitepermission addon, initially blocked on install, then unblocked.
+add_task(
+ // We do not support this add-on type on Android.
+ { skip_if: () => AppConstants.platform == "android" },
+ async function signed_sitepermission_xpi_blocked_on_install() {
+ mockMLBF({
+ blocked: [SIGNED_SITEPERM_KEY],
+ notblocked: [],
+ generationTime: SIGNED_SITEPERM_SIGN_TIME + 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+
+ const install = await promiseInstallFile(SIGNED_SITEPERM_XPI_FILE);
+ Assert.equal(
+ install.error,
+ AddonManager.ERROR_BLOCKLISTED,
+ "Install should have an error"
+ );
+
+ let addon = await promiseAddonByID(SIGNED_SITEPERM_ADDON_ID);
+ // NOTE: if this assertion fails, then SIGNED_SITEPERM_SIGN_TIME has to be
+ // updated accordingly otherwise the addon would not be blocked on install
+ // as this test expects (using the value got from `addon.signedDate.getTime()`)
+ equal(
+ addon.signedDate?.getTime(),
+ SIGNED_SITEPERM_SIGN_TIME,
+ "The addon xpi has the expected signedDate timestamp"
+ );
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Got the expected STATE_BLOCKED blocklistState"
+ );
+ Assert.ok(addon.appDisabled, "Blocked add-on is disabled on install");
+
+ mockMLBF({
+ blocked: [],
+ notblocked: [SIGNED_SITEPERM_KEY],
+ generationTime: SIGNED_SITEPERM_SIGN_TIME - 1,
+ });
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Got the expected STATE_NOT_BLOCKED blocklistState"
+ );
+ Assert.ok(!addon.appDisabled, "Re-enabled after unblock");
+
+ await addon.uninstall();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js
new file mode 100644
index 0000000000..5ac4dc965b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_dump.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * @fileOverview Verifies that the MLBF dump of the addons blocklist is
+ * correctly registered.
+ */
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+// A known blocked version from bug 1626602.
+const blockedAddon = {
+ id: "{6f62927a-e380-401a-8c9e-c485b7d87f0d}",
+ version: "9.2.0",
+ // The following date is the date of the first checked in MLBF. Any MLBF
+ // generated in the future should be generated after this date, to be useful.
+ signedDate: new Date(1588098908496), // 2020-04-28 (dummy date)
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+// A known add-on that is not blocked, as of writing. It is likely not going
+// to be blocked because it does not have any executable code.
+const nonBlockedAddon = {
+ id: "disable-ctrl-q-and-cmd-q@robwu.nl",
+ version: "1",
+ signedDate: new Date(1482430349000), // 2016-12-22 (actual signing time).
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+};
+
+async function sha256(arrayBuffer) {
+ let hash = await crypto.subtle.digest("SHA-256", arrayBuffer);
+ const toHex = b => b.toString(16).padStart(2, "0");
+ return Array.from(new Uint8Array(hash), toHex).join("");
+}
+
+// A list of { inputRecord, downloadPromise }:
+// - inputRecord is the record that was used for looking up the MLBF.
+// - downloadPromise is the result of trying to download it.
+const observed = [];
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ ExtensionBlocklistMLBF.ensureInitialized();
+
+ // Tapping into the internals of ExtensionBlocklistMLBF._fetchMLBF to observe
+ // MLBF request details.
+
+ // Despite being called "download", this does not actually access the network
+ // when there is a valid dump.
+ const originalImpl = ExtensionBlocklistMLBF._client.attachments.download;
+ ExtensionBlocklistMLBF._client.attachments.download = function (record) {
+ let downloadPromise = originalImpl.apply(this, arguments);
+ observed.push({ inputRecord: record, downloadPromise });
+ return downloadPromise;
+ };
+
+ await promiseStartupManager();
+});
+
+async function verifyBlocklistWorksWithDump() {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "A add-on that is known to be on the blocklist should be blocked"
+ );
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(nonBlockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "A known non-blocked add-on should not be blocked"
+ );
+}
+
+add_task(async function verify_dump_first_run() {
+ await verifyBlocklistWorksWithDump();
+ Assert.equal(observed.length, 1, "expected number of MLBF download requests");
+
+ const { inputRecord, downloadPromise } = observed.pop();
+
+ Assert.ok(inputRecord, "addons-bloomfilters collection dump exists");
+
+ const downloadResult = await downloadPromise;
+
+ // Verify that the "download" result really originates from the local dump.
+ // "dump_match" means that the record exists in the collection and that an
+ // attachment was found.
+ //
+ // If this fails:
+ // - "dump_fallback" means that the MLBF attachment is out of sync with the
+ // collection data.
+ // - undefined could mean that the implementation of Attachments.sys.mjs changed.
+ Assert.equal(
+ downloadResult._source,
+ "dump_match",
+ "MLBF attachment should match the RemoteSettings collection"
+ );
+
+ Assert.equal(
+ await sha256(downloadResult.buffer),
+ inputRecord.attachment.hash,
+ "The content of the attachment should actually matches the record"
+ );
+});
+
+add_task(async function use_dump_fallback_when_collection_is_out_of_sync() {
+ await AddonTestUtils.loadBlocklistRawData({
+ // last_modified higher than any value in addons-bloomfilters.json.
+ extensionsMLBF: [{ last_modified: Date.now() }],
+ });
+ Assert.equal(observed.length, 1, "Expected new download on update");
+
+ const { inputRecord, downloadPromise } = observed.pop();
+ Assert.equal(inputRecord, null, "No MLBF record found");
+
+ const downloadResult = await downloadPromise;
+ Assert.equal(
+ downloadResult._source,
+ "dump_fallback",
+ "should have used fallback despite the absence of a MLBF record"
+ );
+
+ await verifyBlocklistWorksWithDump();
+ Assert.equal(observed.length, 0, "Blocklist uses cached result");
+});
+
+// Verifies that the dump would supersede local data. This can happen after an
+// application upgrade, where the local database contains outdated records from
+// a previous application version.
+add_task(async function verify_dump_supersedes_old_dump() {
+ // Delete in-memory value; otherwise the cached record from the previous test
+ // task would be re-used and nothing would be downloaded.
+ delete ExtensionBlocklistMLBF._mlbfData;
+
+ await AddonTestUtils.loadBlocklistRawData({
+ // last_modified lower than any value in addons-bloomfilters.json.
+ extensionsMLBF: [{ last_modified: 1 }],
+ });
+ Assert.equal(observed.length, 1, "Expected new download on update");
+
+ const { inputRecord, downloadPromise } = observed.pop();
+ Assert.ok(inputRecord, "should have read from addons-bloomfilters dump");
+
+ const downloadResult = await downloadPromise;
+ Assert.equal(
+ downloadResult._source,
+ "dump_match",
+ "Should have replaced outdated collection records with dump"
+ );
+
+ await verifyBlocklistWorksWithDump();
+ Assert.equal(observed.length, 0, "Blocklist uses cached result");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js
new file mode 100644
index 0000000000..92bf61dbde
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_fetch.js
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * @fileOverview Tests the MLBF and RemoteSettings synchronization logic.
+ */
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+const { Downloader } = ChromeUtils.importESModule(
+ "resource://services-settings/Attachments.sys.mjs"
+);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+// This test needs to interact with the RemoteSettings client.
+ExtensionBlocklistMLBF.ensureInitialized();
+
+add_task(async function fetch_invalid_mlbf_record() {
+ let invalidRecord = {
+ attachment: { size: 1, hash: "definitely not valid" },
+ generation_time: 1,
+ };
+
+ // _fetchMLBF(invalidRecord) may succeed if there is a MLBF dump packaged with
+ // the application. This test intentionally hides the actual path to get
+ // deterministic results. To check whether the dump is correctly registered,
+ // run test_blocklist_mlbf_dump.js
+
+ // Forget about the packaged attachment.
+ Downloader._RESOURCE_BASE_URL = "invalid://bogus";
+ // NetworkError is expected here. The JSON.parse error could be triggered via
+ // _baseAttachmentsURL < downloadAsBytes < download < download < _fetchMLBF if
+ // the request to services.settings.server ("data:,#remote-settings-dummy/v1")
+ // is fulfilled (but with invalid JSON). That request is not expected to be
+ // fulfilled in the first place, but that is not a concern of this test.
+ // This test passes if _fetchMLBF() rejects when given an invalid record.
+ await Assert.rejects(
+ ExtensionBlocklistMLBF._fetchMLBF(invalidRecord),
+ /NetworkError|SyntaxError: JSON\.parse/,
+ "record not found when there is no packaged MLBF"
+ );
+});
+
+// Other tests can mock _testMLBF, so let's verify that it works as expected.
+add_task(async function fetch_valid_mlbf() {
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ { record: MLBF_RECORD, blob: await load_mlbf_record_as_blob() }
+ );
+
+ const result = await ExtensionBlocklistMLBF._fetchMLBF(MLBF_RECORD);
+ Assert.equal(result.cascadeHash, MLBF_RECORD.attachment.hash, "hash OK");
+ Assert.equal(result.generationTime, MLBF_RECORD.generation_time, "time OK");
+ Assert.ok(result.cascadeFilter.has("@blocked:1"), "item blocked");
+ Assert.ok(!result.cascadeFilter.has("@unblocked:2"), "item not blocked");
+
+ const result2 = await ExtensionBlocklistMLBF._fetchMLBF({
+ attachment: { size: 1, hash: "invalid" },
+ generation_time: Date.now(),
+ });
+ Assert.equal(
+ result2.cascadeHash,
+ MLBF_RECORD.attachment.hash,
+ "The cached MLBF should be used when the attachment is invalid"
+ );
+
+ // The attachment is kept in the database for use by the next test task.
+});
+
+// Test that results of the public API are consistent with the MLBF file.
+add_task(async function public_api_uses_mlbf() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ const blockedAddon = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(0), // a date in the past, before MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+ const nonBlockedAddon = {
+ id: "@unblocked",
+ version: "2",
+ signedDate: new Date(0), // a date in the past, before MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [MLBF_RECORD] });
+
+ Assert.deepEqual(
+ await Blocklist.getAddonBlocklistEntry(blockedAddon),
+ {
+ state: Ci.nsIBlocklistService.STATE_BLOCKED,
+ url: "https://addons.mozilla.org/en-US/xpcshell/blocked-addon/@blocked/1/",
+ },
+ "Blocked addon should have blocked entry"
+ );
+
+ Assert.deepEqual(
+ await Blocklist.getAddonBlocklistEntry(nonBlockedAddon),
+ null,
+ "Non-blocked addon should not be blocked"
+ );
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Blocked entry should have blocked state"
+ );
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(nonBlockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Non-blocked entry should have unblocked state"
+ );
+
+ // Note: Blocklist collection and attachment carries over to the next test.
+});
+
+// Verifies that the metadata (time of validity) of an updated MLBF record is
+// correctly used, even if the MLBF itself has not changed.
+add_task(async function fetch_updated_mlbf_same_hash() {
+ const recordUpdate = {
+ ...MLBF_RECORD,
+ generation_time: MLBF_RECORD.generation_time + 1,
+ };
+ const blockedAddonUpdate = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(recordUpdate.generation_time),
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+
+ // The blocklist already includes "@blocked:1", but the last specified
+ // generation time is MLBF_RECORD.generation_time. So the addon cannot be
+ // blocked, because the block decision could be a false positive.
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddonUpdate),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Add-on not blocked before blocklist update"
+ );
+
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [recordUpdate] });
+ // The MLBF is now known to apply to |blockedAddonUpdate|.
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddonUpdate),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Add-on blocked after update"
+ );
+
+ // Note: Blocklist collection and attachment carries over to the next test.
+});
+
+// Checks the remaining cases of database corruption that haven't been handled
+// before.
+add_task(async function handle_database_corruption() {
+ const blockedAddon = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(0), // a date in the past, before MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+ async function checkBlocklistWorks() {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Add-on should be blocked by the blocklist"
+ );
+ }
+
+ let fetchCount = 0;
+ const originalFetchMLBF = ExtensionBlocklistMLBF._fetchMLBF;
+ ExtensionBlocklistMLBF._fetchMLBF = function () {
+ ++fetchCount;
+ return originalFetchMLBF.apply(this, arguments);
+ };
+
+ // In the fetch_invalid_mlbf_record we checked that a cached / packaged MLBF
+ // attachment is used as a fallback when the record is invalid. Here we also
+ // check that there is a fallback when there is no record at all.
+
+ // Include a dummy record in the list, to prevent RemoteSettings from
+ // importing a JSON dump with unexpected records.
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [{}] });
+ Assert.equal(fetchCount, 1, "MLBF read once despite bad record");
+ // When the collection is empty, the last known MLBF should be used anyway.
+ await checkBlocklistWorks();
+ Assert.equal(fetchCount, 1, "MLBF not read again by blocklist query");
+
+ // Now we also remove the cached file...
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ null
+ );
+ Assert.equal(fetchCount, 1, "MLBF not read again after attachment deletion");
+ // Deleting the file shouldn't cause issues because the MLBF is loaded once
+ // and then kept in memory.
+ await checkBlocklistWorks();
+ Assert.equal(fetchCount, 1, "MLBF not read again by blocklist query 2");
+
+ // Force an update while we don't have any blocklist data nor cache.
+ await ExtensionBlocklistMLBF._onUpdate();
+ Assert.equal(fetchCount, 2, "MLBF read again at forced update");
+ // As a fallback, continue to use the in-memory version of the blocklist.
+ await checkBlocklistWorks();
+ Assert.equal(fetchCount, 2, "MLBF not read again by blocklist query 3");
+
+ // Memory gone, e.g. after a browser restart.
+ delete ExtensionBlocklistMLBF._mlbfData;
+ delete ExtensionBlocklistMLBF._stashes;
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Blocklist can't work if all blocklist data is gone"
+ );
+ Assert.equal(fetchCount, 3, "MLBF read again after restart/cleared cache");
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(blockedAddon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Blocklist can still not work if all blocklist data is gone"
+ );
+ // Ideally, the client packages a dump. But if the client did not package the
+ // dump, then it should not be trying to read the data over and over again.
+ Assert.equal(fetchCount, 3, "MLBF not read again despite absence of MLBF");
+
+ ExtensionBlocklistMLBF._fetchMLBF = originalFetchMLBF;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js
new file mode 100644
index 0000000000..e129efc793
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_stashes.js
@@ -0,0 +1,219 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+const MLBF_LOAD_ATTEMPTS = [];
+ExtensionBlocklistMLBF._fetchMLBF = async record => {
+ MLBF_LOAD_ATTEMPTS.push(record);
+ return {
+ generationTime: 0,
+ cascadeFilter: {
+ has(blockKey) {
+ if (blockKey === "@onlyblockedbymlbf:1") {
+ return true;
+ }
+ throw new Error("bloom filter should not be used in this test");
+ },
+ },
+ };
+};
+
+async function checkBlockState(addonId, version, expectBlocked) {
+ let addon = {
+ id: addonId,
+ version,
+ // Note: signedDate is missing, so the MLBF does not apply
+ // and we will effectively only test stashing.
+ };
+ let state = await Blocklist.getAddonBlocklistState(addon);
+ if (expectBlocked) {
+ Assert.equal(state, Ci.nsIBlocklistService.STATE_BLOCKED);
+ } else {
+ Assert.equal(state, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ }
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+});
+
+// Tests that add-ons can be blocked / unblocked via the stash.
+add_task(async function basic_stash() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {
+ stash_time: 0,
+ stash: {
+ blocked: ["@blocked:1"],
+ unblocked: ["@notblocked:2"],
+ },
+ },
+ ],
+ });
+ await checkBlockState("@blocked", "1", true);
+ await checkBlockState("@notblocked", "2", false);
+ // Not in stash (but unsigned, so shouldn't reach MLBF):
+ await checkBlockState("@blocked", "2", false);
+
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState({
+ id: "@onlyblockedbymlbf",
+ version: "1",
+ signedDate: new Date(0), // = the MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ }),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "falls through to MLBF if entry is not found in stash"
+ );
+
+ Assert.deepEqual(MLBF_LOAD_ATTEMPTS, [null], "MLBF attachment not found");
+});
+
+// To complement the privileged_xpi_not_blocked in test_blocklist_mlbf.js,
+// verify that privileged add-ons can still be blocked through stashes.
+add_task(async function privileged_addon_blocked_by_stash() {
+ const system_addon = {
+ id: "@blocked",
+ version: "1",
+ signedDate: new Date(0), // = the MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ };
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(system_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Privileged add-ons can still be blocked by a stash"
+ );
+
+ system_addon.signedState = AddonManager.SIGNEDSTATE_SYSTEM;
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(system_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Privileged system add-ons can still be blocked by a stash"
+ );
+
+ // For comparison, when an add-on is only blocked by a MLBF, the block
+ // decision is ignored.
+ system_addon.id = "@onlyblockedbymlbf";
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(system_addon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Privileged add-ons cannot be blocked via a MLBF"
+ );
+ // (note that we haven't checked that SIGNEDSTATE_PRIVILEGED is not blocked
+ // via the MLBF, but that is already covered by test_blocklist_mlbf.js ).
+});
+
+// To complement langpack_not_blocked_on_Nightly in test_blocklist_mlbf.js,
+// verify that langpacks can still be blocked through stashes.
+add_task(async function langpack_blocked_by_stash() {
+ const langpack_addon = {
+ id: "@blocked",
+ type: "locale",
+ version: "1",
+ signedDate: new Date(0), // = the MLBF's generationTime.
+ signedState: AddonManager.SIGNEDSTATE_SIGNED,
+ };
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(langpack_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Langpack add-ons can still be blocked by a stash"
+ );
+
+ // For comparison, when an add-on is only blocked by a MLBF, the block
+ // decision is ignored on Nightly (but blocked on non-Nightly).
+ langpack_addon.id = "@onlyblockedbymlbf";
+ if (AppConstants.NIGHTLY_BUILD) {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(langpack_addon),
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ "Langpack add-ons cannot be blocked via a MLBF on Nightly"
+ );
+ } else {
+ Assert.equal(
+ await Blocklist.getAddonBlocklistState(langpack_addon),
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Langpack add-ons can be blocked via a MLBF on non-Nightly"
+ );
+ }
+});
+
+// Tests that invalid stash entries are ignored.
+add_task(async function invalid_stashes() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ {},
+ { stash: null },
+ { stash: 1 },
+ { stash: {} },
+ { stash: { blocked: ["@broken:1", "@okid:1"] } },
+ { stash: { unblocked: ["@broken:2"] } },
+ // The only correct entry:
+ { stash: { blocked: ["@okid:2"], unblocked: ["@okid:1"] } },
+ { stash: { blocked: ["@broken:1", "@okid:1"] } },
+ { stash: { unblocked: ["@broken:2", "@okid:2"] } },
+ ],
+ });
+ // The valid stash entry should be applied:
+ await checkBlockState("@okid", "1", false);
+ await checkBlockState("@okid", "2", true);
+ // Entries from invalid stashes should be ignored:
+ await checkBlockState("@broken", "1", false);
+ await checkBlockState("@broken", "2", false);
+});
+
+// Blocklist stashes should be processed in the reverse chronological order.
+add_task(async function stash_time_order() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ // "@a:1" and "@a:2" are blocked at time 1, but unblocked later.
+ { stash_time: 2, stash: { blocked: [], unblocked: ["@a:1"] } },
+ { stash_time: 1, stash: { blocked: ["@a:1", "@a:2"], unblocked: [] } },
+ { stash_time: 3, stash: { blocked: [], unblocked: ["@a:2"] } },
+
+ // "@b:1" and "@b:2" are unblocked at time 4, but blocked later.
+ { stash_time: 5, stash: { blocked: ["@b:1"], unblocked: [] } },
+ { stash_time: 4, stash: { blocked: [], unblocked: ["@b:1", "@b:2"] } },
+ { stash_time: 6, stash: { blocked: ["@b:2"], unblocked: [] } },
+ ],
+ });
+ await checkBlockState("@a", "1", false);
+ await checkBlockState("@a", "2", false);
+
+ await checkBlockState("@b", "1", true);
+ await checkBlockState("@b", "2", true);
+});
+
+// Attachments with unsupported attachment_type should be ignored.
+add_task(async function mlbf_bloomfilter_full_ignored() {
+ MLBF_LOAD_ATTEMPTS.length = 0;
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [{ attachment_type: "bloomfilter-full", attachment: {} }],
+ });
+
+ // Only bloomfilter-base records should be used.
+ // Since there are no such records, we shouldn't find anything.
+ Assert.deepEqual(MLBF_LOAD_ATTEMPTS, [null], "no matching MLBFs found");
+});
+
+// Tests that the most recent MLBF is downloaded.
+add_task(async function mlbf_generation_time_recent() {
+ MLBF_LOAD_ATTEMPTS.length = 0;
+ const records = [
+ { attachment_type: "bloomfilter-base", attachment: {}, generation_time: 2 },
+ { attachment_type: "bloomfilter-base", attachment: {}, generation_time: 3 },
+ { attachment_type: "bloomfilter-base", attachment: {}, generation_time: 1 },
+ ];
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: records });
+ Assert.equal(
+ MLBF_LOAD_ATTEMPTS[0].generation_time,
+ 3,
+ "expected to load most recent MLBF"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js
new file mode 100644
index 0000000000..963c6dc033
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_telemetry.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const { Downloader } = ChromeUtils.importESModule(
+ "resource://services-settings/Attachments.sys.mjs"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const OLDEST_STASH = { stash: { blocked: [], unblocked: [] }, stash_time: 2e6 };
+const NEWEST_STASH = { stash: { blocked: [], unblocked: [] }, stash_time: 5e6 };
+const RECORDS_WITH_STASHES_AND_MLBF = [MLBF_RECORD, OLDEST_STASH, NEWEST_STASH];
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+function assertTelemetryScalars(expectedScalars) {
+ // On Android, we only report to the Glean system telemetry system.
+ if (IS_ANDROID_BUILD) {
+ info(
+ `Skip assertions on collected samples for ${expectedScalars} on android builds`
+ );
+ return;
+ }
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+ for (const scalarName of Object.keys(expectedScalars || {})) {
+ equal(
+ scalars[scalarName],
+ expectedScalars[scalarName],
+ `Got the expected value for ${scalarName} scalar`
+ );
+ }
+}
+
+function toUTC(time) {
+ return new Date(time).toUTCString();
+}
+
+add_task(async function setup() {
+ if (!IS_ANDROID_BUILD) {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+ // FOG needs to be initialized in order for data to flow.
+ Services.fog.initializeFOG();
+ }
+ await TelemetryController.testSetup();
+ await promiseStartupManager();
+
+ // Disable the packaged record and attachment to make sure that the test
+ // will not fall back to the packaged attachments.
+ Downloader._RESOURCE_BASE_URL = "invalid://bogus";
+});
+
+add_task(async function test_initialization() {
+ Services.fog.testResetFOG();
+ ExtensionBlocklistMLBF.ensureInitialized();
+
+ Assert.equal(undefined, Glean.blocklist.mlbfSource.testGetValue());
+ Assert.equal(undefined, Glean.blocklist.mlbfGenerationTime.testGetValue());
+ Assert.equal(undefined, Glean.blocklist.mlbfStashTimeOldest.testGetValue());
+ Assert.equal(undefined, Glean.blocklist.mlbfStashTimeNewest.testGetValue());
+
+ assertTelemetryScalars({
+ // In other parts of this test, this value is not checked any more.
+ // test_blocklist_telemetry.js already checks lastModified_rs_addons_mlbf.
+ "blocklist.lastModified_rs_addons_mlbf": undefined,
+ "blocklist.mlbf_source": undefined,
+ "blocklist.mlbf_generation_time": undefined,
+ "blocklist.mlbf_stash_time_oldest": undefined,
+ "blocklist.mlbf_stash_time_newest": undefined,
+ });
+});
+
+// Test what happens if there is no blocklist data at all.
+add_task(async function test_without_mlbf() {
+ Services.fog.testResetFOG();
+ // Add one (invalid) value to the blocklist, to prevent the RemoteSettings
+ // client from importing the JSON dump (which could potentially cause the
+ // test to fail due to the unexpected imported records).
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [{}] });
+ Assert.equal("unknown", Glean.blocklist.mlbfSource.testGetValue());
+
+ Assert.equal(0, Glean.blocklist.mlbfGenerationTime.testGetValue().getTime());
+ Assert.equal(0, Glean.blocklist.mlbfStashTimeOldest.testGetValue().getTime());
+ Assert.equal(0, Glean.blocklist.mlbfStashTimeNewest.testGetValue().getTime());
+
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "unknown",
+ "blocklist.mlbf_generation_time": "Missing Date",
+ "blocklist.mlbf_stash_time_oldest": "Missing Date",
+ "blocklist.mlbf_stash_time_newest": "Missing Date",
+ });
+});
+
+// Test the telemetry that would be recorded in the common case.
+add_task(async function test_common_good_case_with_stashes() {
+ Services.fog.testResetFOG();
+ // The exact content of the attachment does not matter in this test, as long
+ // as the data is valid.
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ { record: MLBF_RECORD, blob: await load_mlbf_record_as_blob() }
+ );
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: RECORDS_WITH_STASHES_AND_MLBF,
+ });
+ Assert.equal("cache_match", Glean.blocklist.mlbfSource.testGetValue());
+ Assert.equal(
+ MLBF_RECORD.generation_time,
+ Glean.blocklist.mlbfGenerationTime.testGetValue().getTime()
+ );
+ Assert.equal(
+ OLDEST_STASH.stash_time,
+ Glean.blocklist.mlbfStashTimeOldest.testGetValue().getTime()
+ );
+ Assert.equal(
+ NEWEST_STASH.stash_time,
+ Glean.blocklist.mlbfStashTimeNewest.testGetValue().getTime()
+ );
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "cache_match",
+ "blocklist.mlbf_generation_time": toUTC(MLBF_RECORD.generation_time),
+ "blocklist.mlbf_stash_time_oldest": toUTC(OLDEST_STASH.stash_time),
+ "blocklist.mlbf_stash_time_newest": toUTC(NEWEST_STASH.stash_time),
+ });
+
+ // The records and cached attachment carries over to the next tests.
+});
+
+// Test what happens when there are no stashes in the collection itself.
+add_task(async function test_without_stashes() {
+ Services.fog.testResetFOG();
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF: [MLBF_RECORD] });
+
+ Assert.equal("cache_match", Glean.blocklist.mlbfSource.testGetValue());
+ Assert.equal(
+ MLBF_RECORD.generation_time,
+ Glean.blocklist.mlbfGenerationTime.testGetValue().getTime()
+ );
+
+ Assert.equal(0, Glean.blocklist.mlbfStashTimeOldest.testGetValue().getTime());
+ Assert.equal(0, Glean.blocklist.mlbfStashTimeNewest.testGetValue().getTime());
+
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "cache_match",
+ "blocklist.mlbf_generation_time": toUTC(MLBF_RECORD.generation_time),
+ "blocklist.mlbf_stash_time_oldest": "Missing Date",
+ "blocklist.mlbf_stash_time_newest": "Missing Date",
+ });
+});
+
+// Test what happens when the collection was inadvertently emptied,
+// but still with a cached mlbf from before.
+add_task(async function test_without_collection_but_cache() {
+ Services.fog.testResetFOG();
+ await AddonTestUtils.loadBlocklistRawData({
+ // Insert a dummy record with a value of last_modified which is higher than
+ // any value of last_modified in addons-bloomfilters.json, to prevent the
+ // blocklist implementation from automatically falling back to the packaged
+ // JSON dump.
+ extensionsMLBF: [{ last_modified: Date.now() }],
+ });
+ Assert.equal("cache_fallback", Glean.blocklist.mlbfSource.testGetValue());
+ Assert.equal(
+ MLBF_RECORD.generation_time,
+ Glean.blocklist.mlbfGenerationTime.testGetValue().getTime()
+ );
+
+ Assert.equal(0, Glean.blocklist.mlbfStashTimeOldest.testGetValue().getTime());
+ Assert.equal(0, Glean.blocklist.mlbfStashTimeNewest.testGetValue().getTime());
+
+ assertTelemetryScalars({
+ "blocklist.mlbf_source": "cache_fallback",
+ "blocklist.mlbf_generation_time": toUTC(MLBF_RECORD.generation_time),
+ "blocklist.mlbf_stash_time_oldest": "Missing Date",
+ "blocklist.mlbf_stash_time_newest": "Missing Date",
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js
new file mode 100644
index 0000000000..b98d6e345d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_mlbf_update.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * @fileOverview Checks that the MLBF updating logic works reasonably.
+ */
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+// This test needs to interact with the RemoteSettings client.
+ExtensionBlocklistMLBF.ensureInitialized();
+
+// Multiple internal calls to update should be coalesced and end up with the
+// MLBF attachment from the last update call.
+add_task(async function collapse_multiple_pending_update_requests() {
+ const observed = [];
+
+ // The first step of starting an update is to read from the RemoteSettings
+ // collection. When a non-forced update is requested while another update is
+ // pending, the non-forced update should return/await the previous call
+ // instead of starting a new read/fetch from the RemoteSettings collection.
+ // Add a spy to the RemoteSettings client, so we can verify that the number
+ // of RemoteSettings accesses matches with what we expect.
+ const originalClientGet = ExtensionBlocklistMLBF._client.get;
+ const spyClientGet = (tag, returnValue) => {
+ ExtensionBlocklistMLBF._client.get = async function () {
+ // Record the method call.
+ observed.push(tag);
+ // Clone a valid record and tag it so we can identify it below.
+ let dummyRecord = JSON.parse(JSON.stringify(MLBF_RECORD));
+ dummyRecord.tagged = tag;
+ return [dummyRecord];
+ };
+ };
+
+ // Another significant part of updating is fetching the MLBF attachment.
+ // Add a spy too, so we can check which attachment is being requested.
+ const originalFetchMLBF = ExtensionBlocklistMLBF._fetchMLBF;
+ ExtensionBlocklistMLBF._fetchMLBF = async function (record) {
+ observed.push(`fetchMLBF:${record.tagged}`);
+ throw new Error(`Deliberately ignoring call to MLBF:${record.tagged}`);
+ };
+
+ spyClientGet("initial"); // Very first call = read RS.
+ let update1 = ExtensionBlocklistMLBF._updateMLBF(false);
+ spyClientGet("unexpected update2"); // Non-forced update = reuse update1.
+ let update2 = ExtensionBlocklistMLBF._updateMLBF(false);
+ spyClientGet("forced1"); // forceUpdate=true = supersede previous update.
+ let forcedUpdate1 = ExtensionBlocklistMLBF._updateMLBF(true);
+ spyClientGet("forced2"); // forceUpdate=true = supersede previous update.
+ let forcedUpdate2 = ExtensionBlocklistMLBF._updateMLBF(true);
+
+ let res = await Promise.all([update1, update2, forcedUpdate1, forcedUpdate2]);
+
+ Assert.equal(observed.length, 4, "expected number of observed events");
+ Assert.equal(observed[0], "initial", "First update should request records");
+ Assert.equal(observed[1], "forced1", "Forced update supersedes initial");
+ Assert.equal(observed[2], "forced2", "Forced update supersedes forced1");
+ // We call the _updateMLBF methods immediately after each other. Every update
+ // request starts with an asynchronous operation (looking up the RS records),
+ // so the implementation should return early for all update requests except
+ // for the last one. So we should only observe a fetch for the last request.
+ Assert.equal(observed[3], "fetchMLBF:forced2", "expected fetch result");
+
+ // All update requests should end up with the same result.
+ Assert.equal(res[0], res[1], "update1 == update2");
+ Assert.equal(res[1], res[2], "update2 == forcedUpdate1");
+ Assert.equal(res[2], res[3], "forcedUpdate1 == forcedUpdate2");
+
+ ExtensionBlocklistMLBF._client.get = originalClientGet;
+ ExtensionBlocklistMLBF._fetchMLBF = originalFetchMLBF;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js
new file mode 100644
index 0000000000..243225c6e0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_osabi.js
@@ -0,0 +1,286 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// useMLBF=true only supports blocking by version+ID, not by OS/ABI.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = [
+ {
+ id: "test_bug393285_1@tests.mozilla.org",
+ name: "extension 1",
+ version: "1.0",
+
+ // No info in blocklist, shouldn't be blocked
+ notBlocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ },
+ {
+ id: "test_bug393285_2@tests.mozilla.org",
+ name: "extension 2",
+ version: "1.0",
+
+ // Should always be blocked
+ blocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ },
+ {
+ id: "test_bug393285_3a@tests.mozilla.org",
+ name: "extension 3a",
+ version: "1.0",
+
+ // Only version 1 should be blocked
+ blocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ },
+ {
+ id: "test_bug393285_3b@tests.mozilla.org",
+ name: "extension 3b",
+ version: "2.0",
+
+ // Only version 1 should be blocked
+ notBlocklisted: [["1", "1.9"]],
+ },
+ {
+ id: "test_bug393285_4@tests.mozilla.org",
+ name: "extension 4",
+ version: "1.0",
+
+ // Should be blocked for app version 1
+ blocklisted: [
+ ["1", "1.9"],
+ [null, null],
+ ],
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_5@tests.mozilla.org",
+ name: "extension 5",
+ version: "1.0",
+
+ // Not blocklisted because we are a different OS
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_6@tests.mozilla.org",
+ name: "extension 6",
+ version: "1.0",
+
+ // Blocklisted based on OS
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_7@tests.mozilla.org",
+ name: "extension 7",
+ version: "1.0",
+
+ // Blocklisted based on OS
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_8@tests.mozilla.org",
+ name: "extension 8",
+ version: "1.0",
+
+ // Not blocklisted because we are a different ABI
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_9@tests.mozilla.org",
+ name: "extension 9",
+ version: "1.0",
+
+ // Blocklisted based on ABI
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_10@tests.mozilla.org",
+ name: "extension 10",
+ version: "1.0",
+
+ // Blocklisted based on ABI
+ blocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_11@tests.mozilla.org",
+ name: "extension 11",
+ version: "1.0",
+
+ // Doesn't match both os and abi so not blocked
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_12@tests.mozilla.org",
+ name: "extension 12",
+ version: "1.0",
+
+ // Doesn't match both os and abi so not blocked
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_13@tests.mozilla.org",
+ name: "extension 13",
+ version: "1.0",
+
+ // Doesn't match both os and abi so not blocked
+ notBlocklisted: [["2", "1.9"]],
+ },
+ {
+ id: "test_bug393285_14@tests.mozilla.org",
+ name: "extension 14",
+ version: "1.0",
+
+ // Matches both os and abi so blocked
+ blocklisted: [["2", "1.9"]],
+ },
+];
+
+const ADDON_IDS = ADDONS.map(a => a.id);
+
+const BLOCKLIST_DATA = [
+ {
+ guid: "test_bug393285_2@tests.mozilla.org",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_3a@tests.mozilla.org",
+ versionRange: [{ maxVersion: "1.0", minVersion: "1.0" }],
+ },
+ {
+ guid: "test_bug393285_3b@tests.mozilla.org",
+ versionRange: [{ maxVersion: "1.0", minVersion: "1.0" }],
+ },
+ {
+ guid: "test_bug393285_4@tests.mozilla.org",
+ versionRange: [
+ {
+ maxVersion: "1.0",
+ minVersion: "1.0",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "1.0",
+ minVersion: "1.0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ guid: "test_bug393285_5@tests.mozilla.org",
+ os: "Darwin",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_6@tests.mozilla.org",
+ os: "XPCShell",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_7@tests.mozilla.org",
+ os: "Darwin,XPCShell,WINNT",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_8@tests.mozilla.org",
+ xpcomabi: "x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_9@tests.mozilla.org",
+ xpcomabi: "noarch-spidermonkey",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_10@tests.mozilla.org",
+ xpcomabi: "ppc-gcc3,noarch-spidermonkey,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_11@tests.mozilla.org",
+ os: "Darwin",
+ xpcomabi: "ppc-gcc3,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_12@tests.mozilla.org",
+ os: "Darwin",
+ xpcomabi: "ppc-gcc3,noarch-spidermonkey,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_13@tests.mozilla.org",
+ os: "XPCShell",
+ xpcomabi: "ppc-gcc3,x86-msvc",
+ versionRange: [],
+ },
+ {
+ guid: "test_bug393285_14@tests.mozilla.org",
+ os: "XPCShell,WINNT",
+ xpcomabi: "ppc-gcc3,x86-msvc,noarch-spidermonkey",
+ versionRange: [],
+ },
+];
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+
+ for (let addon of ADDONS) {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: addon.name,
+ version: addon.version,
+ browser_specific_settings: { gecko: { id: addon.id } },
+ },
+ });
+ }
+
+ let addons = await getAddons(ADDON_IDS);
+ for (let id of ADDON_IDS) {
+ equal(
+ addons.get(id).blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
+ `Add-on ${id} should not initially be blocked`
+ );
+ }
+});
+
+add_task(async function test_1() {
+ await AddonTestUtils.loadBlocklistRawData({ extensions: BLOCKLIST_DATA });
+
+ let addons = await getAddons(ADDON_IDS);
+ async function isBlocklisted(addon, appVer, toolkitVer) {
+ let state = await Blocklist.getAddonBlocklistState(
+ addon,
+ appVer,
+ toolkitVer
+ );
+ return state != Services.blocklist.STATE_NOT_BLOCKED;
+ }
+ for (let addon of ADDONS) {
+ let { id } = addon;
+ for (let blocklisted of addon.blocklisted || []) {
+ ok(
+ await isBlocklisted(addons.get(id), ...blocklisted),
+ `Add-on ${id} should be blocklisted in app/platform version ${blocklisted}`
+ );
+ }
+ for (let notBlocklisted of addon.notBlocklisted || []) {
+ ok(
+ !(await isBlocklisted(addons.get(id), ...notBlocklisted)),
+ `Add-on ${id} should not be blocklisted in app/platform version ${notBlocklisted}`
+ );
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js
new file mode 100644
index 0000000000..42eb1305c4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_prefs.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests resetting of preferences in blocklist entry when an add-on is blocked.
+// See bug 802434.
+
+// useMLBF=true only supports blocking, not resetting prefs, since extensions
+// cannot set arbitrary prefs any more after the removal of legacy addons.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = [
+ {
+ guid: "block1@tests.mozilla.org",
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ prefs: ["test.blocklist.pref1", "test.blocklist.pref2"],
+ },
+ {
+ guid: "block2@tests.mozilla.org",
+ versionRange: [
+ {
+ severity: "3",
+ targetApplication: [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+ prefs: ["test.blocklist.pref3", "test.blocklist.pref4"],
+ },
+];
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ await promiseStartupManager();
+
+ // Add 2 extensions
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Blocked add-on-1 with to-be-reset prefs",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "block1@tests.mozilla.org" } },
+ },
+ });
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Blocked add-on-2 with to-be-reset prefs",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "block2@tests.mozilla.org" } },
+ },
+ });
+
+ // Pre-set the preferences that we expect to get reset.
+ Services.prefs.setIntPref("test.blocklist.pref1", 15);
+ Services.prefs.setIntPref("test.blocklist.pref2", 15);
+ Services.prefs.setBoolPref("test.blocklist.pref3", true);
+ Services.prefs.setBoolPref("test.blocklist.pref4", true);
+
+ // Before blocklist is loaded.
+ let [a1, a2] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ ]);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ Assert.equal(Services.prefs.getIntPref("test.blocklist.pref1"), 15);
+ Assert.equal(Services.prefs.getIntPref("test.blocklist.pref2"), 15);
+ Assert.equal(Services.prefs.getBoolPref("test.blocklist.pref3"), true);
+ Assert.equal(Services.prefs.getBoolPref("test.blocklist.pref4"), true);
+});
+
+add_task(async function test_blocks() {
+ await AddonTestUtils.loadBlocklistRawData({ extensions: BLOCKLIST_DATA });
+
+ // Blocklist changes should have applied and the prefs must be reset.
+ let [a1, a2] = await AddonManager.getAddonsByIDs([
+ "block1@tests.mozilla.org",
+ "block2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.blocklistState, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.blocklistState, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ // All these prefs must be reset to defaults.
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref1"), false);
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref2"), false);
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref3"), false);
+ Assert.equal(Services.prefs.prefHasUserValue("test.blocklist.pref4"), false);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js
new file mode 100644
index 0000000000..f48a6b9d8b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_regexp_split.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// useMLBF=true only supports blocking by version+ID, not by regexp.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const BLOCKLIST_DATA = [
+ {
+ guid: "/^abcd.*/",
+ versionRange: [],
+ expectedType: "RegExp",
+ },
+ {
+ guid: "test@example.com",
+ versionRange: [],
+ expectedType: "string",
+ },
+ {
+ guid: "/^((a)|(b)|(c))$/",
+ versionRange: [],
+ expectedType: "Set",
+ },
+ {
+ guid: "/^((a@b)|(\\{6d9ddd6e-c6ee-46de-ab56-ce9080372b3\\})|(c@d.com))$/",
+ versionRange: [],
+ expectedType: "Set",
+ },
+ // The same as the above, but with escape sequences that disqualify it from
+ // being treated as a set (and a different guid)
+ {
+ guid: "/^((s@t)|(\\{6d9eee6e-c6ee-46de-ab56-ce9080372b3\\})|(c@d\\w.com))$/",
+ versionRange: [],
+ expectedType: "RegExp",
+ },
+ // Also the same, but with other magical regex characters.
+ // (and a different guid)
+ {
+ guid: "/^((u@v)|(\\{6d9fff6e*-c6ee-46de-ab56-ce9080372b3\\})|(c@dee?.com))$/",
+ versionRange: [],
+ expectedType: "RegExp",
+ },
+];
+
+/**
+ * Verify that both IDs being OR'd in a regex work,
+ * and that other regular expressions continue being
+ * used as regular expressions.
+ */
+add_task(async function test_check_matching_works() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+ await AddonTestUtils.loadBlocklistRawData({
+ extensions: BLOCKLIST_DATA,
+ });
+
+ const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ let parsedEntries = BlocklistPrivate.ExtensionBlocklistRS._entries;
+
+ // Unfortunately, the parsed entries aren't in the same order as the original.
+ function strForTypeOf(val) {
+ if (typeof val == "string") {
+ return "string";
+ }
+ if (val) {
+ return val.constructor.name;
+ }
+ return "other";
+ }
+ for (let type of ["Set", "RegExp", "string"]) {
+ let numberParsed = parsedEntries.filter(parsedEntry => {
+ return type == strForTypeOf(parsedEntry.matches.id);
+ }).length;
+ let expected = BLOCKLIST_DATA.filter(entry => {
+ return type == entry.expectedType;
+ }).length;
+ Assert.equal(
+ numberParsed,
+ expected,
+ type + " should have expected number of entries"
+ );
+ }
+ // Shouldn't block everything.
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({ id: "nonsense", version: "1" }))
+ );
+ // Should block IDs starting with abcd
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "abcde", version: "1" })
+ );
+ // Should block the literal string listed
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "test@example.com",
+ version: "1",
+ })
+ );
+ // Should block the IDs in (a)|(b)|(c)
+ Assert.ok(await Blocklist.getAddonBlocklistEntry({ id: "a", version: "1" }));
+ Assert.ok(await Blocklist.getAddonBlocklistEntry({ id: "b", version: "1" }));
+ Assert.ok(await Blocklist.getAddonBlocklistEntry({ id: "c", version: "1" }));
+ // Should block all the items processed to a set:
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "a@b", version: "1" })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "{6d9ddd6e-c6ee-46de-ab56-ce9080372b3}",
+ version: "1",
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "c@d.com", version: "1" })
+ );
+ // Should block items that remained a regex:
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "s@t", version: "1" })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "{6d9eee6e-c6ee-46de-ab56-ce9080372b3}",
+ version: "1",
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "c@dx.com", version: "1" })
+ );
+
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "u@v", version: "1" })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "{6d9fff6eeeeeeee-c6ee-46de-ab56-ce9080372b3}",
+ version: "1",
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({ id: "c@dee.com", version: "1" })
+ );
+});
+
+// We should be checking all properties, not just the first one we come across.
+add_task(async function check_all_properties() {
+ await AddonTestUtils.loadBlocklistRawData({
+ extensions: [
+ {
+ guid: "literal@string.com",
+ creator: "Foo",
+ versionRange: [],
+ },
+ {
+ guid: "/regex.*@regex\\.com/",
+ creator: "Foo",
+ versionRange: [],
+ },
+ {
+ guid: "/^((set@set\\.com)|(anotherset@set\\.com)|(reallyenoughsetsalready@set\\.com))$/",
+ creator: "Foo",
+ versionRange: [],
+ },
+ ],
+ });
+
+ let { Blocklist } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+ );
+ // Check 'wrong' creator doesn't match.
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "literal@string.com",
+ version: "1",
+ creator: { name: "Bar" },
+ }))
+ );
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "regexaaaaa@regex.com",
+ version: "1",
+ creator: { name: "Bar" },
+ }))
+ );
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "set@set.com",
+ version: "1",
+ creator: { name: "Bar" },
+ }))
+ );
+
+ // Check 'wrong' ID doesn't match.
+ Assert.ok(
+ !(await Blocklist.getAddonBlocklistEntry({
+ id: "someotherid@foo.com",
+ version: "1",
+ creator: { name: "Foo" },
+ }))
+ );
+
+ // Check items matching all filters do match
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "literal@string.com",
+ version: "1",
+ creator: { name: "Foo" },
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "regexaaaaa@regex.com",
+ version: "1",
+ creator: { name: "Foo" },
+ })
+ );
+ Assert.ok(
+ await Blocklist.getAddonBlocklistEntry({
+ id: "set@set.com",
+ version: "1",
+ creator: { name: "Foo" },
+ })
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js
new file mode 100644
index 0000000000..fffbb8a51e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_severities.js
@@ -0,0 +1,504 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// useMLBF=true only supports one type of severity (hard block). The value of
+// appDisabled in the extension blocklist is checked in test_blocklist_mlbf.js.
+enable_blocklist_v2_instead_of_useMLBF();
+
+const URI_EXTENSION_BLOCKLIST_DIALOG =
+ "chrome://mozapps/content/extensions/blocklist.xhtml";
+
+// Workaround for Bug 658720 - URL formatter can leak during xpcshell tests
+const PREF_BLOCKLIST_ITEM_URL = "extensions.blocklist.itemURL";
+Services.prefs.setCharPref(
+ PREF_BLOCKLIST_ITEM_URL,
+ "http://example.com/blocklist/%blockID%"
+);
+
+async function getAddonBlocklistURL(addon) {
+ let entry = await Blocklist.getAddonBlocklistEntry(addon);
+ return entry && entry.url;
+}
+
+var ADDONS = [
+ {
+ // Tests how the blocklist affects a disabled add-on
+ id: "test_bug455906_1@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 1",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an enabled add-on
+ id: "test_bug455906_2@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 2",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an enabled add-on, to be disabled by the notification
+ id: "test_bug455906_3@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 3",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects a disabled add-on that was already warned about
+ id: "test_bug455906_4@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 4",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an enabled add-on that was already warned about
+ id: "test_bug455906_5@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 5",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an already blocked add-on
+ id: "test_bug455906_6@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 6",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Tests how the blocklist affects an incompatible add-on
+ id: "test_bug455906_7@tests.mozilla.org",
+ name: "Bug 455906 Addon Test 7",
+ version: "5",
+ appVersion: "2",
+ },
+ {
+ // Spare add-on used to ensure we get a notification when switching lists
+ id: "dummy_bug455906_1@tests.mozilla.org",
+ name: "Dummy Addon 1",
+ version: "5",
+ appVersion: "3",
+ },
+ {
+ // Spare add-on used to ensure we get a notification when switching lists
+ id: "dummy_bug455906_2@tests.mozilla.org",
+ name: "Dummy Addon 2",
+ version: "5",
+ appVersion: "3",
+ },
+];
+
+var gNotificationCheck = null;
+
+// Don't need the full interface, attempts to call other methods will just
+// throw which is just fine
+var WindowWatcher = {
+ openWindow(parent, url, name, features, windowArguments) {
+ // Should be called to list the newly blocklisted items
+ equal(url, URI_EXTENSION_BLOCKLIST_DIALOG);
+
+ if (gNotificationCheck) {
+ gNotificationCheck(windowArguments.wrappedJSObject);
+ }
+
+ // run the code after the blocklist is closed
+ Services.obs.notifyObservers(null, "addon-blocklist-closed");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowWatcher"]),
+};
+
+MockRegistrar.register(
+ "@mozilla.org/embedcomp/window-watcher;1",
+ WindowWatcher
+);
+
+function createAddon(addon) {
+ return promiseInstallWebExtension({
+ manifest: {
+ name: addon.name,
+ version: addon.version,
+ browser_specific_settings: {
+ gecko: {
+ id: addon.id,
+ strict_min_version: addon.appVersion,
+ strict_max_version: addon.appVersion,
+ },
+ },
+ },
+ });
+}
+
+const BLOCKLIST_DATA = {
+ start: {
+ // Block 4-6 and a dummy:
+ extensions: [
+ {
+ guid: "test_bug455906_4@tests.mozilla.org",
+ versionRange: [{ severity: "-1" }],
+ },
+ {
+ guid: "test_bug455906_5@tests.mozilla.org",
+ versionRange: [{ severity: "1" }],
+ },
+ {
+ guid: "test_bug455906_6@tests.mozilla.org",
+ versionRange: [{ severity: "2" }],
+ },
+ {
+ guid: "dummy_bug455906_1@tests.mozilla.org",
+ versionRange: [],
+ },
+ ],
+ },
+ warn: {
+ // warn for all test add-ons:
+ extensions: ADDONS.filter(a => a.id.startsWith("test_")).map(a => ({
+ guid: a.id,
+ versionRange: [{ severity: "-1" }],
+ })),
+ },
+ block: {
+ // block all test add-ons:
+ extensions: ADDONS.filter(a => a.id.startsWith("test_")).map(a => ({
+ guid: a.id,
+ blockID: a.id,
+ versionRange: [],
+ })),
+ },
+ empty: {
+ // Block a dummy so there's a change:
+ extensions: [
+ {
+ guid: "dummy_bug455906_2@tests.mozilla.org",
+ versionRange: [],
+ },
+ ],
+ },
+};
+
+async function loadBlocklist(id, callback) {
+ gNotificationCheck = callback;
+
+ await AddonTestUtils.loadBlocklistRawData(BLOCKLIST_DATA[id]);
+}
+
+function create_blocklistURL(blockID) {
+ let url = Services.urlFormatter.formatURLPref(PREF_BLOCKLIST_ITEM_URL);
+ url = url.replace(/%blockID%/g, blockID);
+ return url;
+}
+
+// Before every main test this is the state the add-ons are meant to be in
+async function checkInitialState() {
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[3], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+}
+
+function checkAddonState(addon, state) {
+ return checkAddon(addon.id, addon, state);
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "3");
+
+ await promiseStartupManager();
+
+ // Load the initial blocklist into the profile to check add-ons start in the
+ // right state.
+ await AddonTestUtils.loadBlocklistRawData(BLOCKLIST_DATA.start);
+
+ for (let addon of ADDONS) {
+ await createAddon(addon);
+ }
+});
+
+add_task(async function test_1() {
+ // Tests the add-ons were installed and the initial blocklist applied as expected
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ for (var i = 0; i < ADDONS.length; i++) {
+ ok(addons[i], `Addon ${i + 1} should be installed correctly`);
+ }
+
+ checkAddonState(addons[0], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // Warn add-ons should be soft disabled automatically
+ checkAddonState(addons[3], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+
+ // Blocked and incompatible should be app disabled only
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Put the add-ons into the base state
+ await addons[0].disable();
+ await addons[4].enable();
+
+ await promiseRestartManager();
+ await checkInitialState();
+
+ await loadBlocklist("warn", args => {
+ dump("Checking notification pt 2\n");
+ // This test is artificial, we don't notify for add-ons anymore, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1257565#c111 . Cleaning this up
+ // should happen but this patchset is too huge as it is so I'm deferring it.
+ equal(args.list.length, 2);
+ });
+
+ await promiseRestartManager();
+ dump("Checking results pt 2\n");
+
+ addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ info("Should have disabled this add-on as requested");
+ checkAddonState(addons[2], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+
+ info("The blocked add-on should have changed to soft disabled");
+ checkAddonState(addons[5], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: true,
+ });
+
+ info("These should have been unchanged");
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ // XXXgijs this is supposed to be not user disabled or soft disabled, but because we don't show
+ // the dialog, it's disabled anyway. Comment out this assertion for now...
+ // checkAddonState(addons[1], {userDisabled: false, softDisabled: false, appDisabled: false});
+ checkAddonState(addons[3], {
+ userDisabled: true,
+ softDisabled: true,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // Back to starting state
+ await addons[2].enable();
+ await addons[5].enable();
+
+ await promiseRestartManager();
+ await loadBlocklist("start");
+});
+
+add_task(async function test_pt3() {
+ await promiseRestartManager();
+ await checkInitialState();
+
+ await loadBlocklist("block", args => {
+ dump("Checking notification pt 3\n");
+ equal(args.list.length, 3);
+ });
+
+ await promiseRestartManager();
+ dump("Checking results pt 3\n");
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+
+ // All should have gained the blocklist state, user disabled as previously
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Should have gained the blocklist state but no longer be soft disabled
+ checkAddonState(addons[3], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Check blockIDs are correct
+ equal(
+ await getAddonBlocklistURL(addons[0]),
+ create_blocklistURL(addons[0].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[1]),
+ create_blocklistURL(addons[1].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[2]),
+ create_blocklistURL(addons[2].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[3]),
+ create_blocklistURL(addons[3].id)
+ );
+ equal(
+ await getAddonBlocklistURL(addons[4]),
+ create_blocklistURL(addons[4].id)
+ );
+
+ // Shouldn't be changed
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+
+ // Back to starting state
+ await loadBlocklist("start");
+});
+
+add_task(async function test_pt4() {
+ let addon = await AddonManager.getAddonByID(ADDONS[4].id);
+ await addon.enable();
+
+ await promiseRestartManager();
+ await checkInitialState();
+
+ await loadBlocklist("empty", args => {
+ dump("Checking notification pt 4\n");
+ // See note in other callback - we no longer notify for non-blocked add-ons.
+ ok(false, "Should not get a notification as there are no blocked addons.");
+ });
+
+ await promiseRestartManager();
+ dump("Checking results pt 4\n");
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ // This should have become unblocked
+ checkAddonState(addons[5], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // Should get re-enabled
+ checkAddonState(addons[3], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+
+ // No change for anything else
+ checkAddonState(addons[0], {
+ userDisabled: true,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[1], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[2], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[4], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: false,
+ });
+ checkAddonState(addons[6], {
+ userDisabled: false,
+ softDisabled: false,
+ appDisabled: true,
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js
new file mode 100644
index 0000000000..6d0f89ff8c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_statechange_telemetry.js
@@ -0,0 +1,411 @@
+// Verifies that changes to blocklistState are correctly reported to telemetry.
+
+"use strict";
+
+Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);
+
+// Set min version to 42 because the updater defaults to min version 42.
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+// Use unprivileged signatures because the MLBF-based blocklist does not
+// apply to add-ons with a privileged signature.
+AddonTestUtils.usePrivilegedSignatures = false;
+
+const { Downloader } = ChromeUtils.importESModule(
+ "resource://services-settings/Attachments.sys.mjs"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();
+
+const EXT_ID = "maybeblockme@tests.mozilla.org";
+
+// The addon blocked by the bloom filter (referenced by MLBF_RECORD).
+const EXT_BLOCKED_ID = "@blocked";
+const EXT_BLOCKED_VERSION = "1";
+const EXT_BLOCKED_SIGN_TIME = 12345; // Before MLBF_RECORD.generation_time.
+
+// To serve updates.
+const server = AddonTestUtils.createHttpServer();
+const SERVER_BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`;
+const SERVER_UPDATE_PATH = "/update.json";
+const SERVER_UPDATE_URL = `${SERVER_BASE_URL}${SERVER_UPDATE_PATH}`;
+// update is served via `server` over insecure http.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+async function assertEventDetails(expectedExtras) {
+ if (!IS_ANDROID_BUILD) {
+ const expectedEvents = expectedExtras.map(expectedExtra => {
+ let { object, value, ...extra } = expectedExtra;
+ return ["blocklist", "addonBlockChange", object, value, extra];
+ });
+ await TelemetryTestUtils.assertEvents(expectedEvents, {
+ category: "blocklist",
+ method: "addonBlockChange",
+ });
+ } else {
+ info(
+ `Skip assertions on collected samples for addonBlockChange on android builds`
+ );
+ }
+ assertGleanEventDetails(expectedExtras);
+}
+async function assertGleanEventDetails(expectedExtras) {
+ const snapshot = Glean.blocklist.addonBlockChange.testGetValue();
+ if (expectedExtras.length === 0) {
+ Assert.deepEqual(undefined, snapshot, "Expected zero addonBlockChange");
+ return;
+ }
+ Assert.equal(
+ expectedExtras.length,
+ snapshot?.length,
+ "Number of addonBlockChange records"
+ );
+ for (let i of expectedExtras.keys()) {
+ let actual = snapshot[i].extra;
+ // Glean uses snake_case instead of camelCase.
+ let { blocklistState, ...expected } = expectedExtras[i];
+ expected.blocklist_state = blocklistState;
+ Assert.deepEqual(expected, actual, `Expected addonBlockChange (${i})`);
+ }
+}
+
+// Stage an update on the update server. The add-on must have been created
+// with update_url set to SERVER_UPDATE_URL.
+function setupAddonUpdate(addonId, addonVersion) {
+ let updateXpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: addonVersion,
+ browser_specific_settings: {
+ gecko: { id: addonId, update_url: SERVER_UPDATE_URL },
+ },
+ },
+ });
+ let updateXpiPath = `/update-${addonId}-${addonVersion}.xpi`;
+ server.registerFile(updateXpiPath, updateXpi);
+ AddonTestUtils.registerJSON(server, SERVER_UPDATE_PATH, {
+ addons: {
+ [addonId]: {
+ updates: [
+ {
+ version: addonVersion,
+ update_link: `${SERVER_BASE_URL}${updateXpiPath}`,
+ },
+ ],
+ },
+ },
+ });
+}
+
+async function tryAddonInstall(addonId, addonVersion) {
+ let xpiFile = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: addonVersion,
+ browser_specific_settings: {
+ gecko: { id: addonId, update_url: SERVER_UPDATE_URL },
+ },
+ },
+ });
+ const install = await promiseInstallFile(xpiFile, true);
+ // Passing true to promiseInstallFile means that the xpi may not be installed
+ // if blocked by the blocklist. In that case, |install| may be null.
+ return install?.addon;
+}
+
+add_task(async function setup() {
+ if (!IS_ANDROID_BUILD) {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+ // FOG needs to be initialized in order for data to flow.
+ Services.fog.initializeFOG();
+ }
+ await TelemetryController.testSetup();
+
+ // Disable the packaged record and attachment to make sure that the test
+ // will not fall back to the packaged attachments.
+ Downloader._RESOURCE_BASE_URL = "invalid://bogus";
+
+ await promiseStartupManager();
+});
+
+add_task(async function install_update_not_blocked_is_no_events() {
+ Services.fog.testResetFOG();
+ // Install an add-on that is not blocked. Then update to the next version.
+ let addon = await tryAddonInstall(EXT_ID, "0.1");
+
+ // Version "1" not blocked yet, but will be in the next test task.
+ setupAddonUpdate(EXT_ID, "1");
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ await promiseCompleteInstall(update.updateAvailable);
+ addon = await AddonManager.getAddonByID(EXT_ID);
+ equal(addon.version, "1", "Add-on was updated");
+
+ await assertEventDetails([]);
+});
+
+add_task(async function blocklist_update_events() {
+ Services.fog.testResetFOG();
+ const EXT_HOURS_SINCE_INSTALL = 4321;
+ const addon = await AddonManager.getAddonByID(EXT_ID);
+ addon.__AddonInternal__.installDate =
+ addon.installDate.getTime() - 3600000 * EXT_HOURS_SINCE_INSTALL;
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [
+ { stash: { blocked: [`${EXT_ID}:1`], unblocked: [] }, stash_time: 123 },
+ { stash: { blocked: [`${EXT_ID}:2`], unblocked: [] }, stash_time: 456 },
+ ],
+ });
+
+ await assertEventDetails([
+ {
+ object: "blocklist_update",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "1",
+ signed_date: "0",
+ hours_since: `${EXT_HOURS_SINCE_INSTALL}`,
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+add_task(async function update_check_blocked_by_stash() {
+ Services.fog.testResetFOG();
+ setupAddonUpdate(EXT_ID, "2");
+ let addon = await AddonManager.getAddonByID(EXT_ID);
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ // Blocks in stashes are immediately enforced by update checks.
+ // Blocks stored in MLBFs are only enforced after the package is downloaded,
+ // and that scenario is covered by update_check_blocked_by_stash elsewhere.
+ equal(update.updateAvailable, false, "Update was blocked by stash");
+
+ await assertEventDetails([
+ {
+ object: "addon_update_check",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "2",
+ signed_date: "0",
+ hours_since: "-1",
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+// Any attempt to re-install a blocked add-on should trigger a telemetry
+// event, even though the blocklistState did not change.
+add_task(async function reinstall_blocked_addon() {
+ Services.fog.testResetFOG();
+ let blockedAddon = await AddonManager.getAddonByID(EXT_ID);
+ equal(
+ blockedAddon.blocklistState,
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Addon was initially blocked"
+ );
+
+ let addon = await tryAddonInstall(EXT_ID, "2");
+ ok(!addon, "Add-on install should be blocked by a stash");
+
+ await assertEventDetails([
+ {
+ // Note: installs of existing versions are observed as "addon_install".
+ // Only updates after update checks are tagged as "addon_update".
+ object: "addon_install",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "2",
+ signed_date: "0",
+ hours_since: "-1",
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+// For comparison with the next test task (database_modified), verify that a
+// regular restart without database modifications does not trigger events.
+add_task(async function regular_restart_no_event() {
+ Services.fog.testResetFOG();
+ // Version different/higher than the 42.0 that was passed to createAppInfo at
+ // the start of this test file to force a database rebuild.
+ await promiseRestartManager("90.0");
+ await assertEventDetails([]);
+
+ await promiseRestartManager();
+ await assertEventDetails([]);
+});
+
+add_task(async function database_modified() {
+ Services.fog.testResetFOG();
+ const EXT_HOURS_SINCE_INSTALL = 3;
+ await promiseShutdownManager();
+
+ // Modify the addon database: blocked->not blocked + decrease installDate.
+ let addonDB = await IOUtils.readJSON(gExtensionsJSON.path);
+ let rawAddon = addonDB.addons[0];
+ equal(rawAddon.id, EXT_ID, "Expected entry in addonDB");
+ equal(rawAddon.blocklistState, 2, "Expected STATE_BLOCKED");
+ rawAddon.blocklistState = 0; // STATE_NOT_BLOCKED
+ rawAddon.installDate = Date.now() - 3600000 * EXT_HOURS_SINCE_INSTALL;
+ await IOUtils.writeJSON(gExtensionsJSON.path, addonDB);
+
+ // Bump version to force database rebuild.
+ await promiseStartupManager("91.0");
+ // Shut down because the database reconcilation blocks shutdown, and we want
+ // to be certain that the process has finished before checking the events.
+ await promiseShutdownManager();
+ await assertEventDetails([
+ {
+ object: "addon_db_modified",
+ value: EXT_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: "1",
+ signed_date: "0",
+ hours_since: `${EXT_HOURS_SINCE_INSTALL}`,
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+
+ Services.fog.testResetFOG();
+ await promiseStartupManager();
+ await assertEventDetails([]);
+});
+
+add_task(async function install_replaces_blocked_addon() {
+ Services.fog.testResetFOG();
+ let addon = await tryAddonInstall(EXT_ID, "3");
+ ok(addon, "Update supersedes blocked add-on");
+
+ await assertEventDetails([
+ {
+ object: "addon_install",
+ value: EXT_ID,
+ blocklistState: "0", // Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ addon_version: "3",
+ signed_date: "0",
+ hours_since: "-1",
+ mlbf_last_time: "456",
+ mlbf_generation: "0",
+ mlbf_source: "unknown",
+ },
+ ]);
+});
+
+add_task(async function install_blocked_by_mlbf() {
+ Services.fog.testResetFOG();
+ await ExtensionBlocklistMLBF._client.db.saveAttachment(
+ ExtensionBlocklistMLBF.RS_ATTACHMENT_ID,
+ { record: MLBF_RECORD, blob: await load_mlbf_record_as_blob() }
+ );
+ await AddonTestUtils.loadBlocklistRawData({
+ extensionsMLBF: [MLBF_RECORD],
+ });
+
+ AddonTestUtils.certSignatureDate = EXT_BLOCKED_SIGN_TIME;
+ let addon = await tryAddonInstall(EXT_BLOCKED_ID, EXT_BLOCKED_VERSION);
+ AddonTestUtils.certSignatureDate = null;
+
+ ok(!addon, "Add-on install should be blocked by the MLBF");
+
+ await assertEventDetails([
+ {
+ object: "addon_install",
+ value: EXT_BLOCKED_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: EXT_BLOCKED_VERSION,
+ signed_date: `${EXT_BLOCKED_SIGN_TIME}`,
+ hours_since: "-1",
+ // When there is no stash at all, the MLBF's generation_time is used.
+ mlbf_last_time: `${MLBF_RECORD.generation_time}`,
+ mlbf_generation: `${MLBF_RECORD.generation_time}`,
+ mlbf_source: "cache_match",
+ },
+ ]);
+});
+
+// A limitation of the MLBF-based blocklist is that it needs the add-on package
+// in order to check its signature date.
+// This part of the test verifies that installation of the add-on is blocked,
+// despite the update check tentatively accepting the package.
+// See https://bugzilla.mozilla.org/show_bug.cgi?id=1649896 for rationale.
+add_task(async function update_check_blocked_by_mlbf() {
+ Services.fog.testResetFOG();
+ // Install a version that we can update, lower than EXT_BLOCKED_VERSION.
+ let addon = await tryAddonInstall(EXT_BLOCKED_ID, "0.1");
+
+ setupAddonUpdate(EXT_BLOCKED_ID, EXT_BLOCKED_VERSION);
+ AddonTestUtils.certSignatureDate = EXT_BLOCKED_SIGN_TIME;
+ let update = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ ok(update.updateAvailable, "Update was not blocked by stash");
+
+ await promiseCompleteInstall(update.updateAvailable);
+ AddonTestUtils.certSignatureDate = null;
+
+ addon = await AddonManager.getAddonByID(EXT_BLOCKED_ID);
+ equal(addon.version, EXT_BLOCKED_VERSION, "Add-on was updated");
+ equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_BLOCKED,
+ "Add-on is blocked"
+ );
+ equal(addon.appDisabled, true, "Add-on was disabled because of the block");
+
+ await assertEventDetails([
+ {
+ object: "addon_update",
+ value: EXT_BLOCKED_ID,
+ blocklistState: "2", // Ci.nsIBlocklistService.STATE_BLOCKED
+ addon_version: EXT_BLOCKED_VERSION,
+ signed_date: `${EXT_BLOCKED_SIGN_TIME}`,
+ hours_since: "-1",
+ mlbf_last_time: `${MLBF_RECORD.generation_time}`,
+ mlbf_generation: `${MLBF_RECORD.generation_time}`,
+ mlbf_source: "cache_match",
+ },
+ ]);
+});
+
+add_task(async function update_blocked_to_unblocked() {
+ Services.fog.testResetFOG();
+ // was blocked in update_check_blocked_by_mlbf.
+ let blockedAddon = await AddonManager.getAddonByID(EXT_BLOCKED_ID);
+
+ // 3 is higher than EXT_BLOCKED_VERSION.
+ setupAddonUpdate(EXT_BLOCKED_ID, "3");
+ AddonTestUtils.certSignatureDate = EXT_BLOCKED_SIGN_TIME;
+ let update = await AddonTestUtils.promiseFindAddonUpdates(blockedAddon);
+ ok(update.updateAvailable, "Found an update");
+
+ await promiseCompleteInstall(update.updateAvailable);
+ AddonTestUtils.certSignatureDate = null;
+
+ let addon = await AddonManager.getAddonByID(EXT_BLOCKED_ID);
+ equal(addon.appDisabled, false, "Add-on was re-enabled after unblock");
+ await assertEventDetails([
+ {
+ object: "addon_update",
+ value: EXT_BLOCKED_ID,
+ blocklistState: "0", // Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ addon_version: "3",
+ signed_date: `${EXT_BLOCKED_SIGN_TIME}`,
+ hours_since: "-1",
+ mlbf_last_time: `${MLBF_RECORD.generation_time}`,
+ mlbf_generation: `${MLBF_RECORD.generation_time}`,
+ mlbf_source: "cache_match",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js
new file mode 100644
index 0000000000..b48700570e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_targetapp_filter.js
@@ -0,0 +1,392 @@
+const { BlocklistPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/Blocklist.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+const APP_ID = "xpcshell@tests.mozilla.org";
+const TOOLKIT_ID = "toolkit@mozilla.org";
+
+let client;
+
+async function clear_state() {
+ // Clear local DB.
+ await client.db.clear();
+}
+
+async function createRecords(records) {
+ const withId = records.map((record, i) => ({
+ id: `record-${i}`,
+ ...record,
+ }));
+ // Prevent packaged dump to be loaded with high collection timestamp
+ return client.db.importChanges({}, Date.now(), withId);
+}
+
+function run_test() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "58",
+ ""
+ );
+ // This will initialize the remote settings clients for blocklists,
+ // with their specific options etc.
+ BlocklistPrivate.ExtensionBlocklistRS.ensureInitialized();
+ // Obtain one of the instantiated client for our tests.
+ client = RemoteSettings("addons", { bucketName: "blocklists" });
+
+ run_next_test();
+}
+
+add_task(async function test_supports_filter_expressions() {
+ await createRecords([
+ {
+ name: "My Extension",
+ filter_expression: 'env.appinfo.ID == "xpcshell@tests.mozilla.org"',
+ },
+ {
+ name: "My Extension",
+ filter_expression: "1 == 2",
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 1);
+});
+add_task(clear_state);
+
+add_task(async function test_returns_all_without_target() {
+ await createRecords([
+ {
+ name: "My Extension",
+ },
+ {
+ name: "foopydoo",
+ versionRange: [],
+ },
+ {
+ name: "My Other Extension",
+ versionRange: [
+ {
+ severity: 0,
+ targetApplication: [],
+ },
+ ],
+ },
+ {
+ name: "Java(\\(TM\\))? Plug-in 11\\.(7[6-9]|[8-9]\\d|1([0-6]\\d|70))(\\.\\d+)?([^\\d\\._]|$)",
+ versionRange: [
+ {
+ severity: 0,
+ },
+ ],
+ matchFilename: "libnpjp2\\.so",
+ },
+ {
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [],
+ maxVersion: "1",
+ minVersion: "0",
+ severity: "1",
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 5);
+});
+add_task(clear_state);
+
+add_task(async function test_returns_without_guid_or_with_matching_guid() {
+ await createRecords([
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [{}],
+ },
+ ],
+ },
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "some-guid",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: TOOLKIT_ID,
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ info(JSON.stringify(list, null, 2));
+ equal(list.length, 3);
+ ok(list.every(e => e.willMatch));
+});
+add_task(clear_state);
+
+add_task(
+ async function test_returns_without_app_version_or_with_matching_version() {
+ await createRecords([
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ maxVersion: "9999",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ maxVersion: "1",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: TOOLKIT_ID,
+ minVersion: "0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: TOOLKIT_ID,
+ minVersion: "0",
+ maxVersion: "9999",
+ },
+ ],
+ },
+ ],
+ // We can't test the false case with maxVersion for toolkit, because the toolkit version
+ // is 0 in xpcshell.
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 5);
+ ok(list.every(e => e.willMatch));
+ }
+);
+add_task(clear_state);
+
+add_task(async function test_multiple_version_and_target_applications() {
+ await createRecords([
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "other-guid",
+ },
+ ],
+ },
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ maxVersion: "*",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: "other-guid",
+ },
+ ],
+ },
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "0",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "57.*",
+ },
+ ],
+ },
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "56.*",
+ },
+ {
+ guid: APP_ID,
+ maxVersion: "57.*",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 2);
+ ok(list.every(e => e.willMatch));
+});
+add_task(clear_state);
+
+add_task(async function test_complex_version() {
+ await createRecords([
+ {
+ willMatch: false,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "57.*",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ maxVersion: "9999.*",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ willMatch: true,
+ name: "foopydoo",
+ versionRange: [
+ {
+ targetApplication: [
+ {
+ guid: APP_ID,
+ minVersion: "19.0a1",
+ },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ const list = await client.get();
+ equal(list.length, 2);
+});
+add_task(clear_state);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js
new file mode 100644
index 0000000000..5d229ca23a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklist_telemetry.js
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "49"
+);
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_setup({ skip_if: () => IS_ANDROID_BUILD }, function test_setup() {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+
+ // FOG needs to be initialized in order for data to flow.
+ Services.fog.initializeFOG();
+});
+
+function assertTelemetryScalars(expectedScalars) {
+ if (!IS_ANDROID_BUILD) {
+ let scalars = TelemetryTestUtils.getProcessScalars("parent");
+
+ for (const scalarName of Object.keys(expectedScalars || {})) {
+ equal(
+ scalars[scalarName],
+ expectedScalars[scalarName],
+ `Got the expected value for ${scalarName} scalar`
+ );
+ }
+ } else {
+ info(
+ `Skip assertions on collected samples for ${expectedScalars} on android builds`
+ );
+ }
+}
+
+add_task(async function test_setup() {
+ // Ensure that the telemetry scalar definitions are loaded and the
+ // AddonManager initialized.
+ await TelemetryController.testSetup();
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_blocklist_lastModified_rs_scalars() {
+ Services.fog.testResetFOG();
+ const now = Date.now();
+
+ const lastEntryTimes = {
+ addons: now - 5000,
+ addons_mlbf: now - 4000,
+ };
+
+ const lastEntryTimesUTC = {};
+ const toUTC = t => new Date(t).toUTCString();
+ for (const key of Object.keys(lastEntryTimes)) {
+ lastEntryTimesUTC[key] = toUTC(lastEntryTimes[key]);
+ }
+
+ const {
+ BlocklistPrivate: {
+ BlocklistTelemetry,
+ ExtensionBlocklistMLBF,
+ ExtensionBlocklistRS,
+ },
+ } = ChromeUtils.importESModule("resource://gre/modules/Blocklist.sys.mjs");
+
+ // Return a promise resolved when the recordRSBlocklistLastModified method
+ // has been called (by temporarily replacing the method with a function that
+ // calls the real method and then resolve the promise).
+ function promiseScalarRecorded() {
+ return new Promise(resolve => {
+ let origFn = BlocklistTelemetry.recordRSBlocklistLastModified;
+ BlocklistTelemetry.recordRSBlocklistLastModified = async (...args) => {
+ BlocklistTelemetry.recordRSBlocklistLastModified = origFn;
+ let res = await origFn.apply(BlocklistTelemetry, args);
+ resolve();
+ return res;
+ };
+ });
+ }
+
+ async function fakeRemoteSettingsSync(rsClient, lastModified) {
+ await rsClient.db.importChanges({}, lastModified);
+ await rsClient.emit("sync");
+ }
+
+ assertTelemetryScalars({
+ "blocklist.lastModified_rs_addons_mlbf": undefined,
+ });
+ Assert.equal(
+ undefined,
+ Glean.blocklist.lastModifiedRsAddonsMblf.testGetValue()
+ );
+
+ info("Test RS addon blocklist lastModified scalar");
+
+ await ExtensionBlocklistRS.ensureInitialized();
+ await Promise.all([
+ promiseScalarRecorded(),
+ fakeRemoteSettingsSync(ExtensionBlocklistRS._client, lastEntryTimes.addons),
+ ]);
+
+ assertTelemetryScalars({
+ "blocklist.lastModified_rs_addons_mlbf": undefined,
+ });
+
+ Assert.equal(
+ undefined,
+ Glean.blocklist.lastModifiedRsAddonsMblf.testGetValue()
+ );
+
+ await ExtensionBlocklistMLBF.ensureInitialized();
+ await Promise.all([
+ promiseScalarRecorded(),
+ fakeRemoteSettingsSync(
+ ExtensionBlocklistMLBF._client,
+ lastEntryTimes.addons_mlbf
+ ),
+ ]);
+
+ assertTelemetryScalars({
+ "blocklist.lastModified_rs_addons_mlbf": lastEntryTimesUTC.addons_mlbf,
+ });
+ Assert.equal(
+ new Date(lastEntryTimesUTC.addons_mlbf).getTime(),
+ Glean.blocklist.lastModifiedRsAddonsMblf.testGetValue().getTime()
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js
new file mode 100644
index 0000000000..7383e093ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange.js
@@ -0,0 +1,1389 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that changes that cause an add-on to become unblocked or blocked have
+// the right effect
+
+// The tests follow a mostly common pattern. First they start with the add-ons
+// unblocked, then they make a change that causes the add-ons to become blocked
+// then they make a similar change that keeps the add-ons blocked then they make
+// a change that unblocks the add-ons. Some tests skip the initial part and
+// start with add-ons detected as blocked.
+
+// softblock1 is enabled/disabled by the blocklist changes so its softDisabled
+// property should always match its userDisabled property
+
+// softblock2 gets manually enabled then disabled after it becomes blocked so
+// its softDisabled property should never become true after that
+
+// softblock3 does the same as softblock2 however it remains disabled
+
+// softblock4 is disabled while unblocked and so should never have softDisabled
+// set to true and stay userDisabled. This add-on is not used in tests that
+// start with add-ons blocked as it would be identical to softblock3
+
+const URI_EXTENSION_BLOCKLIST_DIALOG =
+ "chrome://mozapps/content/extensions/blocklist.xhtml";
+
+// Allow insecure updates
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+// TODO bug 1649906: strip blocklist v2-specific parts of this test.
+// All specific logic is already covered by other test files, but the tests
+// here trigger the logic via higher-level methods, so it may make sense to
+// keep this file even after the removal of blocklist v2.
+const useMLBF = Services.prefs.getBoolPref(
+ "extensions.blocklist.useMLBF",
+ true
+);
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+
+function permissionPromptHandler(subject, topic, data) {
+ ok(
+ subject?.wrappedJSObject?.info?.resolve,
+ "Got a permission prompt notification as expected"
+ );
+ subject.wrappedJSObject.info.resolve();
+}
+
+Services.obs.addObserver(
+ permissionPromptHandler,
+ "webextension-permission-prompt"
+);
+
+registerCleanupFunction(() => {
+ Services.obs.removeObserver(
+ permissionPromptHandler,
+ "webextension-permission-prompt"
+ );
+});
+
+const XPIS = {};
+
+const ADDON_IDS = [
+ "softblock1@tests.mozilla.org",
+ "softblock2@tests.mozilla.org",
+ "softblock3@tests.mozilla.org",
+ "softblock4@tests.mozilla.org",
+ "hardblock@tests.mozilla.org",
+ "regexpblock@tests.mozilla.org",
+];
+
+const BLOCK_APP = [
+ {
+ guid: "xpcshell@tests.mozilla.org",
+ maxVersion: "2.*",
+ minVersion: "2",
+ },
+];
+// JEXL filter expression that matches BLOCK_APP.
+const BLOCK_APP_FILTER_EXPRESSION = `env.appinfo.ID == "xpcshell@tests.mozilla.org" && env.appinfo.version >= "2" && env.appinfo.version < "3"`;
+
+function softBlockApp(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ };
+}
+
+function softBlockAddonChange(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [
+ {
+ severity: "1",
+ minVersion: "2",
+ maxVersion: "3",
+ },
+ ],
+ };
+}
+
+function softBlockUpdate2(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [{ severity: "1" }],
+ };
+}
+
+function softBlockManual(id) {
+ return {
+ guid: `${id}@tests.mozilla.org`,
+ versionRange: [
+ {
+ maxVersion: "2",
+ minVersion: "1",
+ severity: "1",
+ },
+ ],
+ };
+}
+
+const BLOCKLIST_DATA = {
+ empty_blocklist: [],
+ app_update: [
+ softBlockApp("softblock1"),
+ softBlockApp("softblock2"),
+ softBlockApp("softblock3"),
+ softBlockApp("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [
+ {
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/",
+ versionRange: [
+ {
+ severity: "1",
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [
+ {
+ targetApplication: BLOCK_APP,
+ },
+ ],
+ },
+ ],
+ addon_change: [
+ softBlockAddonChange("softblock1"),
+ softBlockAddonChange("softblock2"),
+ softBlockAddonChange("softblock3"),
+ softBlockAddonChange("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [
+ {
+ maxVersion: "3",
+ minVersion: "2",
+ },
+ ],
+ },
+ {
+ _comment:
+ "Two RegExp matches, so test flags work - first shouldn't match.",
+ guid: "/^RegExp/",
+ versionRange: [
+ {
+ maxVersion: "3",
+ minVersion: "2",
+ severity: "1",
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [
+ {
+ maxVersion: "3",
+ minVersion: "2",
+ severity: "2",
+ },
+ ],
+ },
+ ],
+ blocklist_update2: [
+ softBlockUpdate2("softblock1"),
+ softBlockUpdate2("softblock2"),
+ softBlockUpdate2("softblock3"),
+ softBlockUpdate2("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [],
+ },
+ {
+ guid: "/^RegExp/",
+ versionRange: [{ severity: "1" }],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [],
+ },
+ ],
+ manual_update: [
+ softBlockManual("softblock1"),
+ softBlockManual("softblock2"),
+ softBlockManual("softblock3"),
+ softBlockManual("softblock4"),
+ {
+ guid: "hardblock@tests.mozilla.org",
+ versionRange: [
+ {
+ maxVersion: "2",
+ minVersion: "1",
+ },
+ ],
+ },
+ {
+ guid: "/^RegExp/i",
+ versionRange: [
+ {
+ maxVersion: "2",
+ minVersion: "1",
+ },
+ ],
+ },
+ ],
+};
+
+// Blocklist v3 (useMLBF) only supports hard blocks by guid+version. Version
+// ranges, regexps and soft blocks are not supported. So adjust expectations to
+// ensure that the test passes even if useMLBF=true, by:
+// - soft blocks are converted to hard blocks.
+// - hard blocks are accepted as-is.
+// - regexps blocks are converted to hard blocks.
+// - Version ranges are expanded to cover all known versions.
+if (useMLBF) {
+ for (let [key, blocks] of Object.entries(BLOCKLIST_DATA)) {
+ BLOCKLIST_DATA[key] = [];
+ for (let block of blocks) {
+ let { guid } = block;
+ if (guid.includes("RegExp")) {
+ guid = "regexpblock@tests.mozilla.org";
+ } else if (!guid.startsWith("soft") && !guid.startsWith("hard")) {
+ throw new Error(`Unexpected mock addon ID: ${guid}`);
+ }
+
+ const {
+ minVersion = "1",
+ maxVersion = "3",
+ targetApplication,
+ } = block.versionRange?.[0] || {};
+
+ for (let v = minVersion; v <= maxVersion; ++v) {
+ BLOCKLIST_DATA[key].push({
+ // Assume that IF targetApplication is set, that it is BLOCK_APP.
+ filter_expression: targetApplication && BLOCK_APP_FILTER_EXPRESSION,
+ stash: {
+ // XPI files use version `${v}.0`, update manifests use `${v}`.
+ blocked: [`${guid}:${v}.0`, `${guid}:${v}`],
+ unblocked: [],
+ },
+ });
+ }
+ }
+ }
+}
+
+// XXXgijs: according to https://bugzilla.mozilla.org/show_bug.cgi?id=1257565#c111
+// this code and the related code in Blocklist.jsm (specific to XML blocklist) is
+// dead code and can be removed. See https://bugzilla.mozilla.org/show_bug.cgi?id=1549550 .
+//
+// Don't need the full interface, attempts to call other methods will just
+// throw which is just fine
+var WindowWatcher = {
+ openWindow(parent, url, name, features, openArgs) {
+ // Should be called to list the newly blocklisted items
+ Assert.equal(url, URI_EXTENSION_BLOCKLIST_DIALOG);
+
+ // Simulate auto-disabling any softblocks
+ var list = openArgs.wrappedJSObject.list;
+ list.forEach(function (aItem) {
+ if (!aItem.blocked) {
+ aItem.disable = true;
+ }
+ });
+
+ // run the code after the blocklist is closed
+ Services.obs.notifyObservers(null, "addon-blocklist-closed");
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowWatcher"]),
+};
+
+MockRegistrar.register(
+ "@mozilla.org/embedcomp/window-watcher;1",
+ WindowWatcher
+);
+
+var InstallConfirm = {
+ confirm(aWindow, aUrl, aInstalls) {
+ aInstalls.forEach(function (aInstall) {
+ aInstall.install();
+ });
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["amIWebInstallPrompt"]),
+};
+
+var InstallConfirmFactory = {
+ createInstance: function createInstance(iid) {
+ return InstallConfirm.QueryInterface(iid);
+ },
+};
+
+var registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+registrar.registerFactory(
+ Components.ID("{f0863905-4dde-42e2-991c-2dc8209bc9ca}"),
+ "Fake Install Prompt",
+ "@mozilla.org/addons/web-install-prompt;1",
+ InstallConfirmFactory
+);
+
+function Pload_blocklist(aId) {
+ return AddonTestUtils.loadBlocklistRawData({
+ [useMLBF ? "extensionsMLBF" : "extensions"]: BLOCKLIST_DATA[aId],
+ });
+}
+
+// Does a background update check for add-ons and returns a promise that
+// resolves when any started installs complete
+function Pbackground_update() {
+ return new Promise((resolve, reject) => {
+ let installCount = 0;
+ let backgroundCheckCompleted = false;
+
+ AddonManager.addInstallListener({
+ onNewInstall(aInstall) {
+ installCount++;
+ },
+
+ onInstallEnded(aInstall) {
+ installCount--;
+ // Wait until all started installs have completed
+ if (installCount) {
+ return;
+ }
+
+ AddonManager.removeInstallListener(this);
+
+ // If the background check hasn't yet completed then let that call the
+ // callback when it is done
+ if (!backgroundCheckCompleted) {
+ return;
+ }
+
+ resolve();
+ },
+ });
+
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(
+ observer,
+ "addons-background-update-complete"
+ );
+ backgroundCheckCompleted = true;
+
+ // If any new installs have started then we'll call the callback once they
+ // are completed
+ if (installCount) {
+ return;
+ }
+
+ resolve();
+ }, "addons-background-update-complete");
+
+ AddonManagerPrivate.backgroundUpdateCheck();
+ });
+}
+
+// Manually updates the test add-ons to the given version
+function Pmanual_update(aVersion) {
+ const names = ["soft1", "soft2", "soft3", "soft4", "hard", "regexp"];
+ return Promise.all(
+ names.map(async name => {
+ let url = `http://example.com/addons/blocklist_${name}_${aVersion}.xpi`;
+ let install = await AddonManager.getInstallForURL(url);
+
+ // installAddonFromAOM() does more checking than install.install().
+ // In particular, it will refuse to install an incompatible addon.
+
+ return new Promise(resolve => {
+ install.addListener({
+ onDownloadCancelled: resolve,
+ onInstallEnded: resolve,
+ });
+
+ AddonManager.installAddonFromAOM(null, null, install);
+ });
+ })
+ );
+}
+
+// Checks that an add-ons properties match expected values
+function check_addon(
+ aAddon,
+ aExpectedVersion,
+ aExpectedUserDisabled,
+ aExpectedSoftDisabled,
+ aExpectedState
+) {
+ if (useMLBF) {
+ if (aAddon.id.startsWith("soft")) {
+ if (aExpectedState === Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
+ // The whole test file assumes that an add-on is "user-disabled" after
+ // an explicit disable(), or after a soft block (without enable()).
+ // With useMLBF, soft blocks are not supported, so the "user-disabled"
+ // state matches the usual behavior of "userDisabled" (=disable()).
+ aExpectedUserDisabled = aAddon.userDisabled;
+ aExpectedSoftDisabled = false;
+ aExpectedState = Ci.nsIBlocklistService.STATE_BLOCKED;
+ }
+ }
+ }
+
+ Assert.notEqual(aAddon, null);
+ info(
+ "Testing " +
+ aAddon.id +
+ " version " +
+ aAddon.version +
+ " user " +
+ aAddon.userDisabled +
+ " soft " +
+ aAddon.softDisabled +
+ " perms " +
+ aAddon.permissions
+ );
+
+ Assert.equal(aAddon.version, aExpectedVersion);
+ Assert.equal(aAddon.blocklistState, aExpectedState);
+ Assert.equal(aAddon.userDisabled, aExpectedUserDisabled);
+ Assert.equal(aAddon.softDisabled, aExpectedSoftDisabled);
+ if (aAddon.softDisabled) {
+ Assert.ok(aAddon.userDisabled);
+ }
+
+ if (aExpectedState == Ci.nsIBlocklistService.STATE_BLOCKED) {
+ info("blocked, PERM_CAN_ENABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+ info("blocked, PERM_CAN_DISABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+ } else if (aAddon.userDisabled) {
+ info("userDisabled, PERM_CAN_ENABLE " + aAddon.id);
+ Assert.ok(hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+ info("userDisabled, PERM_CAN_DISABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+ } else {
+ info("other, PERM_CAN_ENABLE " + aAddon.id);
+ Assert.ok(!hasFlag(aAddon.permissions, AddonManager.PERM_CAN_ENABLE));
+ if (aAddon.type != "theme") {
+ info("other, PERM_CAN_DISABLE " + aAddon.id);
+ Assert.ok(hasFlag(aAddon.permissions, AddonManager.PERM_CAN_DISABLE));
+ }
+ }
+ Assert.equal(
+ aAddon.appDisabled,
+ aExpectedState == Ci.nsIBlocklistService.STATE_BLOCKED
+ );
+
+ let willBeActive = aAddon.isActive;
+ if (hasFlag(aAddon.pendingOperations, AddonManager.PENDING_DISABLE)) {
+ willBeActive = false;
+ } else if (hasFlag(aAddon.pendingOperations, AddonManager.PENDING_ENABLE)) {
+ willBeActive = true;
+ }
+
+ if (
+ aExpectedUserDisabled ||
+ aExpectedState == Ci.nsIBlocklistService.STATE_BLOCKED
+ ) {
+ Assert.ok(!willBeActive);
+ } else {
+ Assert.ok(willBeActive);
+ }
+}
+
+async function promiseRestartManagerWithAppChange(version) {
+ await promiseShutdownManager();
+ await promiseStartupManagerWithAppChange(version);
+}
+
+async function promiseStartupManagerWithAppChange(version) {
+ if (version) {
+ AddonTestUtils.appInfo.version = version;
+ }
+ if (useMLBF) {
+ // The old ExtensionBlocklist enforced the app version/ID part of the block
+ // when the blocklist entry is checked.
+ // The new ExtensionBlocklist (with useMLBF=true) does not separately check
+ // the app version/ID, but the underlying data source (Remote Settings)
+ // does offer the ability to filter entries with `filter_expression`.
+ // Force a reload to ensure that the BLOCK_APP_FILTER_EXPRESSION filter in
+ // this test file is checked again against the new version.
+ await Blocklist.ExtensionBlocklist._updateMLBF();
+ }
+ await promiseStartupManager();
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ if (useMLBF) {
+ const { ClientEnvironmentBase } = ChromeUtils.importESModule(
+ "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs"
+ );
+ Object.defineProperty(ClientEnvironmentBase, "appinfo", {
+ configurable: true,
+ get() {
+ return gAppInfo;
+ },
+ });
+ }
+
+ function getxpibasename(id, version) {
+ // pattern used to map ids like softblock1 to soft1
+ let pattern = /^(soft|hard|regexp)block([1-9]*)@/;
+ let match = id.match(pattern);
+ return `blocklist_${match[1]}${match[2]}_${version}`;
+ }
+ for (let id of ADDON_IDS) {
+ for (let version of [1, 2, 3, 4]) {
+ let name = getxpibasename(id, version);
+
+ let xpi = createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: `${version}.0`,
+ browser_specific_settings: {
+ gecko: {
+ id,
+ // This file is generated below, as updateJson.
+ update_url: `http://example.com/addon_update${version}.json`,
+ },
+ },
+ },
+ });
+
+ // To test updates, individual tasks in this test file start the test by
+ // installing a set of add-ons with version |version| and trigger an
+ // update check, from XPIS.${nameprefix}${version} (version = 1, 2, 3)
+ if (version != 4) {
+ XPIS[name] = xpi;
+ }
+
+ // update_url above points to a test manifest that references the next
+ // version. The xpi is made available on the server, so that the test
+ // can verify that the blocklist works as intended (i.e. update to newer
+ // version is blocked).
+ // There is nothing that updates to version 1, only to versions 2, 3, 4.
+ if (version != 1) {
+ testserver.registerFile(`/addons/${name}.xpi`, xpi);
+ }
+ }
+ }
+
+ // For each version that this test file uses, create a test manifest that
+ // references the next version for each id in ADDON_IDS.
+ for (let version of [1, 2, 3]) {
+ let updateJson = { addons: {} };
+ for (let id of ADDON_IDS) {
+ let nextversion = version + 1;
+ let name = getxpibasename(id, nextversion);
+ updateJson.addons[id] = {
+ updates: [
+ {
+ applications: {
+ gecko: {
+ strict_min_version: "0",
+ advisory_max_version: "*",
+ },
+ },
+ version: `${nextversion}.0`,
+ update_link: `http://example.com/addons/${name}.xpi`,
+ },
+ ],
+ };
+ }
+ AddonTestUtils.registerJSON(
+ testserver,
+ `/addon_update${version}.json`,
+ updateJson
+ );
+ }
+
+ await promiseStartupManager();
+
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+
+ let s4 = await promiseAddonByID("softblock4@tests.mozilla.org");
+ await s4.disable();
+});
+
+// Starts with add-ons unblocked and then switches application versions to
+// change add-ons to blocked and back
+add_task(async function run_app_update_test() {
+ await Pload_blocklist("app_update");
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function app_update_step_2() {
+ await promiseRestartManagerWithAppChange("2");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+});
+
+add_task(async function app_update_step_3() {
+ await promiseRestartManager();
+
+ await promiseRestartManagerWithAppChange("2.5");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function app_update_step_4() {
+ await promiseRestartManagerWithAppChange("1");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Starts with add-ons unblocked and then switches application versions to
+// change add-ons to blocked and back. A DB schema change is faked to force a
+// rebuild when the application version changes
+add_task(async function run_app_update_schema_test() {
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function update_schema_2() {
+ await promiseShutdownManager();
+
+ await changeXPIDBVersion(100);
+ gAppInfo.version = "2";
+ await promiseStartupManagerWithAppChange();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+});
+
+add_task(async function update_schema_3() {
+ await promiseRestartManager();
+
+ await promiseShutdownManager();
+ await changeXPIDBVersion(100);
+ gAppInfo.version = "2.5";
+ await promiseStartupManagerWithAppChange();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function update_schema_4() {
+ await promiseShutdownManager();
+
+ await changeXPIDBVersion(100);
+ await promiseStartupManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function update_schema_5() {
+ await promiseShutdownManager();
+
+ await changeXPIDBVersion(100);
+ gAppInfo.version = "1";
+ await promiseStartupManagerWithAppChange();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Starts with add-ons unblocked and then loads new blocklists to change add-ons
+// to blocked and back again.
+add_task(async function run_blocklist_update_test() {
+ await Pload_blocklist("empty_blocklist");
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await Pload_blocklist("blocklist_update2");
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await promiseRestartManager();
+
+ await Pload_blocklist("blocklist_update2");
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await Pload_blocklist("empty_blocklist");
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Starts with add-ons unblocked and then new versions are installed outside of
+// the app to change them to blocked and back again.
+add_task(async function run_addon_change_test() {
+ await Pload_blocklist("addon_change");
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+add_task(async function run_addon_change_2() {
+ await promiseInstallFile(XPIS.blocklist_soft1_2);
+ await promiseInstallFile(XPIS.blocklist_soft2_2);
+ await promiseInstallFile(XPIS.blocklist_soft3_2);
+ await promiseInstallFile(XPIS.blocklist_soft4_2);
+ await promiseInstallFile(XPIS.blocklist_hard_2);
+ await promiseInstallFile(XPIS.blocklist_regexp_2);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "2.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "2.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "2.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "2.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "2.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "2.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "2.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "2.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+});
+
+add_task(async function run_addon_change_3() {
+ await promiseInstallFile(XPIS.blocklist_soft1_3);
+ await promiseInstallFile(XPIS.blocklist_soft2_3);
+ await promiseInstallFile(XPIS.blocklist_soft3_3);
+ await promiseInstallFile(XPIS.blocklist_soft4_3);
+ await promiseInstallFile(XPIS.blocklist_hard_3);
+ await promiseInstallFile(XPIS.blocklist_regexp_3);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+ check_addon(s4, "3.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
+
+add_task(async function run_addon_change_4() {
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+});
+
+// Add-ons are initially unblocked then attempts to upgrade to blocked versions
+// in the background which should fail
+add_task(async function run_background_update_test() {
+ await promiseRestartManager();
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await Pbackground_update();
+ await promiseRestartManager();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s2,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+// Starts with add-ons blocked and then new versions are detected and installed
+// automatically for unblocked versions.
+add_task(async function run_background_update_2_test() {
+ await promiseInstallFile(XPIS.blocklist_soft1_3);
+ await promiseInstallFile(XPIS.blocklist_soft2_3);
+ await promiseInstallFile(XPIS.blocklist_soft3_3);
+ await promiseInstallFile(XPIS.blocklist_soft4_3);
+ await promiseInstallFile(XPIS.blocklist_hard_3);
+ await promiseInstallFile(XPIS.blocklist_regexp_3);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "3.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await Pbackground_update();
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "4.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "4.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "4.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(h, "4.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "4.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+ await s4.disable();
+});
+
+// The next test task (run_manual_update_test) was written to expect version 1,
+// but after the previous test, version 4 of the add-ons were installed.
+add_task(async function reset_addons_to_version_1_instead_of_4() {
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+});
+
+// Starts with add-ons blocked and then simulates the user upgrading them to
+// unblocked versions.
+add_task(async function run_manual_update_test() {
+ await Pload_blocklist("manual_update");
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await Pmanual_update("2");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ // With useMLBF, s1/s2/s3 are hard blocks, so they cannot update.
+ const sv2 = useMLBF ? "1.0" : "2.0";
+ check_addon(s1, sv2, true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, sv2, true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, sv2, false, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s4, sv2, true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ // Can't manually update to a hardblocked add-on
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await Pmanual_update("3");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s4, "3.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+});
+
+// Starts with add-ons blocked and then new versions are installed outside of
+// the app to change them to unblocked.
+add_task(async function run_manual_update_2_test() {
+ let addons = await promiseAddonsByIDs(ADDON_IDS);
+ await Promise.all(addons.map(addon => addon.uninstall()));
+
+ await promiseInstallFile(XPIS.blocklist_soft1_1);
+ await promiseInstallFile(XPIS.blocklist_soft2_1);
+ await promiseInstallFile(XPIS.blocklist_soft3_1);
+ await promiseInstallFile(XPIS.blocklist_soft4_1);
+ await promiseInstallFile(XPIS.blocklist_hard_1);
+ await promiseInstallFile(XPIS.blocklist_regexp_1);
+
+ let [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await s2.enable();
+ await s2.disable();
+ check_addon(s2, "1.0", true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ await s3.enable();
+ check_addon(
+ s3,
+ "1.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_SOFTBLOCKED
+ );
+
+ await Pmanual_update("2");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ // With useMLBF, s1/s2/s3 are hard blocks, so they cannot update.
+ const sv2 = useMLBF ? "1.0" : "2.0";
+ check_addon(s1, sv2, true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, sv2, true, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, sv2, false, false, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ // Can't manually update to a hardblocked add-on
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+
+ await Pmanual_update("3");
+
+ [s1, s2, s3, s4, h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(
+ s1,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(s2, "3.0", true, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(
+ s3,
+ "3.0",
+ false,
+ false,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ check_addon(h, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+ check_addon(r, "3.0", false, false, Ci.nsIBlocklistService.STATE_NOT_BLOCKED);
+
+ await s1.enable();
+ await s2.enable();
+ await s4.disable();
+});
+
+// Uses the API to install blocked add-ons from the local filesystem
+add_task(async function run_local_install_test() {
+ let addons = await promiseAddonsByIDs(ADDON_IDS);
+ await Promise.all(addons.map(addon => addon.uninstall()));
+
+ await promiseInstallAllFiles([
+ XPIS.blocklist_soft1_1,
+ XPIS.blocklist_soft2_1,
+ XPIS.blocklist_soft3_1,
+ XPIS.blocklist_soft4_1,
+ XPIS.blocklist_hard_1,
+ XPIS.blocklist_regexp_1,
+ ]);
+
+ let installs = await AddonManager.getAllInstalls();
+ // Should have finished all installs without needing to restart
+ Assert.equal(installs.length, 0);
+
+ let [s1, s2, s3 /* s4 */, , h, r] = await promiseAddonsByIDs(ADDON_IDS);
+
+ check_addon(s1, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s2, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(s3, "1.0", true, true, Ci.nsIBlocklistService.STATE_SOFTBLOCKED);
+ check_addon(h, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+ check_addon(r, "1.0", false, false, Ci.nsIBlocklistService.STATE_BLOCKED);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js
new file mode 100644
index 0000000000..d884438def
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_blocklistchange_v2.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// useMLBF=true doesn't support soft blocks, regexps or version ranges.
+// Flip the useMLBF preference to make sure that the test_blocklistchange.js
+// test works with and without this pref (blocklist v2 and blocklist v3).
+enable_blocklist_v2_instead_of_useMLBF();
+
+Services.scriptloader.loadSubScript(
+ Services.io.newFileURI(do_get_file("test_blocklistchange.js")).spec,
+ this
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js
new file mode 100644
index 0000000000..9b1d84b77d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Device.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which differs only on device ID, but otherwise
+// exactly matches the blacklist entry, is not blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x9876");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x9876");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x9876");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("aabb");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_CANVAS2D_ACCELERATION
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js
new file mode 100644
index 0000000000..a1bcde5566
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_DriverNew.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a new-enough driver bypasses the blacklist, even if the rest of
+// the attributes match the blacklist entry.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2202");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("wxyz");
+ gfxInfo.spoofDriverVersion("6");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js
new file mode 100644
index 0000000000..ec74d813ae
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverNew.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which is newer than the equal
+// blacklist entry is allowed.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcdc");
+ gfxInfo.spoofDeviceID("0x1234");
+ // test_gfxBlacklist.json has several entries targeting "os": "All"
+ // ("All" meaning "All Windows"), with several combinations of
+ // "driverVersion" / "driverVersionMax" / "driverVersionComparator".
+ gfxInfo.spoofDriverVersion("8.52.322.1112");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ // XXX don't we? Seems like we do since bug 1294232 with the change in
+ // https://hg.mozilla.org/mozilla-central/diff/8962b8d9b7a6/widget/GfxInfoBase.cpp
+ // To update this test, we'd have to update test_gfxBlacklist.json in a
+ // way similar to how bug 1714673 was resolved for Android.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on OS X.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("dcdc");
+ gfxInfo.spoofDeviceID("uiop");
+ gfxInfo.spoofDriverVersion("6");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "15.0", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_ANGLE);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_HARDWARE_VIDEO_DECODING
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_H264
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_DECODE
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_ENCODE
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_WEBGL_ANGLE);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_CANVAS2D_ACCELERATION
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js
new file mode 100644
index 0000000000..ff887a92eb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_DriverOld.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which is older than the equal
+// blacklist entry is correctly allowed.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcdc");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.1110");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("dcdc");
+ gfxInfo.spoofDeviceID("uiop");
+ gfxInfo.spoofDriverVersion("4");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js
new file mode 100644
index 0000000000..1eef119663
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Equal_OK.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the equal
+// blacklist entry is successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcdc");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.1111");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("dcdc");
+ gfxInfo.spoofDeviceID("uiop");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js
new file mode 100644
index 0000000000..182c825ffb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_DriverOld.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which is lower than the greater-than-or-equal
+// blacklist entry is allowed.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabab");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("abab");
+ gfxInfo.spoofDeviceID("ghjk");
+ gfxInfo.spoofDriverVersion("6");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js
new file mode 100644
index 0000000000..2cc3686007
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_GTE_OK.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the greater-than-or-equal
+// blacklist entry is successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabab");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2202");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't support driver versions on Linux.
+ // XXX don't we? Seems like we do since bug 1294232 with the change in
+ // https://hg.mozilla.org/mozilla-central/diff/8962b8d9b7a6/widget/GfxInfoBase.cpp
+ do_test_finished();
+ return;
+ case "Darwin":
+ // We don't support driver versions on Darwin.
+ do_test_finished();
+ return;
+ case "Android":
+ gfxInfo.spoofVendorID("abab");
+ gfxInfo.spoofDeviceID("ghjk");
+ gfxInfo.spoofDriverVersion("7");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js
new file mode 100644
index 0000000000..169cdc5e62
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_No_Comparison.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the blacklist entry is
+// successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x6666");
+
+ // Spoof the OS version so it matches the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ break;
+ case "Darwin":
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var driverVersion = gfxInfo.adapterDriverVersion;
+ if (driverVersion) {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_WEBRENDER);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js
new file mode 100644
index 0000000000..04d766e027
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OK.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the blacklist entry is
+// successfully blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("asdf");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js
new file mode 100644
index 0000000000..ce5a61cb75
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OS.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which differs only on OS version, but otherwise
+// exactly matches the blacklist entry, is not blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows Vista
+ gfxInfo.spoofOSVersion(0x60000);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0800);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js
new file mode 100644
index 0000000000..7a4ec276ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_match.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether new OS versions are matched properly.
+// Uses test_gfxBlacklist_OSVersion.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+
+ // Spoof the version of the OS appropriately to test the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 8
+ gfxInfo.spoofOSVersion(0x60002);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ // Mountain Lion
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ if (Services.appinfo.OS == "WINNT") {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ } else if (Services.appinfo.OS == "Darwin") {
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ }
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_OSVersion.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js
new file mode 100644
index 0000000000..61dba8db96
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether blocklists specifying new OSes correctly don't block if driver
+// versions are appropriately up-to-date.
+// Uses test_gfxBlacklist_OSVersion.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ gfxInfo.spoofDriverVersion("8.52.322.2202");
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+
+ // Spoof the version of the OS appropriately to test the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 8
+ gfxInfo.spoofOSVersion(0x60002);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ gfxInfo.spoofOSVersion(0xa0800);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ if (Services.appinfo.OS == "WINNT") {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ } else if (Services.appinfo.OS == "Darwin") {
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_OSVersion.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js
new file mode 100644
index 0000000000..117e2a34ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_OSVersion_mismatch_OSVersion.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether old OS versions are not matched when the blacklist contains
+// only new OS versions.
+// Uses test_gfxBlacklist_OSVersion.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+
+ // Spoof the version of the OS appropriately to test the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ // We don't have any OS versions on Linux, just "Linux".
+ do_test_finished();
+ return;
+ case "Darwin":
+ // Lion
+ gfxInfo.spoofOSVersion(0xa0800);
+ break;
+ case "Android":
+ // On Android, the driver version is used as the OS version (because
+ // there's so many of them).
+ do_test_finished();
+ return;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ if (Services.appinfo.OS == "WINNT") {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ } else if (Services.appinfo.OS == "Darwin") {
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_OSVersion.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js
new file mode 100644
index 0000000000..37bc0d3c89
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Vendor.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which differs only on vendor, but otherwise
+// exactly matches the blacklist entry, is not blocked.
+// Uses test_gfxBlacklist.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xdcba");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xdcba");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xdcba");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("dcba");
+ gfxInfo.spoofDeviceID("asdf");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function checkBlacklist() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlacklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js
new file mode 100644
index 0000000000..9a6a904465
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_Version.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether a machine which exactly matches the blocklist entry is
+// successfully blocked.
+// Uses test_gfxBlacklist_AllOS.json
+
+// Performs the initial setup
+async function run_test() {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Save OS in variable since createAppInfo below will change it to "xpcshell".
+ const OS = Services.appinfo.OS;
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "15.0", "8");
+ await promiseStartupManager();
+
+ function checkBlocklist() {
+ var failureId = {};
+ var status;
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT2D,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_g1");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_g2");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_10_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ Assert.equal(failureId.value, "");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_10_1_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ Assert.equal(failureId.value, "");
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_OPENGL_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBGL_OPENGL,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_g11");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBGL_ANGLE,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_NO_ID");
+
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_WEBGL2, failureId);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_DL_BLOCKLIST_NO_ID");
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_STAGEFRIGHT,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_H264,
+ failureId
+ );
+ if (OS == "Android" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ // Hardware acceleration for H.264 varies by device.
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_WEBRTC_H264");
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_ENCODE,
+ failureId
+ );
+ if (OS == "Android" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_WEBRTC_ENCODE");
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_WEBRTC_HW_ACCELERATION_DECODE,
+ failureId
+ );
+ if (OS == "Android" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DEVICE);
+ Assert.equal(failureId.value, "FEATURE_FAILURE_WEBRTC_DECODE");
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_LAYERS,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_HARDWARE_VIDEO_DECODING,
+ failureId
+ );
+ if (OS == "Linux" && status != Ci.nsIGfxInfo.FEATURE_STATUS_OK) {
+ // Linux test suite is running on SW OpenGL backend and we disable
+ // HW video decoding there.
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_PLATFORM_TEST);
+ Assert.equal(
+ failureId.value,
+ "FEATURE_FAILURE_VIDEO_DECODING_TEST_FAILED"
+ );
+ } else {
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+ }
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DIRECT3D_11_ANGLE,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ status = gfxInfo.getFeatureStatus(
+ Ci.nsIGfxInfo.FEATURE_DX_INTEROP2,
+ failureId
+ );
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(function (aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(checkBlocklist);
+ }, "blocklist-data-gfxItems");
+
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist_AllOS.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js
new file mode 100644
index 0000000000..34e92b0e80
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_gfxBlacklist_prefs.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test whether the blacklist successfully adds and removes the prefs that store
+// its decisions when the remote blacklist is changed.
+// Uses test_gfxBlacklist.json and test_gfxBlacklist2.json
+
+// Performs the initial setup
+async function run_test() {
+ try {
+ var gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+ } catch (e) {
+ do_test_finished();
+ return;
+ }
+
+ // We can't do anything if we can't spoof the stuff we need.
+ if (!(gfxInfo instanceof Ci.nsIGfxInfoDebug)) {
+ do_test_finished();
+ return;
+ }
+
+ gfxInfo.QueryInterface(Ci.nsIGfxInfoDebug);
+
+ // Set the vendor/device ID, etc, to match the test file.
+ switch (Services.appinfo.OS) {
+ case "WINNT":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofDriverVersion("8.52.322.2201");
+ // Windows 7
+ gfxInfo.spoofOSVersion(0x60001);
+ break;
+ case "Linux":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ break;
+ case "Darwin":
+ gfxInfo.spoofVendorID("0xabcd");
+ gfxInfo.spoofDeviceID("0x1234");
+ gfxInfo.spoofOSVersion(0xa0900);
+ break;
+ case "Android":
+ gfxInfo.spoofVendorID("abcd");
+ gfxInfo.spoofDeviceID("asdf");
+ gfxInfo.spoofDriverVersion("5");
+ break;
+ }
+
+ do_test_pending();
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "3", "8");
+ await promiseStartupManager();
+
+ function blacklistAdded(aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(ensureBlacklistSet);
+ }
+ function ensureBlacklistSet() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ Assert.equal(
+ Services.prefs.getIntPref("gfx.blacklist.direct2d"),
+ Ci.nsIGfxInfo.FEATURE_BLOCKED_DRIVER_VERSION
+ );
+
+ Services.obs.removeObserver(blacklistAdded, "blocklist-data-gfxItems");
+ Services.obs.addObserver(blacklistRemoved, "blocklist-data-gfxItems");
+ mockGfxBlocklistItems([
+ {
+ os: "WINNT 6.1",
+ vendor: "0xabcd",
+ devices: ["0x2783", "0x2782"],
+ feature: " DIRECT2D ",
+ featureStatus: " BLOCKED_DRIVER_VERSION ",
+ driverVersion: " 8.52.322.2202 ",
+ driverVersionComparator: " LESS_THAN ",
+ },
+ {
+ os: "WINNT 6.0",
+ vendor: "0xdcba",
+ devices: ["0x2783", "0x1234", "0x2782"],
+ feature: " DIRECT3D_9_LAYERS ",
+ featureStatus: " BLOCKED_DRIVER_VERSION ",
+ driverVersion: " 8.52.322.2202 ",
+ driverVersionComparator: " LESS_THAN ",
+ },
+ ]);
+ }
+
+ function blacklistRemoved(aSubject, aTopic, aData) {
+ // If we wait until after we go through the event loop, gfxInfo is sure to
+ // have processed the gfxItems event.
+ executeSoon(ensureBlacklistUnset);
+ }
+ function ensureBlacklistUnset() {
+ var status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT2D);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ // Make sure unrelated features aren't affected
+ status = gfxInfo.getFeatureStatus(Ci.nsIGfxInfo.FEATURE_DIRECT3D_9_LAYERS);
+ Assert.equal(status, Ci.nsIGfxInfo.FEATURE_STATUS_OK);
+
+ var exists = false;
+ try {
+ Services.prefs.getIntPref("gfx.blacklist.direct2d");
+ exists = true;
+ } catch (e) {}
+
+ Assert.ok(!exists);
+
+ do_test_finished();
+ }
+
+ Services.obs.addObserver(blacklistAdded, "blocklist-data-gfxItems");
+ mockGfxBlocklistItemsFromDisk("../data/test_gfxBlacklist.json");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js
new file mode 100644
index 0000000000..edf53183d0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/test_softblocked.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// useMLBF=true only supports hard blocks, not soft blocks.
+enable_blocklist_v2_instead_of_useMLBF();
+
+// Tests that an appDisabled add-on that becomes softBlocked remains disabled
+// when becoming appEnabled
+add_task(async function test_softblock() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Softblocked add-on",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "softblock1@tests.mozilla.org",
+ strict_min_version: "2",
+ strict_max_version: "3",
+ },
+ },
+ },
+ });
+ let s1 = await promiseAddonByID("softblock1@tests.mozilla.org");
+
+ // Make sure to mark it as previously enabled.
+ await s1.enable();
+
+ Assert.ok(!s1.softDisabled);
+ Assert.ok(s1.appDisabled);
+ Assert.ok(!s1.isActive);
+
+ await AddonTestUtils.loadBlocklistRawData({
+ extensions: [
+ {
+ guid: "softblock1@tests.mozilla.org",
+ versionRange: [
+ {
+ severity: "1",
+ },
+ ],
+ },
+ ],
+ });
+
+ Assert.ok(s1.softDisabled);
+ Assert.ok(s1.appDisabled);
+ Assert.ok(!s1.isActive);
+
+ AddonTestUtils.appInfo.platformVersion = "2";
+ await promiseRestartManager("2");
+
+ s1 = await promiseAddonByID("softblock1@tests.mozilla.org");
+
+ Assert.ok(s1.softDisabled);
+ Assert.ok(!s1.appDisabled);
+ Assert.ok(!s1.isActive);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.toml b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.toml
new file mode 100644
index 0000000000..2aee95e952
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/rs-blocklist/xpcshell.toml
@@ -0,0 +1,102 @@
+[DEFAULT]
+tags = "addons blocklist"
+head = "head.js ../head_addons.js"
+firefox-appdir = "browser"
+support-files = [
+ "../data/**",
+ "../../xpinstall/webmidi_permission.xpi",
+]
+
+["test_android_blocklist_dump.js"]
+run-if = ["os == 'android'"]
+
+["test_blocklist_addonBlockURL.js"]
+
+["test_blocklist_appversion.js"]
+skip-if = ["os == 'android' && verify"] # times out
+
+["test_blocklist_clients.js"]
+tags = "remote-settings"
+
+["test_blocklist_gfx.js"]
+
+["test_blocklist_metadata_filters.js"]
+
+["test_blocklist_mlbf.js"]
+
+["test_blocklist_mlbf_dump.js"]
+skip-if = ["os == 'android'"] # blocklist v3 is not bundled with Android builds, see test_android_blocklist_dump.js instead.
+
+["test_blocklist_mlbf_fetch.js"]
+
+["test_blocklist_mlbf_stashes.js"]
+
+["test_blocklist_mlbf_telemetry.js"]
+skip-if = ["appname == 'thunderbird'"] # Data irrelevant to Thunderbird. Bug 1641400.
+
+["test_blocklist_mlbf_update.js"]
+
+["test_blocklist_osabi.js"]
+skip-if = ["os == 'android' && verify"] # times out
+
+["test_blocklist_prefs.js"]
+
+["test_blocklist_regexp_split.js"]
+
+["test_blocklist_severities.js"]
+
+["test_blocklist_statechange_telemetry.js"]
+skip-if = ["appname == 'thunderbird'"] # Data irrelevant to Thunderbird. Bug 1641400.
+
+["test_blocklist_targetapp_filter.js"]
+tags = "remote-settings"
+
+["test_blocklist_telemetry.js"]
+tags = "remote-settings"
+skip-if = ["appname == 'thunderbird'"] # Data irrelevant to Thunderbird. Bug 1641400.
+
+["test_blocklistchange.js"]
+# Times out during parallel runs on desktop
+requesttimeoutfactor = 2
+skip-if = ["os == 'android' && verify"] # times out because it takes too much time to run the full test
+
+["test_blocklistchange_v2.js"]
+# Times out during parallel runs on desktop
+requesttimeoutfactor = 2
+skip-if = ["os == 'android' && verify"] # times out in chaos mode on Android because several minutes are spent waiting at https://hg.mozilla.org/mozilla-central/file/3350b680/toolkit/mozapps/extensions/Blocklist.jsm#l698
+
+["test_gfxBlacklist_Device.js"]
+
+["test_gfxBlacklist_DriverNew.js"]
+
+["test_gfxBlacklist_Equal_DriverNew.js"]
+
+["test_gfxBlacklist_Equal_DriverOld.js"]
+
+["test_gfxBlacklist_Equal_OK.js"]
+
+["test_gfxBlacklist_GTE_DriverOld.js"]
+
+["test_gfxBlacklist_GTE_OK.js"]
+
+["test_gfxBlacklist_No_Comparison.js"]
+
+["test_gfxBlacklist_OK.js"]
+
+["test_gfxBlacklist_OS.js"]
+
+["test_gfxBlacklist_OSVersion_match.js"]
+
+["test_gfxBlacklist_OSVersion_mismatch_DriverVersion.js"]
+
+["test_gfxBlacklist_OSVersion_mismatch_OSVersion.js"]
+
+["test_gfxBlacklist_Vendor.js"]
+
+["test_gfxBlacklist_Version.js"]
+
+["test_gfxBlacklist_prefs.js"]
+# Bug 1248787 - consistently fails
+skip-if = ["true"]
+
+["test_softblocked.js"]
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AMBrowserExtensionsImport.js b/toolkit/mozapps/extensions/test/xpcshell/test_AMBrowserExtensionsImport.js
new file mode 100644
index 0000000000..b3416b227a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AMBrowserExtensionsImport.js
@@ -0,0 +1,500 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { AMBrowserExtensionsImport } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const mockAddonRepository = ({
+ addons = [],
+ expectedBrowserID = null,
+ expectedExtensionIDs = null,
+ matchedIDs = [],
+ unmatchedIDs = [],
+}) => {
+ return {
+ async getMappedAddons(browserID, extensionIDs) {
+ if (expectedBrowserID) {
+ Assert.equal(browserID, expectedBrowserID, "expected browser ID");
+ }
+ if (expectedExtensionIDs) {
+ Assert.deepEqual(
+ extensionIDs,
+ expectedExtensionIDs,
+ "expected extension IDs"
+ );
+ }
+
+ return Promise.resolve({
+ addons,
+ matchedIDs,
+ unmatchedIDs,
+ });
+ },
+ };
+};
+
+const assertStageInstallsResult = (result, importedAddonIDs) => {
+ // Sort the results to always assert the elements in the same order.
+ result.importedAddonIDs.sort();
+ Assert.deepEqual(result, { importedAddonIDs }, "expected results");
+ Assert.ok(
+ AMBrowserExtensionsImport.hasPendingImportedAddons,
+ "expected pending imported add-ons"
+ );
+};
+
+const cancelInstalls = async importedAddonIDs => {
+ const promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-cancelled"
+ );
+ // We want to verify that we received a `onInstallCancelled` event per
+ // (cancelled) install (i.e. per imported add-on).
+ const cancelledPromises = importedAddonIDs.map(id =>
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallCancelled",
+ (install, cancelledByUser) => {
+ Assert.equal(cancelledByUser, false, "Not user-cancelled");
+ return install.addon.id == id;
+ }
+ )
+ );
+ await AMBrowserExtensionsImport.cancelInstalls();
+ await Promise.all([promiseTopic, ...cancelledPromises]);
+ Assert.ok(
+ !AMBrowserExtensionsImport.hasPendingImportedAddons,
+ "expected no pending imported add-ons"
+ );
+};
+
+const TEST_SERVER = createHttpServer({ hosts: ["example.com"] });
+
+const ADDONS = {
+ ext1: {
+ manifest: {
+ name: "Ext 1",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "ff@ext-1" } },
+ },
+ },
+ ext2: {
+ manifest: {
+ name: "Ext 2",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "ff@ext-2" } },
+ },
+ },
+};
+// Populated in `setup()`.
+const XPIS = {};
+// Populated in `setup()`.
+const ADDON_SEARCH_RESULTS = {};
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+add_setup(async function setup() {
+ for (const [name, data] of Object.entries(ADDONS)) {
+ XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data);
+ TEST_SERVER.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+
+ ADDON_SEARCH_RESULTS[name] = {
+ id: data.manifest.browser_specific_settings.gecko.id,
+ name: data.name,
+ version: data.version,
+ sourceURI: Services.io.newURI(`http://example.com/addons/${name}.xpi`),
+ icons: {},
+ };
+ }
+
+ await AddonTestUtils.promiseStartupManager();
+
+ // FOG needs a profile directory to put its data in.
+ const profileDir = do_get_profile();
+
+ // FOG needs to be initialized in order for data to flow.
+ Services.fog.initializeFOG();
+
+ // When we stage installs and then cancel them, `XPIInstall` won't be able to
+ // remove the staging directory (which is expected to be empty) until the
+ // next restart. This causes an `AddonTestUtils` assertion to fail because we
+ // don't expect any staging directory at the end of the tests. That's why we
+ // remove this directory in the cleanup function defined below.
+ //
+ // We only remove the staging directory and that will only works if the
+ // directory is empty, otherwise an unchaught error will be thrown (on
+ // purpose).
+ registerCleanupFunction(() => {
+ const stagingDir = profileDir.clone();
+ stagingDir.append("extensions");
+ stagingDir.append("staged");
+ stagingDir.exists() && stagingDir.remove(/* recursive */ false);
+
+ // Clear the add-on repository override.
+ AMBrowserExtensionsImport._addonRepository = null;
+ });
+});
+
+add_task(async function test_stage_and_complete_installs() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+ const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
+
+ let promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ const result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ // Make sure the prompt handler is the one from `AMBrowserExtensionsImport`
+ // since we don't want to show a permission prompt during an import.
+ for (const install of AMBrowserExtensionsImport._pendingInstallsMap.values()) {
+ Assert.equal(
+ install.promptHandler,
+ AMBrowserExtensionsImport._installPromptHandler,
+ "expected prompt handler to be the one set by AMBrowserExtensionsImport"
+ );
+ }
+
+ promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-complete"
+ );
+ const endedPromises = importedAddonIDs.map(id =>
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id == id
+ )
+ );
+ await AMBrowserExtensionsImport.completeInstalls();
+ await Promise.all([promiseTopic, ...endedPromises]);
+
+ Assert.ok(
+ !AMBrowserExtensionsImport.hasPendingImportedAddons,
+ "expected no pending imported add-ons"
+ );
+ Assert.ok(
+ !AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
+ !AMBrowserExtensionsImport._importInProgress,
+ "expected internal state to be consistent"
+ );
+
+ for (const id of importedAddonIDs) {
+ const addon = await AddonManager.getAddonByID(id);
+ Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
+ await addon.uninstall();
+ }
+});
+
+add_task(async function test_stage_and_cancel_installs() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+ const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
+
+ const promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ const result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ await cancelInstalls(importedAddonIDs);
+});
+
+add_task(async function test_stageInstalls_telemetry() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ const unmatchedIDs = ["unmatched-1", "unmatched-2"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ matchedIDs: ["ext-1", "ext-2"],
+ unmatchedIDs,
+ });
+ const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
+
+ const promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ const result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ Assert.deepEqual(
+ Glean.browserMigration.matchedExtensions.testGetValue(),
+ extensionIDs
+ );
+ Assert.deepEqual(
+ Glean.browserMigration.unmatchedExtensions.testGetValue(),
+ unmatchedIDs
+ );
+
+ await cancelInstalls(importedAddonIDs);
+});
+
+add_task(async function test_call_stageInstalls_twice() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ // Only return one extension.
+ addons: Object.values(ADDON_SEARCH_RESULTS).slice(0, 1),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+ const importedAddonIDs = ["ff@ext-1"];
+
+ const promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ let result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ await Assert.rejects(
+ AMBrowserExtensionsImport.stageInstalls(browserID, []),
+ /Cannot stage installs because there are pending imported add-ons/,
+ "expected rejection because there are pending imported add-ons"
+ );
+
+ // Cancel the installs for the previous import.
+ await cancelInstalls(importedAddonIDs);
+
+ // We should now be able to stage installs again.
+ result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ await cancelInstalls(importedAddonIDs);
+});
+
+add_task(async function test_call_stageInstalls_no_addons() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-123456"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ // Returns no mapped add-ons.
+ addons: [],
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+
+ const result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+
+ Assert.deepEqual(result, { importedAddonIDs: [] }, "expected result");
+ Assert.ok(
+ !AMBrowserExtensionsImport.hasPendingImportedAddons,
+ "expected no pending imported add-ons"
+ );
+ Assert.ok(
+ !AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
+ !AMBrowserExtensionsImport._importInProgress,
+ "expected internal state to be consistent"
+ );
+});
+
+add_task(async function test_import_twice() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+ const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
+
+ let promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ let result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ // Finalize the installs.
+ promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-complete"
+ );
+ const endedPromises = importedAddonIDs.map(id =>
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id == id
+ )
+ );
+ await AMBrowserExtensionsImport.completeInstalls();
+ await Promise.all([promiseTopic, ...endedPromises]);
+
+ // Try to import the same add-ons again. Because these add-ons are already
+ // installed, we shouldn't re-import them again.
+ result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ Assert.deepEqual(result, { importedAddonIDs: [] }, "expected result");
+ Assert.ok(
+ !AMBrowserExtensionsImport.hasPendingImportedAddons,
+ "expected no pending imported add-ons"
+ );
+ Assert.ok(
+ !AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
+ !AMBrowserExtensionsImport._importInProgress,
+ "expected internal state to be consistent"
+ );
+
+ for (const id of importedAddonIDs) {
+ const addon = await AddonManager.getAddonByID(id);
+ Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
+ await addon.uninstall();
+ }
+});
+
+add_task(async function test_call_cancelInstalls_without_pending_import() {
+ await Assert.rejects(
+ AMBrowserExtensionsImport.cancelInstalls(),
+ /No import in progress/,
+ "expected an error"
+ );
+});
+
+add_task(async function test_call_completeInstalls_without_pending_import() {
+ await Assert.rejects(
+ AMBrowserExtensionsImport.completeInstalls(),
+ /No import in progress/,
+ "expected an error"
+ );
+});
+
+add_task(async function test_stage_installs_with_download_aborted() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+ const importedAddonIDs = ["ff@ext-2"];
+
+ // This listener will be triggered once (for the first imported add-on). Its
+ // goal is to cancel the download of an imported add-on and make sure it
+ // doesn't break everything. We still expect the second add-on to import to
+ // be staged for install.
+ const onNewInstall = AddonTestUtils.promiseInstallEvent(
+ "onNewInstall",
+ install => {
+ install.addListener({
+ onDownloadStarted: () => false,
+ });
+ return true;
+ }
+ );
+ const promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ const result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await Promise.all([onNewInstall, promiseTopic]);
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ Assert.ok(
+ AMBrowserExtensionsImport.hasPendingImportedAddons,
+ "expected pending imported add-ons"
+ );
+ Assert.ok(
+ AMBrowserExtensionsImport._canCompleteOrCancelInstalls &&
+ AMBrowserExtensionsImport._importInProgress,
+ "expected internal state to be consistent"
+ );
+
+ // Let's cancel the pending installs.
+ await cancelInstalls(importedAddonIDs);
+});
+
+add_task(async function test_stageInstalls_then_restart_addonManager() {
+ const browserID = "some-browser-id";
+ const extensionIDs = ["ext-1", "ext-2"];
+ const EXPECTED_SOURCE_URI_SPECS = {
+ ["ff@ext-1"]: "http://example.com/addons/ext1.xpi",
+ ["ff@ext-2"]: "http://example.com/addons/ext2.xpi",
+ };
+ AMBrowserExtensionsImport._addonRepository = mockAddonRepository({
+ addons: Object.values(ADDON_SEARCH_RESULTS),
+ expectedBrowserID: browserID,
+ expectedExtensionIDs: extensionIDs,
+ });
+ const importedAddonIDs = ["ff@ext-1", "ff@ext-2"];
+
+ let promiseTopic = TestUtils.topicObserved(
+ "webextension-imported-addons-pending"
+ );
+ let result = await AMBrowserExtensionsImport.stageInstalls(
+ browserID,
+ extensionIDs
+ );
+ await promiseTopic;
+ assertStageInstallsResult(result, importedAddonIDs);
+
+ // We restart the add-ons manager to simulate a browser restart. It isn't
+ // quite the same but that should be enough.
+ await AddonTestUtils.promiseRestartManager();
+
+ for (const id of importedAddonIDs) {
+ const addon = await AddonManager.getAddonByID(id);
+ Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`);
+ // Verify that the sourceURI and installTelemetryInfo also match
+ // the values expected for the addons installed from the browser
+ // imports install flow.
+ Assert.deepEqual(
+ {
+ id: addon.id,
+ sourceURI: addon.sourceURI?.spec,
+ installTelemetryInfo: addon.installTelemetryInfo,
+ },
+ {
+ id,
+ sourceURI: EXPECTED_SOURCE_URI_SPECS[id],
+ installTelemetryInfo: {
+ source: AMBrowserExtensionsImport.TELEMETRY_SOURCE,
+ },
+ },
+ "Got the expected AddonWrapper properties"
+ );
+ await addon.uninstall();
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
new file mode 100644
index 0000000000..e5dffe0b00
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
@@ -0,0 +1,908 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { AbuseReporter, AbuseReportError } = ChromeUtils.importESModule(
+ "resource://gre/modules/AbuseReporter.sys.mjs"
+);
+
+const { ClientID } = ChromeUtils.importESModule(
+ "resource://gre/modules/ClientID.sys.mjs"
+);
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const APPNAME = "XPCShell";
+const APPVERSION = "1";
+const ADDON_ID = "test-addon@tests.mozilla.org";
+const ADDON_ID2 = "test-addon2@tests.mozilla.org";
+const FAKE_INSTALL_INFO = {
+ source: "fake-Install:Source",
+ method: "fake:install method",
+};
+const PREF_REQUIRED_LOCALE = "intl.locale.requested";
+const REPORT_OPTIONS = { reportEntryPoint: "menu" };
+const TELEMETRY_EVENTS_FILTERS = {
+ category: "addonsManager",
+ method: "report",
+};
+
+const FAKE_AMO_DETAILS = {
+ name: {
+ "en-US": "fake name",
+ "it-IT": "fake it-IT name",
+ },
+ current_version: { version: "1.0" },
+ type: "extension",
+ is_recommended: true,
+};
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const server = createHttpServer({ hosts: ["test.addons.org"] });
+
+// Mock abuse report API endpoint.
+let apiRequestHandler;
+server.registerPathHandler("/api/report/", (request, response) => {
+ const stream = request.bodyInputStream;
+ const buffer = NetUtil.readInputStream(stream, stream.available());
+ const data = new TextDecoder().decode(buffer);
+ apiRequestHandler({ data, request, response });
+});
+
+// Mock addon details API endpoint.
+const amoAddonDetailsMap = new Map();
+server.registerPrefixHandler("/api/addons/addon/", (request, response) => {
+ const addonId = request.path.split("/").pop();
+ if (!amoAddonDetailsMap.has(addonId)) {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ response.write(JSON.stringify({ detail: "Not found." }));
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "Success");
+ response.write(JSON.stringify(amoAddonDetailsMap.get(addonId)));
+ }
+});
+
+function getProperties(obj, propNames) {
+ return propNames.reduce((acc, el) => {
+ acc[el] = obj[el];
+ return acc;
+ }, {});
+}
+
+function handleSubmitRequest({ request, response }) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json", false);
+ response.write("{}");
+}
+
+function clearAbuseReportState() {
+ // Clear the timestamp of the last submission.
+ AbuseReporter._lastReportTimestamp = null;
+}
+
+async function installTestExtension(overrideOptions = {}) {
+ const extOptions = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ name: "Test Extension",
+ },
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: FAKE_INSTALL_INFO,
+ ...overrideOptions,
+ };
+
+ const extension = ExtensionTestUtils.loadExtension(extOptions);
+ await extension.startup();
+
+ const addon = await AddonManager.getAddonByID(ADDON_ID);
+
+ return { extension, addon };
+}
+
+async function assertRejectsAbuseReportError(promise, errorType, errorInfo) {
+ let error;
+
+ await Assert.rejects(
+ promise,
+ err => {
+ // Log the actual error to make investigating test failures easier.
+ Cu.reportError(err);
+ error = err;
+ return err instanceof AbuseReportError;
+ },
+ `Got an AbuseReportError`
+ );
+
+ equal(error.errorType, errorType, "Got the expected errorType");
+ equal(error.errorInfo, errorInfo, "Got the expected errorInfo");
+ ok(
+ error.message.includes(errorType),
+ "errorType should be included in the error message"
+ );
+ if (errorInfo) {
+ ok(
+ error.message.includes(errorInfo),
+ "errorInfo should be included in the error message"
+ );
+ }
+}
+
+async function assertBaseReportData({ reportData, addon }) {
+ // Report properties related to addon metadata.
+ equal(reportData.addon, ADDON_ID, "Got expected 'addon'");
+ equal(
+ reportData.addon_version,
+ addon.version,
+ "Got expected 'addon_version'"
+ );
+ equal(
+ reportData.install_date,
+ addon.installDate.toISOString(),
+ "Got expected 'install_date' in ISO format"
+ );
+ equal(
+ reportData.addon_install_origin,
+ addon.sourceURI.spec,
+ "Got expected 'addon_install_origin'"
+ );
+ equal(
+ reportData.addon_install_source,
+ "fake_install_source",
+ "Got expected 'addon_install_source'"
+ );
+ equal(
+ reportData.addon_install_method,
+ "fake_install_method",
+ "Got expected 'addon_install_method'"
+ );
+ equal(
+ reportData.addon_signature,
+ "privileged",
+ "Got expected 'addon_signature'"
+ );
+
+ // Report properties related to the environment.
+ equal(
+ reportData.client_id,
+ await ClientID.getClientIdHash(),
+ "Got the expected 'client_id'"
+ );
+ equal(reportData.app, APPNAME.toLowerCase(), "Got expected 'app'");
+ equal(reportData.appversion, APPVERSION, "Got expected 'appversion'");
+ equal(
+ reportData.lang,
+ Services.locale.appLocaleAsBCP47,
+ "Got expected 'lang'"
+ );
+ equal(
+ reportData.operating_system,
+ AppConstants.platform,
+ "Got expected 'operating_system'"
+ );
+ equal(
+ reportData.operating_system_version,
+ Services.sysinfo.getProperty("version"),
+ "Got expected 'operating_system_version'"
+ );
+}
+
+add_task(async function test_setup() {
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.url",
+ "http://test.addons.org/api/report/"
+ );
+
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.amoDetailsURL",
+ "http://test.addons.org/api/addons/addon"
+ );
+
+ 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;
+ });
+
+ // Register a fake it-IT locale (used to test localized AMO details in some
+ // of the test case defined in this test file).
+ L10nRegistry.getInstance().registerSources([
+ L10nFileSource.createMock(
+ "mock",
+ "app",
+ ["it-IT", "fr-FR"],
+ "resource://fake/locales/{locale}",
+ []
+ ),
+ ]);
+});
+
+add_task(async function test_addon_report_data() {
+ info("Verify report property for a privileged extension");
+ const { addon, extension } = await installTestExtension();
+ const data = await AbuseReporter.getReportData(addon);
+ await assertBaseReportData({ reportData: data, addon });
+ await extension.unload();
+
+ info("Verify 'addon_signature' report property for non privileged extension");
+ AddonTestUtils.usePrivilegedSignatures = false;
+ const { addon: addon2, extension: extension2 } = await installTestExtension();
+ const data2 = await AbuseReporter.getReportData(addon2);
+ equal(
+ data2.addon_signature,
+ "signed",
+ "Got expected 'addon_signature' for non privileged extension"
+ );
+ await extension2.unload();
+
+ info("Verify 'addon_install_method' report property on temporary install");
+ const { addon: addon3, extension: extension3 } = await installTestExtension({
+ useAddonManager: "temporary",
+ });
+ const data3 = await AbuseReporter.getReportData(addon3);
+ equal(
+ data3.addon_install_source,
+ "temporary_addon",
+ "Got expected 'addon_install_method' on temporary install"
+ );
+ await extension3.unload();
+});
+
+add_task(async function test_report_on_not_installed_addon() {
+ Services.telemetry.clearEvents();
+
+ // Make sure that the AMO addons details API endpoint is going to
+ // return a 404 status for the not installed addon.
+ amoAddonDetailsMap.delete(ADDON_ID);
+
+ await assertRejectsAbuseReportError(
+ AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS),
+ "ERROR_ADDON_NOTFOUND"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: { error_type: "ERROR_AMODETAILS_NOTFOUND" },
+ },
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: { error_type: "ERROR_ADDON_NOTFOUND" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ Services.telemetry.clearEvents();
+});
+
+// This tests verifies how the addon installTelemetryInfo values are being
+// normalized into the addon_install_source and addon_install_method
+// expected by the API endpoint.
+add_task(async function test_normalized_addon_install_source_and_method() {
+ async function assertAddonInstallMethod(amInstallTelemetryInfo, expected) {
+ const { addon, extension } = await installTestExtension({
+ amInstallTelemetryInfo,
+ });
+ const {
+ addon_install_method,
+ addon_install_source,
+ addon_install_source_url,
+ } = await AbuseReporter.getReportData(addon);
+
+ Assert.deepEqual(
+ {
+ addon_install_method,
+ addon_install_source,
+ addon_install_source_url,
+ },
+ {
+ addon_install_method: expected.method,
+ addon_install_source: expected.source,
+ addon_install_source_url: expected.sourceURL,
+ },
+ `Got the expected report data for ${JSON.stringify(
+ amInstallTelemetryInfo
+ )}`
+ );
+ await extension.unload();
+ }
+
+ // Array of testcases: the `test` property contains the installTelemetryInfo value
+ // and the `expect` contains the expected normalized values.
+ const TEST_CASES = [
+ // Explicitly verify normalized values on missing telemetry info.
+ {
+ test: null,
+ expect: { source: null, method: null },
+ },
+
+ // Verify expected normalized values for some common install telemetry info.
+ {
+ test: { source: "about:addons", method: "drag-and-drop" },
+ expect: { source: "about_addons", method: "drag_and_drop" },
+ },
+ {
+ test: { source: "amo", method: "amWebAPI" },
+ expect: { source: "amo", method: "amwebapi" },
+ },
+ {
+ test: { source: "app-profile", method: "sideload" },
+ expect: { source: "app_profile", method: "sideload" },
+ },
+ {
+ test: { source: "distribution" },
+ expect: { source: "distribution", method: null },
+ },
+ {
+ test: {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: "http://host.triggered.install/example?test=1",
+ },
+ expect: {
+ method: "installtrigger",
+ source: "test_host",
+ sourceURL: "http://host.triggered.install/example?test=1",
+ },
+ },
+ {
+ test: {
+ method: "link",
+ source: "unknown",
+ sourceURL: "https://another.host/installExtension?name=ext1",
+ },
+ expect: {
+ method: "link",
+ source: "unknown",
+ sourceURL: "https://another.host/installExtension?name=ext1",
+ },
+ },
+ ];
+
+ for (const { expect, test } of TEST_CASES) {
+ await assertAddonInstallMethod(test, expect);
+ }
+});
+
+add_task(async function test_report_create_and_submit() {
+ Services.telemetry.clearEvents();
+
+ // Override the test api server request handler, to be able to
+ // intercept the submittions to the test api server.
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ const { addon, extension } = await installTestExtension();
+
+ const reportEntryPoint = "menu";
+ const report = await AbuseReporter.createAbuseReport(ADDON_ID, {
+ reportEntryPoint,
+ });
+
+ equal(report.addon, addon, "Got the expected addon property");
+ equal(
+ report.reportEntryPoint,
+ reportEntryPoint,
+ "Got the expected reportEntryPoint"
+ );
+
+ const baseReportData = await AbuseReporter.getReportData(addon);
+ const reportProperties = {
+ message: "test message",
+ reason: "test-reason",
+ };
+
+ info("Submitting report");
+ report.setMessage(reportProperties.message);
+ report.setReason(reportProperties.reason);
+ await report.submit();
+
+ const expectedEntries = Object.entries({
+ report_entry_point: reportEntryPoint,
+ ...baseReportData,
+ ...reportProperties,
+ });
+
+ for (const [expectedKey, expectedValue] of expectedEntries) {
+ equal(
+ reportSubmitted[expectedKey],
+ expectedValue,
+ `Got the expected submitted value for "${expectedKey}"`
+ );
+ }
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: reportEntryPoint,
+ value: ADDON_ID,
+ extra: { addon_type: "extension" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_error_recent_submit() {
+ Services.telemetry.clearEvents();
+ clearAbuseReportState();
+
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ const { extension } = await installTestExtension();
+ const report = await AbuseReporter.createAbuseReport(ADDON_ID, {
+ reportEntryPoint: "uninstall",
+ });
+
+ const { extension: extension2 } = await installTestExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID2 } },
+ name: "Test Extension2",
+ },
+ });
+ const report2 = await AbuseReporter.createAbuseReport(
+ ADDON_ID2,
+ REPORT_OPTIONS
+ );
+
+ // Submit the two reports in fast sequence.
+ report.setReason("reason1");
+ report2.setReason("reason2");
+ await report.submit();
+ await assertRejectsAbuseReportError(report2.submit(), "ERROR_RECENT_SUBMIT");
+ equal(
+ reportSubmitted.reason,
+ "reason1",
+ "Server only received the data from the first submission"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "uninstall",
+ value: ADDON_ID,
+ extra: { addon_type: "extension" },
+ },
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID2,
+ extra: {
+ addon_type: "extension",
+ error_type: "ERROR_RECENT_SUBMIT",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ await extension.unload();
+ await extension2.unload();
+});
+
+add_task(async function test_submission_server_error() {
+ const { extension } = await installTestExtension();
+
+ async function testErrorCode({
+ responseStatus,
+ responseText = "",
+ expectedErrorType,
+ expectedErrorInfo,
+ expectRequest = true,
+ }) {
+ info(
+ `Test expected AbuseReportError on response status "${responseStatus}"`
+ );
+ Services.telemetry.clearEvents();
+ clearAbuseReportState();
+
+ let requestReceived = false;
+ apiRequestHandler = ({ request, response }) => {
+ requestReceived = true;
+ response.setStatusLine(request.httpVersion, responseStatus, "Error");
+ response.write(responseText);
+ };
+
+ const report = await AbuseReporter.createAbuseReport(
+ ADDON_ID,
+ REPORT_OPTIONS
+ );
+ report.setReason("a-reason");
+ const promiseSubmit = report.submit();
+ if (typeof expectedErrorType === "string") {
+ // Assert a specific AbuseReportError errorType.
+ await assertRejectsAbuseReportError(
+ promiseSubmit,
+ expectedErrorType,
+ expectedErrorInfo
+ );
+ } else {
+ // Assert on a given Error class.
+ await Assert.rejects(promiseSubmit, expectedErrorType);
+ }
+ equal(
+ requestReceived,
+ expectRequest,
+ `${expectRequest ? "" : "Not "}received a request as expected`
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: {
+ addon_type: "extension",
+ error_type:
+ typeof expectedErrorType === "string"
+ ? expectedErrorType
+ : "ERROR_UNKNOWN",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+ }
+
+ await testErrorCode({
+ responseStatus: 500,
+ responseText: "A server error",
+ expectedErrorType: "ERROR_SERVER",
+ expectedErrorInfo: JSON.stringify({
+ status: 500,
+ responseText: "A server error",
+ }),
+ });
+ await testErrorCode({
+ responseStatus: 404,
+ responseText: "Not found error",
+ expectedErrorType: "ERROR_CLIENT",
+ expectedErrorInfo: JSON.stringify({
+ status: 404,
+ responseText: "Not found error",
+ }),
+ });
+ // Test response with unexpected status code.
+ await testErrorCode({
+ responseStatus: 604,
+ responseText: "An unexpected status code",
+ expectedErrorType: "ERROR_UNKNOWN",
+ expectedErrorInfo: JSON.stringify({
+ status: 604,
+ responseText: "An unexpected status code",
+ }),
+ });
+ // Test response status 200 with invalid json data.
+ await testErrorCode({
+ responseStatus: 200,
+ expectedErrorType: /SyntaxError: JSON.parse/,
+ });
+
+ // Test on invalid url.
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.url",
+ "invalid-protocol://abuse-report"
+ );
+ await testErrorCode({
+ expectedErrorType: "ERROR_NETWORK",
+ expectRequest: false,
+ });
+
+ await extension.unload();
+});
+
+add_task(async function set_test_abusereport_url() {
+ Services.prefs.setCharPref(
+ "extensions.abuseReport.url",
+ "http://test.addons.org/api/report/"
+ );
+});
+
+add_task(async function test_submission_aborting() {
+ Services.telemetry.clearEvents();
+ clearAbuseReportState();
+
+ const { extension } = await installTestExtension();
+
+ // override the api request handler with one that is never going to reply.
+ let receivedRequestsCount = 0;
+ let resolvePendingResponses;
+ const waitToReply = new Promise(
+ resolve => (resolvePendingResponses = resolve)
+ );
+
+ const onRequestReceived = new Promise(resolve => {
+ apiRequestHandler = ({ request, response }) => {
+ response.processAsync();
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ receivedRequestsCount++;
+ resolve();
+
+ // Keep the request pending until resolvePendingResponses have been
+ // called.
+ waitToReply.then(() => {
+ response.finish();
+ });
+ };
+ });
+
+ const report = await AbuseReporter.createAbuseReport(
+ ADDON_ID,
+ REPORT_OPTIONS
+ );
+ report.setReason("a-reason");
+ const promiseResult = report.submit();
+
+ await onRequestReceived;
+
+ Assert.greater(
+ receivedRequestsCount,
+ 0,
+ "Got the expected number of requests"
+ );
+ Assert.strictEqual(
+ await Promise.race([promiseResult, Promise.resolve("pending")]),
+ "pending",
+ "Submission fetch request should still be pending"
+ );
+
+ report.abort();
+
+ await assertRejectsAbuseReportError(promiseResult, "ERROR_ABORTED_SUBMIT");
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: REPORT_OPTIONS.reportEntryPoint,
+ value: ADDON_ID,
+ extra: { addon_type: "extension", error_type: "ERROR_ABORTED_SUBMIT" },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ await extension.unload();
+
+ // Unblock pending requests on the server request handler side, so that the
+ // test file can shutdown (otherwise the test run will be stuck after this
+ // task completed).
+ resolvePendingResponses();
+});
+
+add_task(async function test_truncated_string_properties() {
+ const generateString = len => new Array(len).fill("a").join("");
+
+ const LONG_STRINGS_ADDON_ID = "addon-with-long-strings-props@mochi.test";
+ const { extension } = await installTestExtension({
+ manifest: {
+ name: generateString(400),
+ description: generateString(400),
+ browser_specific_settings: { gecko: { id: LONG_STRINGS_ADDON_ID } },
+ },
+ });
+
+ // Override the test api server request handler, to be able to
+ // intercept the properties actually submitted.
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ const report = await AbuseReporter.createAbuseReport(
+ LONG_STRINGS_ADDON_ID,
+ REPORT_OPTIONS
+ );
+
+ report.setMessage("fake-message");
+ report.setReason("fake-reason");
+ await report.submit();
+
+ const expected = {
+ addon_name: generateString(255),
+ addon_summary: generateString(255),
+ };
+
+ Assert.deepEqual(
+ {
+ addon_name: reportSubmitted.addon_name,
+ addon_summary: reportSubmitted.addon_summary,
+ },
+ expected,
+ "Got the long strings truncated as expected"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_report_recommended() {
+ const NON_RECOMMENDED_ADDON_ID = "non-recommended-addon@mochi.test";
+ const RECOMMENDED_ADDON_ID = "recommended-addon@mochi.test";
+
+ const now = Date.now();
+ const not_before = new Date(now - 3600000).toISOString();
+ const not_after = new Date(now + 3600000).toISOString();
+
+ const { extension: nonRecommended } = await installTestExtension({
+ manifest: {
+ name: "Fake non recommended addon",
+ browser_specific_settings: { gecko: { id: NON_RECOMMENDED_ADDON_ID } },
+ },
+ });
+
+ const { extension: recommended } = await installTestExtension({
+ manifest: {
+ name: "Fake recommended addon",
+ browser_specific_settings: { gecko: { id: RECOMMENDED_ADDON_ID } },
+ },
+ files: {
+ "mozilla-recommendation.json": {
+ addon_id: RECOMMENDED_ADDON_ID,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+
+ // Override the test api server request handler, to be able to
+ // intercept the properties actually submitted.
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ async function checkReportedSignature(addonId, expectedAddonSignature) {
+ clearAbuseReportState();
+ const report = await AbuseReporter.createAbuseReport(
+ addonId,
+ REPORT_OPTIONS
+ );
+ report.setMessage("fake-message");
+ report.setReason("fake-reason");
+ await report.submit();
+ equal(
+ reportSubmitted.addon_signature,
+ expectedAddonSignature,
+ `Got the expected addon_signature for ${addonId}`
+ );
+ }
+
+ await checkReportedSignature(NON_RECOMMENDED_ADDON_ID, "signed");
+ await checkReportedSignature(RECOMMENDED_ADDON_ID, "curated");
+
+ await nonRecommended.unload();
+ await recommended.unload();
+});
+
+add_task(async function test_query_amo_details() {
+ async function assertReportOnAMODetails({
+ addonId,
+ addonType = "extension",
+ expectedReport,
+ } = {}) {
+ // Clear last report timestamp and any telemetry event recorded so far.
+ clearAbuseReportState();
+ Services.telemetry.clearEvents();
+
+ const report = await AbuseReporter.createAbuseReport(addonId, {
+ reportEntryPoint: "menu",
+ });
+
+ let reportSubmitted;
+ apiRequestHandler = ({ data, request, response }) => {
+ reportSubmitted = JSON.parse(data);
+ handleSubmitRequest({ request, response });
+ };
+
+ report.setMessage("fake message");
+ report.setReason("reason1");
+ await report.submit();
+
+ Assert.deepEqual(
+ expectedReport,
+ getProperties(reportSubmitted, Object.keys(expectedReport)),
+ "Got the expected report properties"
+ );
+
+ // Telemetry recorded for the successfully submitted report.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "menu",
+ value: addonId,
+ extra: { addon_type: FAKE_AMO_DETAILS.type },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS
+ );
+
+ clearAbuseReportState();
+ }
+
+ // Add the expected AMO addons details.
+ const addonId = "not-installed-addon@mochi.test";
+ amoAddonDetailsMap.set(addonId, FAKE_AMO_DETAILS);
+
+ // Test on the default en-US locale.
+ Services.prefs.setCharPref(PREF_REQUIRED_LOCALE, "en-US");
+ let locale = Services.locale.appLocaleAsBCP47;
+ equal(locale, "en-US", "Got the expected app locale set");
+
+ let expectedReport = {
+ addon: addonId,
+ addon_name: FAKE_AMO_DETAILS.name[locale],
+ addon_version: FAKE_AMO_DETAILS.current_version.version,
+ addon_install_source: "not_installed",
+ addon_install_method: null,
+ addon_signature: "curated",
+ };
+
+ await assertReportOnAMODetails({ addonId, expectedReport });
+
+ // Test with a non-default locale also available in the AMO details.
+ Services.prefs.setCharPref(PREF_REQUIRED_LOCALE, "it-IT");
+ locale = Services.locale.appLocaleAsBCP47;
+ equal(locale, "it-IT", "Got the expected app locale set");
+
+ expectedReport = {
+ ...expectedReport,
+ addon_name: FAKE_AMO_DETAILS.name[locale],
+ };
+ await assertReportOnAMODetails({ addonId, expectedReport });
+
+ // Test with a non-default locale not available in the AMO details.
+ Services.prefs.setCharPref(PREF_REQUIRED_LOCALE, "fr-FR");
+ locale = Services.locale.appLocaleAsBCP47;
+ equal(locale, "fr-FR", "Got the expected app locale set");
+
+ expectedReport = {
+ ...expectedReport,
+ // Fallbacks on en-US for non available locales.
+ addon_name: FAKE_AMO_DETAILS.name["en-US"],
+ };
+ await assertReportOnAMODetails({ addonId, expectedReport });
+
+ Services.prefs.clearUserPref(PREF_REQUIRED_LOCALE);
+
+ amoAddonDetailsMap.clear();
+});
+
+add_task(async function test_statictheme_normalized_into_type_theme() {
+ const themeId = "not-installed-statictheme@mochi.test";
+ amoAddonDetailsMap.set(themeId, {
+ ...FAKE_AMO_DETAILS,
+ type: "statictheme",
+ });
+
+ const report = await AbuseReporter.createAbuseReport(themeId, REPORT_OPTIONS);
+
+ equal(report.addon.id, themeId, "Got a report for the expected theme id");
+ equal(report.addon.type, "theme", "Got the expected addon type");
+
+ amoAddonDetailsMap.clear();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js
new file mode 100644
index 0000000000..6f1c99eaa8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js
@@ -0,0 +1,488 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests AddonRepository.jsm
+
+var gServer = createHttpServer({ hosts: ["example.com"] });
+
+const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons";
+const PREF_GETADDONS_BROWSESEARCHRESULTS =
+ "extensions.getAddons.search.browseURL";
+const PREF_GET_BROWSER_MAPPINGS = "extensions.getAddons.browserMappings.url";
+
+const BASE_URL = "http://example.com";
+const DEFAULT_URL = "about:blank";
+
+const ADDONS = [
+ {
+ manifest: {
+ name: "XPI Add-on 1",
+ version: "1.1",
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_1@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 2",
+ version: "1.2",
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_2@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 3",
+ version: "1.3",
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_3@tests.mozilla.org" },
+ },
+ },
+ },
+];
+
+// Path to source URI of installing add-on
+const INSTALL_URL2 = "/addons/test_AddonRepository_2.xpi";
+// Path to source URI of non-active add-on (state = STATE_AVAILABLE)
+const INSTALL_URL3 = "/addons/test_AddonRepository_3.xpi";
+
+// Properties of an individual add-on that should be checked
+// Note: name is checked separately
+var ADDON_PROPERTIES = [
+ "id",
+ "type",
+ "version",
+ "creator",
+ "developers",
+ "description",
+ "fullDescription",
+ "iconURL",
+ "icons",
+ "screenshots",
+ "supportURL",
+ "contributionURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+ "dailyUsers",
+ "sourceURI",
+ "updateDate",
+ "amoListingURL",
+];
+
+// Results of getAddonsByIDs
+var GET_RESULTS = [
+ {
+ id: "test1@tests.mozilla.org",
+ type: "extension",
+ version: "1.1",
+ creator: {
+ name: "Test Creator 1",
+ url: BASE_URL + "/creator1.html",
+ },
+ developers: [
+ {
+ name: "Test Developer 1",
+ url: BASE_URL + "/developer1.html",
+ },
+ ],
+ description: "Test Summary 1",
+ fullDescription: "Test Description 1",
+ iconURL: BASE_URL + "/icon1.png",
+ icons: { 32: BASE_URL + "/icon1.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/full1-1.png",
+ width: 400,
+ height: 300,
+ thumbnailURL: BASE_URL + "/thumbnail1-1.png",
+ thumbnailWidth: 200,
+ thumbnailHeight: 150,
+ caption: "Caption 1 - 1",
+ },
+ {
+ url: BASE_URL + "/full2-1.png",
+ thumbnailURL: BASE_URL + "/thumbnail2-1.png",
+ caption: "Caption 2 - 1",
+ },
+ ],
+ supportURL: BASE_URL + "/support1.html",
+ contributionURL: BASE_URL + "/contribution1.html",
+ averageRating: 4,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/review1.html",
+ weeklyDownloads: 3333,
+ sourceURI: BASE_URL + INSTALL_URL2,
+ updateDate: new Date(1265033045000),
+ amoListingURL:
+ "https://addons.mozilla.org/en-US/firefox/addon/test1@tests.mozilla.org/",
+ },
+ {
+ id: "test2@tests.mozilla.org",
+ type: "extension",
+ version: "2.0",
+ icons: {},
+ sourceURI: "http://example.com/addons/bleah.xpi",
+ },
+ {
+ id: "test_AddonRepository_1@tests.mozilla.org",
+ type: "theme",
+ version: "1.4",
+ icons: {},
+ },
+];
+
+// Values for testing AddonRepository.getAddonsByIDs()
+var GET_TEST = {
+ preference: PREF_GETADDONS_BYIDS,
+ preferenceValue: BASE_URL + "/%OS%/%VERSION%/%IDS%",
+ failedIDs: ["test1@tests.mozilla.org"],
+ failedURL: "/XPCShell/1/test1%40tests.mozilla.org",
+ successfulIDs: [
+ "test1@tests.mozilla.org",
+ "test2@tests.mozilla.org",
+ "{00000000-1111-2222-3333-444444444444}",
+ "test_AddonRepository_1@tests.mozilla.org",
+ ],
+ successfulURL:
+ "/XPCShell/1/test1%40tests.mozilla.org%2C" +
+ "test2%40tests.mozilla.org%2C" +
+ "%7B00000000-1111-2222-3333-444444444444%7D%2C" +
+ "test_AddonRepository_1%40tests.mozilla.org",
+ successfulRTAURL:
+ "/XPCShell/1/rta%3AdGVzdDFAdGVzdHMubW96aWxsYS5vcmc%2C" +
+ "test2%40tests.mozilla.org%2C" +
+ "%7B00000000-1111-2222-3333-444444444444%7D%2C" +
+ "test_AddonRepository_1%40tests.mozilla.org",
+};
+
+const GET_BROWSER_MAPPINGS_URL = `${BASE_URL}/browser-mappings/%BROWSER%`;
+
+// Test that actual results and expected results are equal
+function check_results(aActualAddons, aExpectedAddons) {
+ do_check_addons(aActualAddons, aExpectedAddons, ADDON_PROPERTIES);
+
+ // Additional tests
+ aActualAddons.forEach(function check_each_addon(aActualAddon) {
+ // Separately check name so better messages are output when test fails
+ if (aActualAddon.name == "FAIL") {
+ do_throw(aActualAddon.id + " - " + aActualAddon.description);
+ }
+ if (aActualAddon.name != "PASS") {
+ do_throw(aActualAddon.id + " - invalid add-on name " + aActualAddon.name);
+ }
+ });
+}
+
+add_task(async function setup() {
+ // Setup for test
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+
+ let xpis = ADDONS.map(addon => createTempWebExtensionFile(addon));
+
+ // Register other add-on XPI files
+ gServer.registerFile(INSTALL_URL2, xpis[1]);
+ gServer.registerFile(INSTALL_URL3, xpis[2]);
+
+ // Register files used to test search failure
+ gServer.registerFile(
+ GET_TEST.failedURL,
+ do_get_file("data/test_AddonRepository_fail.json")
+ );
+
+ // Register files used to test search success
+ gServer.registerFile(
+ GET_TEST.successfulURL,
+ do_get_file("data/test_AddonRepository_getAddonsByIDs.json")
+ );
+ // Register file for RTA test
+ gServer.registerFile(
+ GET_TEST.successfulRTAURL,
+ do_get_file("data/test_AddonRepository_getAddonsByIDs.json")
+ );
+
+ // Register some files/handlers for browser mapping tests.
+ gServer.registerFile(
+ // Keep in sync with `GET_BROWSER_MAPPINGS_URL`.
+ "/browser-mappings/valid-browser-id",
+ do_get_file("data/test_AddonRepository_getMappedAddons.json")
+ );
+ gServer.registerFile(
+ // This is used in `test_getMappedAddons_empty_mapping()` and should be
+ // updated if `GET_BROWSER_MAPPINGS_URL` is also updated.
+ "/browser-mappings/browser-id-empty-results",
+ do_get_file("data/test_AddonRepository_getMappedAddons_empty.json")
+ );
+ gServer.registerPrefixHandler(
+ // Keep in sync with the pref set in `test_getMappedAddons_with_paging()`.
+ "/browser-mappings/with-paging/valid-browser-id/",
+ // This handler parses the query string of the request it receives in order
+ // to force the `getMappedAddons()` method to call the same API endpoint a
+ // few times (by incrementing the integer value in the query string every
+ // time). After that, this handler returns a "next' URL that points to one
+ // of the valid endpoints registered above, which won't have a "next" URL.
+ // We do this to verify that `getMappedAddons()` supports paginated API
+ // results.
+ (request, response) => {
+ const page = parseInt(request.queryString, 10);
+ const nextPath =
+ page < 3
+ ? `with-paging/valid-browser-id/?${page + 1}`
+ : `valid-browser-id`;
+
+ response.setHeader("content-type", "application/json");
+ response.write(
+ JSON.stringify({
+ count: 0,
+ next: `${BASE_URL}/browser-mappings/${nextPath}`,
+ page_count: page,
+ page_size: 1,
+ previous: null,
+ results: [],
+ })
+ );
+ }
+ );
+
+ await promiseStartupManager();
+
+ // Install an add-on so can check that it isn't returned in the results
+ await promiseInstallFile(xpis[0]);
+ await promiseRestartManager();
+
+ // Create an active AddonInstall so can check that it isn't returned in the results
+ let install = await AddonManager.getInstallForURL(BASE_URL + INSTALL_URL2);
+ let promise = promiseCompleteInstall(install);
+ registerCleanupFunction(() => promise);
+
+ // Create a non-active AddonInstall so can check that it is returned in the results
+ await AddonManager.getInstallForURL(BASE_URL + INSTALL_URL3);
+});
+
+// Tests homepageURL and getSearchURL()
+add_task(async function test_1() {
+ function check_urls(aPreference, aGetURL, aTests) {
+ aTests.forEach(function (aTest) {
+ Services.prefs.setCharPref(aPreference, aTest.preferenceValue);
+ Assert.equal(aGetURL(aTest), aTest.expectedURL);
+ });
+ }
+
+ var urlTests = [
+ {
+ preferenceValue: BASE_URL,
+ expectedURL: BASE_URL,
+ },
+ {
+ preferenceValue: BASE_URL + "/%OS%/%VERSION%",
+ expectedURL: BASE_URL + "/XPCShell/1",
+ },
+ ];
+
+ // Extra tests for AddonRepository.getSearchURL();
+ var searchURLTests = [
+ {
+ searchTerms: "test",
+ preferenceValue: BASE_URL + "/search?q=%TERMS%",
+ expectedURL: BASE_URL + "/search?q=test",
+ },
+ {
+ searchTerms: "test search",
+ preferenceValue: BASE_URL + "/%TERMS%",
+ expectedURL: BASE_URL + "/test%20search",
+ },
+ {
+ searchTerms: 'odd=search:with&weird"characters',
+ preferenceValue: BASE_URL + "/%TERMS%",
+ expectedURL: BASE_URL + "/odd%3Dsearch%3Awith%26weird%22characters",
+ },
+ ];
+
+ // Setup tests for homepageURL and getSearchURL()
+ var tests = [
+ {
+ initiallyUndefined: true,
+ preference: PREF_GETADDONS_BROWSEADDONS,
+ urlTests,
+ getURL: () => AddonRepository.homepageURL,
+ },
+ {
+ initiallyUndefined: false,
+ preference: PREF_GETADDONS_BROWSESEARCHRESULTS,
+ urlTests: urlTests.concat(searchURLTests),
+ getURL: function getSearchURL(aTest) {
+ var searchTerms =
+ aTest && aTest.searchTerms ? aTest.searchTerms : "unused terms";
+ return AddonRepository.getSearchURL(searchTerms);
+ },
+ },
+ ];
+
+ tests.forEach(function url_test(aTest) {
+ if (aTest.initiallyUndefined) {
+ // Preference is not defined by default
+ Assert.equal(
+ Services.prefs.getPrefType(aTest.preference),
+ Services.prefs.PREF_INVALID
+ );
+ Assert.equal(aTest.getURL(), DEFAULT_URL);
+ }
+
+ check_urls(aTest.preference, aTest.getURL, aTest.urlTests);
+ });
+});
+
+// Tests failure of AddonRepository.getAddonsByIDs()
+add_task(async function test_getAddonsByID_fails() {
+ Services.prefs.setCharPref(GET_TEST.preference, GET_TEST.preferenceValue);
+
+ await Assert.rejects(
+ AddonRepository.getAddonsByIDs(GET_TEST.failedIDs),
+ /Error: GET.*?failed/
+ );
+});
+
+// Tests success of AddonRepository.getAddonsByIDs()
+add_task(async function test_getAddonsByID_succeeds() {
+ let result = await AddonRepository.getAddonsByIDs(GET_TEST.successfulIDs);
+
+ check_results(result, GET_RESULTS);
+});
+
+// Tests success of AddonRepository.getAddonsByIDs() with rta ID.
+add_task(async function test_getAddonsByID_rta() {
+ let id = `rta:${btoa(GET_TEST.successfulIDs[0])}`.slice(0, -1);
+ GET_TEST.successfulIDs[0] = id;
+ let result = await AddonRepository.getAddonsByIDs(GET_TEST.successfulIDs);
+
+ check_results(result, GET_RESULTS);
+});
+
+add_task(
+ {
+ pref_set: [[PREF_GET_BROWSER_MAPPINGS, GET_BROWSER_MAPPINGS_URL]],
+ },
+ async function test_getMappedAddons() {
+ const {
+ addons: result,
+ matchedIDs,
+ unmatchedIDs,
+ } = await AddonRepository.getMappedAddons("valid-browser-id", [
+ "browser-extension-test-1",
+ "browser-extension-test-2",
+ // This one is mapped but the search API won't return any data.
+ "browser-extension-test-3",
+ "browser-extension-test-4",
+ // These ones are not mapped to any Firefox add-ons.
+ "browser-extension-test-5",
+ "browser-extension-test-6",
+ ]);
+ Assert.equal(result.length, 3, "expected 3 mapped add-ons");
+ check_results(result, GET_RESULTS);
+ Assert.deepEqual(matchedIDs, [
+ "browser-extension-test-1",
+ "browser-extension-test-2",
+ "browser-extension-test-3",
+ "browser-extension-test-4",
+ ]);
+ Assert.deepEqual(unmatchedIDs, [
+ "browser-extension-test-5",
+ "browser-extension-test-6",
+ ]);
+ }
+);
+
+add_task(
+ {
+ pref_set: [[PREF_GET_BROWSER_MAPPINGS, GET_BROWSER_MAPPINGS_URL]],
+ },
+ async function test_getMappedAddons_empty_list_of_ids() {
+ const {
+ addons: result,
+ matchedIDs,
+ unmatchedIDs,
+ } = await AddonRepository.getMappedAddons("valid-browser-id", []);
+ Assert.equal(result.length, 0, "expected 0 mapped add-ons");
+ Assert.equal(matchedIDs.length, 0, "expected 0 matched IDs");
+ Assert.equal(unmatchedIDs.length, 0, "expected 0 unmatched IDs");
+ }
+);
+
+add_task(
+ {
+ pref_set: [[PREF_GET_BROWSER_MAPPINGS, GET_BROWSER_MAPPINGS_URL]],
+ },
+ async function test_getMappedAddons_invalid_ids() {
+ const {
+ addons: result,
+ matchedIDs,
+ unmatchedIDs,
+ } = await AddonRepository.getMappedAddons("valid-browser-id", [
+ "",
+ null,
+ undefined,
+ ]);
+ Assert.equal(result.length, 0, "expected 0 mapped add-ons");
+ Assert.equal(matchedIDs.length, 0, "expected 0 matched IDs");
+ Assert.deepEqual(unmatchedIDs, ["", null, undefined]);
+ }
+);
+
+add_task(
+ {
+ pref_set: [[PREF_GET_BROWSER_MAPPINGS, GET_BROWSER_MAPPINGS_URL]],
+ },
+ async function test_getMappedAddons_empty_mapping() {
+ const {
+ addons: result,
+ matchedIDs,
+ unmatchedIDs,
+ } = await AddonRepository.getMappedAddons("browser-id-empty-results", [
+ "browser-extension-test-1",
+ "browser-extension-test-2",
+ "browser-extension-test-3",
+ ]);
+ Assert.equal(result.length, 0, "expected no mapped add-ons");
+ Assert.equal(matchedIDs.length, 0, "expected 0 matched IDs");
+ Assert.equal(unmatchedIDs.length, 3, "expected 3 unmatched IDs");
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ [
+ PREF_GET_BROWSER_MAPPINGS,
+ `${BASE_URL}/browser-mappings/with-paging/%BROWSER%/?1`,
+ ],
+ ],
+ },
+ async function test_getMappedAddons_with_paging() {
+ const {
+ addons: result,
+ matchedIDs,
+ unmatchedIDs,
+ } = await AddonRepository.getMappedAddons("valid-browser-id", [
+ "browser-extension-test-1",
+ "browser-extension-test-2",
+ // This one is mapped but the search API won't return any data.
+ "browser-extension-test-3",
+ "browser-extension-test-4",
+ ]);
+ Assert.equal(result.length, 3, "expected 3 mapped add-ons");
+ check_results(result, GET_RESULTS);
+ Assert.deepEqual(matchedIDs, [
+ "browser-extension-test-1",
+ "browser-extension-test-2",
+ "browser-extension-test-3",
+ "browser-extension-test-4",
+ ]);
+ Assert.deepEqual(unmatchedIDs, []);
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_appIsShuttingDown.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_appIsShuttingDown.js
new file mode 100644
index 0000000000..4f23026ed3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_appIsShuttingDown.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests AddonRepository.jsm when backgroundUpdateChecks are hit while the application
+// shutdown has been already initiated (See Bug 1841444).
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_setup(async () => {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+ );
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_backgroundUpdateCheck_after_shutdown_initiated() {
+ const sandbox = sinon.createSandbox();
+ ok(
+ !AddonRepository.appIsShuttingDown,
+ "Expect real appIsShuttingDown getter to be returning false"
+ );
+ sandbox.stub(AddonRepository, "appIsShuttingDown").get(() => true);
+ ok(
+ AddonRepository.appIsShuttingDown,
+ "Expect mocked appIsShuttingDown getter to be returning true"
+ );
+ sandbox.spy(AddonRepository, "_getAllInstalledAddons");
+ equal(
+ AddonRepository._getAllInstalledAddons.callCount,
+ 0,
+ "Expect _getAllInstalledAddons callCount to be initially 0"
+ );
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ // We expect backgroundUpdateCheck to be returning earlier and not be calling _getAllInstalledAddons method at all.
+ equal(
+ AddonRepository._getAllInstalledAddons.callCount,
+ 0,
+ "Expect _getAllInstalledAddons to not have been called"
+ );
+ sandbox.restore();
+});
+
+add_task(async function test_fetchPaged_after_shutdown_initiated() {
+ const sandbox = sinon.createSandbox();
+ ok(
+ !AddonRepository.appIsShuttingDown,
+ "Expect real appIsShuttingDown getter to be returning false"
+ );
+ sandbox.stub(AddonRepository, "appIsShuttingDown").get(() => true);
+ ok(
+ AddonRepository.appIsShuttingDown,
+ "Expect mocked appIsShuttingDown getter to be returning true"
+ );
+ sandbox.spy(AddonRepository, "_createServiceRequest");
+ equal(
+ AddonRepository._createServiceRequest.callCount,
+ 0,
+ "Expect _createServiceRequest callCount to be initially 0"
+ );
+
+ await Assert.rejects(
+ AddonRepository.getAddonsByIDs(["ext01@testext", "ext02@testext"]),
+ /Reject ServiceRequest for ".*", shutdown already in progress/,
+ "Expect getAddonsByIds to reject when called after shutdown was already initiated"
+ );
+
+ // We expect backgroundUpdateCheck to be returning earlier and not be calling _getAllInstalledAddons method at all.
+ equal(
+ AddonRepository._createServiceRequest.callCount,
+ 0,
+ "Expect _createServiceRequest to not have been called"
+ );
+ sandbox.restore();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js
new file mode 100644
index 0000000000..2a1bc2721b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache.js
@@ -0,0 +1,728 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests caching in AddonRepository.sys.mjs.
+
+var gServer;
+
+const HOST = "example.com";
+const BASE_URL = "http://example.com";
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types";
+const GETADDONS_RESULTS = BASE_URL + "/data/test_AddonRepository_cache.json";
+const EMPTY_RESULT = BASE_URL + "/data/test_AddonRepository_empty.json";
+const FAILED_RESULT = BASE_URL + "/data/test_AddonRepository_fail.json";
+
+const FILE_DATABASE = "addons.json";
+
+const ADDONS = [
+ {
+ manifest: {
+ name: "XPI Add-on 1",
+ version: "1.1",
+
+ description: "XPI Add-on 1 - Description",
+ developer: {
+ name: "XPI Add-on 1 - Author",
+ },
+
+ homepage_url: "http://example.com/xpi/1/homepage.html",
+ icons: {
+ 32: "icon.png",
+ },
+
+ options_ui: {
+ page: "options.html",
+ },
+
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_1@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 2",
+ version: "1.2",
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_2@tests.mozilla.org" },
+ },
+ },
+ },
+ {
+ manifest: {
+ name: "XPI Add-on 3",
+ version: "1.3",
+ icons: {
+ 32: "icon.png",
+ },
+ theme: {},
+ browser_specific_settings: {
+ gecko: { id: "test_AddonRepository_3@tests.mozilla.org" },
+ },
+ },
+ files: {
+ "preview.png": "",
+ },
+ },
+];
+
+const ADDON_IDS = ADDONS.map(
+ addon => addon.manifest.browser_specific_settings.gecko.id
+);
+const ADDON_FILES = ADDONS.map(addon =>
+ AddonTestUtils.createTempWebExtensionFile(addon)
+);
+
+const PREF_ADDON0_CACHE_ENABLED =
+ "extensions." + ADDON_IDS[0] + ".getAddons.cache.enabled";
+const PREF_ADDON1_CACHE_ENABLED =
+ "extensions." + ADDON_IDS[1] + ".getAddons.cache.enabled";
+
+// Properties of an individual add-on that should be checked
+// Note: updateDate is checked separately
+const ADDON_PROPERTIES = [
+ "id",
+ "type",
+ "name",
+ "version",
+ "developers",
+ "description",
+ "fullDescription",
+ "icons",
+ "screenshots",
+ "homepageURL",
+ "supportURL",
+ "optionsURL",
+ "averageRating",
+ "reviewCount",
+ "reviewURL",
+ "weeklyDownloads",
+ "sourceURI",
+];
+
+// The updateDate property is annoying to test for XPI add-ons.
+// However, since we only care about whether the repository value vs. the
+// XPI value is used, we can just test if the property value matches
+// the repository value
+const REPOSITORY_UPDATEDATE = 9;
+
+// Get the URI of a subfile locating directly in the folder of
+// the add-on corresponding to the specified id
+function get_subfile_uri(aId, aFilename) {
+ let file = gProfD.clone();
+ file.append("extensions");
+ return do_get_addon_root_uri(file, aId) + aFilename;
+}
+
+// Expected repository add-ons
+const REPOSITORY_ADDONS = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "Repo Add-on 1",
+ version: "2.1",
+ developers: [
+ {
+ name: "Repo Add-on 1 - First Developer",
+ url: BASE_URL + "/repo/1/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 1 - Second Developer",
+ url: BASE_URL + "/repo/1/secondDeveloper.html",
+ },
+ ],
+ description: "Repo Add-on 1 - Description\nSecond line",
+ fullDescription: "Repo Add-on 1 - Full Description & some extra",
+ icons: { 32: BASE_URL + "/repo/1/icon.png" },
+ homepageURL: BASE_URL + "/repo/1/homepage.html",
+ supportURL: BASE_URL + "/repo/1/support.html",
+ averageRating: 1,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/repo/1/review.html",
+ weeklyDownloads: 3331,
+ sourceURI: BASE_URL + "/repo/1/install.xpi",
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "Repo Add-on 2",
+ version: "2.2",
+ developers: [
+ {
+ name: "Repo Add-on 2 - First Developer",
+ url: BASE_URL + "/repo/2/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 2 - Second Developer",
+ url: BASE_URL + "/repo/2/secondDeveloper.html",
+ },
+ ],
+ description: "Repo Add-on 2 - Description",
+ fullDescription: "Repo Add-on 2 - Full Description",
+ icons: { 32: BASE_URL + "/repo/2/icon.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/2/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/firstThumbnail.png",
+ caption: "Repo Add-on 2 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/2/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/secondThumbnail.png",
+ caption: "Repo Add-on 2 - Second Caption",
+ },
+ ],
+ homepageURL: BASE_URL + "/repo/2/homepage.html",
+ supportURL: BASE_URL + "/repo/2/support.html",
+ averageRating: 2,
+ reviewCount: 1112,
+ reviewURL: BASE_URL + "/repo/2/review.html",
+ weeklyDownloads: 3332,
+ sourceURI: BASE_URL + "/repo/2/install.xpi",
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "Repo Add-on 3",
+ version: "2.3",
+ icons: { 32: BASE_URL + "/repo/3/icon.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/3/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/firstThumbnail.png",
+ caption: "Repo Add-on 3 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/3/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/secondThumbnail.png",
+ caption: "Repo Add-on 3 - Second Caption",
+ },
+ ],
+ },
+];
+
+function extensionURL(id, path) {
+ return WebExtensionPolicy.getByID(id).getURL(path);
+}
+
+// Expected add-ons when not using cache
+const WITHOUT_CACHE = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "XPI Add-on 1",
+ version: "1.1",
+ authors: [{ name: "XPI Add-on 1 - Author" }],
+ description: "XPI Add-on 1 - Description",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[0], "icon.png") };
+ },
+ homepageURL: `${BASE_URL}/xpi/1/homepage.html`,
+ get optionsURL() {
+ return extensionURL(ADDON_IDS[0], "options.html");
+ },
+ sourceURI: NetUtil.newURI(ADDON_FILES[0]).spec,
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "XPI Add-on 2",
+ version: "1.2",
+ sourceURI: NetUtil.newURI(ADDON_FILES[1]).spec,
+ icons: {},
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "XPI Add-on 3",
+ version: "1.3",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[2], "icon.png") };
+ },
+ screenshots: [
+ {
+ get url() {
+ return get_subfile_uri(ADDON_IDS[2], "preview.png");
+ },
+ },
+ ],
+ sourceURI: NetUtil.newURI(ADDON_FILES[2]).spec,
+ },
+];
+
+// Expected add-ons when using cache
+const WITH_CACHE = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "XPI Add-on 1",
+ version: "1.1",
+ developers: [
+ {
+ name: "Repo Add-on 1 - First Developer",
+ url: BASE_URL + "/repo/1/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 1 - Second Developer",
+ url: BASE_URL + "/repo/1/secondDeveloper.html",
+ },
+ ],
+ description: "XPI Add-on 1 - Description",
+ fullDescription: "Repo Add-on 1 - Full Description & some extra",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[0], "icon.png") };
+ },
+ homepageURL: BASE_URL + "/xpi/1/homepage.html",
+ supportURL: BASE_URL + "/repo/1/support.html",
+ get optionsURL() {
+ return extensionURL(ADDON_IDS[0], "options.html");
+ },
+ averageRating: 1,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/repo/1/review.html",
+ weeklyDownloads: 3331,
+ sourceURI: NetUtil.newURI(ADDON_FILES[0]).spec,
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "XPI Add-on 2",
+ version: "1.2",
+ developers: [
+ {
+ name: "Repo Add-on 2 - First Developer",
+ url: BASE_URL + "/repo/2/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 2 - Second Developer",
+ url: BASE_URL + "/repo/2/secondDeveloper.html",
+ },
+ ],
+ description: "Repo Add-on 2 - Description",
+ fullDescription: "Repo Add-on 2 - Full Description",
+ icons: { 32: BASE_URL + "/repo/2/icon.png" },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/2/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/firstThumbnail.png",
+ caption: "Repo Add-on 2 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/2/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/2/secondThumbnail.png",
+ caption: "Repo Add-on 2 - Second Caption",
+ },
+ ],
+ homepageURL: BASE_URL + "/repo/2/homepage.html",
+ supportURL: BASE_URL + "/repo/2/support.html",
+ averageRating: 2,
+ reviewCount: 1112,
+ reviewURL: BASE_URL + "/repo/2/review.html",
+ weeklyDownloads: 3332,
+ sourceURI: NetUtil.newURI(ADDON_FILES[1]).spec,
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "XPI Add-on 3",
+ version: "1.3",
+ get iconURL() {
+ return get_subfile_uri(ADDON_IDS[2], "icon.png");
+ },
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[2], "icon.png") };
+ },
+ screenshots: [
+ {
+ url: BASE_URL + "/repo/3/firstFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/firstThumbnail.png",
+ caption: "Repo Add-on 3 - First Caption",
+ },
+ {
+ url: BASE_URL + "/repo/3/secondFull.png",
+ thumbnailURL: BASE_URL + "/repo/3/secondThumbnail.png",
+ caption: "Repo Add-on 3 - Second Caption",
+ },
+ ],
+ sourceURI: NetUtil.newURI(ADDON_FILES[2]).spec,
+ },
+];
+
+// Expected add-ons when using cache for extension, but no cache for themes.
+const WITH_EXTENSION_CACHE = [
+ {
+ id: ADDON_IDS[0],
+ type: "extension",
+ name: "XPI Add-on 1",
+ version: "1.1",
+ developers: [
+ {
+ name: "Repo Add-on 1 - First Developer",
+ url: BASE_URL + "/repo/1/firstDeveloper.html",
+ },
+ {
+ name: "Repo Add-on 1 - Second Developer",
+ url: BASE_URL + "/repo/1/secondDeveloper.html",
+ },
+ ],
+ description: "XPI Add-on 1 - Description",
+ fullDescription: "Repo Add-on 1 - Full Description & some extra",
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[0], "icon.png") };
+ },
+ homepageURL: BASE_URL + "/xpi/1/homepage.html",
+ supportURL: BASE_URL + "/repo/1/support.html",
+ get optionsURL() {
+ return extensionURL(ADDON_IDS[0], "options.html");
+ },
+ averageRating: 1,
+ reviewCount: 1111,
+ reviewURL: BASE_URL + "/repo/1/review.html",
+ weeklyDownloads: 3331,
+ sourceURI: NetUtil.newURI(ADDON_FILES[0]).spec,
+ },
+ {
+ id: ADDON_IDS[1],
+ type: "theme",
+ name: "XPI Add-on 2",
+ version: "1.2",
+ sourceURI: NetUtil.newURI(ADDON_FILES[1]).spec,
+ icons: {},
+ },
+ {
+ id: ADDON_IDS[2],
+ type: "theme",
+ name: "XPI Add-on 3",
+ version: "1.3",
+ get iconURL() {
+ return get_subfile_uri(ADDON_IDS[2], "icon.png");
+ },
+ get icons() {
+ return { 32: get_subfile_uri(ADDON_IDS[2], "icon.png") };
+ },
+ screenshots: [
+ {
+ get url() {
+ return get_subfile_uri(ADDON_IDS[2], "preview.png");
+ },
+ },
+ ],
+ sourceURI: NetUtil.newURI(ADDON_FILES[2]).spec,
+ },
+];
+
+var gDBFile = gProfD.clone();
+gDBFile.append(FILE_DATABASE);
+
+/*
+ * Check the actual add-on results against the expected add-on results
+ *
+ * @param aActualAddons
+ * The array of actual add-ons to check
+ * @param aExpectedAddons
+ * The array of expected add-ons to check against
+ * @param aFromRepository
+ * An optional boolean representing if the add-ons are from
+ * the repository
+ */
+function check_results(aActualAddons, aExpectedAddons, aFromRepository) {
+ aFromRepository = !!aFromRepository;
+
+ do_check_addons(aActualAddons, aExpectedAddons, ADDON_PROPERTIES);
+
+ // Separately test updateDate (it should only be equal to the
+ // REPOSITORY values if it is from the repository)
+ aActualAddons.forEach(function (aActualAddon) {
+ if (aActualAddon.updateDate) {
+ let time = aActualAddon.updateDate.getTime();
+ Assert.equal(time === 1000 * REPOSITORY_UPDATEDATE, aFromRepository);
+ }
+ });
+}
+
+/*
+ * Check the add-ons in the cache. This function also tests
+ * AddonRepository.getCachedAddonByID()
+ *
+ * @param aExpectedToFind
+ * An array of booleans representing which REPOSITORY_ADDONS are
+ * expected to be found in the cache
+ * @param aExpectedImmediately
+ * A boolean representing if results from the cache are expected
+ * immediately. Results are not immediate if the cache has not been
+ * initialized yet.
+ * @return Promise{null}
+ * Resolves once the checks are complete
+ */
+function check_cache(aExpectedToFind, aExpectedImmediately) {
+ Assert.equal(aExpectedToFind.length, REPOSITORY_ADDONS.length);
+
+ let lookups = [];
+
+ for (let i = 0; i < REPOSITORY_ADDONS.length; i++) {
+ lookups.push(
+ new Promise((resolve, reject) => {
+ let immediatelyFound = true;
+ let expected = aExpectedToFind[i] ? REPOSITORY_ADDONS[i] : null;
+ // can't Promise-wrap this because we're also testing whether the callback is
+ // sync or async
+ AddonRepository.getCachedAddonByID(
+ REPOSITORY_ADDONS[i].id,
+ function (aAddon) {
+ Assert.equal(immediatelyFound, aExpectedImmediately);
+ if (expected == null) {
+ Assert.equal(aAddon, null);
+ } else {
+ check_results([aAddon], [expected], true);
+ }
+ resolve();
+ }
+ );
+ immediatelyFound = false;
+ })
+ );
+ }
+ return Promise.all(lookups);
+}
+
+/*
+ * Task to check an initialized cache by checking the cache, then restarting the
+ * manager, and checking the cache. This checks that the cache is consistent
+ * across manager restarts.
+ *
+ * @param aExpectedToFind
+ * An array of booleans representing which REPOSITORY_ADDONS are
+ * expected to be found in the cache
+ */
+async function check_initialized_cache(aExpectedToFind) {
+ await check_cache(aExpectedToFind, true);
+ await promiseRestartManager();
+
+ // If cache is disabled, then expect results immediately
+ let cacheEnabled = Services.prefs.getBoolPref(PREF_GETADDONS_CACHE_ENABLED);
+ await check_cache(aExpectedToFind, !cacheEnabled);
+}
+
+add_task(async function setup() {
+ // Setup for test
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+
+ await promiseStartupManager();
+
+ // Install XPI add-ons
+ await promiseInstallAllFiles(ADDON_FILES);
+ await promiseRestartManager();
+
+ gServer = AddonTestUtils.createHttpServer({ hosts: [HOST] });
+ gServer.registerDirectory("/data/", do_get_file("data"));
+});
+
+// Tests AddonRepository.cacheEnabled
+add_task(async function run_test_1() {
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+ Assert.ok(!AddonRepository.cacheEnabled);
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ Assert.ok(AddonRepository.cacheEnabled);
+});
+
+// Tests that the cache and database begin as empty
+add_task(async function run_test_2() {
+ Assert.ok(!gDBFile.exists());
+ await check_cache([false, false, false], false);
+ await AddonRepository.flush();
+});
+
+// Tests repopulateCache when the search fails
+add_task(async function run_test_3() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, FAILED_RESULT);
+
+ await AddonRepository.backgroundUpdateCheck();
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests repopulateCache when search returns no results
+add_task(async function run_test_4() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, EMPTY_RESULT);
+
+ await AddonRepository.backgroundUpdateCheck();
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests repopulateCache when search returns results
+add_task(async function run_test_5() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ await AddonRepository.backgroundUpdateCheck();
+ await check_initialized_cache([true, true, true]);
+});
+
+// Tests repopulateCache when caching is disabled for a single add-on
+add_task(async function run_test_5_1() {
+ Services.prefs.setBoolPref(PREF_ADDON0_CACHE_ENABLED, false);
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ // Reset pref for next test
+ Services.prefs.setBoolPref(PREF_ADDON0_CACHE_ENABLED, true);
+
+ await check_initialized_cache([false, true, true]);
+});
+
+// Tests repopulateCache when caching is disabled
+add_task(async function run_test_6() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+
+ await AddonRepository.backgroundUpdateCheck();
+ // Database should have been deleted
+ Assert.ok(!gDBFile.exists());
+
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ await check_cache([false, false, false], false);
+ await AddonRepository.flush();
+});
+
+// Tests cacheAddons when the search fails
+add_task(async function run_test_7() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, FAILED_RESULT);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests cacheAddons when the search returns no results
+add_task(async function run_test_8() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, EMPTY_RESULT);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ await check_initialized_cache([false, false, false]);
+});
+
+// Tests cacheAddons for a single add-on when search returns results
+add_task(async function run_test_9() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ await AddonRepository.cacheAddons([ADDON_IDS[0]]);
+ await check_initialized_cache([true, false, false]);
+});
+
+// Tests cacheAddons when caching is disabled for a single add-on
+add_task(async function run_test_9_1() {
+ Services.prefs.setBoolPref(PREF_ADDON1_CACHE_ENABLED, false);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+
+ // Reset pref for next test
+ Services.prefs.setBoolPref(PREF_ADDON1_CACHE_ENABLED, true);
+
+ await check_initialized_cache([true, false, true]);
+});
+
+// Tests cacheAddons for multiple add-ons, some already in the cache,
+add_task(async function run_test_10() {
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ await check_initialized_cache([true, true, true]);
+});
+
+// Tests cacheAddons when caching is disabled
+add_task(async function run_test_11() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+
+ await AddonRepository.cacheAddons(ADDON_IDS);
+ Assert.ok(gDBFile.exists());
+
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ await check_initialized_cache([true, true, true]);
+});
+
+// Tests that XPI add-ons do not use any of the repository properties if
+// caching is disabled, even if there are repository properties available
+add_task(async function run_test_12() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, false);
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ let addons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(addons, WITHOUT_CACHE);
+});
+
+// Tests that a background update with caching disabled deletes the add-ons
+// database, and that XPI add-ons still do not use any of repository properties
+add_task(async function run_test_13() {
+ Assert.ok(gDBFile.exists());
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, EMPTY_RESULT);
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ // Database should have been deleted
+ Assert.ok(!gDBFile.exists());
+
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITHOUT_CACHE);
+});
+
+// Tests that the XPI add-ons have the correct properties if caching is
+// enabled but has no information
+add_task(async function run_test_14() {
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await AddonRepository.flush();
+ Assert.ok(gDBFile.exists());
+
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITHOUT_CACHE);
+});
+
+// Tests that the XPI add-ons correctly use the repository properties when
+// caching is enabled and the repository information is available
+add_task(async function run_test_15() {
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_CACHE);
+});
+
+// Tests that restarting the manager does not change the checked properties
+// on the XPI add-ons (repository properties still exist and are still properly
+// used)
+add_task(async function run_test_16() {
+ await promiseRestartManager();
+
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_CACHE);
+});
+
+// Tests that setting a list of types to cache works
+add_task(async function run_test_17() {
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_CACHE_TYPES,
+ "foo,bar,extension,baz"
+ );
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_EXTENSION_CACHE);
+ Services.prefs.clearUserPref(PREF_GETADDONS_CACHE_TYPES);
+});
+
+// Tests that the cache is retained when the server/API is unreachable.
+// Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1870905
+add_task(async function run_test_18() {
+ // The response is expected to be JSON, so setting it to non-JSON is
+ // equivalent to the server being unreachable in the implementation.
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, "data:text/not-json,");
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ let aAddons = await promiseAddonsByIDs(ADDON_IDS);
+ check_results(aAddons, WITH_EXTENSION_CACHE);
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, GETADDONS_RESULTS);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js
new file mode 100644
index 0000000000..37e60e27dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_cache_locale.js
@@ -0,0 +1,217 @@
+"user strict";
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate";
+Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/data/", do_get_file("data"));
+
+// Use %LOCALE% as the default pref does. It is set from appLocaleAsBCP47.
+Services.prefs.setStringPref(
+ PREF_GETADDONS_BYIDS,
+ "http://example.com/addons.json?guids=%IDS%&locale=%LOCALE%"
+);
+
+const TEST_ADDON_ID = "test_AddonRepository_1@tests.mozilla.org";
+
+const repositoryAddons = {
+ "test_AddonRepository_1@tests.mozilla.org": {
+ name: "Repo Add-on 1",
+ type: "extension",
+ guid: TEST_ADDON_ID,
+ current_version: {
+ version: "2.1",
+ files: [
+ {
+ platform: "all",
+ size: 9,
+ url: "http://example.com/repo/1/install.xpi",
+ },
+ ],
+ },
+ },
+ "langpack-und@test.mozilla.org": {
+ // included only to avoid exceptions in AddonRepository
+ name: "und langpack",
+ type: "language",
+ guid: "langpack-und@test.mozilla.org",
+ current_version: {
+ version: "1.1",
+ files: [
+ {
+ platform: "all",
+ size: 9,
+ url: "http://example.com/repo/1/langpack.xpi",
+ },
+ ],
+ },
+ },
+};
+
+server.registerPathHandler("/addons.json", (request, response) => {
+ let search = new URLSearchParams(request.queryString);
+ let IDs = search.get("guids").split(",");
+ let locale = search.get("locale");
+
+ let repositoryData = {
+ page_size: 25,
+ page_count: 1,
+ count: 0,
+ next: null,
+ previous: null,
+ results: [],
+ };
+ for (let id of IDs) {
+ let data = JSON.parse(JSON.stringify(repositoryAddons[id]));
+ data.summary = `This is an ${locale} addon data object`;
+ data.description = `Full Description ${locale}`;
+ repositoryData.results.push(data);
+ }
+ repositoryData.count = repositoryData.results.length;
+
+ // The request contains the IDs to retreive, but we're just handling the
+ // two test addons so it's static data.
+ response.setHeader("content-type", "application/json");
+ response.write(JSON.stringify(repositoryData));
+});
+
+const ADDONS = [
+ {
+ manifest: {
+ name: "XPI Add-on 1",
+ version: "1.1",
+
+ description: "XPI Add-on 1 - Description",
+ developer: {
+ name: "XPI Add-on 1 - Author",
+ },
+
+ homepage_url: "http://example.com/xpi/1/homepage.html",
+ icons: {
+ 32: "icon.png",
+ },
+
+ options_ui: {
+ page: "options.html",
+ },
+
+ browser_specific_settings: {
+ gecko: { id: TEST_ADDON_ID },
+ },
+ },
+ },
+ {
+ // Necessary to provide the "und" locale
+ manifest: {
+ name: "und Language Pack",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "langpack-und@test.mozilla.org",
+ },
+ },
+ sources: {
+ browser: {
+ base_path: "browser/",
+ },
+ },
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global/",
+ },
+ version: "20171001190118",
+ },
+ },
+ author: "Mozilla Localization Task Force",
+ description: "Language pack for Testy for und",
+ },
+ },
+];
+const ADDON_FILES = ADDONS.map(addon =>
+ AddonTestUtils.createTempWebExtensionFile(addon)
+);
+
+const REQ_LOC_CHANGE_EVENT = "intl:requested-locales-changed";
+
+function promiseLocaleChanged(requestedLocale) {
+ if (Services.locale.appLocaleAsBCP47 == requestedLocale) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ let localeObserver = {
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case REQ_LOC_CHANGE_EVENT:
+ let reqLocs = Services.locale.requestedLocales;
+ equal(reqLocs[0], requestedLocale);
+ Services.obs.removeObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ resolve();
+ }
+ },
+ };
+ Services.obs.addObserver(localeObserver, REQ_LOC_CHANGE_EVENT);
+ Services.locale.requestedLocales = [requestedLocale];
+ });
+}
+
+function promiseMetaDataUpdate() {
+ return new Promise(resolve => {
+ let listener = args => {
+ Services.prefs.removeObserver(PREF_METADATA_LASTUPDATE, listener);
+ resolve();
+ };
+
+ Services.prefs.addObserver(PREF_METADATA_LASTUPDATE, listener);
+ });
+}
+
+function promiseLocale(locale) {
+ return Promise.all([promiseLocaleChanged(locale), promiseMetaDataUpdate()]);
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ for (let xpi of ADDON_FILES) {
+ await promiseInstallFile(xpi);
+ }
+});
+
+add_task(async function test_locale_change() {
+ await promiseLocale("en-US");
+ let addon = await AddonRepository.getCachedAddonByID(TEST_ADDON_ID);
+ Assert.ok(addon.description.includes("en-US"), "description is en-us");
+ Assert.ok(
+ addon.fullDescription.includes("en-US"),
+ "fullDescription is en-us"
+ );
+
+ // This pref is a 1s resolution, set it to zero so the
+ // next test can wait on it being updated again.
+ Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, 0);
+ // Wait for the last update timestamp to be updated.
+ await promiseLocale("und");
+
+ addon = await AddonRepository.getCachedAddonByID(TEST_ADDON_ID);
+
+ Assert.ok(
+ addon.description.includes("und"),
+ `description is ${addon.description}`
+ );
+ Assert.ok(
+ addon.fullDescription.includes("und"),
+ `fullDescription is ${addon.fullDescription}`
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js
new file mode 100644
index 0000000000..e84ce4ea30
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_langpacks.js
@@ -0,0 +1,135 @@
+const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url";
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+Services.prefs.setStringPref(
+ PREF_GET_LANGPACKS,
+ "http://example.com/langpacks.json"
+);
+
+add_task(async function test_getlangpacks() {
+ function setData(data) {
+ if (typeof data != "string") {
+ data = JSON.stringify(data);
+ }
+
+ server.registerPathHandler("/langpacks.json", (request, response) => {
+ response.setHeader("content-type", "application/json");
+ response.write(data);
+ });
+ }
+
+ const EXPECTED = [
+ {
+ target_locale: "kl",
+ url: "http://example.com/langpack1.xpi",
+ hash: "sha256:0123456789abcdef",
+ },
+ {
+ target_locale: "fo",
+ url: "http://example.com/langpack2.xpi",
+ hash: "sha256:fedcba9876543210",
+ },
+ ];
+
+ setData({
+ results: [
+ // A simple entry
+ {
+ target_locale: EXPECTED[0].target_locale,
+ current_compatible_version: {
+ files: [
+ {
+ platform: "all",
+ url: EXPECTED[0].url,
+ hash: EXPECTED[0].hash,
+ },
+ ],
+ },
+ },
+
+ // An entry with multiple supported platforms
+ {
+ target_locale: EXPECTED[1].target_locale,
+ current_compatible_version: {
+ files: [
+ {
+ platform: "somethingelse",
+ url: "http://example.com/bogus.xpi",
+ hash: "sha256:abcd",
+ },
+ {
+ platform: Services.appinfo.OS.toLowerCase(),
+ url: EXPECTED[1].url,
+ hash: EXPECTED[1].hash,
+ },
+ ],
+ },
+ },
+
+ // An entry with no matching platform
+ {
+ target_locale: "bla",
+ current_compatible_version: {
+ files: [
+ {
+ platform: "unsupportedplatform",
+ url: "http://example.com/bogus2.xpi",
+ hash: "sha256:1234",
+ },
+ ],
+ },
+ },
+ ],
+ });
+
+ let result = await AddonRepository.getAvailableLangpacks();
+ equal(result.length, 2, "Got 2 results");
+
+ deepEqual(result[0], EXPECTED[0], "Got expected result for simple entry");
+ deepEqual(
+ result[1],
+ EXPECTED[1],
+ "Got expected result for multi-platform entry"
+ );
+
+ setData("not valid json");
+ await Assert.rejects(
+ AddonRepository.getAvailableLangpacks(),
+ /SyntaxError/,
+ "Got parse error on invalid JSON"
+ );
+});
+
+// Tests that cookies are not sent with langpack requests.
+add_task(async function test_cookies() {
+ let lastRequest = null;
+ server.registerPathHandler("/langpacks.json", (request, response) => {
+ lastRequest = request;
+ response.write(JSON.stringify({ results: [] }));
+ });
+
+ const COOKIE = "test";
+ let expiration = Date.now() / 1000 + 60 * 60;
+ Services.cookies.add(
+ "example.com",
+ "/",
+ COOKIE,
+ "testing",
+ false,
+ false,
+ false,
+ expiration,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ await AddonRepository.getAvailableLangpacks();
+
+ notEqual(lastRequest, null, "Received langpack request");
+ equal(
+ lastRequest.hasHeader("Cookie"),
+ false,
+ "Langpack request has no cookies"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js
new file mode 100644
index 0000000000..0773917d84
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository_paging.js
@@ -0,0 +1,91 @@
+// Test that AMO api results that are returned in muliple pages are
+// properly handled.
+add_task(async function test_paged_api() {
+ const MAX_ADDON = 3;
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "2");
+
+ let testserver = createHttpServer();
+ const PORT = testserver.identity.primaryPort;
+
+ const EMPTY_RESPONSE = {
+ next: null,
+ results: [],
+ };
+
+ function name(n) {
+ return `Addon ${n}`;
+ }
+ function id(n) {
+ return `test${n}@tests.mozilla.org`;
+ }
+ function summary(n) {
+ return `Summary for addon ${n}`;
+ }
+ function description(n) {
+ return `Description for addon ${n}`;
+ }
+
+ testserver.registerPathHandler("/empty", (request, response) => {
+ response.setHeader("content-type", "application/json");
+ response.write(JSON.stringify(EMPTY_RESPONSE));
+ });
+
+ testserver.registerPrefixHandler("/addons/", (request, response) => {
+ let [page] = /\d+/.exec(request.path);
+ page = page ? parseInt(page, 10) : 0;
+ page = Math.min(page, MAX_ADDON);
+
+ let result = {
+ next:
+ page == MAX_ADDON
+ ? null
+ : `http://localhost:${PORT}/addons/${page + 1}`,
+ results: [
+ {
+ name: name(page),
+ type: "extension",
+ guid: id(page),
+ summary: summary(page),
+ description: description(page),
+ },
+ ],
+ };
+
+ response.setHeader("content-type", "application/json");
+ response.write(JSON.stringify(result));
+ });
+
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ `http://localhost:${PORT}/addons/0`
+ );
+
+ await promiseStartupManager();
+
+ for (let i = 0; i <= MAX_ADDON; i++) {
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: id(i) } },
+ },
+ });
+ }
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+
+ let ids = [];
+ for (let i = 0; i <= MAX_ADDON; i++) {
+ ids.push(id(i));
+ }
+ let addons = await AddonRepository.getAddonsByIDs(ids);
+
+ equal(addons.length, MAX_ADDON + 1);
+ for (let i = 0; i <= MAX_ADDON; i++) {
+ equal(addons[i].name, name(i));
+ equal(addons[i].id, id(i));
+ equal(addons[i].description, summary(i));
+ equal(addons[i].fullDescription, description(i));
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js
new file mode 100644
index 0000000000..04373e85ff
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js
@@ -0,0 +1,310 @@
+"use strict";
+
+const { ProductAddonChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs"
+);
+
+const LocalFile = new Components.Constructor(
+ "@mozilla.org/file/local;1",
+ Ci.nsIFile,
+ "initWithPath"
+);
+
+Services.prefs.setBoolPref("media.gmp-manager.updateEnabled", true);
+
+var testserver = new HttpServer();
+testserver.registerDirectory("/data/", do_get_file("data/productaddons"));
+testserver.start();
+var root =
+ testserver.identity.primaryScheme +
+ "://" +
+ testserver.identity.primaryHost +
+ ":" +
+ testserver.identity.primaryPort +
+ "/data/";
+
+/**
+ * Compares binary data of 2 arrays and returns true if they are the same
+ *
+ * @param arr1 The first array to compare
+ * @param arr2 The second array to compare
+ */
+function compareBinaryData(arr1, arr2) {
+ Assert.equal(arr1.length, arr2.length);
+ for (let i = 0; i < arr1.length; i++) {
+ if (arr1[i] != arr2[i]) {
+ info(
+ "Data differs at index " +
+ i +
+ ", arr1: " +
+ arr1[i] +
+ ", arr2: " +
+ arr2[i]
+ );
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * Reads a file's data and returns it
+ *
+ * @param file The file to read the data from
+ * @return array of bytes for the data in the file.
+ */
+function getBinaryFileData(file) {
+ let fileStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ // Open as RD_ONLY with default permissions.
+ fileStream.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+
+ let stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ stream.setInputStream(fileStream);
+ let bytes = stream.readByteArray(stream.available());
+ fileStream.close();
+ return bytes;
+}
+
+/**
+ * Compares binary data of 2 files and returns true if they are the same
+ *
+ * @param file1 The first file to compare
+ * @param file2 The second file to compare
+ */
+function compareFiles(file1, file2) {
+ return compareBinaryData(getBinaryFileData(file1), getBinaryFileData(file2));
+}
+
+add_task(async function test_404() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "404.xml"),
+ /got node name: html/
+ );
+});
+
+add_task(async function test_not_xml() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "bad.txt"),
+ /got node name: parsererror/
+ );
+});
+
+add_task(async function test_invalid_xml() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "bad.xml"),
+ /got node name: parsererror/
+ );
+});
+
+add_task(async function test_wrong_xml() {
+ await Assert.rejects(
+ ProductAddonChecker.getProductAddonList(root + "bad2.xml"),
+ /got node name: test/
+ );
+});
+
+add_task(async function test_missing() {
+ let addons = await ProductAddonChecker.getProductAddonList(
+ root + "missing.xml"
+ );
+ Assert.equal(addons, null);
+});
+
+add_task(async function test_empty() {
+ let res = await ProductAddonChecker.getProductAddonList(root + "empty.xml");
+ Assert.ok(Array.isArray(res.addons));
+ Assert.equal(res.addons.length, 0);
+});
+
+add_task(async function test_good_xml() {
+ let res = await ProductAddonChecker.getProductAddonList(root + "good.xml");
+ Assert.ok(Array.isArray(res.addons));
+
+ // There are three valid entries in the XML
+ Assert.equal(res.addons.length, 5);
+
+ let addon = res.addons[0];
+ Assert.equal(addon.id, "test1");
+ Assert.equal(addon.URL, "http://example.com/test1.xpi");
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+
+ addon = res.addons[1];
+ Assert.equal(addon.id, "test2");
+ Assert.equal(addon.URL, "http://example.com/test2.xpi");
+ Assert.equal(addon.hashFunction, "md5");
+ Assert.equal(addon.hashValue, "djhfgsjdhf");
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+
+ addon = res.addons[2];
+ Assert.equal(addon.id, "test3");
+ Assert.equal(addon.URL, "http://example.com/test3.xpi");
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.size, 45);
+
+ addon = res.addons[3];
+ Assert.equal(addon.id, "test4");
+ Assert.equal(addon.URL, undefined);
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+
+ addon = res.addons[4];
+ Assert.equal(addon.id, undefined);
+ Assert.equal(addon.URL, "http://example.com/test5.xpi");
+ Assert.equal(addon.hashFunction, undefined);
+ Assert.equal(addon.hashValue, undefined);
+ Assert.equal(addon.version, undefined);
+ Assert.equal(addon.size, undefined);
+});
+
+add_task(async function test_download_nourl() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({});
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a missing url");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a missing url."
+ );
+ }
+});
+
+add_task(async function test_download_missing() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "nofile.xpi",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a missing file");
+ } catch (e) {
+ Assert.ok(true, "Should have thrown when downloading a missing file.");
+ }
+});
+
+add_task(async function test_download_noverify() {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ });
+
+ let stat = await IOUtils.stat(path);
+ Assert.ok(!stat.type !== "directory");
+ Assert.equal(stat.size, 452);
+
+ Assert.ok(
+ compareFiles(
+ do_get_file("data/productaddons/unsigned.xpi"),
+ new LocalFile(path)
+ )
+ );
+
+ await IOUtils.remove(path);
+});
+
+add_task(async function test_download_badsize() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ size: 400,
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad size");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad size."
+ );
+ }
+});
+
+add_task(async function test_download_badhashfn() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ hashFunction: "sha2567",
+ hashValue:
+ "9b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad hash function");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad hash function."
+ );
+ }
+});
+
+add_task(async function test_download_sha1_unsupported() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ hashFunction: "sha1",
+ hashValue: "3d0dc22e1f394e159b08aaf5f0f97de4d5c65f4f",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad hash function");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad hash function."
+ );
+ }
+});
+
+add_task(async function test_download_badhash() {
+ try {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ hashFunction: "sha256",
+ hashValue:
+ "8b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3",
+ });
+
+ await IOUtils.remove(path);
+ do_throw("Should not have downloaded a file with a bad hash");
+ } catch (e) {
+ Assert.ok(
+ true,
+ "Should have thrown when downloading a file with a bad hash."
+ );
+ }
+});
+
+add_task(async function test_download_works() {
+ let path = await ProductAddonChecker.downloadAddon({
+ URL: root + "unsigned.xpi",
+ size: 452,
+ hashFunction: "sha256",
+ hashValue:
+ "9b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3",
+ });
+
+ let stat = await IOUtils.stat(path);
+ Assert.ok(stat.type !== "directory");
+
+ Assert.ok(
+ compareFiles(
+ do_get_file("data/productaddons/unsigned.xpi"),
+ new LocalFile(path)
+ )
+ );
+
+ await IOUtils.remove(path);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js
new file mode 100644
index 0000000000..5ae61568ef
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker_signatures.js
@@ -0,0 +1,201 @@
+"use strict";
+
+const { ProductAddonChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/ProductAddonChecker.sys.mjs"
+);
+
+Services.prefs.setBoolPref("media.gmp-manager.updateEnabled", true);
+
+// Setup a test server for content signature tests.
+const signedTestServer = new HttpServer();
+const testDataDir = "data/productaddons/";
+
+// Start the server so we can grab the identity. We need to know this so the
+// server can reference itself in the handlers that will be set up.
+signedTestServer.start();
+const signedBaseUri =
+ signedTestServer.identity.primaryScheme +
+ "://" +
+ signedTestServer.identity.primaryHost +
+ ":" +
+ signedTestServer.identity.primaryPort;
+
+// Setup endpoint to handle x5u lookups correctly.
+const validX5uPath = "/valid_x5u";
+// These certificates are generated using ./mach generate-test-certs <path_to_certspec>
+const validCertChain = loadCertChain(testDataDir + "content_signing", [
+ "aus_ee",
+ "int",
+]);
+signedTestServer.registerPathHandler(validX5uPath, (req, res) => {
+ res.write(validCertChain.join("\n"));
+});
+const validX5uUrl = signedBaseUri + validX5uPath;
+
+// Setup endpoint to handle x5u lookups incorrectly.
+const invalidX5uPath = "/invalid_x5u";
+const invalidCertChain = loadCertChain(testDataDir + "content_signing", [
+ "aus_ee",
+ // This cert chain is missing the intermediate cert!
+]);
+signedTestServer.registerPathHandler(invalidX5uPath, (req, res) => {
+ res.write(invalidCertChain.join("\n"));
+});
+const invalidX5uUrl = signedBaseUri + invalidX5uPath;
+
+// Will hold the XML data from good.xml.
+let goodXml;
+// This sig is generated using the following command at mozilla-central root
+// `cat toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml | ./mach python security/manager/ssl/tests/unit/test_content_signing/pysign.py`
+// If test certificates are regenerated, this signature must also be.
+const goodXmlContentSignature =
+ "7QYnPqFoOlS02BpDdIRIljzmPr6BFwPs1z1y8KJUBlnU7EVG6FbnXmVVt5Op9wDzgvhXX7th8qFJvpPOZs_B_tHRDNJ8SK0HN95BAN15z3ZW2r95SSHmU-fP2JgoNOR3";
+
+const goodXmlPath = "/good.xml";
+// Requests use query strings to test different signature states.
+const validSignatureQuery = "validSignature";
+const invalidSignatureQuery = "invalidSignature";
+const missingSignatureQuery = "missingSignature";
+const incompleteSignatureQuery = "incompleteSignature";
+const badX5uSignatureQuery = "badX5uSignature";
+signedTestServer.registerPathHandler(goodXmlPath, (req, res) => {
+ if (req.queryString == validSignatureQuery) {
+ res.setHeader(
+ "content-signature",
+ `x5u=${validX5uUrl}; p384ecdsa=${goodXmlContentSignature}`
+ );
+ } else if (req.queryString == invalidSignatureQuery) {
+ res.setHeader("content-signature", `x5u=${validX5uUrl}; p384ecdsa=garbage`);
+ } else if (req.queryString == missingSignatureQuery) {
+ // Intentionally don't set the header.
+ } else if (req.queryString == incompleteSignatureQuery) {
+ res.setHeader(
+ "content-signature",
+ `x5u=${validX5uUrl}` // There's no p384ecdsa part!
+ );
+ } else if (req.queryString == badX5uSignatureQuery) {
+ res.setHeader(
+ "content-signature",
+ `x5u=${invalidX5uUrl}; p384ecdsa=${goodXmlContentSignature}`
+ );
+ } else {
+ Assert.ok(
+ false,
+ "Invalid queryString passed to server! Tests shouldn't do that!"
+ );
+ }
+ res.write(goodXml);
+});
+
+// Handle aysnc load of good.xml.
+add_task(async function load_good_xml() {
+ goodXml = await IOUtils.readUTF8(do_get_file(testDataDir + "good.xml").path);
+});
+
+add_task(async function test_valid_content_signature() {
+ try {
+ const res = await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + validSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(true, "Should successfully get addon list");
+
+ // Smoke test the results are as expected.
+ Assert.equal(res.addons[0].id, "test1");
+ Assert.equal(res.addons[1].id, "test2");
+ Assert.equal(res.addons[2].id, "test3");
+ Assert.equal(res.addons[3].id, "test4");
+ Assert.equal(res.addons[4].id, undefined);
+ } catch (e) {
+ Assert.ok(
+ false,
+ `Should successfully get addon list, instead failed with ${e}`
+ );
+ }
+});
+
+add_task(async function test_invalid_content_signature() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + invalidSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ // The nsIContentSignatureVerifier will throw an error on this path,
+ // check that we've caught and re-thrown, but don't check the full error
+ // message as it's messy and subject to change.
+ Assert.ok(
+ e.message.startsWith("Content signature validation failed:"),
+ "Should get signature failure message"
+ );
+ }
+});
+
+add_task(async function test_missing_content_signature_header() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + missingSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ Assert.equal(
+ e.addonCheckerErr,
+ ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR
+ );
+ Assert.equal(
+ e.message,
+ "Content signature validation failed: missing content signature header"
+ );
+ }
+});
+
+add_task(async function test_incomplete_content_signature_header() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + incompleteSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ Assert.equal(
+ e.addonCheckerErr,
+ ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR
+ );
+ Assert.equal(
+ e.message,
+ "Content signature validation failed: missing signature"
+ );
+ }
+});
+
+add_task(async function test_bad_x5u_content_signature_header() {
+ try {
+ await ProductAddonChecker.getProductAddonList(
+ signedBaseUri + goodXmlPath + "?" + badX5uSignatureQuery,
+ /*allowNonBuiltIn*/ false,
+ /*allowedCerts*/ false,
+ /*verifyContentSignature*/ true
+ );
+ Assert.ok(false, "Should fail to get addon list");
+ } catch (e) {
+ Assert.ok(true, "Should fail to get addon list");
+ Assert.equal(
+ e.addonCheckerErr,
+ ProductAddonChecker.VERIFICATION_INVALID_ERR
+ );
+ Assert.equal(e.message, "Content signature is not valid");
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js b/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js
new file mode 100644
index 0000000000..9bca9d17b1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AMRemoteSettings.js
@@ -0,0 +1,217 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Globals imported from head_telemetry.js
+/* globals setupTelemetryForTests, resetTelemetryData */
+
+const { QuarantinedDomains } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ computeSha1HashAsString: "resource://gre/modules/addons/crypto-utils.sys.mjs",
+});
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+const QUARANTINE_LIST_PREF = "extensions.quarantinedDomains.list";
+
+function assertQuarantinedListPref(expectedPrefValue) {
+ Assert.equal(
+ Services.prefs.getPrefType(QUARANTINE_LIST_PREF),
+ Services.prefs.PREF_STRING,
+ `Expect ${QUARANTINE_LIST_PREF} preference type to be string`
+ );
+
+ Assert.equal(
+ Services.prefs.getStringPref(QUARANTINE_LIST_PREF),
+ expectedPrefValue,
+ `Got the expected value set on ${QUARANTINE_LIST_PREF}`
+ );
+}
+
+function assertQuarantinedListTelemetry(expectedTelemetryHash) {
+ Assert.deepEqual(
+ {
+ listhash: Glean.extensionsQuarantinedDomains.listhash.testGetValue(),
+ remotehash: Glean.extensionsQuarantinedDomains.remotehash.testGetValue(),
+ },
+ expectedTelemetryHash,
+ "Got the expected computed domains list probes recorded by the Glean metrics"
+ );
+
+ const scalars = Services.telemetry.getSnapshotForScalars().parent;
+ Assert.deepEqual(
+ {
+ listhash: scalars?.["extensions.quarantinedDomains.listhash"],
+ remotehash: scalars?.["extensions.quarantinedDomains.remotehash"],
+ },
+ expectedTelemetryHash,
+ "Got the expected metrics mirrored into the unified telemetry scalars"
+ );
+}
+
+async function testQuarantinedDomainsFromRemoteSettings() {
+ // Same as MAX_PREF_LENGTH as defined in Preferences.cpp,
+ // see https://searchfox.org/mozilla-central/rev/06510249/modules/libpref/Preferences.cpp#162
+ const MAX_PREF_LENGTH = 1 * 1024 * 1024;
+ const quarantinedDomainsSets = {
+ testSet1: "example.com,example.org",
+ testSet2: "someothersite.org,testset2.org",
+ };
+
+ // Make sure there isn't initially any pre-existing telemetry data.
+ resetTelemetryData();
+
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "quarantinedDomains-01-testSet-toolong",
+ // We expect this entry to throw when trying to set a string pref
+ // that doesn't fit in the string prefs size limits.
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: "x".repeat(MAX_PREF_LENGTH + 1),
+ },
+ installTriggerDeprecation: null,
+ },
+ {
+ id: "quarantinedDomains-02-testSet1",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: quarantinedDomainsSets.testSet1,
+ },
+ installTriggerDeprecation: null,
+ },
+ {
+ // We expect this pref to override the pref set based on the
+ // previous entry.
+ id: "quarantinedDomains-03-testSet2",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: quarantinedDomainsSets.testSet2,
+ },
+ installTriggerDeprecation: null,
+ },
+ {
+ // Expect this entry to leave the domains list pref unchanged.
+ id: "quarantinedDomains-04-null",
+ quarantinedDomains: null,
+ installTriggerDeprecation: null,
+ },
+ ]);
+
+ Assert.equal(
+ Services.prefs.getPrefType(QUARANTINE_LIST_PREF),
+ Services.prefs.PREF_STRING,
+ `Expect ${QUARANTINE_LIST_PREF} preference type to be string`
+ );
+ // The entry too big to fix in the pref value should throw but not preventing
+ // the other entries from being processed.
+ // The Last collection entry setting the pref wins, and so we expect
+ // the pref to be set to the domains listed in the collection
+ // entry with id "quarantinedDomains-testSet2".
+ assertQuarantinedListPref(quarantinedDomainsSets.testSet2);
+ assertQuarantinedListTelemetry({
+ listhash: computeSha1HashAsString(quarantinedDomainsSets.testSet2),
+ remotehash: computeSha1HashAsString(quarantinedDomainsSets.testSet2),
+ });
+
+ // Confirm that the updated quarantined domains list is now reflected
+ // by the results returned by WebExtensionPolicy.isQuarantinedURI.
+ // NOTE: Additional test coverage over the quarantined domains behaviors
+ // are part of a separate xpcshell test
+ // (see toolkit/components/extensions/test/xpcshell/test_QuarantinedDomains.js).
+ for (const domain of quarantinedDomainsSets.testSet2.split(",")) {
+ let uri = Services.io.newURI(`https://${domain}/`);
+ ok(
+ WebExtensionPolicy.isQuarantinedURI(uri),
+ `Expect ${domain} to be quarantined`
+ );
+ }
+
+ for (const domain of quarantinedDomainsSets.testSet1.split(",")) {
+ let uri = Services.io.newURI(`https://${domain}/`);
+ ok(
+ !WebExtensionPolicy.isQuarantinedURI(uri),
+ `Expect ${domain} to not be quarantined`
+ );
+ }
+
+ const NEW_PREF_VALUE = "newdomain1.org,newdomain2.org";
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ // This entry doesn't includes an installTriggerDeprecation property
+ // (and then we verify that the pref is still set as expected).
+ id: "quarantinedDomains-withoutInstallTriggerDeprecation",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: NEW_PREF_VALUE,
+ },
+ },
+ ]);
+ assertQuarantinedListPref(NEW_PREF_VALUE);
+ assertQuarantinedListTelemetry({
+ listhash: computeSha1HashAsString(NEW_PREF_VALUE),
+ remotehash: computeSha1HashAsString(NEW_PREF_VALUE),
+ });
+
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ // This entry includes an unexpected property
+ // (and then we verify that the pref is still set as expected).
+ id: "quarantinedDomains-withoutInstallTriggerDeprecation",
+ quarantinedDomains: {
+ [QUARANTINE_LIST_PREF]: quarantinedDomainsSets.testSet1,
+ },
+ someUnexpectedProperty: "some unexpected value",
+ },
+ ]);
+ assertQuarantinedListPref(quarantinedDomainsSets.testSet1);
+ assertQuarantinedListTelemetry({
+ listhash: computeSha1HashAsString(quarantinedDomainsSets.testSet1),
+ remotehash: computeSha1HashAsString(quarantinedDomainsSets.testSet1),
+ });
+
+ info(
+ "Tamper with the domains list pref value, verify the remotesettings value is set back after restart"
+ );
+ const MANUALLY_CHANGED_PREF_VALUE =
+ quarantinedDomainsSets.testSet1 + ",test123.example.org";
+ Services.prefs.setStringPref(
+ QUARANTINE_LIST_PREF,
+ MANUALLY_CHANGED_PREF_VALUE
+ );
+ // At this point we expect the value of the hash recorded in telemetry to differ
+ // between the listhash and remotehash glean metrics.
+ assertQuarantinedListTelemetry({
+ listhash: computeSha1HashAsString(MANUALLY_CHANGED_PREF_VALUE),
+ remotehash: computeSha1HashAsString(quarantinedDomainsSets.testSet1),
+ });
+
+ // Then, we expect the remotehash and listhash to match each other again
+ // after the browser restart and the pref value to be back to the last
+ // value got from RemoteSettings.
+ info("Mock browser restart");
+ // Clear telemetry data that was collected so far.
+ resetTelemetryData();
+ const promisePrefChanged = TestUtils.waitForPrefChange(QUARANTINE_LIST_PREF);
+ await AddonTestUtils.promiseRestartManager();
+ info(
+ `Wait for expected change notified for the ${QUARANTINE_LIST_PREF} pref`
+ );
+ await promisePrefChanged;
+
+ assertQuarantinedListPref(quarantinedDomainsSets.testSet1);
+ assertQuarantinedListTelemetry({
+ listhash: computeSha1HashAsString(quarantinedDomainsSets.testSet1),
+ remotehash: computeSha1HashAsString(quarantinedDomainsSets.testSet1),
+ });
+}
+
+add_setup(async () => {
+ setupTelemetryForTests();
+ await AddonTestUtils.promiseStartupManager();
+
+ Assert.ok(
+ QuarantinedDomains._initialized,
+ "QuarantinedDomains is initialized"
+ );
+});
+
+add_task(testQuarantinedDomainsFromRemoteSettings);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AddonWrapper.js b/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AddonWrapper.js
new file mode 100644
index 0000000000..46312b192b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_QuarantinedDomains_AddonWrapper.js
@@ -0,0 +1,207 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { QuarantinedDomains } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+add_setup(async () => {
+ await AddonTestUtils.promiseStartupManager();
+});
+
+function assertQuarantineIgnoredByUserPrefsRemoved() {
+ const { PREF_ADDONS_BRANCH_NAME } = QuarantinedDomains;
+ const prefBranch = Services.prefs.getBranch(PREF_ADDONS_BRANCH_NAME);
+ for (const prefSuffix of prefBranch.getChildList("")) {
+ Assert.equal(
+ prefBranch.getPrefType(prefSuffix),
+ prefBranch.PREF_INVALID,
+ `${PREF_ADDONS_BRANCH_NAME}${prefSuffix} pref should have been removed`
+ );
+ }
+}
+
+async function testQuarantineDomainsAddonWrapperProperties() {
+ // Make sure no extension is initially user exempted.
+ const prefBranch = Services.prefs.getBranch(
+ QuarantinedDomains.PREF_ADDONS_BRANCH_NAME
+ );
+ for (const leafName of prefBranch.getChildList("")) {
+ Services.prefs.clearUserPref(
+ QuarantinedDomains.PREF_ADDONS_BRANCH_NAME + leafName
+ );
+ }
+
+ const REGULAR_EXT_ID = "regular@ext.id";
+ const PRIVILEGE_EXT_ID = "privileged@ext.id";
+ const RECOMMENDED_EXT_ID = "recommended@ext.id";
+
+ const regularExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: REGULAR_EXT_ID } },
+ },
+ });
+
+ const privilegedExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: PRIVILEGE_EXT_ID } },
+ },
+ });
+
+ const testStartTime = Date.now();
+ // Keep the recommendations validity range used here in sync with
+ // the one used in test_recommendations.js.
+ const not_before = new Date(testStartTime - 3600000).toISOString();
+ const not_after = new Date(testStartTime + 3600000).toISOString();
+ const RECOMMENDATION_FILE_NAME = "mozilla-recommendation.json";
+ const recommendedExt = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: RECOMMENDED_EXT_ID } },
+ },
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: RECOMMENDED_EXT_ID,
+ states: ["fake", "states"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+
+ await regularExt.startup();
+ await privilegedExt.startup();
+ await recommendedExt.startup();
+
+ function assertAddonWrapperProps(addon, expectedProps) {
+ const expectedPropNames = Object.keys(expectedProps);
+ if (!expectedPropNames.length) {
+ throw new Error("expectedProps shouldn't be empty");
+ }
+ for (const propName of expectedPropNames) {
+ Assert.deepEqual(
+ addon[propName],
+ expectedProps[propName],
+ `Got the expected value on ${propName} property from ${addon.id}`
+ );
+ }
+ }
+
+ const EXPECTED_PROPS_REGULAR_EXT = {
+ id: regularExt.id,
+ isPrivileged: false,
+ recommendationStates: [],
+ quarantineIgnoredByApp: false,
+ quarantineIgnoredByUser: false,
+ canChangeQuarantineIgnored: true,
+ };
+
+ const EXPECTED_PROPS_PRIVILEGED_EXT = {
+ id: privilegedExt.id,
+ isPrivileged: true,
+ recommendationStates: [],
+ // Expected to be true due to privileged signature.
+ quarantineIgnoredByApp: true,
+ quarantineIgnoredByUser: false,
+ // Expected to be false for app allowed.
+ canChangeQuarantineIgnored: false,
+ };
+
+ const EXPECTED_PROPS_RECOMMENDED_EXT = {
+ id: recommendedExt.id,
+ isPrivileged: false,
+ recommendationStates: ["fake", "states"],
+ // Expected to be true due to recommendationStates.
+ quarantineIgnoredByApp: true,
+ quarantineIgnoredByUser: false,
+ // Expected to be false for app allowed.
+ canChangeQuarantineIgnored: false,
+ };
+
+ assertAddonWrapperProps(regularExt.addon, EXPECTED_PROPS_REGULAR_EXT);
+
+ assertAddonWrapperProps(privilegedExt.addon, EXPECTED_PROPS_PRIVILEGED_EXT);
+
+ assertAddonWrapperProps(recommendedExt.addon, EXPECTED_PROPS_RECOMMENDED_EXT);
+
+ info("Verify quarantineIgnoredByUser property changed");
+ let promisePropChanged =
+ AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ regularExt.addon.quarantineIgnoredByUser = true;
+ assertAddonWrapperProps(regularExt.addon, {
+ ...EXPECTED_PROPS_REGULAR_EXT,
+ quarantineIgnoredByUser: true,
+ });
+ info("Wait for onPropertyChanged listener to be called");
+ let [addon, props] = await promisePropChanged;
+ Assert.deepEqual(
+ {
+ addonId: addon.id,
+ props,
+ },
+ {
+ addonId: regularExt.id,
+ props: ["quarantineIgnoredByUser"],
+ },
+ "Got the expected params from onPropertyChanged listener call"
+ );
+ Services.prefs.clearUserPref(
+ QuarantinedDomains.getUserAllowedAddonIdPrefName(regularExt.id)
+ );
+
+ info("Verify canChangeQuarantineIgnored on quarantineDomainsEnabled false");
+ Services.prefs.setBoolPref("extensions.quarantinedDomains.enabled", false);
+ assertAddonWrapperProps(regularExt.addon, {
+ ...EXPECTED_PROPS_REGULAR_EXT,
+ canChangeQuarantineIgnored: false,
+ });
+ Services.prefs.clearUserPref("extensions.quarantinedDomains.enabled");
+ assertAddonWrapperProps(regularExt.addon, EXPECTED_PROPS_REGULAR_EXT);
+
+ info("Verify canChangeQuarantineIgnored on uiDisabled true");
+ Services.prefs.setBoolPref("extensions.quarantinedDomains.uiDisabled", true);
+ assertAddonWrapperProps(regularExt.addon, {
+ ...EXPECTED_PROPS_REGULAR_EXT,
+ canChangeQuarantineIgnored: false,
+ });
+ Services.prefs.clearUserPref("extensions.quarantinedDomains.uiDisabled");
+ assertAddonWrapperProps(regularExt.addon, EXPECTED_PROPS_REGULAR_EXT);
+
+ info(
+ "Verify that the per-addon quarantineIgnoredByUser pref is removed on addon uninstall"
+ );
+
+ promisePropChanged = AddonTestUtils.promiseAddonEvent("onPropertyChanged");
+ regularExt.addon.quarantineIgnoredByUser = true;
+ await promisePropChanged;
+ Assert.equal(
+ Services.prefs.getBoolPref(
+ QuarantinedDomains.getUserAllowedAddonIdPrefName(regularExt.id)
+ ),
+ true,
+ "Expect the per-addon quarantineIgnoredByUser to be set"
+ );
+
+ await recommendedExt.unload();
+ await privilegedExt.unload();
+ await regularExt.unload();
+
+ assertQuarantineIgnoredByUserPrefsRemoved();
+}
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.quarantinedDomains.enabled", true],
+ ["extensions.quarantinedDomains.uiDisabled", false],
+ ],
+ },
+ testQuarantineDomainsAddonWrapperProperties
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js b/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
new file mode 100644
index 0000000000..97294bf6ed
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
@@ -0,0 +1,133 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test that we only check manifest age for disabled extensions
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ registerCleanupFunction(promiseShutdownManager);
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "enabled@tests.mozilla.org" } },
+ },
+ });
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "disabled@tests.mozilla.org" },
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID("disabled@tests.mozilla.org");
+ notEqual(addon, null);
+ await addon.disable();
+});
+
+// Keep track of the last time stamp we've used, so that we can keep moving
+// it forward (if we touch two different files in the same add-on with the same
+// timestamp we may not consider the change significant)
+var lastTimestamp = Date.now();
+
+/*
+ * Helper function to touch a file and then test whether we detect the change.
+ * @param XS The XPIState object.
+ * @param aPath File path to touch.
+ * @param aChange True if we should notice the change, False if we shouldn't.
+ */
+function checkChange(XS, aPath, aChange) {
+ Assert.ok(aPath.exists());
+ lastTimestamp += 10000;
+ info("Touching file " + aPath.path + " with " + lastTimestamp);
+ aPath.lastModifiedTime = lastTimestamp;
+ Assert.equal(XS.scanForChanges(), aChange);
+ // Save the pref so we don't detect this change again
+ XS.save();
+}
+
+// Get a reference to the XPIState (loaded by startupManager) so we can unit test it.
+function getXS() {
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ return XPIExports.XPIInternal.XPIStates;
+}
+
+async function getXSJSON() {
+ await AddonTestUtils.loadAddonsList(true);
+
+ return aomStartup.readStartupData();
+}
+
+add_task(async function detect_touches() {
+ let XS = getXS();
+
+ // Should be no changes detected here, because everything should start out up-to-date.
+ Assert.ok(!XS.scanForChanges());
+
+ let states = XS.getLocation("app-profile");
+
+ // State should correctly reflect enabled/disabled
+
+ let state = states.get("enabled@tests.mozilla.org");
+ Assert.notEqual(state, null, "Found xpi state for enabled extension");
+ Assert.ok(state.enabled, "enabled extension has correct xpi state");
+
+ state = states.get("disabled@tests.mozilla.org");
+ Assert.notEqual(state, null, "Found xpi state for disabled extension");
+ Assert.ok(!state.enabled, "disabled extension has correct xpi state");
+
+ // Touch various files and make sure the change is detected.
+
+ // We notice that a packed XPI is touched for an enabled add-on.
+ let peFile = profileDir.clone();
+ peFile.append("enabled@tests.mozilla.org.xpi");
+ checkChange(XS, peFile, true);
+
+ // We should notice the packed XPI change for a disabled add-on too.
+ let pdFile = profileDir.clone();
+ pdFile.append("disabled@tests.mozilla.org.xpi");
+ checkChange(XS, pdFile, true);
+});
+
+/*
+ * Uninstalling extensions should immediately remove them from XPIStates.
+ */
+add_task(async function uninstall_bootstrap() {
+ let pe = await promiseAddonByID("enabled@tests.mozilla.org");
+ await pe.uninstall();
+
+ let xpiState = await getXSJSON();
+ Assert.equal(
+ false,
+ "enabled@tests.mozilla.org" in xpiState["app-profile"].addons
+ );
+});
+
+/*
+ * Installing an extension should immediately add it to XPIState
+ */
+add_task(async function install_bootstrap() {
+ const ID = "addon@tests.mozilla.org";
+ let XS = getXS();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ let addon = await promiseAddonByID(ID);
+
+ let xState = XS.getAddon("app-profile", ID);
+ Assert.ok(!!xState);
+ Assert.ok(xState.enabled);
+ Assert.equal(xState.mtime, addon.updateDate.getTime());
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js b/toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js
new file mode 100644
index 0000000000..9e6b2faa67
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_XPIcancel.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test the cancellable doing/done/cancelAll API in XPIProvider
+
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+function run_test() {
+ // Check that cancelling with nothing in progress doesn't blow up
+ XPIExports.XPIInstall.cancelAll();
+
+ // Check that a basic object gets cancelled
+ let getsCancelled = {
+ isCancelled: false,
+ cancel() {
+ if (this.isCancelled) {
+ do_throw("Already cancelled");
+ }
+ this.isCancelled = true;
+ },
+ };
+ XPIExports.XPIInstall.doing(getsCancelled);
+ XPIExports.XPIInstall.cancelAll();
+ Assert.ok(getsCancelled.isCancelled);
+
+ // Check that if we complete a cancellable, it doesn't get cancelled
+ let doesntGetCancelled = {
+ cancel: () => do_throw("This should not have been cancelled"),
+ };
+ XPIExports.XPIInstall.doing(doesntGetCancelled);
+ Assert.ok(XPIExports.XPIInstall.done(doesntGetCancelled));
+ XPIExports.XPIInstall.cancelAll();
+
+ // A cancellable that adds a cancellable
+ getsCancelled.isCancelled = false;
+ let addsAnother = {
+ isCancelled: false,
+ cancel() {
+ if (this.isCancelled) {
+ do_throw("Already cancelled");
+ }
+ this.isCancelled = true;
+ XPIExports.XPIInstall.doing(getsCancelled);
+ },
+ };
+ XPIExports.XPIInstall.doing(addsAnother);
+ XPIExports.XPIInstall.cancelAll();
+ Assert.ok(addsAnother.isCancelled);
+ Assert.ok(getsCancelled.isCancelled);
+
+ // A cancellable that removes another. This assumes that Set() iterates in the
+ // order that members were added
+ let removesAnother = {
+ isCancelled: false,
+ cancel() {
+ if (this.isCancelled) {
+ do_throw("Already cancelled");
+ }
+ this.isCancelled = true;
+ XPIExports.XPIInstall.done(doesntGetCancelled);
+ },
+ };
+ XPIExports.XPIInstall.doing(removesAnother);
+ XPIExports.XPIInstall.doing(doesntGetCancelled);
+ XPIExports.XPIInstall.cancelAll();
+ Assert.ok(removesAnother.isCancelled);
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js b/toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js
new file mode 100644
index 0000000000..715b74a068
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_addonStartup.js
@@ -0,0 +1,93 @@
+"use strict";
+
+add_task(async function test_XPIStates_invalid_paths() {
+ let { path } = gAddonStartup;
+
+ let startupDatasets = [
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "xpcshell-something-or-other@mozilla.org",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "/home/xpcshell/.mozilla/firefox/default/extensions",
+ },
+ },
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "xpcshell-something-or-other@mozilla.org",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "c:\\Users\\XpcShell\\Application Data\\Mozilla Firefox\\Profiles\\meh",
+ },
+ },
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "/home/xpcshell/my-extensions/something-or-other",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "/home/xpcshell/.mozilla/firefox/default/extensions",
+ },
+ },
+ {
+ "app-profile": {
+ addons: {
+ "xpcshell-something-or-other@mozilla.org": {
+ bootstrapped: true,
+ dependencies: [],
+ enabled: true,
+ hasEmbeddedWebExtension: false,
+ lastModifiedTime: 1,
+ path: "c:\\Users\\XpcShell\\my-extensions\\something-or-other",
+ version: "0.0.0",
+ },
+ },
+ checkStartupModifications: true,
+ path: "c:\\Users\\XpcShell\\Application Data\\Mozilla Firefox\\Profiles\\meh",
+ },
+ },
+ ];
+
+ for (let startupData of startupDatasets) {
+ await IOUtils.writeJSON(path, startupData, { compress: true });
+
+ try {
+ let result = aomStartup.readStartupData();
+ info(`readStartupData() returned ${JSON.stringify(result)}`);
+ } catch (e) {
+ // We don't care if this throws, only that it doesn't crash.
+ info(`readStartupData() threw: ${e}`);
+ equal(
+ e.result,
+ Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH,
+ "Got expected error code"
+ );
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js b/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js
new file mode 100644
index 0000000000..6a533f540a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_addon_manager_telemetry_events.js
@@ -0,0 +1,1049 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+const { AMTelemetry } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+// We don't have an easy way to serve update manifests from a secure URL.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const EVENT_CATEGORY = "addonsManager";
+const EVENT_METHODS_INSTALL = ["install", "update"];
+const EVENT_METHODS_MANAGE = ["disable", "enable", "uninstall"];
+const EVENT_METHODS = [...EVENT_METHODS_INSTALL, ...EVENT_METHODS_MANAGE];
+const GLEAN_EVENT_NAMES = ["install", "update", "manage"];
+
+const FAKE_INSTALL_TELEMETRY_INFO = {
+ source: "fake-install-source",
+ method: "fake-install-method",
+};
+
+add_setup(() => {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+function getTelemetryEvents(includeMethods = EVENT_METHODS) {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ ok(
+ snapshot.parent && !!snapshot.parent.length,
+ "Got parent telemetry events in the snapshot"
+ );
+
+ return snapshot.parent
+ .filter(([timestamp, category, method]) => {
+ const includeMethod = includeMethods
+ ? includeMethods.includes(method)
+ : true;
+
+ return category === EVENT_CATEGORY && includeMethod;
+ })
+ .map(event => {
+ return {
+ method: event[2],
+ object: event[3],
+ value: event[4],
+ extra: event[5],
+ };
+ });
+}
+
+function assertNoTelemetryEvents() {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ if (!snapshot.parent || snapshot.parent.length === 0) {
+ ok(true, "Got no parent telemetry events as expected");
+ return;
+ }
+
+ let filteredEvents = snapshot.parent.filter(
+ ([timestamp, category, method]) => {
+ return category === EVENT_CATEGORY;
+ }
+ );
+
+ Assert.deepEqual(filteredEvents, [], "Got no AMTelemetry events as expected");
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // Thunderbird doesn't have one or more of the probes used in this test.
+ // Ensure the data is collected anyway.
+ 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();
+
+ await promiseStartupManager();
+});
+
+// Test the basic install and management flows.
+add_task(
+ {
+ // We need to enable this pref because some assertions verify that
+ // `installOrigins` is collected in some Telemetry events.
+ pref_set: [["extensions.install_origins.enabled", true]],
+ },
+ async function test_basic_telemetry_events() {
+ const EXTENSION_ID = "basic@test.extension";
+
+ const manifest = {
+ browser_specific_settings: { gecko: { id: EXTENSION_ID } },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: FAKE_INSTALL_TELEMETRY_INFO,
+ });
+
+ await extension.startup();
+
+ const addon = await promiseAddonByID(EXTENSION_ID);
+
+ info("Disabling the extension");
+ await addon.disable();
+
+ info("Set pending uninstall on the extension");
+ const onceAddonUninstalling = promiseAddonEvent("onUninstalling");
+ addon.uninstall(true);
+ await onceAddonUninstalling;
+
+ info("Cancel pending uninstall");
+ const oncePendingUninstallCancelled = promiseAddonEvent(
+ "onOperationCancelled"
+ );
+ addon.cancelUninstall();
+ await oncePendingUninstallCancelled;
+
+ info("Re-enabling the extension");
+ const onceAddonStarted = promiseWebExtensionStartup(EXTENSION_ID);
+ const onceAddonEnabled = promiseAddonEvent("onEnabled");
+ addon.enable();
+ await Promise.all([onceAddonEnabled, onceAddonStarted]);
+
+ await extension.unload();
+
+ let amEvents = getTelemetryEvents();
+ let gleanEvents = AddonTestUtils.getAMGleanEvents(GLEAN_EVENT_NAMES);
+
+ const amMethods = amEvents.map(evt => evt.method);
+ const expectedMethods = [
+ // These two install methods are related to the steps "started" and "completed".
+ "install",
+ "install",
+ // Sequence of disable and enable (pending uninstall and undo uninstall are not going to
+ // record any telemetry events).
+ "disable",
+ "enable",
+ // The final "uninstall" when the test extension is unloaded.
+ "uninstall",
+ ];
+ Assert.deepEqual(
+ amMethods,
+ expectedMethods,
+ "Got the addonsManager telemetry events in the expected order"
+ );
+ Assert.deepEqual(
+ expectedMethods,
+ gleanEvents.map(evt => {
+ // Install events don't have a method, so use ducktyping to recognize
+ // them: they have a step, but unlike update events, no updated_from.
+ if (evt.step && !evt.updated_from) {
+ return "install";
+ }
+ return evt.method;
+ }),
+ "Got the addonsManager Glean events in the expected order."
+ );
+
+ const installEvents = amEvents.filter(evt => evt.method === "install");
+ const expectedInstallEvents = [
+ {
+ method: "install",
+ object: "extension",
+ value: "1",
+ extra: {
+ addon_id: "basic@test.extension",
+ step: "started",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ {
+ method: "install",
+ object: "extension",
+ value: "1",
+ extra: {
+ addon_id: "basic@test.extension",
+ step: "completed",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ ];
+ Assert.deepEqual(
+ installEvents,
+ expectedInstallEvents,
+ "Got the expected addonsManager.install events"
+ );
+
+ let gleanInstall = {
+ addon_type: "extension",
+ addon_id: "basic@test.extension",
+ source: FAKE_INSTALL_TELEMETRY_INFO.source,
+ source_method: FAKE_INSTALL_TELEMETRY_INFO.method,
+ install_origins: "0",
+ };
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("install"),
+ [
+ { step: "started", ...gleanInstall },
+ { step: "completed", ...gleanInstall },
+ ],
+ "Got the expected addonsManager Glean events."
+ );
+
+ let gleanManage = {
+ addon_type: "extension",
+ addon_id: "basic@test.extension",
+ source: FAKE_INSTALL_TELEMETRY_INFO.source,
+ source_method: FAKE_INSTALL_TELEMETRY_INFO.method,
+ };
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("manage"),
+ [
+ { method: "disable", ...gleanManage },
+ { method: "enable", ...gleanManage },
+ { method: "uninstall", ...gleanManage },
+ ],
+ "Got the expected addonsManager Glean events"
+ );
+
+ const manageEvents = amEvents.filter(evt =>
+ EVENT_METHODS_MANAGE.includes(evt.method)
+ );
+ const expectedExtra = FAKE_INSTALL_TELEMETRY_INFO;
+ const expectedManageEvents = [
+ {
+ method: "disable",
+ object: "extension",
+ value: "basic@test.extension",
+ extra: expectedExtra,
+ },
+ {
+ method: "enable",
+ object: "extension",
+ value: "basic@test.extension",
+ extra: expectedExtra,
+ },
+ {
+ method: "uninstall",
+ object: "extension",
+ value: "basic@test.extension",
+ extra: expectedExtra,
+ },
+ ];
+ Assert.deepEqual(
+ manageEvents,
+ expectedManageEvents,
+ "Got the expected addonsManager.manage events"
+ );
+
+ Services.fog.testResetFOG();
+ // Verify that on every install flow, the value of the addonsManager.install Telemetry events
+ // is being incremented.
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: FAKE_INSTALL_TELEMETRY_INFO,
+ });
+
+ await extension.startup();
+ await extension.unload();
+
+ const eventsFromNewInstall = getTelemetryEvents();
+ equal(
+ eventsFromNewInstall.length,
+ 3,
+ "Got the expected number of addonsManager install events"
+ );
+
+ equal(
+ 3,
+ AddonTestUtils.getAMGleanEvents(GLEAN_EVENT_NAMES).length,
+ "Got the expected number of addonsManager Glean events."
+ );
+ equal(
+ 2,
+ AddonTestUtils.getAMGleanEvents("install", { install_id: "2" }).length,
+ "Got the expected install_id for Glean install event."
+ );
+
+ const eventValues = eventsFromNewInstall
+ .filter(evt => evt.method === "install")
+ .map(evt => evt.value);
+ const expectedValues = ["2", "2"];
+ Assert.deepEqual(
+ eventValues,
+ expectedValues,
+ "Got the expected install id"
+ );
+
+ Services.fog.testResetFOG();
+ }
+);
+
+add_task(
+ {
+ // We need to enable this pref because some assertions verify that
+ // `installOrigins` is collected in some Telemetry events.
+ pref_set: [["extensions.install_origins.enabled", true]],
+ },
+ async function test_update_telemetry_events() {
+ const EXTENSION_ID = "basic@test.extension";
+
+ const testserver = AddonTestUtils.createHttpServer({
+ hosts: ["example.com"],
+ });
+
+ const updateUrl = `http://example.com/updates.json`;
+
+ const testAddon = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: updateUrl,
+ },
+ },
+ },
+ });
+
+ const testUserRequestedUpdate = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: updateUrl,
+ },
+ },
+ },
+ });
+ const testAppRequestedUpdate = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.1",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ update_url: updateUrl,
+ },
+ },
+ },
+ });
+
+ testserver.registerFile(
+ `/addons/${EXTENSION_ID}-2.0.xpi`,
+ testUserRequestedUpdate
+ );
+ testserver.registerFile(
+ `/addons/${EXTENSION_ID}-2.1.xpi`,
+ testAppRequestedUpdate
+ );
+
+ let updates = [
+ {
+ version: "2.0",
+ update_link: `http://example.com/addons/${EXTENSION_ID}-2.0.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ];
+
+ testserver.registerPathHandler("/updates.json", (request, response) => {
+ response.write(`{
+ "addons": {
+ "${EXTENSION_ID}": {
+ "updates": ${JSON.stringify(updates)}
+ }
+ }
+ }`);
+ });
+
+ await promiseInstallFile(testAddon, false, FAKE_INSTALL_TELEMETRY_INFO);
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+
+ // User requested update.
+ await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let installs = await AddonManager.getAllInstalls();
+ await promiseCompleteAllInstalls(installs);
+
+ updates = [
+ {
+ version: "2.1",
+ update_link: `http://example.com/addons/${EXTENSION_ID}-2.1.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ];
+
+ // App requested update.
+ await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ let installs2 = await AddonManager.getAllInstalls();
+ await promiseCompleteAllInstalls(installs2);
+
+ updates = [
+ {
+ version: "2.1.1",
+ update_link: `http://example.com/addons/${EXTENSION_ID}-2.1.1-network-failure.xpi`,
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ },
+ },
+ },
+ ];
+
+ // Update which fails to download.
+ await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ let installs3 = await AddonManager.getAllInstalls();
+ await promiseCompleteAllInstalls(installs3);
+
+ let amEvents = getTelemetryEvents();
+
+ const installEvents = amEvents
+ .filter(evt => evt.method === "install")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ const addon_id = "basic@test.extension";
+ const object = "extension";
+
+ let gleanInstall = {
+ addon_id,
+ addon_type: "extension",
+ install_origins: "0",
+ source: FAKE_INSTALL_TELEMETRY_INFO.source,
+ source_method: FAKE_INSTALL_TELEMETRY_INFO.method,
+ };
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("install"),
+ [
+ { step: "started", ...gleanInstall },
+ { step: "completed", ...gleanInstall },
+ ],
+ "Got the expected install Glean events."
+ );
+
+ Assert.deepEqual(
+ installEvents,
+ [
+ {
+ method: "install",
+ object,
+ extra: {
+ addon_id,
+ step: "started",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ {
+ method: "install",
+ object,
+ extra: {
+ addon_id,
+ step: "completed",
+ install_origins: "0",
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ },
+ },
+ ],
+ "Got the expected addonsManager.install events"
+ );
+
+ const updateEvents = amEvents
+ .filter(evt => evt.method === "update")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ const method = "update";
+ const baseExtra = FAKE_INSTALL_TELEMETRY_INFO;
+
+ let glean = AddonTestUtils.getAMGleanEvents("update");
+ glean.forEach(e => delete e.download_time);
+
+ let gleanUpdate = {
+ addon_id,
+ addon_type: "extension",
+ source: FAKE_INSTALL_TELEMETRY_INFO.source,
+ source_method: FAKE_INSTALL_TELEMETRY_INFO.method,
+ };
+ Assert.deepEqual(
+ glean,
+ [
+ { step: "started", updated_from: "user", ...gleanUpdate },
+ { step: "download_started", updated_from: "user", ...gleanUpdate },
+ { step: "download_completed", updated_from: "user", ...gleanUpdate },
+ { step: "completed", updated_from: "user", ...gleanUpdate },
+ { step: "started", updated_from: "app", ...gleanUpdate },
+ { step: "download_started", updated_from: "app", ...gleanUpdate },
+ { step: "download_completed", updated_from: "app", ...gleanUpdate },
+ { step: "completed", updated_from: "app", ...gleanUpdate },
+ { step: "started", updated_from: "app", ...gleanUpdate },
+ { step: "download_started", updated_from: "app", ...gleanUpdate },
+ {
+ step: "download_failed",
+ updated_from: "app",
+ error: "ERROR_NETWORK_FAILURE",
+ ...gleanUpdate,
+ },
+ ],
+ "Got the expected Glean update events."
+ );
+
+ const expectedUpdateEvents = [
+ // User-requested update to the 2.1 version.
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "started",
+ updated_from: "user",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_started",
+ updated_from: "user",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_completed",
+ updated_from: "user",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "completed",
+ updated_from: "user",
+ },
+ },
+ // App-requested update to the 2.1 version.
+ {
+ method,
+ object,
+ extra: { ...baseExtra, addon_id, step: "started", updated_from: "app" },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_started",
+ updated_from: "app",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_completed",
+ updated_from: "app",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "completed",
+ updated_from: "app",
+ },
+ },
+ // Broken update to the 2.1.1 version (on ERROR_NETWORK_FAILURE).
+ {
+ method,
+ object,
+ extra: { ...baseExtra, addon_id, step: "started", updated_from: "app" },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ step: "download_started",
+ updated_from: "app",
+ },
+ },
+ {
+ method,
+ object,
+ extra: {
+ ...baseExtra,
+ addon_id,
+ error: "ERROR_NETWORK_FAILURE",
+ step: "download_failed",
+ updated_from: "app",
+ },
+ },
+ ];
+
+ AddonTestUtils.getAMGleanEvents("update")
+ .filter(e => ["download_completed", "download_failed"].includes(e.step))
+ .forEach(e =>
+ Assert.greater(
+ parseInt(e.download_time, 10),
+ 0,
+ `At step ${e.step} download_time: ${e.download_time}`
+ )
+ );
+
+ for (let i = 0; i < updateEvents.length; i++) {
+ if (
+ ["download_completed", "download_failed"].includes(
+ updateEvents[i].extra.step
+ )
+ ) {
+ const download_time = parseInt(updateEvents[i].extra.download_time, 10);
+ ok(
+ !isNaN(download_time) && download_time > 0,
+ `Got a download_time extra in ${updateEvents[i].extra.step} events: ${download_time}`
+ );
+
+ delete updateEvents[i].extra.download_time;
+ }
+
+ Assert.deepEqual(
+ updateEvents[i],
+ expectedUpdateEvents[i],
+ "Got the expected addonsManager.update events"
+ );
+ }
+
+ equal(
+ updateEvents.length,
+ expectedUpdateEvents.length,
+ "Got the expected number of addonsManager.update events"
+ );
+
+ await addon.uninstall();
+
+ // Clear any AMTelemetry events related to the uninstalled extensions.
+ getTelemetryEvents();
+ Services.fog.testResetFOG();
+ }
+);
+
+add_task(async function test_no_telemetry_events_on_internal_sources() {
+ assertNoTelemetryEvents();
+
+ const INTERNAL_EXTENSION_ID = "internal@test.extension";
+
+ // Install an extension which has internal as its installation source,
+ // and expect it to do not appear in the collected telemetry events.
+ let internalExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: INTERNAL_EXTENSION_ID } },
+ },
+ useAddonManager: "permanent",
+ amInstallTelemetryInfo: { source: "internal" },
+ });
+
+ await internalExtension.startup();
+
+ const internalAddon = await promiseAddonByID(INTERNAL_EXTENSION_ID);
+
+ info("Disabling the internal extension");
+ const onceInternalAddonDisabled = promiseAddonEvent("onDisabled");
+ internalAddon.disable();
+ await onceInternalAddonDisabled;
+
+ info("Re-enabling the internal extension");
+ const onceInternalAddonStarted = promiseWebExtensionStartup(
+ INTERNAL_EXTENSION_ID
+ );
+ const onceInternalAddonEnabled = promiseAddonEvent("onEnabled");
+ internalAddon.enable();
+ await Promise.all([onceInternalAddonEnabled, onceInternalAddonStarted]);
+
+ await internalExtension.unload();
+
+ assertNoTelemetryEvents();
+});
+
+add_task(async function test_collect_attribution_data_for_amo() {
+ assertNoTelemetryEvents();
+
+ // We pass the `source` value to `amInstallTelemetryInfo` in this test so the
+ // host could be anything in this variable below. Whether to collect
+ // attribution data for AMO is determined by the `source` value, not this
+ // host.
+ const url = "https://addons.mozilla.org/";
+ const addonId = "{28374a9a-676c-5640-bfa7-865cd4686ead}";
+ // This is the SHA256 hash of the `addonId` above.
+ const expectedHashedAddonId =
+ "cf815c9f45c249473d630705f89e64d359737a106a375bbb83be71e6d52dc234";
+
+ for (const { source, sourceURL, expectNoEvent, expectedAmoAttribution } of [
+ // Basic test.
+ {
+ source: "amo",
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectedAmoAttribution: {
+ utm_content: "utm-content-value",
+ },
+ },
+ // No UTM parameters will produce an event without any attribution data.
+ {
+ source: "amo",
+ sourceURL: url,
+ expectedAmoAttribution: {},
+ },
+ // Invalid source URLs will produce an event without any attribution data.
+ {
+ source: "amo",
+ sourceURL: "invalid-url",
+ expectedAmoAttribution: {},
+ },
+ // No source URL.
+ {
+ source: "amo",
+ sourceURL: null,
+ expectedAmoAttribution: {},
+ },
+ {
+ source: "amo",
+ sourceURL: undefined,
+ expectedAmoAttribution: {},
+ },
+ // Ignore unsupported/bogus UTM parameters.
+ {
+ source: "amo",
+ sourceURL: [
+ `${url}?utm_content=utm-content-value`,
+ "utm_foo=invalid",
+ "utm_campaign=some-campaign",
+ "utm_term=invalid-too",
+ ].join("&"),
+ expectedAmoAttribution: {
+ utm_campaign: "some-campaign",
+ utm_content: "utm-content-value",
+ },
+ },
+ {
+ source: "amo",
+ sourceURL: `${url}?foo=bar&q=azerty`,
+ expectedAmoAttribution: {},
+ },
+ // Long values are truncated.
+ {
+ source: "amo",
+ sourceURL: `${url}?utm_medium=${"a".repeat(100)}`,
+ expectedAmoAttribution: {
+ utm_medium: "a".repeat(40),
+ },
+ },
+ // Only collect the first value if the parameter is passed more than once.
+ {
+ source: "amo",
+ sourceURL: `${url}?utm_source=first-source&utm_source=second-source`,
+ expectedAmoAttribution: {
+ utm_source: "first-source",
+ },
+ },
+ // When source is "disco", we don't collect the UTM parameters.
+ {
+ source: "disco",
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectedAmoAttribution: {},
+ },
+ // When source is neither "amo" nor "disco", we don't collect anything.
+ {
+ source: "link",
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectNoEvent: true,
+ },
+ {
+ source: null,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectNoEvent: true,
+ },
+ {
+ source: undefined,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ expectNoEvent: true,
+ },
+ ]) {
+ const extDefinition = {
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: addonId } },
+ },
+ amInstallTelemetryInfo: {
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ sourceURL,
+ source,
+ },
+ };
+ let extension = ExtensionTestUtils.loadExtension(extDefinition);
+
+ await extension.startup();
+
+ const installStatsEvents = getTelemetryEvents(["install_stats"]);
+ let gleanEvents = AddonTestUtils.getAMGleanEvents(["installStats"]);
+ Services.fog.testResetFOG();
+
+ if (expectNoEvent === true) {
+ Assert.equal(
+ installStatsEvents.length,
+ 0,
+ "no install_stats event should be recorded"
+ );
+ Assert.equal(
+ gleanEvents.length,
+ 0,
+ "No install_stats Glean event should be recorded."
+ );
+ } else {
+ Assert.equal(
+ installStatsEvents.length,
+ 1,
+ "only one install_stats event should be recorded"
+ );
+ Assert.equal(
+ gleanEvents.length,
+ 1,
+ "Only one install_stats Glean event should be recorded."
+ );
+
+ const installStatsEvent = installStatsEvents[0];
+
+ Assert.deepEqual(installStatsEvent, {
+ method: "install_stats",
+ object: "extension",
+ value: expectedHashedAddonId,
+ extra: {
+ addon_id: addonId,
+ ...expectedAmoAttribution,
+ },
+ });
+ Assert.deepEqual(gleanEvents[0], {
+ addon_id: addonId,
+ addon_type: "extension",
+ ...expectedAmoAttribution,
+ });
+ }
+
+ await extension.upgrade({
+ ...extDefinition,
+ manifest: {
+ ...extDefinition.manifest,
+ version: "2.0",
+ },
+ });
+
+ Assert.deepEqual(
+ getTelemetryEvents(["install_stats"]),
+ [],
+ "no install_stats event should be recorded on addon updates"
+ );
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents(["installStats"]),
+ [],
+ "No install_stats Glean event should be recorded on addon updates."
+ );
+
+ await extension.unload();
+ Services.fog.testResetFOG();
+ }
+
+ getTelemetryEvents();
+});
+
+add_task(async function test_collect_attribution_data_for_amo_with_long_id() {
+ assertNoTelemetryEvents();
+
+ // We pass the `source` value to `installTelemetryInfo` in this test so the
+ // host could be anything in this variable below. Whether to collect
+ // attribution data for AMO is determined by the `source` value, not this
+ // host.
+ const url = "https://addons.mozilla.org/";
+ const addonId = `@${"a".repeat(90)}`;
+ // This is the SHA256 hash of the `addonId` above.
+ const expectedHashedAddonId =
+ "964d902353fc1c127228b66ec8a174c340cb2e02dbb550c6000fb1cd3ca2f489";
+
+ const installTelemetryInfo = {
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ source: "amo",
+ };
+
+ // We call `recordInstallStatsEvent()` directly because using an add-on ID
+ // longer than 64 chars causes signing issues in tests (because of the
+ // differences between the fake CertDB injected by
+ // `AddonTestUtils.overrideCertDB()` and the real one).
+ const fakeAddonInstall = {
+ addon: { id: addonId },
+ type: "extension",
+ installTelemetryInfo,
+ hashedAddonId: expectedHashedAddonId,
+ };
+ AMTelemetry.recordInstallStatsEvent(fakeAddonInstall);
+
+ const installStatsEvents = getTelemetryEvents(["install_stats"]);
+ Assert.equal(
+ installStatsEvents.length,
+ 1,
+ "only one install_stats event should be recorded"
+ );
+
+ const installStatsEvent = installStatsEvents[0];
+
+ Assert.deepEqual(installStatsEvent, {
+ method: "install_stats",
+ object: "extension",
+ value: expectedHashedAddonId,
+ extra: {
+ addon_id: AMTelemetry.getTrimmedString(addonId),
+ utm_content: "utm-content-value",
+ },
+ });
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents(["installStats"]),
+ [
+ {
+ addon_type: "extension",
+ addon_id: AMTelemetry.getTrimmedString(addonId),
+ utm_content: "utm-content-value",
+ },
+ ],
+ "Got the expected install_stats Glean event."
+ );
+ Services.fog.testResetFOG();
+});
+
+add_task(async function test_collect_attribution_data_for_rtamo() {
+ assertNoTelemetryEvents();
+
+ const url = "https://addons.mozilla.org/";
+ const addonId = "{28374a9a-676c-5640-bfa7-865cd4686ead}";
+ // This is the SHA256 hash of the `addonId` above.
+ const expectedHashedAddonId =
+ "cf815c9f45c249473d630705f89e64d359737a106a375bbb83be71e6d52dc234";
+
+ // We simulate what is happening in:
+ // https://searchfox.org/mozilla-central/rev/d2786d9a6af7507bc3443023f0495b36b7e84c2d/browser/components/newtab/content-src/lib/aboutwelcome-utils.js#91
+ const installTelemetryInfo = {
+ ...FAKE_INSTALL_TELEMETRY_INFO,
+ sourceURL: `${url}?utm_content=utm-content-value`,
+ source: "rtamo",
+ };
+
+ const fakeAddonInstall = {
+ addon: { id: addonId },
+ type: "extension",
+ installTelemetryInfo,
+ hashedAddonId: expectedHashedAddonId,
+ };
+ AMTelemetry.recordInstallStatsEvent(fakeAddonInstall);
+
+ const installStatsEvents = getTelemetryEvents(["install_stats"]);
+ Assert.equal(
+ installStatsEvents.length,
+ 1,
+ "only one install_stats event should be recorded"
+ );
+
+ const installStatsEvent = installStatsEvents[0];
+
+ Assert.deepEqual(installStatsEvent, {
+ method: "install_stats",
+ object: "extension",
+ value: expectedHashedAddonId,
+ extra: {
+ addon_id: AMTelemetry.getTrimmedString(addonId),
+ },
+ });
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents(["installStats"]),
+ [
+ {
+ addon_type: "extension",
+ addon_id: AMTelemetry.getTrimmedString(addonId),
+ },
+ ],
+ "Got the expected install_stats Glean event."
+ );
+ Services.fog.testResetFOG();
+});
+
+add_task(async function teardown() {
+ await TelemetryController.testShutdown();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js
new file mode 100644
index 0000000000..8176c5881a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_amo_stats_telemetry.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryController } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryController.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "42"
+);
+
+add_task(async function setup() {
+ // We need to set this pref to `true` in order to collect add-ons Telemetry
+ // data (which is considered extended data and disabled in CI).
+ const overridePreReleasePref = "toolkit.telemetry.testing.overridePreRelease";
+ let oldOverrideValue = Services.prefs.getBoolPref(
+ overridePreReleasePref,
+ false
+ );
+ Services.prefs.setBoolPref(overridePreReleasePref, true);
+ registerCleanupFunction(() => {
+ Services.prefs.setBoolPref(overridePreReleasePref, oldOverrideValue);
+ });
+
+ await TelemetryController.testSetup();
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_ping_payload_and_environment() {
+ const extensions = [
+ {
+ id: "addons-telemetry@test-extension-1",
+ name: "some extension 1",
+ version: "1.2.3",
+ },
+ {
+ id: "addons-telemetry@test-extension-2",
+ name: "some extension 2",
+ version: "0.1",
+ },
+ ];
+
+ // Install some extensions.
+ const installedExtensions = [];
+ for (const { id, name, version } of extensions) {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name,
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+ installedExtensions.push(extension);
+
+ await extension.startup();
+ }
+
+ const { payload, environment } = TelemetryController.getCurrentPingData();
+
+ // Important: `payload.info.addons` is being used for AMO usage stats.
+ Assert.ok("addons" in payload.info, "payload.info.addons is defined");
+ Assert.equal(
+ payload.info.addons,
+ extensions
+ .map(({ id, version }) => `${encodeURIComponent(id)}:${version}`)
+ .join(",")
+ );
+ Assert.ok(
+ "XPI" in payload.addonDetails,
+ "payload.addonDetails.XPI is defined"
+ );
+ for (const { id, name } of extensions) {
+ Assert.ok(id in payload.addonDetails.XPI);
+ Assert.equal(payload.addonDetails.XPI[id].name, name);
+ }
+
+ const { addons } = environment;
+ Assert.ok(
+ "activeAddons" in addons,
+ "environment.addons.activeAddons is defined"
+ );
+ Assert.ok("theme" in addons, "environment.addons.theme is defined");
+ for (const { id } of extensions) {
+ Assert.ok(id in environment.addons.activeAddons);
+ }
+
+ for (const extension of installedExtensions) {
+ await extension.unload();
+ }
+});
+
+add_task(async function cleanup() {
+ await TelemetryController.testShutdown();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js b/toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js
new file mode 100644
index 0000000000..f59a14dbbc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_aom_startup.js
@@ -0,0 +1,189 @@
+"use strict";
+
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+const aomStartup = Cc["@mozilla.org/addons/addon-manager-startup;1"].getService(
+ Ci.amIAddonManagerStartup
+);
+
+const gProfDir = do_get_profile();
+
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+const DUMMY_ID = "@dummy";
+const DUMMY_ADDONS = {
+ addons: {
+ "@dummy": {
+ lastModifiedTime: 1337,
+ rootURI: "resource:///modules/themes/dummy/",
+ version: "1",
+ },
+ },
+};
+
+const TEST_ADDON_ID = "@test-theme";
+const TEST_THEME = {
+ lastModifiedTime: 1337,
+ rootURI: "resource:///modules/themes/test/",
+ version: "1",
+};
+
+const TEST_ADDONS = {
+ addons: {
+ "@test-theme": TEST_THEME,
+ },
+};
+
+// Utility to write out various addonStartup.json files.
+async function writeAOMStartupData(data) {
+ let jsonFile = new JSONFile({
+ path: PathUtils.join(gProfDir.path, "addonStartup.json.lz4"),
+ compression: "lz4",
+ });
+ jsonFile.data = data;
+ await jsonFile._save();
+ return aomStartup.readStartupData();
+}
+
+// This tests that any buitin removed from the build will
+// get removed from the state data.
+add_task(async function test_startup_missing_builtin() {
+ let startupData = await writeAOMStartupData({
+ "app-builtin": DUMMY_ADDONS,
+ });
+ Assert.ok(
+ !!startupData["app-builtin"].addons[DUMMY_ID],
+ "non-existant addon is in startup data"
+ );
+
+ await AddonTestUtils.promiseStartupManager();
+ await AddonTestUtils.promiseShutdownManager();
+
+ // This data is flushed on shutdown, so we check it after shutdown.
+ startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-builtin"].addons[DUMMY_ID],
+ undefined,
+ "non-existant addon is removed from startup data"
+ );
+});
+
+// This test verifies that a builtin installed prior to the
+// second scan is not overwritten by old state data during
+// the scan.
+add_task(async function test_startup_default_theme_moved() {
+ let startupData = await writeAOMStartupData({
+ "app-profile": DUMMY_ADDONS,
+ "app-builtin": TEST_ADDONS,
+ });
+ Assert.ok(
+ !!startupData["app-profile"].addons[DUMMY_ID],
+ "non-existant addon is in startup data"
+ );
+ Assert.ok(
+ !!startupData["app-builtin"].addons[TEST_ADDON_ID],
+ "test addon is in startup data"
+ );
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: TEST_ADDON_ID } },
+ version: "1.1",
+ theme: {},
+ },
+ };
+
+ await setupBuiltinExtension(themeDef, "second-loc");
+ await AddonTestUtils.promiseStartupManager("44");
+ await AddonManager.maybeInstallBuiltinAddon(
+ TEST_ADDON_ID,
+ "1.1",
+ "resource://second-loc/"
+ );
+ await AddonManagerPrivate.getNewSideloads();
+
+ let addon = await AddonManager.getAddonByID(TEST_ADDON_ID);
+ Assert.ok(!addon.foreignInstall, "addon was not marked as a foreign install");
+ Assert.equal(addon.version, "1.1", "addon version is correct");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // This data is flushed on shutdown, so we check it after shutdown.
+ startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-builtin"].addons[TEST_ADDON_ID].version,
+ "1.1",
+ "startup data is correct in cache"
+ );
+ Assert.equal(
+ startupData["app-builtin"].addons[DUMMY_ID],
+ undefined,
+ "non-existant addon is removed from startup data"
+ );
+});
+
+// This test verifies that a builtin addon being updated
+// is not marked as a foreignInstall.
+add_task(async function test_startup_builtin_not_foreign() {
+ let startupData = await writeAOMStartupData({
+ "app-profile": DUMMY_ADDONS,
+ "app-builtin": {
+ addons: {
+ "@test-theme": {
+ ...TEST_THEME,
+ rootURI: "resource://second-loc/",
+ },
+ },
+ },
+ });
+ Assert.ok(
+ !!startupData["app-profile"].addons[DUMMY_ID],
+ "non-existant addon is in startup data"
+ );
+ Assert.ok(
+ !!startupData["app-builtin"].addons[TEST_ADDON_ID],
+ "test addon is in startup data"
+ );
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: TEST_ADDON_ID } },
+ version: "1.1",
+ theme: {},
+ },
+ };
+
+ await setupBuiltinExtension(themeDef, "second-loc");
+ await AddonTestUtils.promiseStartupManager("43");
+ await AddonManager.maybeInstallBuiltinAddon(
+ TEST_ADDON_ID,
+ "1.1",
+ "resource://second-loc/"
+ );
+ await AddonManagerPrivate.getNewSideloads();
+
+ let addon = await AddonManager.getAddonByID(TEST_ADDON_ID);
+ Assert.ok(!addon.foreignInstall, "addon was not marked as a foreign install");
+ Assert.equal(addon.version, "1.1", "addon version is correct");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // This data is flushed on shutdown, so we check it after shutdown.
+ startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-builtin"].addons[TEST_ADDON_ID].version,
+ "1.1",
+ "startup data is correct in cache"
+ );
+ Assert.equal(
+ startupData["app-builtin"].addons[DUMMY_ID],
+ undefined,
+ "non-existant addon is removed from startup data"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js b/toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js
new file mode 100644
index 0000000000..ec6c30bd52
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_bad_json.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that we rebuild the database correctly if it contains
+// JSON data that parses correctly but doesn't contain required fields
+
+add_task(async function () {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+
+ const ID = "addon@tests.mozilla.org";
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ await promiseShutdownManager();
+
+ // First startup/shutdown finished
+ // Replace the JSON store with something bogus
+ await IOUtils.writeJSON(gExtensionsJSON.path, {
+ not: "what we expect to find",
+ });
+
+ await promiseStartupManager();
+ // Retrieve an addon to force the database to rebuild
+ let addon = await AddonManager.getAddonByID(ID);
+
+ Assert.equal(addon.id, ID);
+
+ await promiseShutdownManager();
+
+ // Make sure our JSON database has schemaVersion and our installed extension
+ let data = await IOUtils.readJSON(gExtensionsJSON.path);
+ Assert.ok("schemaVersion" in data);
+ Assert.equal(data.addons[0].id, ID);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_badschema.js b/toolkit/mozapps/extensions/test/xpcshell/test_badschema.js
new file mode 100644
index 0000000000..0fc810bf91
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_badschema.js
@@ -0,0 +1,237 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that we rebuild something sensible from a database with a bad schema
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+// register files with server
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const ADDONS = {
+ "addon1@tests.mozilla.org": {
+ manifest: {
+ name: "Test 1",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ },
+ desiredValues: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon2@tests.mozilla.org": {
+ manifest: {
+ name: "Test 2",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon2@tests.mozilla.org",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ desiredValues: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon3@tests.mozilla.org": {
+ manifest: {
+ name: "Test 3",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ findUpdates: true,
+ desiredValues: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon4@tests.mozilla.org": {
+ manifest: {
+ name: "Test 4",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ findUpdates: true,
+ desiredValues: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon5@tests.mozilla.org": {
+ manifest: {
+ name: "Test 5",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon5@tests.mozilla.org",
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ desiredValues: {
+ isActive: false,
+ userDisabled: false,
+ appDisabled: true,
+ pendingOperations: 0,
+ },
+ },
+
+ "theme1@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 1",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme1@tests.mozilla.org",
+ },
+ },
+ },
+ desiredValues: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "theme2@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 2",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme2@tests.mozilla.org",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: false,
+ },
+ desiredValues: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+};
+
+const IDS = Object.keys(ADDONS);
+
+function promiseUpdates(addon) {
+ return new Promise(resolve => {
+ addon.findUpdates(
+ { onUpdateFinished: resolve },
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ });
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ for (let addon of Object.values(ADDONS)) {
+ let webext = createTempWebExtensionFile({ manifest: addon.manifest });
+ await AddonTestUtils.manuallyInstall(webext);
+ }
+
+ await promiseStartupManager();
+
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ if (addon.initialState) {
+ await setInitialState(addons.get(id), addon.initialState);
+ }
+ if (addon.findUpdates) {
+ await promiseUpdates(addons.get(id));
+ }
+ }
+});
+
+add_task(async function test_after_restart() {
+ await promiseRestartManager();
+
+ info("Test add-on state after restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredValues);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_after_schema_version_change() {
+ // After restarting the database won't be open so we can alter
+ // the schema
+ await changeXPIDBVersion(100);
+
+ await promiseStartupManager();
+
+ info("Test add-on state after schema version change");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredValues);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_after_second_restart() {
+ await promiseStartupManager();
+
+ info("Test add-on state after second restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredValues);
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js b/toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js
new file mode 100644
index 0000000000..261ef61807
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_bug587088.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test is currently dead code.
+/* eslint-disable */
+
+// Tests that trying to upgrade or uninstall an extension that has a file locked
+// will roll back the upgrade or uninstall and retry at the next restart
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = [
+ {
+ "install.rdf": {
+ id: "addon1@tests.mozilla.org",
+ version: "1.0",
+ name: "Bug 587088 Test",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ },
+ testfile: "",
+ testfile1: "",
+ },
+
+ {
+ "install.rdf": {
+ id: "addon1@tests.mozilla.org",
+ version: "2.0",
+ name: "Bug 587088 Test",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ },
+ testfile: "",
+ testfile2: "",
+ },
+];
+
+add_task(async function setup() {
+ // This is only an issue on windows.
+ if (!("nsIWindowsRegKey" in Ci)) return;
+
+ do_test_pending();
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+});
+
+function check_addon(aAddon, aVersion) {
+ Assert.notEqual(aAddon, null);
+ Assert.equal(aAddon.version, aVersion);
+ Assert.ok(aAddon.isActive);
+ Assert.ok(isExtensionInAddonsList(profileDir, aAddon.id));
+
+ Assert.ok(aAddon.hasResource("testfile"));
+ if (aVersion == "1.0") {
+ Assert.ok(aAddon.hasResource("testfile1"));
+ Assert.ok(!aAddon.hasResource("testfile2"));
+ } else {
+ Assert.ok(!aAddon.hasResource("testfile1"));
+ Assert.ok(aAddon.hasResource("testfile2"));
+ }
+
+ Assert.equal(aAddon.pendingOperations, AddonManager.PENDING_NONE);
+}
+
+function check_addon_upgrading(aAddon) {
+ Assert.notEqual(aAddon, null);
+ Assert.equal(aAddon.version, "1.0");
+ Assert.ok(aAddon.isActive);
+ Assert.ok(isExtensionInAddonsList(profileDir, aAddon.id));
+
+ Assert.ok(aAddon.hasResource("testfile"));
+ Assert.ok(aAddon.hasResource("testfile1"));
+ Assert.ok(!aAddon.hasResource("testfile2"));
+
+ Assert.equal(aAddon.pendingOperations, AddonManager.PENDING_UPGRADE);
+
+ Assert.equal(aAddon.pendingUpgrade.version, "2.0");
+}
+
+function check_addon_uninstalling(aAddon, aAfterRestart) {
+ Assert.notEqual(aAddon, null);
+ Assert.equal(aAddon.version, "1.0");
+
+ if (aAfterRestart) {
+ Assert.ok(!aAddon.isActive);
+ Assert.ok(!isExtensionInAddonsList(profileDir, aAddon.id));
+ } else {
+ Assert.ok(aAddon.isActive);
+ Assert.ok(isExtensionInAddonsList(profileDir, aAddon.id));
+ }
+
+ Assert.ok(aAddon.hasResource("testfile"));
+ Assert.ok(aAddon.hasResource("testfile1"));
+ Assert.ok(!aAddon.hasResource("testfile2"));
+
+ Assert.equal(aAddon.pendingOperations, AddonManager.PENDING_UNINSTALL);
+}
+
+add_task(async function test_1() {
+ await AddonTestUtils.promiseInstallXPI(ADDONS[0]);
+
+ await promiseRestartManager();
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon(a1, "1.0");
+
+ // Lock either install.rdf for unpacked add-ons or the xpi for packed add-ons.
+ let uri = a1.getResourceURI("install.rdf");
+ if (uri instanceof Ci.nsIJARURI) uri = uri.JARFile;
+
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(uri.QueryInterface(Ci.nsIFileURL).file, -1, 0, 0);
+
+ await AddonTestUtils.promiseInstallXPI(ADDONS[1]);
+
+ check_addon_upgrading(a1);
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_upgrading(a1_2);
+
+ await promiseRestartManager();
+
+ let a1_3 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_upgrading(a1_3);
+
+ fstream.close();
+
+ await promiseRestartManager();
+
+ let a1_4 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon(a1_4, "2.0");
+
+ await a1_4.uninstall();
+});
+
+// Test that a failed uninstall gets rolled back
+add_task(async function test_2() {
+ await promiseRestartManager();
+
+ await AddonTestUtils.promiseInstallXPI(ADDONS[0]);
+ await promiseRestartManager();
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon(a1, "1.0");
+
+ // Lock either install.rdf for unpacked add-ons or the xpi for packed add-ons.
+ let uri = a1.getResourceURI("install.rdf");
+ if (uri instanceof Ci.nsIJARURI) uri = uri.JARFile;
+
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(uri.QueryInterface(Ci.nsIFileURL).file, -1, 0, 0);
+
+ await a1.uninstall();
+
+ check_addon_uninstalling(a1);
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_uninstalling(a1_2, true);
+
+ await promiseRestartManager();
+
+ let a1_3 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ check_addon_uninstalling(a1_3, true);
+
+ fstream.close();
+
+ await promiseRestartManager();
+
+ let a1_4 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.equal(a1_4, null);
+ var dir = profileDir.clone();
+ dir.append(do_get_expected_addon_name("addon1@tests.mozilla.org"));
+ Assert.ok(!dir.exists());
+ Assert.ok(!isExtensionInAddonsList(profileDir, "addon1@tests.mozilla.org"));
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js b/toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js
new file mode 100644
index 0000000000..30801e9f72
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_builtin_location.js
@@ -0,0 +1,149 @@
+"use strict";
+
+/* globals browser */
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+);
+
+async function getWrapper(id, hidden) {
+ let wrapper = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ hidden,
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ await wrapper.awaitMessage("started");
+ return wrapper;
+}
+
+// Tests installing an extension from the built-in location.
+add_task(async function test_builtin_location() {
+ let id = "builtin@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ let wrapper = await getWrapper(id);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(wrapper.extension.isAppProvided, "Addon is app provided");
+ ok(!addon.hidden, "Addon is not hidden");
+
+ // Built-in extensions are not checked against the blocklist,
+ // so we shouldn't have loaded it.
+ ok(!Services.blocklist.isLoaded, "Blocklist hasn't been loaded");
+
+ // After a restart, the extension should start up normally.
+ await promiseRestartManager();
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in built-in location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that causes a database rebuild, it should still work
+ await promiseRestartManager("2");
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in built-in location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that changes the schema version, it should still work
+ await promiseShutdownManager();
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in built-in location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ await wrapper.unload();
+
+ addon = await promiseAddonByID(id);
+ equal(addon, null, "Addon is gone after uninstall");
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Tests installing a hidden extension from the built-in location.
+add_task(async function test_builtin_location_hidden() {
+ let id = "hidden@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ let wrapper = await getWrapper(id, true);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(wrapper.extension.isAppProvided, "Addon is app provided");
+ ok(addon.hidden, "Addon is hidden");
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Tests updates to builtin extensions
+add_task(async function test_builtin_update() {
+ let id = "bleah@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+
+ let wrapper = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version: "1.0",
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ await wrapper.awaitMessage("started");
+
+ await AddonTestUtils.promiseShutdownManager();
+
+ // Change the built-in
+ await setupBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version: "2.0",
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+
+ let updateReason;
+ AddonTestUtils.on("bootstrap-method", (method, params, reason) => {
+ updateReason = reason;
+ });
+
+ // Re-start, with a new app version
+ await AddonTestUtils.promiseStartupManager("3");
+
+ await wrapper.awaitMessage("started");
+
+ equal(
+ updateReason,
+ BOOTSTRAP_REASONS.ADODN_UPGRADE,
+ "Builtin addon's bootstrap update() method was called at startup"
+ );
+
+ await wrapper.unload();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js b/toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js
new file mode 100644
index 0000000000..4a570b57fb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_cacheflush.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that flushing the zipreader cache happens when appropriate
+
+var gExpectedFile = null;
+var gCacheFlushCount = 0;
+
+var CacheFlushObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "flush-cache-entry") {
+ return;
+ }
+
+ // Ignore flushes from the fake cert DB or extension-process-script
+ if (aData == "cert-override" || aSubject == null) {
+ return;
+ }
+
+ if (!gExpectedFile) {
+ return;
+ }
+ ok(aSubject instanceof Ci.nsIFile);
+ equal(aSubject.path, gExpectedFile.path);
+ gCacheFlushCount++;
+ },
+};
+
+add_task(async function setup() {
+ Services.obs.addObserver(CacheFlushObserver, "flush-cache-entry");
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "2");
+
+ await promiseStartupManager();
+});
+
+// Tests that the cache is flushed when installing a restartless add-on
+add_task(async function test_flush_restartless_install() {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ name: "Cache Flush Test",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: "addon2@tests.mozilla.org" } },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ await new Promise(resolve => {
+ install.addListener({
+ onInstallStarted() {
+ // We should flush the staged XPI when completing the install
+ gExpectedFile = gProfD.clone();
+ gExpectedFile.append("extensions");
+ gExpectedFile.append("staged");
+ gExpectedFile.append("addon2@tests.mozilla.org.xpi");
+ },
+
+ onInstallEnded() {
+ equal(gCacheFlushCount, 1);
+ gExpectedFile = null;
+ gCacheFlushCount = 0;
+
+ resolve();
+ },
+ });
+
+ install.install();
+ });
+});
+
+// Tests that the cache is flushed when uninstalling a restartless add-on
+add_task(async function test_flush_uninstall() {
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+
+ // We should flush the installed XPI when uninstalling
+ gExpectedFile = gProfD.clone();
+ gExpectedFile.append("extensions");
+ gExpectedFile.append("addon2@tests.mozilla.org.xpi");
+
+ await addon.uninstall();
+
+ Assert.greaterOrEqual(gCacheFlushCount, 1);
+ gExpectedFile = null;
+ gCacheFlushCount = 0;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js b/toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js
new file mode 100644
index 0000000000..d7661d52ad
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_childprocess.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that the AddonManager refuses to load in child processes.
+
+// NOTE: This test does NOT load head_addons.js, because that would indirectly
+// load AddonManager.sys.mjs. In this test, we want to be the first to load the
+// AddonManager module to verify that it cannot be loaded in child processes.
+
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+function run_test() {
+ updateAppInfo();
+ Services.appinfo.processType = Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
+ try {
+ ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
+ do_throw("AddonManager should have refused to load");
+ } catch (ex) {
+ info(ex.message);
+ Assert.ok(!!ex.message);
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js b/toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js
new file mode 100644
index 0000000000..154a28713d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_colorways_builtin_theme_upgrades.js
@@ -0,0 +1,582 @@
+"use strict";
+
+const { BuiltInThemes } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemes.sys.mjs"
+);
+const { BuiltInThemeConfig } = ChromeUtils.importESModule(
+ "resource:///modules/BuiltInThemeConfig.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+// Enable SCOPE_APPLICATION for builtin testing.
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+const ADDON_ID = "mock-colorway@mozilla.org";
+const ADDON_ID_RETAINED = "mock-disabled-retained-colorway@mozilla.org";
+const ADDON_ID_NOT_RETAINED = "mock-disabled-not-retained-colorway@mozilla.org";
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+const NOT_MIGRATED_THEME = "mock-not-migrated-theme@mozilla.org";
+
+const RETAINED_THEMES_PREF = "browser.theme.retainedExpiredThemes";
+const COLORWAY_MIGRATION_PREF = "browser.theme.colorway-migration";
+
+const ICON_SVG = `
+ <svg width="63" height="62" viewBox="0 0 63 62" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="31.5" cy="31" r="31" fill="url(#paint0_linear)"/>
+ <defs>
+ <linearGradient id="paint0_linear" x1="44.4829" y1="19" x2="10.4829" y2="53" gradientUnits="userSpaceOnUse">
+ <stop stop-color="hsl(147, 94%, 25%)"/>
+ <stop offset="1" stop-color="hsl(146, 38%, 49%)"/>
+ </linearGradient>
+ </defs>
+ </svg>
+`;
+const createMockThemeManifest = (id, version) => ({
+ name: "A mock colorway theme",
+ author: "Mozilla",
+ version,
+ icons: { 32: "icon.svg" },
+ theme: {
+ colors: {
+ toolbar: "red",
+ },
+ },
+ browser_specific_settings: {
+ gecko: { id },
+ },
+});
+
+let server = createHttpServer();
+
+const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ `${SERVER_BASE_URL}/upgrade.json`
+);
+
+AddonTestUtils.registerJSON(server, "/upgrade.json", {
+ addons: {
+ [ADDON_ID]: {
+ updates: [
+ {
+ version: "2.0.0",
+ update_link: `${SERVER_BASE_URL}/${ADDON_ID}.xpi`,
+ },
+ ],
+ },
+ [ADDON_ID_RETAINED]: {
+ updates: [
+ {
+ version: "3.0.0",
+ update_link: `${SERVER_BASE_URL}/${ADDON_ID_RETAINED}.xpi`,
+ },
+ ],
+ },
+ // We list the test extension with addon id ADDON_ID_NOT_RETAINED here,
+ // but the xpi file doesn't exist because we expect that we wouldn't
+ // be checking this extension for updates, and that expected behavior
+ // regresses, the test would fail either for the explicit assertion
+ // (checking that we don't find an update) or because we would be trying
+ // to download a file from an url that isn't going to be handled.
+ [ADDON_ID_NOT_RETAINED]: {
+ updates: [
+ {
+ version: "4.0.0",
+ update_link: `${SERVER_BASE_URL}/non-existing.xpi`,
+ },
+ ],
+ },
+ },
+});
+
+function createWebExtensionFile(id, version) {
+ return AddonTestUtils.createTempWebExtensionFile({
+ files: { "icon.svg": ICON_SVG },
+ manifest: createMockThemeManifest(id, version),
+ });
+}
+
+let xpiUpdate = createWebExtensionFile(ADDON_ID, "2.0.0");
+let retainedThemeUpdate = createWebExtensionFile(ADDON_ID_RETAINED, "3.0.0");
+
+server.registerFile(`/${ADDON_ID}.xpi`, xpiUpdate);
+server.registerFile(`/${ADDON_ID_RETAINED}.xpi`, retainedThemeUpdate);
+
+function assertAddonWrapperProperties(
+ addonWrapper,
+ { id, version, isBuiltin, type, isBuiltinColorwayTheme, scope }
+) {
+ Assert.deepEqual(
+ {
+ id: addonWrapper.id,
+ version: addonWrapper.version,
+ type: addonWrapper.type,
+ scope: addonWrapper.scope,
+ isBuiltin: addonWrapper.isBuiltin,
+ isBuiltinColorwayTheme: addonWrapper.isBuiltinColorwayTheme,
+ },
+ {
+ id,
+ version,
+ type,
+ scope,
+ isBuiltin,
+ isBuiltinColorwayTheme,
+ },
+ `Got expected properties on addon wrapper for "${id}"`
+ );
+}
+
+function assertAddonCanUpgrade(addonWrapper, canUpgrade) {
+ equal(
+ !!(addonWrapper.permissions & AddonManager.PERM_CAN_UPGRADE),
+ canUpgrade,
+ `Expected "${addonWrapper.id}" to ${
+ canUpgrade ? "have" : "not have"
+ } PERM_CAN_UPGRADE AOM permission`
+ );
+}
+
+function assertIsActiveThemeID(addonId) {
+ equal(
+ Services.prefs.getCharPref("extensions.activeThemeID"),
+ addonId,
+ `Expect ${addonId} to be the currently active theme`
+ );
+}
+
+function assertIsExpiredTheme(addonId, expectExpired) {
+ equal(
+ // themeIsExpired returns undefined for themes without an expiry date,
+ // normalized here to always be a boolean.
+ !!BuiltInThemes.themeIsExpired(addonId),
+ expectExpired,
+ `Expect ${addonId} to be recognized as an expired colorway theme`
+ );
+}
+
+function assertIsRetainedExpiredTheme(addonId, expectRetainedExpired) {
+ equal(
+ BuiltInThemes.isRetainedExpiredTheme(addonId),
+ expectRetainedExpired,
+ `Expect ${addonId} to be recognized as a retained expired colorway theme`
+ );
+}
+
+function waitForBootstrapUpdateMethod(addonId, newVersion) {
+ return new Promise(resolve => {
+ function listener(_evt, { method, params }) {
+ if (
+ method === "update" &&
+ params.id === addonId &&
+ params.newVersion === newVersion
+ ) {
+ AddonTestUtils.off("bootstrap-method", listener);
+ info(`Update bootstrap method called for ${addonId} ${newVersion}`);
+ resolve();
+ }
+ }
+ AddonTestUtils.on("bootstrap-method", listener);
+ });
+}
+
+let waitForTemporaryXPIFilesRemoved;
+
+add_setup(async () => {
+ info("Creating BuiltInThemes stubs");
+ const sandbox = sinon.createSandbox();
+ // Restoring the mocked BuiltInThemeConfig doesn't really matter for xpcshell
+ // because each test file will run in its own separate xpcshell instance,
+ // but cleaning it up doesn't harm neither.
+ registerCleanupFunction(() => {
+ info("Restoring BuiltInThemes sandbox for cleanup");
+ sandbox.restore();
+ BuiltInThemes.builtInThemeMap = BuiltInThemeConfig;
+ });
+
+ // Mock BuiltInThemes builtInThemeMap.
+ BuiltInThemes.builtInThemeMap = new Map();
+ sandbox.stub(BuiltInThemes.builtInThemeMap, "get").callsFake(id => {
+ info(`Mock BuiltInthemes.builtInThemeMap.get result for ${id}`);
+ // No theme info is expected to be returned for the default-theme.
+ if (id === DEFAULT_THEME_ID) {
+ return undefined;
+ }
+ if (!id.endsWith("colorway@mozilla.org")) {
+ return BuiltInThemeConfig.get(id);
+ }
+ let mockThemeProperties = {
+ collection: "Mock expired colorway collection",
+ figureUrl: "about:blank",
+ expiry: new Date("1970-01-01"),
+ };
+ return mockThemeProperties;
+ });
+
+ // Start AOM and make sure updates are enabled.
+ await AddonTestUtils.promiseStartupManager();
+ AddonManager.updateEnabled = true;
+
+ // Enable the default theme explicitly (mainly because on DevEdition builds
+ // the dark theme would be the one enabled by default).
+ const defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME_ID);
+ await defaultTheme.enable();
+ assertIsActiveThemeID(defaultTheme.id);
+
+ const tmpFiles = new Set();
+ const addonInstallListener = {
+ onInstallEnded: function collectTmpFiles(install) {
+ tmpFiles.add(install.file);
+ },
+ };
+ AddonManager.addInstallListener(addonInstallListener);
+ registerCleanupFunction(() => {
+ AddonManager.removeInstallListener(addonInstallListener);
+ });
+
+ // Make sure all the tempfile created for the background updates have
+ // been removed (otherwise AddonTestUtils cleanup function will trigger
+ // intermittent test failures due to unexpected xpi files that may still
+ // be found in the temporary directory).
+ waitForTemporaryXPIFilesRemoved = async () => {
+ info(
+ "Wait for temporary xpi files created by the background updates to have been removed"
+ );
+ const files = Array.from(tmpFiles);
+ tmpFiles.clear();
+ await TestUtils.waitForCondition(async () => {
+ for (const file of files) {
+ if (await file.exists()) {
+ return false;
+ }
+ }
+ return true;
+ }, "Wait for the temporary files created for the background updates to have been removed");
+ };
+});
+
+add_task(
+ {
+ pref_set: [[COLORWAY_MIGRATION_PREF, false]],
+ },
+ async function test_colorways_migration_disabled() {
+ info("Install and activate a colorway built-in test theme");
+
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const activeTheme = await AddonManager.getAddonByID(ADDON_ID);
+ assertAddonWrapperProperties(activeTheme, {
+ id: ADDON_ID,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+ const promiseThemeEnabled = AddonTestUtils.promiseAddonEvent(
+ "onEnabled",
+ addon => addon.id === ADDON_ID
+ );
+ await activeTheme.enable();
+ await promiseThemeEnabled;
+ ok(activeTheme.isActive, "Expect the colorways theme to be active");
+ assertIsActiveThemeID(activeTheme.id);
+
+ info("Verify that built-in colorway migration is disabled as expected");
+
+ assertAddonCanUpgrade(activeTheme, false);
+
+ const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
+ "addons-background-updates-found"
+ );
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ const [, numUpdatesFound] = await promiseBackgroundUpdatesFound;
+ equal(numUpdatesFound, 0, "Expect no add-on updates to be found");
+
+ await activeTheme.uninstall();
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ [COLORWAY_MIGRATION_PREF, true],
+ [
+ RETAINED_THEMES_PREF,
+ JSON.stringify([ADDON_ID_RETAINED, NOT_MIGRATED_THEME]),
+ ],
+ ],
+ },
+ async function test_colorways_builtin_upgrade() {
+ info("Verify default theme initially enabled");
+ const defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME_ID);
+ assertAddonWrapperProperties(defaultTheme, {
+ id: DEFAULT_THEME_ID,
+ version: defaultTheme.version,
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: false,
+ });
+ ok(
+ defaultTheme.isActive,
+ "Expect the default theme to be initially active"
+ );
+ assertIsActiveThemeID(defaultTheme.id);
+
+ info("Install the non retained expired colorway test theme");
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID_NOT_RETAINED, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const notRetainedTheme = await AddonManager.getAddonByID(
+ ADDON_ID_NOT_RETAINED
+ );
+ assertAddonWrapperProperties(notRetainedTheme, {
+ id: ADDON_ID_NOT_RETAINED,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+
+ info("Install the retained expired colorway test theme");
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID_RETAINED, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const retainedTheme = await AddonManager.getAddonByID(ADDON_ID_RETAINED);
+ assertAddonWrapperProperties(retainedTheme, {
+ id: ADDON_ID_RETAINED,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+
+ info("Install the active colorway test theme");
+ await installBuiltinExtension(
+ {
+ manifest: createMockThemeManifest(ADDON_ID, "1.0.0"),
+ },
+ false /* waitForStartup */
+ );
+ const activeTheme = await AddonManager.getAddonByID(ADDON_ID);
+ assertAddonWrapperProperties(activeTheme, {
+ id: ADDON_ID,
+ version: "1.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_APPLICATION,
+ isBuiltin: true,
+ isBuiltinColorwayTheme: true,
+ });
+ const promiseThemeEnabled = AddonTestUtils.promiseAddonEvent(
+ "onEnabled",
+ addon => addon.id === ADDON_ID
+ );
+ await activeTheme.enable();
+ await promiseThemeEnabled;
+ ok(activeTheme.isActive, "Expect the colorways theme to be active");
+ assertIsActiveThemeID(activeTheme.id);
+
+ info("Verify only active and retained colorways themes can be upgraded");
+ assertIsActiveThemeID(activeTheme.id);
+ assertIsExpiredTheme(activeTheme.id, true);
+ assertIsRetainedExpiredTheme(activeTheme.id, false);
+
+ assertIsExpiredTheme(retainedTheme.id, true);
+ assertIsRetainedExpiredTheme(retainedTheme.id, true);
+
+ assertIsExpiredTheme(notRetainedTheme.id, true);
+ assertIsRetainedExpiredTheme(notRetainedTheme.id, false);
+
+ assertIsExpiredTheme(defaultTheme.id, false);
+ assertIsRetainedExpiredTheme(defaultTheme.id, false);
+
+ assertAddonCanUpgrade(retainedTheme, true);
+ assertAddonCanUpgrade(notRetainedTheme, false);
+ assertAddonCanUpgrade(activeTheme, true);
+ // Make sure a non-colorways built-in theme cannot check for updates.
+ assertAddonCanUpgrade(defaultTheme, false);
+
+ Assert.deepEqual(
+ Services.prefs.getStringPref(RETAINED_THEMES_PREF),
+ JSON.stringify([retainedTheme.id, NOT_MIGRATED_THEME]),
+ `Expect the retained theme id to be listed in the ${RETAINED_THEMES_PREF} pref`
+ );
+
+ const promiseUpdatesInstalled = Promise.all([
+ waitForBootstrapUpdateMethod(ADDON_ID, "2.0.0"),
+ waitForBootstrapUpdateMethod(ADDON_ID_RETAINED, "3.0.0"),
+ ]);
+
+ const promiseInstallsEnded = Promise.all([
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id === ADDON_ID
+ ),
+ AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded",
+ install => install.addon.id === ADDON_ID_RETAINED
+ ),
+ ]);
+
+ const promiseActiveThemeStartupCompleted =
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID);
+
+ const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
+ "addons-background-updates-found"
+ );
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ const [, numUpdatesFound] = await promiseBackgroundUpdatesFound;
+ equal(numUpdatesFound, 2, "Expect 2 add-on updates to have been found");
+
+ info("Wait for the 2 expected updates to be completed");
+ await promiseUpdatesInstalled;
+
+ const updatedActiveTheme = await AddonManager.getAddonByID(ADDON_ID);
+ assertAddonWrapperProperties(updatedActiveTheme, {
+ id: ADDON_ID,
+ version: "2.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_PROFILE,
+ isBuiltin: false,
+ isBuiltinColorwayTheme: false,
+ });
+ // Expect the updated active theme to stay set as the currently active theme.
+ assertIsActiveThemeID(updatedActiveTheme.id);
+
+ info("Verify addon update on disabled builtin colorway theme");
+
+ const updatedRetainedTheme = await AddonManager.getAddonByID(
+ ADDON_ID_RETAINED
+ );
+ assertAddonWrapperProperties(updatedRetainedTheme, {
+ id: ADDON_ID_RETAINED,
+ version: "3.0.0",
+ type: "theme",
+ scope: AddonManager.SCOPE_PROFILE,
+ isBuiltin: false,
+ isBuiltinColorwayTheme: false,
+ });
+ // Expect the updated active theme to stay set as the currently active theme.
+ assertIsActiveThemeID(updatedActiveTheme.id);
+ ok(updatedActiveTheme.isActive, "Expect the colorways theme to be active");
+
+ // We need to wait for the active theme startup otherwise uninstall the active
+ // theme will fail to remove the xpi file because it is stil active while the
+ // test is running on windows builds.
+ info("Wait for the active theme to have been fully loaded");
+ await promiseActiveThemeStartupCompleted;
+
+ await promiseInstallsEnded;
+
+ Assert.deepEqual(
+ Services.prefs.getStringPref(RETAINED_THEMES_PREF),
+ JSON.stringify([NOT_MIGRATED_THEME]),
+ `Expect migrated retained theme to not be listed anymore in the ${RETAINED_THEMES_PREF} pref`
+ );
+
+ info(
+ "uninstall test colorways themes and expect default theme to become active"
+ );
+
+ const promiseUninstalled = Promise.all([
+ AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === ADDON_ID
+ ),
+ AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === ADDON_ID_RETAINED
+ ),
+ AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === ADDON_ID_NOT_RETAINED
+ ),
+ ]);
+
+ const promiseDefaultThemeEnabled =
+ AddonTestUtils.promiseAddonEvent("onEnabled");
+
+ await updatedActiveTheme.uninstall();
+ await updatedRetainedTheme.uninstall();
+ await notRetainedTheme.uninstall();
+
+ await promiseUninstalled;
+
+ info("Wait for the default theme to become active");
+ // Waiting explicitly for the onEnabled addon event prevents a race between
+ // the test task exiting (and the AddonManager being shutdown automatically
+ // as a side effect of that) and the XPIProvider trying to call the addon event
+ // listeners for the default theme being enabled), which would trigger a test
+ // failure after the test is existing.
+ await promiseDefaultThemeEnabled;
+
+ ok(defaultTheme.isActive, "Expect the default theme to be active");
+ assertIsActiveThemeID(defaultTheme.id);
+
+ // Wait for the temporary file to be actually removed, otherwise the hack we use
+ // to mock an AOM restart (which is unloading the related jsm modules) may
+ // affect the successfull removal of the temporary file because some of the
+ // global helpers defined and used inside the XPIProvider may have been gone
+ // already and intermittently trigger unexpected errors.
+ await waitForTemporaryXPIFilesRemoved();
+
+ // Restart the addon manager to confirm that the migrated colorways themes
+ // are still gone after an AOM restart and that the previously installed
+ // builtin hasn't been made implicitly visible again.
+ info(
+ "Verify old builtin colorways are not visible and default-theme still active after AOM restart"
+ );
+ await AddonTestUtils.promiseRestartManager();
+
+ const defaultThemeAfterRestart = await AddonManager.getAddonByID(
+ DEFAULT_THEME_ID
+ );
+ ok(
+ defaultThemeAfterRestart.isActive,
+ "Expect the default theme to be active"
+ );
+
+ equal(
+ (await AddonManager.getAddonByID(ADDON_ID))?.version,
+ undefined,
+ "Expect the active theme addon to not be available anymore after being uninstalled"
+ );
+ equal(
+ (await AddonManager.getAddonByID(ADDON_ID_RETAINED))?.version,
+ undefined,
+ "Expect the retained theme addon to not be available anymore after being uninstalled"
+ );
+ equal(
+ (await AddonManager.getAddonByID(ADDON_ID_NOT_RETAINED))?.version,
+ undefined,
+ "Expect the not retained expired theme addon to not be available anymore after being uninstalled"
+ );
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_cookies.js b/toolkit/mozapps/extensions/test/xpcshell/test_cookies.js
new file mode 100644
index 0000000000..56f745929b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_cookies.js
@@ -0,0 +1,102 @@
+"use strict";
+
+let server = createHttpServer({ hosts: ["example.com"] });
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "45", "45");
+
+Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", true);
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+// Tests that cookies are not sent with background requests.
+add_task(async function test_cookies() {
+ const ID = "bg-cookies@tests.mozilla.org";
+
+ // Add a new handler to the test web server for the given file path.
+ // The handler appends the incoming requests to `results` and replies
+ // with the provided body.
+ function makeHandler(path, results, body) {
+ server.registerPathHandler(path, (request, response) => {
+ results.push(request);
+ response.write(body);
+ });
+ }
+
+ let gets = [];
+ makeHandler("/get", gets, JSON.stringify({ results: [] }));
+ Services.prefs.setCharPref(PREF_GETADDONS_BYIDS, "http://example.com/get");
+
+ let updates = [];
+ makeHandler(
+ "/update",
+ updates,
+ JSON.stringify({
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/update.xpi",
+ applications: {
+ gecko: {},
+ },
+ },
+ ],
+ },
+ },
+ })
+ );
+
+ let xpiFetches = [];
+ makeHandler("/update.xpi", xpiFetches, "");
+
+ const COOKIE = "test";
+ // cookies.add() takes a time in seconds
+ let expiration = Date.now() / 1000 + 60 * 60;
+ Services.cookies.add(
+ "example.com",
+ "/",
+ COOKIE,
+ "testing",
+ false,
+ false,
+ false,
+ expiration,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update",
+ },
+ },
+ },
+ });
+
+ equal(gets.length, 1, "Saw one addon metadata request");
+ equal(gets[0].hasHeader("Cookie"), false, "Metadata request has no cookies");
+
+ await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onDownloadFailed"),
+ AddonManagerPrivate.backgroundUpdateCheck(),
+ ]);
+
+ equal(updates.length, 1, "Saw one update check request");
+ equal(updates[0].hasHeader("Cookie"), false, "Update request has no cookies");
+
+ equal(xpiFetches.length, 1, "Saw one request for updated xpi");
+ equal(
+ xpiFetches[0].hasHeader("Cookie"),
+ false,
+ "Request for updated XPI has no cookies"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js b/toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js
new file mode 100644
index 0000000000..727c643763
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_corrupt.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that we rebuild something sensible from a corrupt database
+
+// Create and configure the HTTP server.
+var testserver = createHttpServer({ hosts: ["example.com"] });
+
+// register files with server
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const ADDONS = {
+ // Will get a compatibility update and stay enabled
+ "addon3@tests.mozilla.org": {
+ manifest: {
+ name: "Test 3",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ },
+ },
+ },
+ findUpdates: true,
+ desiredState: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ // Will get a compatibility update and be enabled
+ "addon4@tests.mozilla.org": {
+ manifest: {
+ name: "Test 4",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: "http://example.com/data/test_corrupt.json",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ findUpdates: true,
+ desiredState: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon5@tests.mozilla.org": {
+ manifest: {
+ name: "Test 5",
+ browser_specific_settings: { gecko: { id: "addon5@tests.mozilla.org" } },
+ },
+ desiredState: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "addon7@tests.mozilla.org": {
+ manifest: {
+ name: "Test 7",
+ browser_specific_settings: { gecko: { id: "addon7@tests.mozilla.org" } },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ desiredState: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ // The default theme
+ "theme1@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 1",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme1@tests.mozilla.org",
+ },
+ },
+ },
+ desiredState: {
+ isActive: false,
+ userDisabled: true,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+
+ "theme2@tests.mozilla.org": {
+ manifest: {
+ manifest_version: 2,
+ name: "Theme 2",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: "theme2@tests.mozilla.org",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: false,
+ },
+ desiredState: {
+ isActive: true,
+ userDisabled: false,
+ appDisabled: false,
+ pendingOperations: 0,
+ },
+ },
+};
+
+const IDS = Object.keys(ADDONS);
+
+function promiseUpdates(addon) {
+ return new Promise(resolve => {
+ addon.findUpdates(
+ { onUpdateFinished: resolve },
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ });
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ for (let addon of Object.values(ADDONS)) {
+ let webext = createTempWebExtensionFile({ manifest: addon.manifest });
+ await AddonTestUtils.manuallyInstall(webext);
+ }
+
+ await promiseStartupManager();
+
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ if (addon.initialState) {
+ await setInitialState(addons.get(id), addon.initialState);
+ }
+ if (addon.findUpdates) {
+ await promiseUpdates(addons.get(id));
+ }
+ }
+});
+
+add_task(async function test_after_restart() {
+ await promiseRestartManager();
+
+ info("Test add-on state after restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredState);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_after_corruption() {
+ // Shutdown and replace the database with a corrupt file (a directory
+ // serves this purpose). On startup the add-ons manager won't rebuild
+ // because there is a file there still.
+ gExtensionsJSON.remove(true);
+ gExtensionsJSON.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ await promiseStartupManager();
+
+ Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+ await AddonManagerPrivate.databaseReady;
+
+ // Accessing the add-ons should open and recover the database
+ info("Test add-on state after corruption");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredState);
+ }
+
+ await Assert.rejects(
+ promiseShutdownManager(),
+ /NotAllowedError: Could not open the file at .+ for writing/
+ );
+});
+
+add_task(async function test_after_second_restart() {
+ await promiseStartupManager();
+
+ info("Test add-on state after second restart");
+ let addons = await getAddons(IDS);
+ for (let [id, addon] of Object.entries(ADDONS)) {
+ checkAddon(id, addons.get(id), addon.desiredState);
+ }
+
+ await Assert.rejects(
+ promiseShutdownManager(),
+ /NotAllowedError: Could not open the file at .+ for writing/
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js b/toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js
new file mode 100644
index 0000000000..4458c6d592
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_crash_annotation_quoting.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that strange characters in an add-on version don't break the
+// crash annotation.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+add_task(async function run_test() {
+ await promiseStartupManager();
+
+ let n = 1;
+ for (let version in ["1,0", "1:0"]) {
+ let id = `addon${n++}@tests.mozilla.org`;
+ await promiseInstallWebExtension({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ do_check_in_crash_annotation(id, version);
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_db_path.js b/toolkit/mozapps/extensions/test/xpcshell/test_db_path.js
new file mode 100644
index 0000000000..a9a54291f0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_db_path.js
@@ -0,0 +1,64 @@
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const DEFAULT_THEME_ID = "default-theme@mozilla.org";
+
+let global = this;
+
+// Test that paths in the extensions database are stored properly
+// if they include non-ascii characters (see bug 1428234 for an example of
+// a past bug with such paths)
+add_task(async function test_non_ascii_path() {
+ const PROFILE_VAR = "XPCSHELL_TEST_PROFILE_DIR";
+ let profileDir = PathUtils.join(
+ Services.env.get(PROFILE_VAR),
+ "\u00ce \u00e5m \u00f1\u00f8t \u00e5s\u00e7ii"
+ );
+ Services.env.set(PROFILE_VAR, profileDir);
+
+ AddonTestUtils.init(global);
+ AddonTestUtils.overrideCertDB();
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+ );
+
+ const ID1 = "profile1@tests.mozilla.org";
+ let xpi1 = await AddonTestUtils.createTempWebExtensionFile({
+ id: ID1,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ const ID2 = "profile2@tests.mozilla.org";
+ let xpi2 = await AddonTestUtils.createTempWebExtensionFile({
+ id: ID2,
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID2 } },
+ },
+ });
+
+ await AddonTestUtils.manuallyInstall(xpi1);
+ await AddonTestUtils.promiseStartupManager();
+ await AddonTestUtils.promiseInstallFile(xpi2);
+ await AddonTestUtils.promiseShutdownManager();
+
+ let dbfile = PathUtils.join(profileDir, "extensions.json");
+ let data = await IOUtils.readJSON(dbfile);
+
+ let addons = data.addons.filter(a => a.id !== DEFAULT_THEME_ID);
+ Assert.ok(Array.isArray(addons), "extensions.json has addons array");
+ Assert.equal(2, addons.length, "extensions.json has 2 addons");
+ Assert.ok(
+ addons[0].path.startsWith(profileDir),
+ "path property for sideloaded extension has the proper profile directory"
+ );
+ Assert.ok(
+ addons[1].path.startsWith(profileDir),
+ "path property for extension installed at runtime has the proper profile directory"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
new file mode 100644
index 0000000000..7b1c6fbef9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
@@ -0,0 +1,556 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that delaying an update works for WebExtensions.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+if (AppConstants.platform == "win" && AppConstants.DEBUG) {
+ // Shutdown timing is flaky in this test, and remote extensions
+ // sometimes wind up leaving the XPI locked at the point when we try
+ // to remove it.
+ Services.prefs.setBoolPref("extensions.webextensions.remote", false);
+}
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+/* globals browser*/
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const stageDir = profileDir.clone();
+stageDir.append("staged");
+
+const IGNORE_ID = "test_delay_update_ignore_webext@tests.mozilla.org";
+const COMPLETE_ID = "test_delay_update_complete_webext@tests.mozilla.org";
+const DEFER_ID = "test_delay_update_defer_webext@tests.mozilla.org";
+const STAGED_ID = "test_delay_update_staged_webext@tests.mozilla.org";
+const STAGED_NO_UPDATE_URL_ID =
+ "test_delay_update_staged_webext_no_update_url@tests.mozilla.org";
+const NOUPDATE_ID = "test_no_update_webext@tests.mozilla.org";
+
+// Create and configure the HTTP server.
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+BootstrapMonitor.init();
+
+const ADDONS = {
+ test_delay_update_complete_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: COMPLETE_ID },
+ },
+ },
+ },
+ test_delay_update_defer_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: DEFER_ID },
+ },
+ },
+ },
+ test_delay_update_staged_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_ID,
+ update_url: `http://example.com/data/test_delay_updates_staged.json`,
+ strict_min_version: "1",
+ strict_max_version: "41",
+ },
+ },
+ },
+ },
+ test_delay_update_staged_webextension_no_update_url_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_NO_UPDATE_URL_ID,
+ strict_min_version: "1",
+ strict_max_version: "41",
+ },
+ },
+ },
+ },
+ test_delay_update_ignore_webextension_v2: {
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Delay Upgrade",
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: IGNORE_ID },
+ },
+ },
+ },
+};
+
+const XPIS = {};
+for (let [name, files] of Object.entries(ADDONS)) {
+ XPIS[name] = AddonTestUtils.createTempXPIFile(files);
+ testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+}
+
+// add-on registers upgrade listener, and ignores update.
+add_task(async function delay_updates_ignore() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: IGNORE_ID,
+ update_url: `http://example.com/data/test_delay_updates_ignore.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ if (details) {
+ 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")]);
+ BootstrapMonitor.checkInstalled(IGNORE_ID, "1.0");
+
+ let addon = await promiseAddonByID(IGNORE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+ BootstrapMonitor.checkInstalled(IGNORE_ID, "1.0");
+
+ // addon upgrade has been delayed
+ let addon_postponed = await promiseAddonByID(IGNORE_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ await extension.awaitFinish("delay");
+
+ // restarting allows upgrade to proceed
+ await promiseRestartManager();
+
+ let addon_upgraded = await promiseAddonByID(IGNORE_ID);
+ await extension.awaitStartup();
+ BootstrapMonitor.checkUpdated(IGNORE_ID, "2.0");
+
+ Assert.notEqual(addon_upgraded, null);
+ Assert.equal(addon_upgraded.version, "2.0");
+ Assert.equal(addon_upgraded.name, "Delay Upgrade");
+ Assert.ok(addon_upgraded.isCompatible);
+ Assert.ok(!addon_upgraded.appDisabled);
+ Assert.ok(addon_upgraded.isActive);
+ Assert.equal(addon_upgraded.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, and allows update.
+add_task(async function delay_updates_complete() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: COMPLETE_ID,
+ update_url: `http://example.com/data/test_delay_updates_complete.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.notifyPass("reload");
+ browser.runtime.reload();
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(COMPLETE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ let promiseInstalled = promiseAddonEvent("onInstalled");
+ await promiseCompleteAllInstalls([install]);
+
+ await extension.awaitFinish("reload");
+
+ // addon upgrade has been allowed
+ let [addon_allowed] = await promiseInstalled;
+ await extension.awaitStartup();
+
+ Assert.notEqual(addon_allowed, null);
+ Assert.equal(addon_allowed.version, "2.0");
+ Assert.equal(addon_allowed.name, "Delay Upgrade");
+ Assert.ok(addon_allowed.isCompatible);
+ Assert.ok(!addon_allowed.appDisabled);
+ Assert.ok(addon_allowed.isActive);
+ Assert.equal(addon_allowed.type, "extension");
+
+ await new Promise(executeSoon);
+
+ if (stageDir.exists()) {
+ do_throw(
+ "Staging directory should not exist for formerly-postponed extension"
+ );
+ }
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, initially defers update then allows upgrade
+add_task(async function delay_updates_defer() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: DEFER_ID,
+ update_url: `http://example.com/data/test_delay_updates_defer.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ // Upgrade will only proceed when "allow" message received.
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "allow") {
+ browser.test.notifyPass("allowed");
+ browser.runtime.reload();
+ } else {
+ browser.test.fail(`wrong message: ${msg}`);
+ }
+ });
+ browser.test.sendMessage("truly ready");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(DEFER_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+
+ let promiseInstalled = promiseAddonEvent("onInstalled");
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+
+ // upgrade is initially postponed
+ let addon_postponed = await promiseAddonByID(DEFER_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ // add-on will not allow upgrade until message is received
+ await extension.awaitMessage("truly ready");
+ extension.sendMessage("allow");
+ await extension.awaitFinish("allowed");
+
+ // addon upgrade has been allowed
+ let [addon_allowed] = await promiseInstalled;
+ await extension.awaitStartup();
+
+ Assert.notEqual(addon_allowed, null);
+ Assert.equal(addon_allowed.version, "2.0");
+ Assert.equal(addon_allowed.name, "Delay Upgrade");
+ Assert.ok(addon_allowed.isCompatible);
+ Assert.ok(!addon_allowed.appDisabled);
+ Assert.ok(addon_allowed.isActive);
+ Assert.equal(addon_allowed.type, "extension");
+
+ await promiseRestartManager();
+
+ // restart changes nothing
+ addon_allowed = await promiseAddonByID(DEFER_ID);
+ await extension.awaitStartup();
+
+ Assert.notEqual(addon_allowed, null);
+ Assert.equal(addon_allowed.version, "2.0");
+ Assert.equal(addon_allowed.name, "Delay Upgrade");
+ Assert.ok(addon_allowed.isCompatible);
+ Assert.ok(!addon_allowed.appDisabled);
+ Assert.ok(addon_allowed.isActive);
+ Assert.equal(addon_allowed.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener to deny update, completes after restart,
+// even though the updated XPI is incompatible - the information returned
+// by the update server defined in its manifest returns a compatible range
+add_task(async function delay_updates_staged() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_ID,
+ update_url: `http://example.com/data/test_delay_updates_staged.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("denied");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(STAGED_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+
+ // upgrade is initially postponed
+ let addon_postponed = await promiseAddonByID(STAGED_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ // add-on reports an available upgrade, but denied it till next restart
+ await extension.awaitMessage("denied");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ // add-on should have been updated during restart
+ let addon_upgraded = await promiseAddonByID(STAGED_ID);
+ Assert.notEqual(addon_upgraded, null);
+ Assert.equal(addon_upgraded.version, "2.0");
+ Assert.equal(addon_upgraded.name, "Delay Upgrade");
+ Assert.ok(addon_upgraded.isCompatible);
+ Assert.ok(!addon_upgraded.appDisabled);
+ Assert.ok(addon_upgraded.isActive);
+ Assert.equal(addon_upgraded.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener to deny update, does not complete after
+// restart, because the updated XPI is incompatible - there is no update server
+// defined in its manifest, which could return a compatible range
+add_task(async function delay_updates_staged_no_update_url() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: STAGED_NO_UPDATE_URL_ID,
+ update_url: `http://example.com/data/test_delay_updates_staged.json`,
+ },
+ },
+ },
+ background() {
+ browser.runtime.onUpdateAvailable.addListener(details => {
+ browser.test.sendMessage("denied");
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(STAGED_NO_UPDATE_URL_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await promiseCompleteAllInstalls([install]);
+
+ Assert.equal(install.state, AddonManager.STATE_POSTPONED);
+
+ // upgrade is initially postponed
+ let addon_postponed = await promiseAddonByID(STAGED_NO_UPDATE_URL_ID);
+ Assert.notEqual(addon_postponed, null);
+ Assert.equal(addon_postponed.version, "1.0");
+ Assert.equal(addon_postponed.name, "Generated extension");
+ Assert.ok(addon_postponed.isCompatible);
+ Assert.ok(!addon_postponed.appDisabled);
+ Assert.ok(addon_postponed.isActive);
+ Assert.equal(addon_postponed.type, "extension");
+
+ // add-on reports an available upgrade, but denied it till next restart
+ await extension.awaitMessage("denied");
+
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ // add-on should not have been updated during restart
+ let addon_upgraded = await promiseAddonByID(STAGED_NO_UPDATE_URL_ID);
+ Assert.notEqual(addon_upgraded, null);
+ Assert.equal(addon_upgraded.version, "1.0");
+ Assert.equal(addon_upgraded.name, "Generated extension");
+ Assert.ok(addon_upgraded.isCompatible);
+ Assert.ok(!addon_upgraded.appDisabled);
+ Assert.ok(addon_upgraded.isActive);
+ Assert.equal(addon_upgraded.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
+
+// browser.runtime.reload() without a pending upgrade should just reload.
+add_task(async function runtime_reload() {
+ await promiseStartupManager();
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: NOUPDATE_ID,
+ update_url: `http://example.com/data/test_no_update.json`,
+ },
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg == "reload") {
+ browser.runtime.reload();
+ } else {
+ browser.test.fail(`wrong message: ${msg}`);
+ }
+ });
+ browser.test.sendMessage("ready");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("ready")]);
+
+ let addon = await promiseAddonByID(NOUPDATE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ await promiseFindAddonUpdates(addon);
+
+ extension.sendMessage("reload");
+ // Wait for extension to restart, to make sure reload works.
+ await AddonTestUtils.promiseWebExtensionStartup(NOUPDATE_ID);
+ await extension.awaitMessage("ready");
+
+ addon = await promiseAddonByID(NOUPDATE_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(addon.name, "Generated extension");
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ await extension.unload();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js b/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js
new file mode 100644
index 0000000000..476c9e0595
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dependencies.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const ADDONS = [
+ {
+ id: "addon1@experiments.addons.mozilla.org",
+ dependencies: ["experiments.addon2"],
+ },
+ {
+ id: "addon2@experiments.addons.mozilla.org",
+ dependencies: ["experiments.addon3"],
+ },
+ {
+ id: "addon3@experiments.addons.mozilla.org",
+ },
+ {
+ id: "addon4@experiments.addons.mozilla.org",
+ },
+ {
+ id: "addon5@experiments.addons.mozilla.org",
+ dependencies: ["experiments.addon2"],
+ },
+];
+
+let addonFiles = [];
+
+let events = [];
+
+function promiseAddonStartup(id) {
+ return new Promise(resolve => {
+ const onBootstrapMethod = (event, { method, params }) => {
+ if (method == "startup" && params.id == id) {
+ AddonTestUtils.off("bootstrap-method", onBootstrapMethod);
+ resolve();
+ }
+ };
+
+ AddonTestUtils.on("bootstrap-method", onBootstrapMethod);
+ });
+}
+
+add_task(async function setup() {
+ await promiseStartupManager();
+
+ const onBootstrapMethod = (event, { method, params }) => {
+ if (method == "startup" || method == "shutdown") {
+ events.push([method, params.id]);
+ }
+ };
+
+ AddonTestUtils.on("bootstrap-method", onBootstrapMethod);
+ registerCleanupFunction(() => {
+ AddonTestUtils.off("bootstrap-method", onBootstrapMethod);
+ });
+
+ for (let addon of ADDONS) {
+ let manifest = {
+ browser_specific_settings: { gecko: { id: addon.id } },
+ permissions: addon.dependencies,
+ };
+
+ addonFiles.push(await createTempWebExtensionFile({ manifest }));
+ }
+});
+
+add_task(async function () {
+ deepEqual(events, [], "Should have no events");
+
+ await promiseInstallFile(addonFiles[3]);
+
+ deepEqual(events, [["startup", ADDONS[3].id]]);
+
+ events.length = 0;
+
+ await promiseInstallFile(addonFiles[0]);
+ deepEqual(events, [], "Should have no events");
+
+ await promiseInstallFile(addonFiles[1]);
+ deepEqual(events, [], "Should have no events");
+
+ await Promise.all([
+ promiseInstallFile(addonFiles[2]),
+ promiseAddonStartup(ADDONS[0].id),
+ ]);
+
+ deepEqual(events, [
+ ["startup", ADDONS[2].id],
+ ["startup", ADDONS[1].id],
+ ["startup", ADDONS[0].id],
+ ]);
+
+ events.length = 0;
+
+ await Promise.all([
+ promiseInstallFile(addonFiles[2]),
+ promiseAddonStartup(ADDONS[0].id),
+ ]);
+
+ deepEqual(events, [
+ ["shutdown", ADDONS[0].id],
+ ["shutdown", ADDONS[1].id],
+ ["shutdown", ADDONS[2].id],
+
+ ["startup", ADDONS[2].id],
+ ["startup", ADDONS[1].id],
+ ["startup", ADDONS[0].id],
+ ]);
+
+ events.length = 0;
+
+ await promiseInstallFile(addonFiles[4]);
+
+ deepEqual(events, [["startup", ADDONS[4].id]]);
+
+ events.length = 0;
+
+ await promiseRestartManager();
+
+ deepEqual(events, [
+ ["shutdown", ADDONS[4].id],
+ ["shutdown", ADDONS[3].id],
+ ["shutdown", ADDONS[0].id],
+ ["shutdown", ADDONS[1].id],
+ ["shutdown", ADDONS[2].id],
+
+ ["startup", ADDONS[2].id],
+ ["startup", ADDONS[1].id],
+ ["startup", ADDONS[0].id],
+ ["startup", ADDONS[3].id],
+ ["startup", ADDONS[4].id],
+ ]);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
new file mode 100644
index 0000000000..1f52c8a8bc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_dictionary_webextension.js
@@ -0,0 +1,263 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "spellCheck",
+ "@mozilla.org/spellchecker/engine;1",
+ "mozISpellCheckingEngine"
+);
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "61", "61");
+
+ // Initialize the URLPreloader so that we can load the built-in
+ // add-ons list, which contains the list of built-in dictionaries.
+ AddonTestUtils.initializeURLPreloader();
+
+ await promiseStartupManager();
+
+ // Starts collecting the Addon Manager Telemetry events.
+ AddonTestUtils.hookAMTelemetryEvents();
+
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+add_task(
+ {
+ // We need to enable this pref because some assertions verify that
+ // `installOrigins` is collected in some Telemetry events.
+ pref_set: [["extensions.install_origins.enabled", true]],
+ },
+ async function test_validation() {
+ await Assert.rejects(
+ promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-no-dic@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+ }),
+ /Expected file to be downloaded for install/
+ );
+
+ await Assert.rejects(
+ promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-no-aff@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": "",
+ },
+ }),
+ /Expected file to be downloaded for install/
+ );
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-1@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ });
+
+ let addon2 = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US-2@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "dictionaries/en-US.dic",
+ },
+ },
+
+ files: {
+ "dictionaries/en-US.dic": "",
+ "dictionaries/en-US.aff": "",
+ },
+ });
+
+ await addon.uninstall();
+ await addon2.uninstall();
+
+ let amEvents = AddonTestUtils.getAMTelemetryEvents();
+
+ let amInstallEvents = amEvents
+ .filter(evt => evt.method === "install")
+ .map(evt => {
+ const { object, extra } = evt;
+ return { object, extra };
+ });
+
+ const errorExtra = {
+ step: "started",
+ error: "ERROR_CORRUPT_FILE",
+ install_origins: "0",
+ };
+
+ Assert.deepEqual(
+ amInstallEvents.filter(evt => evt.object === "unknown"),
+ [
+ {
+ object: "unknown",
+ extra: errorExtra,
+ },
+ {
+ object: "unknown",
+ extra: errorExtra,
+ },
+ ],
+ "Got the expected install telemetry events for the corrupted dictionaries"
+ );
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("install", { addon_type: "unknown" }),
+ [
+ { addon_type: "unknown", ...errorExtra },
+ { addon_type: "unknown", ...errorExtra },
+ ],
+ "Got the expected install Glean events for the corrupted dictionaries"
+ );
+
+ const extra1 = { addon_id: addon.id, install_origins: "0" };
+ Assert.deepEqual(
+ amInstallEvents.filter(evt => evt.extra.addon_id === addon.id),
+ [
+ {
+ object: "dictionary",
+ extra: { step: "started", ...extra1 },
+ },
+ {
+ object: "dictionary",
+ extra: { step: "completed", ...extra1 },
+ },
+ ],
+ "Got the expected install telemetry events for the first installed dictionary"
+ );
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("install", { addon_id: addon.id }),
+ [
+ { addon_type: "dictionary", step: "started", ...extra1 },
+ { addon_type: "dictionary", step: "completed", ...extra1 },
+ ],
+ "Got expected install Glean events for the first installed dictionary."
+ );
+
+ const extra2 = { addon_id: addon2.id, install_origins: "0" };
+ Assert.deepEqual(
+ amInstallEvents.filter(evt => evt.extra.addon_id === addon2.id),
+ [
+ {
+ object: "dictionary",
+ extra: { step: "started", ...extra2 },
+ },
+ {
+ object: "dictionary",
+ extra: { step: "completed", ...extra2 },
+ },
+ ],
+ "Got the expected install telemetry events for the second installed dictionary"
+ );
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("install", { addon_id: addon2.id }),
+ [
+ { addon_type: "dictionary", step: "started", ...extra2 },
+ { addon_type: "dictionary", step: "completed", ...extra2 },
+ ]
+ );
+
+ let amUninstallEvents = amEvents
+ .filter(evt => evt.method === "uninstall")
+ .map(evt => {
+ const { object, value } = evt;
+ return { object, value };
+ });
+
+ Assert.deepEqual(
+ amUninstallEvents,
+ [
+ { object: "dictionary", value: addon.id },
+ { object: "dictionary", value: addon2.id },
+ ],
+ "Got the expected uninstall telemetry events"
+ );
+
+ Assert.deepEqual(
+ AddonTestUtils.getAMGleanEvents("manage", { method: "uninstall" }),
+ [
+ { addon_type: "dictionary", addon_id: addon.id, method: "uninstall" },
+ { addon_type: "dictionary", addon_id: addon2.id, method: "uninstall" },
+ ],
+ "Got the expected uninstall Glean events."
+ );
+ }
+);
+
+const WORD = "Flehgragh";
+
+add_task(async function test_registration() {
+ spellCheck.dictionaries = ["en-US"];
+
+ ok(!spellCheck.check(WORD), "Word should not pass check before add-on loads");
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "en-US@dictionaries.mozilla.org" },
+ },
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+
+ files: {
+ "en-US.dic": `2\n${WORD}\nnativ/A\n`,
+ "en-US.aff": `
+SFX A Y 1
+SFX A 0 en [^elr]
+ `,
+ },
+ });
+
+ ok(
+ spellCheck.check(WORD),
+ "Word should pass check while add-on load is loaded"
+ );
+ ok(spellCheck.check("nativen"), "Words should have correct affixes");
+
+ await addon.uninstall();
+
+ await new Promise(executeSoon);
+
+ ok(
+ !spellCheck.check(WORD),
+ "Word should not pass check after add-on unloads"
+ );
+});
+
+add_task(function teardown_telemetry_events() {
+ // Ignore any additional telemetry events collected in this file.
+ AddonTestUtils.getAMTelemetryEvents();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_distribution.js b/toolkit/mozapps/extensions/test/xpcshell/test_distribution.js
new file mode 100644
index 0000000000..61231160d8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_distribution.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-ons distributed with the application get installed
+// correctly
+
+// Allow distributed add-ons to install
+Services.prefs.setBoolPref("extensions.installDistroAddons", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const distroDir = gProfD.clone();
+distroDir.append("distribution");
+distroDir.append("extensions");
+registerDirectory("XREAppDist", distroDir.parent);
+
+async function setOldModificationTime() {
+ // Make sure the installed extension has an old modification time so any
+ // changes will be detected
+ await promiseShutdownManager();
+ let extension = gProfD.clone();
+ extension.append("extensions");
+ extension.append(`${ID}.xpi`);
+ setExtensionModifiedTime(extension, Date.now() - MAKE_FILE_OLD_DIFFERENCE);
+ await promiseStartupManager();
+}
+
+const ID = "addon@tests.mozilla.org";
+
+async function writeDistroAddon(version) {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${ID}.xpi`);
+}
+
+// Tests that on the first startup the add-on gets installed
+add_task(async function run_test_1() {
+ await writeDistroAddon("1.0");
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+});
+
+// Tests that starting with a newer version in the distribution dir doesn't
+// install it yet
+add_task(async function run_test_2() {
+ await setOldModificationTime();
+
+ await writeDistroAddon("2.0");
+ await promiseRestartManager();
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+});
+
+// Test that an app upgrade installs the newer version
+add_task(async function run_test_3() {
+ await promiseRestartManager("2");
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "2.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+});
+
+// Test that an app upgrade doesn't downgrade the extension
+add_task(async function run_test_4() {
+ await setOldModificationTime();
+
+ await writeDistroAddon("1.0");
+ await promiseRestartManager("3");
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "2.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+});
+
+// Tests that after uninstalling a restart doesn't re-install the extension
+add_task(async function run_test_5() {
+ let a1 = await AddonManager.getAddonByID(ID);
+ await a1.uninstall();
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID(ID);
+ Assert.equal(a1_2, null);
+});
+
+// Tests that upgrading the application still doesn't re-install the uninstalled
+// extension
+add_task(async function run_test_6() {
+ await promiseRestartManager("4");
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.equal(a1, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js
new file mode 100644
index 0000000000..0e594d60ec
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_distribution_langpack.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-ons distributed with the application in
+// langauge subdirectories correctly get installed
+
+// Allow distributed add-ons to install
+Services.prefs.setBoolPref("extensions.installDistroAddons", true);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+const distroDir = gProfD.clone();
+distroDir.append("distribution");
+distroDir.append("extensions");
+registerDirectory("XREAppDist", distroDir.parent);
+const enUSDistroDir = distroDir.clone();
+enUSDistroDir.append("locale-en-US");
+const deDEDistroDir = distroDir.clone();
+deDEDistroDir.append("locale-de-DE");
+const esESDistroDir = distroDir.clone();
+esESDistroDir.append("locale-es-ES");
+
+const enUSID = "addon-en-US@tests.mozilla.org";
+const deDEID = "addon-de-DE@tests.mozilla.org";
+const esESID = "addon-es-ES@tests.mozilla.org";
+
+async function writeDistroAddons(version) {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: enUSID } },
+ },
+ });
+ xpi.copyTo(enUSDistroDir, `${enUSID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: deDEID } },
+ },
+ });
+ xpi.copyTo(deDEDistroDir, `${deDEID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: esESID } },
+ },
+ });
+ xpi.copyTo(esESDistroDir, `${esESID}.xpi`);
+}
+
+add_task(async function setup() {
+ await writeDistroAddons("1.0");
+});
+
+// Tests that on the first startup the requested locale
+// add-on gets installed, and others don't.
+add_task(async function run_locale_test() {
+ Services.locale.availableLocales = ["de-DE", "en-US"];
+ Services.locale.requestedLocales = ["de-DE"];
+
+ Assert.equal(Services.locale.requestedLocale, "de-DE");
+
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(deDEID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+
+ let a2 = await AddonManager.getAddonByID(enUSID);
+ Assert.equal(a2, null);
+
+ let a3 = await AddonManager.getAddonByID(esESID);
+ Assert.equal(a3, null);
+
+ await a1.uninstall();
+ await promiseShutdownManager();
+});
+
+// Tests that on the first startup the correct fallback locale
+// add-on gets installed, and others don't.
+add_task(async function run_fallback_test() {
+ Services.locale.availableLocales = ["es-ES", "en-US"];
+ Services.locale.requestedLocales = ["es-UY"];
+
+ Assert.equal(Services.locale.requestedLocale, "es-UY");
+
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(esESID);
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(a1.isActive);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(!a1.foreignInstall);
+
+ let a2 = await AddonManager.getAddonByID(enUSID);
+ Assert.equal(a2, null);
+
+ let a3 = await AddonManager.getAddonByID(deDEID);
+ Assert.equal(a3, null);
+
+ await a1.uninstall();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js b/toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js
new file mode 100644
index 0000000000..943b3cf0c3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_embedderDisabled.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ADDON_ID = "embedder-disabled@tests.mozilla.org";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_IS_EMBEDDED);
+});
+
+async function installExtension() {
+ return promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ });
+}
+
+add_task(async function test_setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function embedder_disabled_while_not_embedding() {
+ const addon = await installExtension();
+ let exceptionThrown = false;
+ try {
+ await addon.setEmbedderDisabled(true);
+ } catch (exception) {
+ exceptionThrown = true;
+ }
+
+ equal(exceptionThrown, true);
+
+ // Verify that the addon is not affected
+ equal(addon.isActive, true);
+ equal(addon.embedderDisabled, undefined);
+
+ await addon.uninstall();
+});
+
+add_task(async function unset_embedder_disabled_while_not_embedding() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const addon = await installExtension();
+ await addon.setEmbedderDisabled(true);
+
+ // Verify the addon is not active anymore
+ equal(addon.isActive, false);
+ equal(addon.embedderDisabled, true);
+
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, false);
+
+ // Verify that embedder disabled cannot be read if not embedding
+ equal(addon.embedderDisabled, undefined);
+
+ await addon.disable();
+ await addon.enable();
+
+ // Verify that embedder disabled can be removed
+ equal(addon.isActive, true);
+ equal(addon.embedderDisabled, undefined);
+
+ await addon.uninstall();
+});
+
+add_task(async function embedder_disabled_while_embedding() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const addon = await installExtension();
+ await addon.setEmbedderDisabled(true);
+
+ // Verify the addon is not active anymore
+ equal(addon.embedderDisabled, true);
+ equal(addon.isActive, false);
+
+ await addon.setEmbedderDisabled(false);
+
+ // Verify that the addon is now enabled again
+ equal(addon.isActive, true);
+ equal(addon.embedderDisabled, false);
+ await addon.uninstall();
+
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, false);
+});
+
+add_task(async function embedder_disabled_while_user_disabled() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const addon = await installExtension();
+ await addon.disable();
+
+ // Verify that the addon is userDisabled
+ equal(addon.isActive, false);
+ equal(addon.userDisabled, true);
+ equal(addon.embedderDisabled, false);
+
+ await addon.setEmbedderDisabled(true);
+
+ // Verify that the addon can be userDisabled and embedderDisabled
+ equal(addon.isActive, false);
+ equal(addon.userDisabled, true);
+ equal(addon.embedderDisabled, true);
+
+ await addon.setEmbedderDisabled(false);
+
+ // Verify that unsetting embedderDisabled doesn't enable the addon
+ equal(addon.isActive, false);
+ equal(addon.userDisabled, true);
+ equal(addon.embedderDisabled, false);
+
+ await addon.enable();
+
+ // Verify that the addon can be enabled after unsetting userDisabled
+ equal(addon.isActive, true);
+ equal(addon.userDisabled, false);
+ equal(addon.embedderDisabled, false);
+
+ await addon.uninstall();
+
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, false);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_error.js b/toolkit/mozapps/extensions/test/xpcshell/test_error.js
new file mode 100644
index 0000000000..ee972f222e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_error.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that various error conditions are handled correctly
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+});
+
+// Checks that a local file validates ok
+add_task(async function run_test_1() {
+ let xpi = await createTempWebExtensionFile({});
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOADED);
+ Assert.equal(install.error, 0);
+
+ install.cancel();
+});
+
+// Checks that a corrupt file shows an error
+add_task(async function run_test_2() {
+ let xpi = AddonTestUtils.allocTempXPIFile();
+ await IOUtils.writeUTF8(xpi.path, "this is not a zip file");
+
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_CORRUPT_FILE);
+});
+
+// Checks that an empty file shows an error
+add_task(async function run_test_3() {
+ let xpi = await AddonTestUtils.createTempXPIFile({});
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_CORRUPT_FILE);
+});
+
+// Checks that a file that doesn't match its hash shows an error
+add_task(async function run_test_4() {
+ let xpi = await createTempWebExtensionFile({});
+ let url = Services.io.newFileURI(xpi).spec;
+ let install = await AddonManager.getInstallForURL(url, { hash: "sha1:foo" });
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_INCORRECT_HASH);
+});
+
+// Checks that a file that doesn't exist shows an error
+add_task(async function run_test_5() {
+ let file = do_get_file("data");
+ file.append("missing.xpi");
+ let install = await AddonManager.getInstallForFile(file);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_NETWORK_FAILURE);
+});
+
+// Checks that an add-on with an illegal ID shows an error
+add_task(async function run_test_6() {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "invalid" } },
+ },
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.notEqual(install, null);
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_CORRUPT_FILE);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js b/toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js
new file mode 100644
index 0000000000..9fbff4efe1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_ext_management.js
@@ -0,0 +1,223 @@
+"use strict";
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "48", "48");
+ await promiseStartupManager();
+});
+
+/* eslint-disable no-undef */
+// Shared background function for getSelf tests
+function backgroundGetSelf() {
+ browser.management.getSelf().then(
+ extInfo => {
+ let url = browser.runtime.getURL("*");
+ extInfo.hostPermissions = extInfo.hostPermissions.filter(i => i != url);
+
+ // Internal permissions are currently part of the permissions included
+ // in the management.getSelf results, and in non release channels
+ // any temporary installed extension is recognized as privileged
+ // and some internal permission would be added automatically.
+ //
+ // TODO(Bug 1713344): this may become unnecessary if we filter out
+ // the internal permissions from the management API results.
+ extInfo.permissions = extInfo.permissions.filter(
+ i => !i.startsWith("internal:")
+ );
+
+ extInfo.url = browser.runtime.getURL("");
+ browser.test.sendMessage("management-getSelf", extInfo);
+ },
+ error => {
+ browser.test.notifyFail(`getSelf rejected with error: ${error}`);
+ }
+ );
+}
+/* eslint-enable no-undef */
+
+add_task(async function test_management_get_self_complete() {
+ const id = "get_self_test_complete@tests.mozilla.com";
+ const permissions = ["management", "cookies"];
+ const hostPermissions = ["*://example.org/*", "https://foo.example.org/*"];
+
+ let manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ update_url: "https://updates.mozilla.com/",
+ },
+ },
+ name: "test extension name",
+ short_name: "test extension short name",
+ description: "test extension description",
+ version: "1.0",
+ homepage_url: "http://www.example.com/",
+ options_ui: {
+ page: "get_self_options.html",
+ },
+ icons: {
+ 16: "icons/icon-16.png",
+ 48: "icons/icon-48.png",
+ },
+ permissions: [...permissions, ...hostPermissions],
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background: backgroundGetSelf,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let extInfo = await extension.awaitMessage("management-getSelf");
+
+ equal(extInfo.id, id, "getSelf returned the expected id");
+ equal(
+ extInfo.installType,
+ "development",
+ "getSelf returned the expected installType"
+ );
+ for (let prop of ["name", "description", "version"]) {
+ equal(
+ extInfo[prop],
+ manifest[prop],
+ `getSelf returned the expected ${prop}`
+ );
+ }
+ equal(
+ extInfo.shortName,
+ manifest.short_name,
+ "getSelf returned the expected shortName"
+ );
+ equal(
+ extInfo.mayDisable,
+ true,
+ "getSelf returned the expected value for mayDisable"
+ );
+ equal(
+ extInfo.enabled,
+ true,
+ "getSelf returned the expected value for enabled"
+ );
+ equal(
+ extInfo.homepageUrl,
+ manifest.homepage_url,
+ "getSelf returned the expected homepageUrl"
+ );
+ equal(
+ extInfo.updateUrl,
+ manifest.browser_specific_settings.gecko.update_url,
+ "getSelf returned the expected updateUrl"
+ );
+ ok(
+ extInfo.optionsUrl.endsWith(manifest.options_ui.page),
+ "getSelf returned the expected optionsUrl"
+ );
+ for (let [index, size] of Object.keys(manifest.icons).sort().entries()) {
+ let iconUrl = `${extInfo.url}${manifest.icons[size]}`;
+ equal(
+ extInfo.icons[index].size,
+ +size,
+ "getSelf returned the expected icon size"
+ );
+ equal(
+ extInfo.icons[index].url,
+ iconUrl,
+ "getSelf returned the expected icon url"
+ );
+ }
+ deepEqual(
+ extInfo.permissions.sort(),
+ permissions.sort(),
+ "getSelf returned the expected permissions"
+ );
+ deepEqual(
+ extInfo.hostPermissions.sort(),
+ hostPermissions.sort(),
+ "getSelf returned the expected hostPermissions"
+ );
+ equal(
+ extInfo.installType,
+ "development",
+ "getSelf returned the expected installType"
+ );
+ await extension.unload();
+});
+
+add_task(async function test_management_get_self_minimal() {
+ const id = "get_self_test_minimal@tests.mozilla.com";
+
+ let manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ name: "test extension name",
+ version: "1.0",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background: backgroundGetSelf,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+ let extInfo = await extension.awaitMessage("management-getSelf");
+
+ equal(extInfo.id, id, "getSelf returned the expected id");
+ equal(
+ extInfo.installType,
+ "development",
+ "getSelf returned the expected installType"
+ );
+ for (let prop of ["name", "version"]) {
+ equal(
+ extInfo[prop],
+ manifest[prop],
+ `getSelf returned the expected ${prop}`
+ );
+ }
+ for (let prop of ["shortName", "description", "optionsUrl"]) {
+ equal(extInfo[prop], "", `getSelf returned the expected ${prop}`);
+ }
+ for (let prop of ["homepageUrl", " updateUrl", "icons"]) {
+ equal(
+ Reflect.getOwnPropertyDescriptor(extInfo, prop),
+ undefined,
+ `getSelf did not return a ${prop} property`
+ );
+ }
+ for (let prop of ["permissions", "hostPermissions"]) {
+ deepEqual(extInfo[prop], [], `getSelf returned the expected ${prop}`);
+ }
+ await extension.unload();
+});
+
+add_task(async function test_management_get_self_permanent() {
+ const id = "get_self_test_permanent@tests.mozilla.com";
+
+ let manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id,
+ },
+ },
+ name: "test extension name",
+ version: "1.0",
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ background: backgroundGetSelf,
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+ let extInfo = await extension.awaitMessage("management-getSelf");
+
+ equal(extInfo.id, id, "getSelf returned the expected id");
+ equal(
+ extInfo.installType,
+ "normal",
+ "getSelf returned the expected installType"
+ );
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js b/toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js
new file mode 100644
index 0000000000..c72737d4fe
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_filepointer.js
@@ -0,0 +1,327 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that various operations with file pointers work and do not affect the
+// source files
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+profileDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+
+const sourceDir = gProfD.clone();
+sourceDir.append("source");
+
+function promiseWriteWebExtension(path, data) {
+ let files = ExtensionTestCommon.generateFiles(data);
+ return AddonTestUtils.promiseWriteFilesToDir(path, files);
+}
+
+function promiseWritePointer(aId, aName) {
+ let path = PathUtils.join(profileDir.path, aName || aId);
+
+ let target = PathUtils.join(sourceDir.path, do_get_expected_addon_name(aId));
+
+ return IOUtils.writeUTF8(path, target);
+}
+
+function promiseWriteRelativePointer(aId, aName) {
+ let path = PathUtils.join(profileDir.path, aName || aId);
+
+ let absTarget = sourceDir.clone();
+ absTarget.append(do_get_expected_addon_name(aId));
+
+ let relTarget = absTarget.getRelativeDescriptor(profileDir);
+
+ return IOUtils.writeUTF8(path, relTarget);
+}
+
+add_task(async function setup() {
+ ok(TEST_UNPACKED, "Pointer files only work with unpacked directories");
+
+ // Unpacked extensions are never signed, so this can only run with
+ // signature checks disabled.
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+});
+
+// Tests that installing a new add-on by pointer works
+add_task(async function test_new_pointer_install() {
+ let target = PathUtils.join(sourceDir.path, ID1);
+ await promiseWriteWebExtension(target, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ await promiseWritePointer(ID1);
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let file = getAddonFile(addon);
+ equal(file.parent.path, sourceDir.path);
+
+ let rootUri = do_get_addon_root_uri(sourceDir, ID1);
+ let uri = addon.getResourceURI();
+ equal(uri.spec, rootUri);
+
+ // Check that upgrade is disabled for addons installed by file-pointers.
+ equal(addon.permissions & AddonManager.PERM_CAN_UPGRADE, 0);
+});
+
+// Tests that installing the addon from some other source doesn't clobber
+// the original sources
+add_task(async function test_addon_over_pointer() {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(
+ xpi,
+ "application/x-xpinstall"
+ );
+ await install.install();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "2.0");
+
+ let url = addon.getResourceURI();
+ if (url instanceof Ci.nsIJARURI) {
+ url = url.JARFile;
+ }
+ let { file } = url.QueryInterface(Ci.nsIFileURL);
+ equal(file.parent.path, profileDir.path);
+
+ let rootUri = do_get_addon_root_uri(profileDir, ID1);
+ let uri = addon.getResourceURI();
+ equal(uri.spec, rootUri);
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+
+ await addon.uninstall();
+});
+
+// Tests that uninstalling doesn't clobber the original sources
+add_task(async function test_uninstall_pointer() {
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ await addon.uninstall();
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+});
+
+// Tests that misnaming a pointer doesn't clobber the sources
+add_task(async function test_bad_pointer() {
+ await promiseWritePointer(ID2, ID1);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+
+ let pointer = profileDir.clone();
+ pointer.append(ID2);
+ ok(!pointer.exists());
+});
+
+// Tests that changing the ID of an existing add-on doesn't clobber the sources
+add_task(async function test_bad_pointer_id() {
+ let dir = sourceDir.clone();
+ dir.append(ID1);
+
+ // Make sure the modification time changes enough to be detected.
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ await promiseWriteWebExtension(dir.path, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID2 } },
+ },
+ });
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+
+ await promiseRestartManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+
+ let source = sourceDir.clone();
+ source.append(ID1);
+ ok(source.exists());
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ ok(!pointer.exists());
+});
+
+// Removing the pointer file should uninstall the add-on
+add_task(async function test_remove_pointer() {
+ let dir = sourceDir.clone();
+ dir.append(ID1);
+
+ await promiseWriteWebExtension(dir.path, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+ await promiseWritePointer(ID1);
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ pointer.remove(false);
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ equal(addon, null);
+});
+
+// Removing the pointer file and replacing it with a directory should work
+add_task(async function test_replace_pointer() {
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ pointer.remove(false);
+
+ await promiseWriteWebExtension(PathUtils.join(profileDir.path, ID1), {
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "2.0");
+
+ await addon.uninstall();
+});
+
+// Changes to the source files should be detected
+add_task(async function test_change_pointer_sources() {
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ let dir = sourceDir.clone();
+ dir.append(ID1);
+ await promiseWriteWebExtension(dir.path, {
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ setExtensionModifiedTime(dir, dir.lastModifiedTime - 5000);
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "2.0");
+
+ await addon.uninstall();
+});
+
+// Removing the add-on the pointer file points at should uninstall the add-on
+add_task(async function test_remove_pointer_target() {
+ let target = PathUtils.join(sourceDir.path, ID1);
+ await promiseWriteWebExtension(target, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ await promiseWritePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ notEqual(addon, null);
+ equal(addon.version, "1.0");
+
+ await IOUtils.remove(target, { recursive: true });
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(ID1);
+ equal(addon, null);
+
+ let pointer = profileDir.clone();
+ pointer.append(ID1);
+ ok(!pointer.exists());
+});
+
+// Tests that installing a new add-on by pointer with a relative path works
+add_task(async function test_new_relative_pointer() {
+ let target = PathUtils.join(sourceDir.path, ID1);
+ await promiseWriteWebExtension(target, {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID1 } },
+ },
+ });
+ await promiseWriteRelativePointer(ID1);
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ID1);
+ equal(addon.version, "1.0");
+
+ let { file } = addon.getResourceURI().QueryInterface(Ci.nsIFileURL);
+ equal(file.parent.path, sourceDir.path);
+
+ let rootUri = do_get_addon_root_uri(sourceDir, ID1);
+ let uri = addon.getResourceURI();
+ equal(uri.spec, rootUri);
+
+ // Check that upgrade is disabled for addons installed by file-pointers.
+ equal(addon.permissions & AddonManager.PERM_CAN_UPGRADE, 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_general.js b/toolkit/mozapps/extensions/test/xpcshell/test_general.js
new file mode 100644
index 0000000000..896e69d6f8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_general.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This just verifies that the EM can actually startup and shutdown a few times
+// without any errors
+
+// We have to look up how many add-ons are present since there will be plugins
+// etc. detected
+var gCount;
+
+async function run_test() {
+ do_test_pending();
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+ let list = await AddonManager.getAddonsByTypes(null);
+ gCount = list.length;
+
+ executeSoon(run_test_1);
+}
+
+async function run_test_1() {
+ await promiseRestartManager();
+
+ let addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(gCount, addons.length);
+
+ executeSoon(run_test_2);
+}
+
+async function run_test_2() {
+ await promiseShutdownManager();
+
+ await promiseStartupManager();
+
+ let addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(gCount, addons.length);
+
+ executeSoon(run_test_3);
+}
+
+async function run_test_3() {
+ await promiseRestartManager();
+
+ let addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(gCount, addons.length);
+ do_test_finished();
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js b/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js
new file mode 100644
index 0000000000..1f9b65ca85
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_getInstallSourceFromHost.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+add_task(function test_getInstallSourceFromHost_helpers() {
+ const test_hostname =
+ AppConstants.MOZ_APP_NAME !== "thunderbird"
+ ? "addons.allizom.org"
+ : "addons-stage.thunderbird.net";
+
+ const sourceHostTestCases = [
+ {
+ host: test_hostname,
+ installSourceFromHost: "test-host",
+ },
+ {
+ host: "addons.mozilla.org",
+ installSourceFromHost: "amo",
+ },
+ {
+ host: "discovery.addons.mozilla.org",
+ installSourceFromHost: "disco",
+ },
+ {
+ host: "about:blank",
+ installSourceFromHost: "unknown",
+ },
+ {
+ host: "fake-extension-uuid",
+ installSourceFromHost: "unknown",
+ },
+ {
+ host: null,
+ installSourceFromHost: "unknown",
+ },
+ ];
+
+ for (let testCase of sourceHostTestCases) {
+ let { host, installSourceFromHost } = testCase;
+
+ equal(
+ AddonManager.getInstallSourceFromHost(host),
+ installSourceFromHost,
+ `Got the expected result from getInstallFromHost for host ${host}`
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js b/toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js
new file mode 100644
index 0000000000..8f2978c116
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_gmpProvider.js
@@ -0,0 +1,477 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { GMPTestUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/GMPProvider.sys.mjs"
+);
+const { GMPInstallManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/GMPInstallManager.sys.mjs"
+);
+const {
+ GMPPrefs,
+ GMP_PLUGIN_IDS,
+ OPEN_H264_ID,
+ WIDEVINE_L1_ID,
+ WIDEVINE_L3_ID,
+} = ChromeUtils.importESModule("resource://gre/modules/GMPUtils.sys.mjs");
+const { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+);
+
+ChromeUtils.defineLazyGetter(
+ this,
+ "addonsBundle",
+ () => new Localization(["toolkit/about/aboutAddons.ftl"])
+);
+
+var gMockAddons = new Map();
+var gMockEmeAddons = new Map();
+
+const mockH264Addon = Object.freeze({
+ id: OPEN_H264_ID,
+ isValid: true,
+ isInstalled: false,
+ nameId: "plugins-openh264-name",
+ descriptionId: "plugins-openh264-description",
+ libName: "gmpopenh264",
+ usedFallback: true,
+});
+gMockAddons.set(mockH264Addon.id, mockH264Addon);
+
+const mockWidevineL1Addon = Object.freeze({
+ id: WIDEVINE_L1_ID,
+ isValid: true,
+ isInstalled: false,
+ nameId: "plugins-widevine-name",
+ descriptionId: "plugins-widevine-description",
+ libName: "Google.Widevine.CDM",
+ usedFallback: true,
+});
+gMockAddons.set(mockWidevineL1Addon.id, mockWidevineL1Addon);
+gMockEmeAddons.set(mockWidevineL1Addon.id, mockWidevineL1Addon);
+
+const mockWidevineAddon = Object.freeze({
+ id: WIDEVINE_L3_ID,
+ isValid: true,
+ isInstalled: false,
+ nameId: "plugins-widevine-name",
+ descriptionId: "plugins-widevine-description",
+ libName: "widevinecdm",
+ usedFallback: true,
+});
+gMockAddons.set(mockWidevineAddon.id, mockWidevineAddon);
+gMockEmeAddons.set(mockWidevineAddon.id, mockWidevineAddon);
+
+var gInstalledAddonId = "";
+var gPrefs = Services.prefs;
+var gGetKey = GMPPrefs.getPrefKey;
+
+const MockGMPInstallManagerPrototype = {
+ checkForAddons: () =>
+ Promise.resolve({
+ addons: [...gMockAddons.values()],
+ }),
+
+ installAddon: addon => {
+ gInstalledAddonId = addon.id;
+ return Promise.resolve();
+ },
+};
+
+add_setup(async () => {
+ Assert.deepEqual(
+ GMP_PLUGIN_IDS,
+ Array.from(gMockAddons.keys()),
+ "set of mock addons matches the actual set of plugins"
+ );
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // The GMPProvider does not register until the first content process
+ // is launched, so we simulate that by firing this notification.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+
+ await promiseStartupManager();
+
+ gPrefs.setBoolPref(GMPPrefs.KEY_LOGGING_DUMP, true);
+ gPrefs.setIntPref(GMPPrefs.KEY_LOGGING_LEVEL, 0);
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+ for (let addon of gMockAddons.values()) {
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_VISIBLE, addon.id), true);
+ gPrefs.setBoolPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_FORCE_SUPPORTED, addon.id),
+ true
+ );
+ }
+});
+
+add_task(async function test_notInstalled() {
+ for (let addon of gMockAddons.values()) {
+ gPrefs.setCharPref(gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id), "");
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), false);
+ }
+
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ Assert.ok(!addon.isInstalled);
+ Assert.equal(addon.type, "plugin");
+ Assert.equal(addon.version, "");
+
+ let mockAddon = gMockAddons.get(addon.id);
+
+ Assert.notEqual(mockAddon, null);
+ let name = await addonsBundle.formatValue(mockAddon.nameId);
+ Assert.equal(addon.name, name);
+ let description = await addonsBundle.formatValue(mockAddon.descriptionId);
+ Assert.equal(addon.description, description);
+
+ Assert.ok(!addon.isActive);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.userDisabled);
+
+ Assert.equal(
+ addon.blocklistState,
+ Ci.nsIBlocklistService.STATE_NOT_BLOCKED
+ );
+ Assert.equal(addon.scope, AddonManager.SCOPE_APPLICATION);
+ Assert.equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.equal(addon.operationsRequiringRestart, AddonManager.PENDING_NONE);
+
+ Assert.equal(
+ addon.permissions,
+ AddonManager.PERM_CAN_UPGRADE | AddonManager.PERM_CAN_ENABLE
+ );
+
+ Assert.equal(addon.updateDate, null);
+
+ Assert.ok(addon.isCompatible);
+ Assert.ok(addon.isPlatformCompatible);
+ Assert.ok(addon.providesUpdatesSecurely);
+ Assert.ok(!addon.foreignInstall);
+
+ let libraries = addon.pluginLibraries;
+ Assert.ok(libraries);
+ Assert.equal(libraries.length, 0);
+ Assert.equal(addon.pluginFullpath, "");
+ }
+});
+
+add_task(async function test_installed() {
+ const TEST_DATE = new Date(2013, 0, 1, 12);
+ const TEST_VERSION = "1.2.3.4";
+ const TEST_TIME_SEC = Math.round(TEST_DATE.getTime() / 1000);
+
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ let mockAddon = gMockAddons.get(addon.id);
+ Assert.notEqual(mockAddon, null);
+
+ let file = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file.append(addon.id);
+ file.append(TEST_VERSION);
+ gPrefs.setBoolPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, mockAddon.id),
+ false
+ );
+ gPrefs.setIntPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, mockAddon.id),
+ TEST_TIME_SEC
+ );
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, mockAddon.id),
+ TEST_VERSION
+ );
+
+ Assert.ok(addon.isInstalled);
+ Assert.equal(addon.type, "plugin");
+ Assert.ok(!addon.isActive);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.userDisabled);
+
+ let name = await addonsBundle.formatValue(mockAddon.nameId);
+ Assert.equal(addon.name, name);
+ Assert.equal(addon.version, TEST_VERSION);
+
+ Assert.equal(
+ addon.permissions,
+ AddonManager.PERM_CAN_UPGRADE | AddonManager.PERM_CAN_ENABLE
+ );
+
+ Assert.equal(addon.updateDate.getTime(), TEST_TIME_SEC * 1000);
+
+ let libraries = addon.pluginLibraries;
+ Assert.ok(libraries);
+ Assert.equal(libraries.length, 1);
+ Assert.equal(libraries[0], TEST_VERSION);
+ let fullpath = addon.pluginFullpath;
+ Assert.equal(fullpath.length, 1);
+ Assert.equal(fullpath[0], file.path);
+ }
+});
+
+add_task(async function test_enable() {
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+
+ Assert.ok(addon.isActive);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+
+ Assert.equal(
+ addon.permissions,
+ AddonManager.PERM_CAN_UPGRADE | AddonManager.PERM_CAN_DISABLE
+ );
+ }
+});
+
+add_task(async function test_globalEmeDisabled() {
+ let addons = await promiseAddonsByIDs([...gMockEmeAddons.keys()]);
+ Assert.equal(addons.length, gMockEmeAddons.size);
+
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, false);
+ for (let addon of addons) {
+ Assert.ok(!addon.isActive);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+
+ Assert.equal(addon.permissions, 0);
+ }
+ gPrefs.setBoolPref(GMPPrefs.KEY_EME_ENABLED, true);
+});
+
+add_task(async function test_autoUpdatePrefPersistance() {
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ let autoupdateKey = gGetKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id);
+ gPrefs.clearUserPref(autoupdateKey);
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+ Assert.ok(!gPrefs.getBoolPref(autoupdateKey));
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+ Assert.equal(addon.applyBackgroundUpdates, AddonManager.AUTOUPDATE_ENABLE);
+ Assert.ok(gPrefs.getBoolPref(autoupdateKey));
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+ Assert.ok(!gPrefs.prefHasUserValue(autoupdateKey));
+ }
+});
+
+function createMockPluginFilesIfNeeded(aFile, aPlugin) {
+ function createFile(aFileName) {
+ let f = aFile.clone();
+ f.append(aFileName);
+ if (!f.exists()) {
+ f.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ }
+ }
+
+ let libName =
+ AppConstants.DLL_PREFIX + aPlugin.libName + AppConstants.DLL_SUFFIX;
+
+ createFile(libName);
+ if (aPlugin.id == WIDEVINE_L1_ID || aPlugin.id == WIDEVINE_L3_ID) {
+ createFile("manifest.json");
+ } else {
+ createFile(aPlugin.id.substring(4) + ".info");
+ }
+}
+
+add_task(async function test_pluginRegistration() {
+ const TEST_VERSION = "1.2.3.4";
+
+ let addedPaths = [];
+ let removedPaths = [];
+ let clearPaths = () => {
+ addedPaths = [];
+ removedPaths = [];
+ };
+
+ const MockGMPService = {
+ addPluginDirectory: path => {
+ if (!addedPaths.includes(path)) {
+ addedPaths.push(path);
+ }
+ },
+ removePluginDirectory: path => {
+ if (!removedPaths.includes(path)) {
+ removedPaths.push(path);
+ }
+ },
+ removeAndDeletePluginDirectory: path => {
+ if (!removedPaths.includes(path)) {
+ removedPaths.push(path);
+ }
+ },
+ };
+
+ let profD = do_get_profile();
+ for (let addon of gMockAddons.values()) {
+ await GMPTestUtils.overrideGmpService(MockGMPService, () =>
+ testAddon(addon)
+ );
+ }
+
+ async function testAddon(addon) {
+ let file = profD.clone();
+ file.append(addon.id);
+ file.append(TEST_VERSION);
+
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+
+ // Test that plugin registration fails if the plugin dynamic library and
+ // info files are not present.
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Create dummy GMP library/info files, and test that plugin registration
+ // succeeds during startup, now that we've added GMP info/lib files.
+ createMockPluginFilesIfNeeded(file, addon);
+
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ await promiseRestartManager();
+ Assert.notEqual(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, []);
+
+ // Setting the ABI to something invalid should cause plugin to be removed at startup.
+ clearPaths();
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_ABI, addon.id),
+ "invalid-ABI"
+ );
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Setting the ABI to expected ABI should cause registration at startup.
+ clearPaths();
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_ABI, addon.id),
+ UpdateUtils.ABI
+ );
+ await promiseRestartManager();
+ Assert.notEqual(addedPaths.indexOf(file.path), -1);
+ Assert.deepEqual(removedPaths, []);
+
+ // Check that clearing the version doesn't trigger registration.
+ clearPaths();
+ gPrefs.clearUserPref(gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id));
+ Assert.deepEqual(addedPaths, []);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Restarting with no version set should not trigger registration.
+ clearPaths();
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.equal(removedPaths.indexOf(file.path), -1);
+
+ // Changing the pref mid-session should cause unregistration and registration.
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ const TEST_VERSION_2 = "5.6.7.8";
+ let file2 = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ file2.append(addon.id);
+ file2.append(TEST_VERSION_2);
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION_2
+ );
+ Assert.deepEqual(addedPaths, [file2.path]);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Disabling the plugin should cause unregistration.
+ gPrefs.setCharPref(
+ gGetKey(GMPPrefs.KEY_PLUGIN_VERSION, addon.id),
+ TEST_VERSION
+ );
+ clearPaths();
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), false);
+ Assert.deepEqual(addedPaths, []);
+ Assert.deepEqual(removedPaths, [file.path]);
+
+ // Restarting with the plugin disabled should not cause registration.
+ clearPaths();
+ await promiseRestartManager();
+ Assert.equal(addedPaths.indexOf(file.path), -1);
+ Assert.equal(removedPaths.indexOf(file.path), -1);
+
+ // Re-enabling the plugin should cause registration.
+ clearPaths();
+ gPrefs.setBoolPref(gGetKey(GMPPrefs.KEY_PLUGIN_ENABLED, addon.id), true);
+ Assert.deepEqual(addedPaths, [file.path]);
+ Assert.deepEqual(removedPaths, []);
+ }
+});
+
+add_task(async function test_periodicUpdate() {
+ // The GMPInstallManager constructor has an empty body,
+ // so replacing the prototype is safe.
+ let originalInstallManager = GMPInstallManager.prototype;
+ GMPInstallManager.prototype = MockGMPInstallManagerPrototype;
+
+ let addons = await promiseAddonsByIDs([...gMockAddons.keys()]);
+ Assert.equal(addons.length, gMockAddons.size);
+
+ for (let addon of addons) {
+ gPrefs.clearUserPref(gGetKey(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, addon.id));
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+ gPrefs.setIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0);
+ let result = await addon.findUpdates(
+ {},
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ Assert.strictEqual(result, false);
+
+ addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_ENABLE;
+ gPrefs.setIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, Date.now() / 1000 - 60);
+ result = await addon.findUpdates(
+ {},
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ Assert.strictEqual(result, false);
+
+ const SEC_IN_A_DAY = 24 * 60 * 60;
+ gPrefs.setIntPref(
+ GMPPrefs.KEY_UPDATE_LAST_CHECK,
+ Date.now() / 1000 - 2 * SEC_IN_A_DAY
+ );
+ gInstalledAddonId = "";
+ result = await addon.findUpdates(
+ {},
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ Assert.strictEqual(result, true);
+ Assert.equal(gInstalledAddonId, addon.id);
+ }
+
+ GMPInstallManager.prototype = originalInstallManager;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_harness.js b/toolkit/mozapps/extensions/test/xpcshell/test_harness.js
new file mode 100644
index 0000000000..8be3cdcf22
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_harness.js
@@ -0,0 +1,13 @@
+"use strict";
+
+// Test that the test harness is sane.
+
+// Test that the temporary directory is actually overridden in the
+// directory service.
+add_task(async function test_TmpD_override() {
+ equal(
+ FileUtils.getDir("TmpD", []).path,
+ AddonTestUtils.tempDir.path,
+ "Should get the correct temporary directory from the directory service"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_hidden.js b/toolkit/mozapps/extensions/test/xpcshell/test_hidden.js
new file mode 100644
index 0000000000..3d1c187b81
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_hidden.js
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged");
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_hidden() {
+ let xpi1 = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "privileged@tests.mozilla.org",
+ },
+ },
+
+ name: "Hidden Extension",
+ hidden: true,
+ },
+ });
+
+ let xpi2 = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: "unprivileged@tests.mozilla.org",
+ },
+ },
+
+ name: "Non-Hidden Extension",
+ hidden: true,
+ },
+ });
+
+ await promiseInstallAllFiles([xpi1, xpi2]);
+
+ let [addon1, addon2] = await promiseAddonsByIDs([
+ "privileged@tests.mozilla.org",
+ "unprivileged@tests.mozilla.org",
+ ]);
+
+ ok(addon1.isPrivileged, "Privileged is privileged");
+ ok(addon1.hidden, "Privileged extension should be hidden");
+ ok(!addon2.isPrivileged, "Unprivileged extension is not privileged");
+ ok(!addon2.hidden, "Unprivileged extension should not be hidden");
+
+ await promiseRestartManager();
+
+ [addon1, addon2] = await promiseAddonsByIDs([
+ "privileged@tests.mozilla.org",
+ "unprivileged@tests.mozilla.org",
+ ]);
+
+ ok(addon1.isPrivileged, "Privileged is privileged");
+ ok(addon1.hidden, "Privileged extension should be hidden");
+ ok(!addon2.isPrivileged, "Unprivileged extension is not privileged");
+ ok(!addon2.hidden, "Unprivileged extension should not be hidden");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ browser_specific_settings: { gecko: { id: "privileged@but-temporary" } },
+ hidden: true,
+ },
+ });
+ await extension.startup();
+ let tempAddon = extension.addon;
+ ok(tempAddon.isPrivileged, "Temporary add-on is privileged");
+ ok(
+ !tempAddon.hidden,
+ "Temporary add-on is not hidden despite being privileged"
+ );
+ await extension.unload();
+});
+
+add_task(
+ {
+ pref_set: [["extensions.manifestV3.enabled", true]],
+ },
+ async function test_hidden_and_browser_action_props_are_mutually_exclusive() {
+ const TEST_CASES = [
+ {
+ title: "hidden and browser_action",
+ manifest: {
+ hidden: true,
+ browser_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden and page_action",
+ manifest: {
+ hidden: true,
+ page_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden, browser_action and page_action",
+ manifest: {
+ hidden: true,
+ browser_action: {},
+ page_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden and no browser_action or page_action",
+ manifest: {
+ hidden: true,
+ },
+ expectError: false,
+ },
+ {
+ title: "not hidden and browser_action",
+ manifest: {
+ hidden: false,
+ browser_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "not hidden and page_action",
+ manifest: {
+ hidden: false,
+ page_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "no hidden prop and browser_action",
+ manifest: {
+ browser_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "hidden and action",
+ manifest: {
+ manifest_version: 3,
+ hidden: true,
+ action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "hidden, action and page_action",
+ manifest: {
+ manifest_version: 3,
+ hidden: true,
+ action: {},
+ page_action: {},
+ },
+ expectError: true,
+ },
+ {
+ title: "no hidden prop and action",
+ manifest: {
+ manifest_version: 3,
+ action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "no hidden prop and page_action",
+ manifest: {
+ page_action: {},
+ },
+ expectError: false,
+ },
+ {
+ title: "hidden and action but not privileged",
+ manifest: {
+ manifest_version: 3,
+ hidden: true,
+ action: {},
+ },
+ expectError: false,
+ isPrivileged: false,
+ },
+ {
+ title: "hidden and browser_action but not privileged",
+ manifest: {
+ hidden: true,
+ browser_action: {},
+ },
+ expectError: false,
+ isPrivileged: false,
+ },
+ {
+ title: "hidden and page_action but not privileged",
+ manifest: {
+ hidden: true,
+ page_action: {},
+ },
+ expectError: false,
+ isPrivileged: false,
+ },
+ ];
+
+ let count = 0;
+
+ for (const {
+ title,
+ manifest,
+ expectError,
+ isPrivileged = true,
+ } of TEST_CASES) {
+ info(`== ${title} ==`);
+
+ // Thunderbird doesn't have page actions.
+ if (manifest.page_action && AppConstants.MOZ_APP_NAME == "thunderbird") {
+ continue;
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: `${isPrivileged ? "" : "not-"}privileged@ext-${count++}`,
+ },
+ },
+ permissions: ["mozillaAddons"],
+ ...manifest,
+ },
+ background() {
+ /* globals browser */
+ browser.test.sendMessage("ok");
+ },
+ isPrivileged,
+ });
+
+ if (expectError) {
+ await Assert.rejects(
+ extension.startup(),
+ /Cannot use browser and\/or page actions in hidden add-ons/,
+ "expected extension not started"
+ );
+ } else {
+ await extension.startup();
+ await extension.awaitMessage("ok");
+ await extension.unload();
+ }
+ }
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install.js b/toolkit/mozapps/extensions/test/xpcshell/test_install.js
new file mode 100644
index 0000000000..a9ae16fff3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install.js
@@ -0,0 +1,1063 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+var gInstallDate;
+
+const ADDONS = {
+ test_install1: {
+ manifest: {
+ name: "Test 1",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "addon1@tests.mozilla.org" } },
+ },
+ },
+ test_install2_1: {
+ manifest: {
+ name: "Test 2",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: "addon2@tests.mozilla.org" } },
+ },
+ },
+ test_install2_2: {
+ manifest: {
+ name: "Test 2",
+ version: "3.0",
+ browser_specific_settings: { gecko: { id: "addon2@tests.mozilla.org" } },
+ },
+ },
+ test_install3: {
+ manifest: {
+ name: "Test 3",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ strict_min_version: "0",
+ strict_max_version: "0",
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ },
+};
+
+const XPIS = {};
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const UPDATE_JSON = {
+ addons: {
+ "addon3@tests.mozilla.org": {
+ updates: [
+ {
+ version: "1.0",
+ applications: {
+ gecko: {
+ strict_min_version: "0",
+ strict_max_version: "2",
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+const GETADDONS_JSON = {
+ page_size: 25,
+ page_count: 1,
+ count: 1,
+ next: null,
+ previous: null,
+ results: [
+ {
+ name: "Test 2",
+ type: "extension",
+ guid: "addon2@tests.mozilla.org",
+ current_version: {
+ version: "1.0",
+ files: [
+ {
+ size: 2,
+ url: "http://example.com/test_install2_1.xpi",
+ },
+ ],
+ },
+ authors: [
+ {
+ name: "Test Creator",
+ url: "http://example.com/creator.html",
+ },
+ ],
+ summary: "Repository summary",
+ description: "Repository description",
+ url: "https://addons.mozilla.org/en-US/firefox/addon/addon2@tests.mozilla.org/",
+ },
+ ],
+};
+
+function checkInstall(install, expected) {
+ for (let [key, value] of Object.entries(expected)) {
+ if (value instanceof Ci.nsIURI) {
+ equal(
+ install[key] && install[key].spec,
+ value.spec,
+ `Expected value of install.${key}`
+ );
+ } else {
+ deepEqual(install[key], value, `Expected value of install.${key}`);
+ }
+ }
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ for (let [name, data] of Object.entries(ADDONS)) {
+ XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data);
+ testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+ }
+
+ await promiseStartupManager();
+
+ // Create and configure the HTTP server.
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE_JSON);
+ testserver.registerDirectory("/data/", do_get_file("data"));
+ testserver.registerPathHandler("/redirect", function (aRequest, aResponse) {
+ aResponse.setStatusLine(null, 301, "Moved Permanently");
+ let url = aRequest.host + ":" + aRequest.port + aRequest.queryString;
+ aResponse.setHeader("Location", "http://" + url);
+ });
+ gPort = testserver.identity.primaryPort;
+});
+
+// Checks that an install from a local file proceeds as expected
+add_task(async function test_install_file() {
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForFile(XPIS.test_install1),
+ ]);
+
+ let uri = Services.io.newFileURI(XPIS.test_install1);
+ checkInstall(install, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 1",
+ state: AddonManager.STATE_DOWNLOADED,
+ sourceURI: uri,
+ });
+
+ let { addon } = install;
+ checkAddon("addon1@tests.mozilla.org", addon, {
+ install,
+ sourceURI: uri,
+ });
+ notEqual(addon.syncGUID, null);
+ equal(
+ addon.getResourceURI("manifest.json").spec,
+ `jar:${uri.spec}!/manifest.json`
+ );
+
+ let activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ let fooInstalls = await AddonManager.getInstallsByTypes(["foo"]);
+ equal(fooInstalls.length, 0);
+
+ let extensionInstalls = await AddonManager.getInstallsByTypes(["extension"]);
+ equal(extensionInstalls.length, 1);
+ equal(extensionInstalls[0], install);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ },
+ () => install.install()
+ );
+
+ addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ ok(addon);
+
+ ok(!hasFlag(addon.permissions, AddonManager.PERM_CAN_ENABLE));
+ ok(hasFlag(addon.permissions, AddonManager.PERM_CAN_DISABLE));
+
+ let updateDate = Date.now();
+
+ await promiseRestartManager();
+
+ activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls, 0);
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ let uri2 = do_get_addon_root_uri(profileDir, "addon1@tests.mozilla.org");
+
+ checkAddon("addon1@tests.mozilla.org", a1, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 1",
+ foreignInstall: false,
+ sourceURI: Services.io.newFileURI(XPIS.test_install1),
+ });
+
+ notEqual(a1.syncGUID, null);
+ Assert.greaterOrEqual(a1.syncGUID.length, 9);
+
+ ok(isExtensionInBootstrappedList(profileDir, a1.id));
+ ok(XPIS.test_install1.exists());
+ do_check_in_crash_annotation(a1.id, a1.version);
+
+ let difference = a1.installDate.getTime() - updateDate;
+ if (Math.abs(difference) > MAX_TIME_DIFFERENCE) {
+ do_throw("Add-on install time was out by " + difference + "ms");
+ }
+
+ difference = a1.updateDate.getTime() - updateDate;
+ if (Math.abs(difference) > MAX_TIME_DIFFERENCE) {
+ do_throw("Add-on update time was out by " + difference + "ms");
+ }
+
+ equal(a1.getResourceURI("manifest.json").spec, uri2 + "manifest.json");
+
+ // Ensure that extension bundle (or icon if unpacked) has updated
+ // lastModifiedDate.
+ let testFile = getAddonFile(a1);
+ ok(testFile.exists());
+ difference = testFile.lastModifiedTime - Date.now();
+ Assert.less(Math.abs(difference), MAX_TIME_DIFFERENCE);
+
+ await a1.uninstall();
+ let { id, version } = a1;
+ await promiseRestartManager();
+ do_check_not_in_crash_annotation(id, version);
+});
+
+// Tests that an install from a url downloads.
+add_task(async function test_install_url() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url, {
+ name: "Test 2",
+ version: "1.0",
+ });
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 2",
+ state: AddonManager.STATE_AVAILABLE,
+ sourceURI: Services.io.newURI(url),
+ });
+
+ let activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ checkInstall(install, {
+ version: "2.0",
+ name: "Test 2",
+ state: AddonManager.STATE_DOWNLOADED,
+ });
+ equal(install.addon.install, install);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon2@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ let updateDate = Date.now();
+
+ await promiseRestartManager();
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs, 0);
+
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", a2, {
+ type: "extension",
+ version: "2.0",
+ name: "Test 2",
+ sourceURI: Services.io.newURI(url),
+ });
+ notEqual(a2.syncGUID, null);
+
+ ok(isExtensionInBootstrappedList(profileDir, a2.id));
+ ok(XPIS.test_install2_1.exists());
+ do_check_in_crash_annotation(a2.id, a2.version);
+
+ let difference = a2.installDate.getTime() - updateDate;
+ Assert.lessOrEqual(
+ Math.abs(difference),
+ MAX_TIME_DIFFERENCE,
+ "Add-on install time was correct"
+ );
+
+ difference = a2.updateDate.getTime() - updateDate;
+ Assert.lessOrEqual(
+ Math.abs(difference),
+ MAX_TIME_DIFFERENCE,
+ "Add-on update time was correct"
+ );
+
+ gInstallDate = a2.installDate;
+});
+
+// Tests that installing a new version of an existing add-on works
+add_task(async function test_install_new_version() {
+ let url = "http://example.com/addons/test_install2_2.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url, {
+ name: "Test 2",
+ version: "3.0",
+ }),
+ ]);
+
+ checkInstall(install, {
+ version: "3.0",
+ name: "Test 2",
+ state: AddonManager.STATE_AVAILABLE,
+ existingAddon: null,
+ });
+
+ let activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ checkInstall(install, {
+ version: "3.0",
+ name: "Test 2",
+ state: AddonManager.STATE_DOWNLOADED,
+ existingAddon: await AddonManager.getAddonByID("addon2@tests.mozilla.org"),
+ });
+
+ equal(install.addon.install, install);
+
+ // Installation will continue when there is nothing returned.
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon2@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ await promiseRestartManager();
+
+ let installs2 = await AddonManager.getInstallsByTypes(null);
+ equal(installs2.length, 0);
+
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", a2, {
+ type: "extension",
+ version: "3.0",
+ name: "Test 2",
+ isActive: true,
+ foreignInstall: false,
+ sourceURI: Services.io.newURI(url),
+ installDate: gInstallDate,
+ });
+
+ ok(isExtensionInBootstrappedList(profileDir, a2.id));
+ ok(XPIS.test_install2_2.exists());
+ do_check_in_crash_annotation(a2.id, a2.version);
+
+ // Update date should be later (or the same if this test is too fast)
+ Assert.lessOrEqual(a2.installDate, a2.updateDate);
+
+ await a2.uninstall();
+});
+
+// Tests that an install that requires a compatibility update works
+add_task(async function test_install_compat_update() {
+ let url = "http://example.com/addons/test_install3.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ await AddonManager.getInstallForURL(url, {
+ name: "Test 3",
+ version: "1.0",
+ }),
+ ]);
+
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 3",
+ state: AddonManager.STATE_AVAILABLE,
+ });
+
+ let activeInstalls = await AddonManager.getInstallsByTypes(null);
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 3",
+ state: AddonManager.STATE_DOWNLOADED,
+ existingAddon: null,
+ });
+ checkAddon("addon3@tests.mozilla.org", install.addon, {
+ appDisabled: false,
+ });
+
+ // Continue the install
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon3@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ await promiseRestartManager();
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs, 0);
+
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ checkAddon("addon3@tests.mozilla.org", a3, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 3",
+ isActive: true,
+ appDisabled: false,
+ });
+ notEqual(a3.syncGUID, null);
+
+ ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ ok(XPIS.test_install3.exists());
+ await a3.uninstall();
+});
+
+add_task(async function test_compat_update_local() {
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForFile(XPIS.test_install3),
+ ]);
+ ok(install.addon.isCompatible);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "addon3@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => install.install()
+ );
+
+ await promiseRestartManager();
+
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ checkAddon("addon3@tests.mozilla.org", a3, {
+ type: "extension",
+ version: "1.0",
+ name: "Test 3",
+ isActive: true,
+ appDisabled: false,
+ });
+ notEqual(a3.syncGUID, null);
+
+ ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ ok(XPIS.test_install3.exists());
+ await a3.uninstall();
+});
+
+// Test that after cancelling a download it is removed from the active installs
+add_task(async function test_cancel() {
+ let url = "http://example.com/addons/test_install3.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url, {
+ name: "Test 3",
+ version: "1.0",
+ }),
+ ]);
+
+ checkInstall(install, {
+ version: "1.0",
+ name: "Test 3",
+ state: AddonManager.STATE_AVAILABLE,
+ });
+
+ let activeInstalls = await AddonManager.getInstallsByTypes(null);
+ equal(activeInstalls.length, 1);
+ equal(activeInstalls[0], install);
+
+ let promise;
+ function cancel() {
+ promise = expectEvents(
+ {
+ installEvents: [{ event: "onDownloadCancelled" }],
+ },
+ () => {
+ install.cancel();
+ }
+ );
+ }
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", callback: cancel },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await promise;
+
+ let file = install.file;
+
+ // Allow the file removal to complete
+ activeInstalls = await AddonManager.getAllInstalls();
+ equal(activeInstalls.length, 0);
+ ok(!file.exists());
+});
+
+// Check that cancelling the install from onDownloadStarted actually cancels it
+add_task(async function test_cancel_onDownloadStarted() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url),
+ ]);
+
+ equal(install.file, null);
+
+ install.addListener({
+ onDownloadStarted() {
+ install.removeListener(this);
+ executeSoon(() => install.cancel());
+ },
+ });
+
+ let promise = AddonTestUtils.promiseInstallEvent("onDownloadCancelled");
+ install.install();
+ await promise;
+
+ // Wait another tick to see if it continues downloading.
+ // The listener only really tests if we give it time to see progress, the
+ // file check isn't ideal either
+ install.addListener({
+ onDownloadProgress() {
+ do_throw("Download should not have continued");
+ },
+ onDownloadEnded() {
+ do_throw("Download should not have continued");
+ },
+ });
+
+ let file = install.file;
+ await Promise.resolve();
+ ok(!file.exists());
+});
+
+// Checks that cancelling the install from onDownloadEnded actually cancels it
+add_task(async function test_cancel_onDownloadEnded() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url),
+ ]);
+
+ equal(install.file, null);
+
+ let promise;
+ function cancel() {
+ promise = expectEvents(
+ {
+ installEvents: [{ event: "onDownloadCancelled" }],
+ },
+ async () => {
+ install.cancel();
+ }
+ );
+ }
+
+ await expectEvents(
+ {
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", callback: cancel },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await promise;
+
+ install.addListener({
+ onInstallStarted() {
+ do_throw("Install should not have continued");
+ },
+ });
+});
+
+// Verify that the userDisabled value carries over to the upgrade by default
+add_task(async function test_userDisabled_update() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let [, install] = await Promise.all([
+ AddonTestUtils.promiseInstallEvent("onNewInstall"),
+ AddonManager.getInstallForURL(url),
+ ]);
+
+ await install.install();
+
+ ok(!install.addon.userDisabled);
+ await install.addon.disable();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ url = "http://example.com/addons/test_install2_2.xpi";
+ install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ checkAddon("addon2@tests.mozilla.org", install.addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ await addon.uninstall();
+});
+
+// Verify that changing the userDisabled value before onInstallEnded works
+add_task(async function test_userDisabled() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ ok(!install.addon.userDisabled);
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: false,
+ isActive: true,
+ });
+
+ url = "http://example.com/addons/test_install2_2.xpi";
+ install = await AddonManager.getInstallForURL(url);
+
+ install.addListener({
+ onInstallStarted() {
+ ok(!install.addon.userDisabled);
+ install.addon.disable();
+ },
+ });
+
+ await install.install();
+
+ addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ checkAddon("addon2@tests.mozilla.org", addon, {
+ userDisabled: true,
+ isActive: false,
+ });
+
+ await addon.uninstall();
+});
+
+// Checks that metadata is not stored if the pref is set to false
+add_task(async function test_18_1() {
+ AddonTestUtils.registerJSON(testserver, "/getaddons.json", GETADDONS_JSON);
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ "http://example.com/getaddons.json"
+ );
+
+ Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.addon2@tests.mozilla.org.getAddons.cache.enabled",
+ false
+ );
+
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ notEqual(install.addon.fullDescription, "Repository description");
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ notEqual(addon.fullDescription, "Repository description");
+
+ await addon.uninstall();
+});
+
+// Checks that metadata is downloaded for new installs and is visible before and
+// after restart
+add_task(async function test_metadata() {
+ Services.prefs.setBoolPref(
+ "extensions.addon2@tests.mozilla.org.getAddons.cache.enabled",
+ true
+ );
+
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ equal(install.addon.fullDescription, "Repository description");
+ equal(
+ install.addon.amoListingURL,
+ "https://addons.mozilla.org/en-US/firefox/addon/addon2@tests.mozilla.org/"
+ );
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ equal(addon.fullDescription, "Repository description");
+ equal(
+ addon.amoListingURL,
+ "https://addons.mozilla.org/en-US/firefox/addon/addon2@tests.mozilla.org/"
+ );
+
+ await addon.uninstall();
+});
+
+// Do the same again to make sure it works when the data is already in the cache
+add_task(async function test_metadata_again() {
+ let url = "http://example.com/addons/test_install2_1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ await install.install();
+
+ equal(install.addon.fullDescription, "Repository description");
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ equal(addon.fullDescription, "Repository description");
+ equal(
+ addon.amoListingURL,
+ "https://addons.mozilla.org/en-US/firefox/addon/addon2@tests.mozilla.org/"
+ );
+
+ await addon.uninstall();
+});
+
+// Tests that an install can be restarted after being cancelled
+add_task(async function test_restart() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadEnded() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {}
+
+ let promise = expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await Promise.all([
+ promise,
+ promiseWebExtensionStartup("addon1@tests.mozilla.org"),
+ ]);
+
+ await install.addon.uninstall();
+});
+
+// Tests that an install can be restarted after being cancelled when a hash
+// was provided
+add_task(async function test_restart_hash() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url, {
+ hash: do_get_file_hash(XPIS.test_install1),
+ });
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadEnded() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {}
+
+ let promise = expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await Promise.all([
+ promise,
+ promiseWebExtensionStartup("addon1@tests.mozilla.org"),
+ ]);
+
+ await install.addon.uninstall();
+});
+
+// Tests that an install with a bad hash can be restarted after it fails, though
+// it will only fail again
+add_task(async function test_restart_badhash() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url, { hash: "sha1:foo" });
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadEnded() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {}
+
+ try {
+ await install.install();
+ ok(false, "Install should not have succeeded");
+ } catch (err) {
+ ok(true, "Resumed install should have failed");
+ }
+});
+
+// Tests that installs with a hash for a local file work
+add_task(async function test_local_hash() {
+ let url = Services.io.newFileURI(XPIS.test_install1).spec;
+ let install = await AddonManager.getInstallForURL(url, {
+ hash: do_get_file_hash(XPIS.test_install1),
+ });
+
+ checkInstall(install, {
+ state: AddonManager.STATE_DOWNLOADED,
+ error: 0,
+ });
+
+ install.cancel();
+});
+
+// Test that an install cannot be canceled after the install is completed.
+add_task(async function test_cancel_completed() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+
+ let cancelPromise = new Promise((resolve, reject) => {
+ install.addListener({
+ onInstallEnded() {
+ try {
+ install.cancel();
+ reject("Cancel should fail.");
+ } catch (e) {
+ resolve();
+ }
+ },
+ });
+ });
+
+ install.install();
+ await cancelPromise;
+
+ equal(install.state, AddonManager.STATE_INSTALLED);
+});
+
+// Test that an install may be canceled after a redirect.
+add_task(async function test_cancel_redirect() {
+ let url = "http://example.com/redirect?/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+
+ install.addListener({
+ onDownloadProgress() {
+ install.cancel();
+ },
+ });
+
+ let promise = AddonTestUtils.promiseInstallEvent("onDownloadCancelled");
+
+ install.install();
+ await promise;
+
+ equal(install.state, AddonManager.STATE_CANCELLED);
+});
+
+// Tests that an install can be restarted during onDownloadCancelled after being
+// cancelled in mid-download
+add_task(async function test_restart2() {
+ let url = "http://example.com/addons/test_install1.xpi";
+ let install = await AddonManager.getInstallForURL(url);
+
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+
+ install.addListener({
+ onDownloadProgress() {
+ install.removeListener(this);
+ install.cancel();
+ },
+ });
+
+ let promise = AddonTestUtils.promiseInstallEvent("onDownloadCancelled");
+ install.install();
+ await promise;
+
+ equal(install.state, AddonManager.STATE_CANCELLED);
+
+ promise = expectEvents(
+ {
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ let file = install.file;
+ install.install();
+ notEqual(file.path, install.file.path);
+ ok(!file.exists());
+ }
+ );
+
+ await Promise.all([
+ promise,
+ promiseWebExtensionStartup("addon1@tests.mozilla.org"),
+ ]);
+
+ await install.addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js b/toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js
new file mode 100644
index 0000000000..7ef584b54d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_installOrigins.js
@@ -0,0 +1,549 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = false;
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+// This pref is not set in Thunderbird, and needs to be true for the test to pass.
+Services.prefs.setBoolPref("extensions.postDownloadThirdPartyPrompt", true);
+
+let server = AddonTestUtils.createHttpServer({
+ hosts: ["example.com", "example.org", "amo.example.com", "github.io"],
+});
+
+server.registerFile(
+ `/addons/origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "origins@example.com",
+ },
+ },
+ install_origins: ["http://example.com"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/sitepermission.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins sitepermission test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "sitepermission@example.com",
+ },
+ },
+ install_origins: ["http://example.com"],
+ site_permissions: ["midi"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/sitepermission-suffix.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins sitepermission public suffix test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "sitepermission-suffix@github.io",
+ },
+ },
+ install_origins: ["http://github.io"],
+ site_permissions: ["midi"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/two_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "two_origins@example.com",
+ },
+ },
+ install_origins: ["http://example.com", "http://example.org"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/no_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "no_origins@example.com",
+ },
+ },
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/empty_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 2,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "no_origins@example.com",
+ },
+ },
+ install_origins: [],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/v3_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 3,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "v3_origins@example.com",
+ },
+ },
+ install_origins: ["http://example.com"],
+ },
+ })
+);
+
+server.registerFile(
+ `/addons/v3_no_origins.xpi`,
+ AddonTestUtils.createTempXPIFile({
+ "manifest.json": {
+ manifest_version: 3,
+ name: "Install Origins test",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "v3_no_origins@example.com",
+ },
+ },
+ },
+ })
+);
+
+add_setup(() => {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+function testInstallEvent(expectTelemetry) {
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ );
+
+ ok(
+ snapshot.parent && !!snapshot.parent.length,
+ "Got parent telemetry events in the snapshot"
+ );
+
+ let events = snapshot.parent
+ .filter(
+ ([timestamp, category, method, object, value, extra]) =>
+ category === "addonsManager" &&
+ method == "install" &&
+ extra.step == expectTelemetry.step
+ )
+ .map(event => event[5]);
+ equal(events.length, 1, "one event for install completion");
+ Assert.deepEqual(events[0], expectTelemetry, "telemetry matches");
+
+ let gleanEvents = AddonTestUtils.getAMGleanEvents("install", {
+ step: expectTelemetry.step,
+ });
+ Services.fog.testResetFOG();
+
+ equal(gleanEvents.length, 1, "One glean event for install completion.");
+ delete gleanEvents[0].addon_type;
+ Assert.deepEqual(gleanEvents[0], expectTelemetry, "Glean telemetry matches.");
+}
+
+function promiseCompleteWebInstall(
+ install,
+ triggeringPrincipal,
+ expectPrompts = true
+) {
+ let listener;
+ return new Promise(_resolve => {
+ let resolve = () => {
+ install.removeListener(listener);
+ _resolve();
+ };
+
+ listener = {
+ onDownloadFailed: resolve,
+ onDownloadCancelled: resolve,
+ onInstallFailed: resolve,
+ onInstallCancelled: resolve,
+ onInstallEnded: resolve,
+ onInstallPostponed: resolve,
+ };
+
+ install.addListener(listener);
+
+ // Observers to bypass panels and continue install.
+ if (expectPrompts) {
+ TestUtils.topicObserved("addon-install-blocked").then(([subject]) => {
+ let installInfo = subject.wrappedJSObject;
+ info(`==== test got addon-install-blocked ${subject} ${installInfo}`);
+ installInfo.install();
+ });
+
+ TestUtils.topicObserved("addon-install-confirmation").then(
+ (subject, data) => {
+ info(`==== test got addon-install-confirmation`);
+ let installInfo = subject.wrappedJSObject;
+ for (let installer of installInfo.installs) {
+ installer.install();
+ }
+ }
+ );
+ TestUtils.topicObserved("webextension-permission-prompt").then(
+ ([subject]) => {
+ const { info } = subject.wrappedJSObject || {};
+ info.resolve();
+ }
+ );
+ }
+
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ null /* aBrowser */,
+ triggeringPrincipal,
+ install
+ );
+ });
+}
+
+async function testAddonInstall(test) {
+ let { name, xpiUrl, installPrincipal, expectState, expectTelemetry } = test;
+ info(`testAddonInstall: ${name}`);
+ let expectInstall = expectState == AddonManager.STATE_INSTALLED;
+ let install = await AddonManager.getInstallForURL(xpiUrl, {
+ triggeringPrincipal: installPrincipal,
+ });
+ await promiseCompleteWebInstall(install, installPrincipal, expectInstall);
+
+ // Test origins telemetry
+ testInstallEvent(expectTelemetry);
+
+ if (expectInstall) {
+ equal(
+ install.state,
+ expectState,
+ `${name} ${install.addon.id} install was completed`
+ );
+ // Wait the extension startup to ensure manifest.json has been read,
+ // otherwise we get NS_ERROR_FILE_NOT_FOUND log spam.
+ await WebExtensionPolicy.getByID(install.addon.id)?.readyPromise;
+ await install.addon.uninstall();
+ } else {
+ equal(
+ install.state,
+ expectState,
+ `${name} ${install.addon?.id} install failed`
+ );
+ }
+}
+
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL_AMO = ssm.createContentPrincipalFromOrigin(
+ "https://amo.example.com"
+);
+const PRINCIPAL_COM =
+ ssm.createContentPrincipalFromOrigin("http://example.com");
+const SUB_PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+ "http://abc.example.com"
+);
+const THIRDPARTY_PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+ "http://fake-example.com"
+);
+const PRINCIPAL_ORG =
+ ssm.createContentPrincipalFromOrigin("http://example.org");
+const PRINCIPAL_ETLD = ssm.createContentPrincipalFromOrigin("http://github.io");
+
+const TESTS = [
+ {
+ name: "Install MV2 with install_origins",
+ xpiUrl: "http://example.com/addons/origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV2 without install_origins",
+ xpiUrl: "http://example.com/addons/no_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "no_origins@example.com",
+ install_origins: "0",
+ },
+ },
+ {
+ name: "Install valid xpi location from invalid website",
+ xpiUrl: "http://example.com/addons/origins.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install invalid xpi location from valid website",
+ xpiUrl: "http://example.org/addons/origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV3 with install_origins",
+ xpiUrl: "http://example.com/addons/v3_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV3 with install_origins from AMO",
+ xpiUrl: "http://example.com/addons/v3_origins.xpi",
+ installPrincipal: PRINCIPAL_AMO,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install MV3 without install_origins",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "v3_no_origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "0",
+ },
+ },
+ {
+ // An installing principal with install permission is
+ // considered "AMO" in code, and will always be allowed.
+ name: "Install MV3 without install_origins from AMO",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: PRINCIPAL_AMO,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_no_origins@example.com",
+ install_origins: "0",
+ },
+ },
+ {
+ name: "Install MV3 without install_origins from null principal",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+ expectState: AddonManager.STATE_CANCELLED,
+ expectTelemetry: { step: "site_blocked", install_origins: "0" },
+ },
+ {
+ name: "Install addon with two install_origins",
+ xpiUrl: "http://example.com/addons/two_origins.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "two_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install addon with two install_origins",
+ xpiUrl: "http://example.com/addons/two_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "two_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install from site with empty install_origins",
+ xpiUrl: "http://example.com/addons/empty_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "no_origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install from site with empty install_origins",
+ xpiUrl: "http://example.com/addons/empty_origins.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "no_origins@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install with empty install_origins from AMO",
+ xpiUrl: "http://amo.example.com/addons/empty_origins.xpi",
+ installPrincipal: PRINCIPAL_AMO,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "no_origins@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from domain",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "sitepermission@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from subdomain",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: SUB_PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "sitepermission@example.com",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from thirdparty domain should fail",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: THIRDPARTY_PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "sitepermission@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from different domain",
+ xpiUrl: "http://example.com/addons/sitepermission.xpi",
+ installPrincipal: PRINCIPAL_ORG,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "sitepermission@example.com",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+ {
+ name: "Install sitepermission from public suffix domain",
+ xpiUrl: "http://github.io/addons/sitepermission-suffix.xpi",
+ installPrincipal: PRINCIPAL_ETLD,
+ expectState: AddonManager.STATE_INSTALL_FAILED,
+ expectTelemetry: {
+ step: "failed",
+ addon_id: "sitepermission-suffix@github.io",
+ error: "ERROR_INVALID_DOMAIN",
+ install_origins: "1",
+ },
+ },
+];
+
+add_task(async function test_install_url() {
+ Services.prefs.setBoolPref("extensions.install_origins.enabled", true);
+ PermissionTestUtils.add(
+ PRINCIPAL_AMO,
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ await promiseStartupManager();
+
+ for (let test of TESTS) {
+ await testAddonInstall(test);
+ }
+});
+
+add_task(async function test_install_origins_disabled() {
+ Services.prefs.setBoolPref("extensions.install_origins.enabled", false);
+ await testAddonInstall({
+ name: "Install MV3 without install_origins, verification disabled",
+ xpiUrl: "http://example.com/addons/v3_no_origins.xpi",
+ installPrincipal: PRINCIPAL_COM,
+ expectState: AddonManager.STATE_INSTALLED,
+ expectTelemetry: {
+ step: "completed",
+ addon_id: "v3_no_origins@example.com",
+ },
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js b/toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js
new file mode 100644
index 0000000000..0be6ec0359
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_cancel.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+);
+
+class TestListener {
+ constructor(listener) {
+ this.listener = listener;
+ }
+
+ onDataAvailable(...args) {
+ this.origListener.onDataAvailable(...args);
+ }
+
+ onStartRequest(request) {
+ this.origListener.onStartRequest(request);
+ }
+
+ onStopRequest(request, status) {
+ if (this.listener.onStopRequest) {
+ this.listener.onStopRequest(request, status);
+ }
+ this.origListener.onStopRequest(request, status);
+ }
+}
+
+function startListener(listener) {
+ let observer = {
+ observe(subject, topic, data) {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (channel.URI.spec === "http://example.com/addons/test.xpi") {
+ let channelListener = new TestListener(listener);
+ channelListener.origListener = subject
+ .QueryInterface(Ci.nsITraceableChannel)
+ .setNewListener(channelListener);
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+}
+
+add_task(async function setup() {
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: "cancel@test" } },
+ },
+ });
+ testserver.registerFile(`/addons/test.xpi`, xpi);
+ await AddonTestUtils.promiseStartupManager();
+});
+
+// This test checks that canceling an install after the download is completed fails
+// and throws an exception as expected
+add_task(async function test_install_cancelled() {
+ let url = "http://example.com/addons/test.xpi";
+ let install = await AddonManager.getInstallForURL(url, {
+ name: "Test",
+ version: "1.0",
+ });
+
+ let cancelInstall = new Promise(resolve => {
+ startListener({
+ onStopRequest() {
+ resolve(Promise.resolve().then(() => install.cancel()));
+ },
+ });
+ });
+
+ await install.install().then(() => {
+ ok(true, "install succeeded");
+ });
+
+ await cancelInstall
+ .then(() => {
+ ok(false, "cancel should not succeed");
+ })
+ .catch(e => {
+ ok(!!e, "cancel threw an exception");
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js b/toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js
new file mode 100644
index 0000000000..75cc91038e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_file_change.js
@@ -0,0 +1,180 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+/* globals browser */
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+async function createXPIWithID(addonId, version = "1.0") {
+ let xpiFile = await createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id: addonId } },
+ },
+ });
+ return xpiFile;
+}
+
+const ERROR_PATTERN_INSTALL_FAIL = /Failed to install .+ from .+ to /;
+const ERROR_PATTERN_POSTPONE_FAIL = /Failed to postpone install of /;
+
+async function promiseInstallFail(install, expectedErrorPattern) {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await Assert.rejects(
+ install.install(),
+ /^Error: Install failed: onInstallFailed$/
+ );
+ });
+ messages = messages.filter(msg => expectedErrorPattern.test(msg.message));
+ equal(messages.length, 1, "Expected log messages");
+ equal(install.state, AddonManager.STATE_INSTALL_FAILED);
+ equal(install.error, AddonManager.ERROR_FILE_ACCESS);
+ equal((await AddonManager.getAllInstalls()).length, 0, "no pending installs");
+}
+
+add_task(async function test_file_deleted() {
+ let xpiFile = await createXPIWithID("delete@me");
+ let install = await AddonManager.getInstallForFile(xpiFile);
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ xpiFile.remove(false);
+
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+
+ equal(await AddonManager.getAddonByID("delete@me"), null);
+});
+
+add_task(async function test_file_emptied() {
+ let xpiFile = await createXPIWithID("empty@me");
+ let install = await AddonManager.getInstallForFile(xpiFile);
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ await IOUtils.write(xpiFile.path, new Uint8Array());
+
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+
+ equal(await AddonManager.getAddonByID("empty@me"), null);
+});
+
+add_task(async function test_file_replaced() {
+ let xpiFile = await createXPIWithID("replace@me");
+ let install = await AddonManager.getInstallForFile(xpiFile);
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ await IOUtils.copy(
+ (
+ await createXPIWithID("replace@me", "2")
+ ).path,
+ xpiFile.path
+ );
+
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+
+ equal(await AddonManager.getAddonByID("replace@me"), null);
+});
+
+async function do_test_update_with_file_replaced(wantPostponeTest) {
+ const ADDON_ID = wantPostponeTest ? "postpone@me" : "update@me";
+ function backgroundWithPostpone() {
+ // The registration of this listener postpones the update.
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.fail("Unusable update should not call onUpdateAvailable");
+ });
+ }
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ADDON_ID,
+ update_url: `http://example.com/update-${ADDON_ID}.json`,
+ },
+ },
+ },
+ background: wantPostponeTest ? backgroundWithPostpone : () => {},
+ });
+
+ server.registerFile(
+ `/update-${ADDON_ID}.xpi`,
+ await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ })
+ );
+ AddonTestUtils.registerJSON(server, `/update-${ADDON_ID}.json`, {
+ addons: {
+ [ADDON_ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: `http://example.com/update-${ADDON_ID}.xpi`,
+ },
+ ],
+ },
+ },
+ });
+
+ // Setup completed, let's try to verify that file corruption halts the update.
+
+ let addon = await promiseAddonByID(ADDON_ID);
+ equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ equal(install.version, "2.0");
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+ equal(install.existingAddon, addon);
+ equal(install.file, null);
+
+ let promptCount = 0;
+ let didReplaceFile = false;
+ install.promptHandler = async function () {
+ ++promptCount;
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+ await IOUtils.copy(
+ (
+ await createXPIWithID(ADDON_ID, "3")
+ ).path,
+ install.file.path
+ );
+ didReplaceFile = true;
+ equal(install.state, AddonManager.STATE_DOWNLOADED, "State not changed");
+ };
+
+ if (wantPostponeTest) {
+ await promiseInstallFail(install, ERROR_PATTERN_POSTPONE_FAIL);
+ } else {
+ await promiseInstallFail(install, ERROR_PATTERN_INSTALL_FAIL);
+ }
+
+ equal(promptCount, 1);
+ ok(didReplaceFile, "Replaced update with different file");
+
+ // Now verify that the add-on is still at the old version.
+ addon = await promiseAddonByID(ADDON_ID);
+ equal(addon.version, "1.0");
+
+ await addon.uninstall();
+}
+
+add_task(async function test_update_and_file_replaced() {
+ await do_test_update_with_file_replaced();
+});
+
+add_task(async function test_update_postponed_and_file_replaced() {
+ await do_test_update_with_file_replaced(/* wantPostponeTest = */ true);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js b/toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js
new file mode 100644
index 0000000000..af88b55959
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_install_icons.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// use httpserver to find an available port
+var gServer = new HttpServer();
+gServer.start(-1);
+gPort = gServer.identity.primaryPort;
+
+var addon_url = "http://localhost:" + gPort + "/test.xpi";
+var icon32_url = "http://localhost:" + gPort + "/icon.png";
+var icon64_url = "http://localhost:" + gPort + "/icon64.png";
+
+async function run_test() {
+ do_test_pending();
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+
+ test_1();
+}
+
+async function test_1() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url);
+ Assert.equal(aInstall.iconURL, null);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], undefined);
+ Assert.equal(aInstall.icons[64], undefined);
+ test_2();
+}
+
+async function test_2() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url, {
+ icons: icon32_url,
+ });
+ Assert.equal(aInstall.iconURL, icon32_url);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], icon32_url);
+ Assert.equal(aInstall.icons[64], undefined);
+ test_3();
+}
+
+async function test_3() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url, {
+ icons: { 32: icon32_url },
+ });
+ Assert.equal(aInstall.iconURL, icon32_url);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], icon32_url);
+ Assert.equal(aInstall.icons[64], undefined);
+ test_4();
+}
+
+async function test_4() {
+ let aInstall = await AddonManager.getInstallForURL(addon_url, {
+ icons: { 32: icon32_url, 64: icon64_url },
+ });
+ Assert.equal(aInstall.iconURL, icon32_url);
+ Assert.notEqual(aInstall.icons, null);
+ Assert.equal(aInstall.icons[32], icon32_url);
+ Assert.equal(aInstall.icons[64], icon64_url);
+ executeSoon(do_test_finished);
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js
new file mode 100644
index 0000000000..dfaeaa44f2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_deprecation.js
@@ -0,0 +1,346 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42");
+
+const testserver = createHttpServer({ hosts: ["example.com"] });
+
+function createTestPage(body) {
+ return `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ ${body}
+ </body>
+ </html>
+ `;
+}
+
+testserver.registerPathHandler(
+ "/installtrigger_ua_detection.html",
+ (request, response) => {
+ response.write(
+ createTestPage(`
+ <button/>
+ <script>
+ document.querySelector("button").onclick = () => {
+ typeof InstallTrigger;
+ };
+ </script>
+ `)
+ );
+ }
+);
+
+testserver.registerPathHandler(
+ "/installtrigger_install.html",
+ (request, response) => {
+ response.write(
+ createTestPage(`
+ <button/>
+ <script>
+ const install = InstallTrigger.install.bind(InstallTrigger);
+ document.querySelector("button").onclick = () => {
+ install({ fakeextensionurl: "http://example.com/fakeextensionurl.xpi" });
+ };
+ </script>
+ `)
+ );
+ }
+);
+
+async function testDeprecationWarning(testPageURL, expectedDeprecationWarning) {
+ const page = await ExtensionTestUtils.loadContentPage(testPageURL);
+
+ const { message, messageInnerWindowID, pageInnerWindowID } = await page.spawn(
+ [expectedDeprecationWarning],
+ expectedWarning => {
+ return new Promise(resolve => {
+ const consoleListener = consoleMsg => {
+ if (
+ consoleMsg instanceof Ci.nsIScriptError &&
+ consoleMsg.message?.includes(expectedWarning)
+ ) {
+ Services.console.unregisterListener(consoleListener);
+ resolve({
+ message: consoleMsg.message,
+ messageInnerWindowID: consoleMsg.innerWindowID,
+ pageInnerWindowID: this.content.windowGlobalChild.innerWindowId,
+ });
+ }
+ };
+
+ Services.console.registerListener(consoleListener);
+ this.content.document.querySelector("button").click();
+ });
+ }
+ );
+
+ equal(
+ typeof messageInnerWindowID,
+ "number",
+ `Warning message should be associated to an innerWindowID`
+ );
+ equal(
+ messageInnerWindowID,
+ pageInnerWindowID,
+ `Deprecation warning "${message}" has been logged and associated to the expected window`
+ );
+
+ await page.close();
+
+ return message;
+}
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ },
+ function testDeprecationWarningsOnUADetection() {
+ return testDeprecationWarning(
+ "http://example.com/installtrigger_ua_detection.html",
+ "InstallTrigger is deprecated and will be removed in the future."
+ );
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ },
+ async function testDeprecationWarningsOnInstallTriggerInstall() {
+ const message = await testDeprecationWarning(
+ "http://example.com/installtrigger_install.html",
+ "InstallTrigger.install() is deprecated and will be removed in the future."
+ );
+
+ const moreInfoURL =
+ "https://extensionworkshop.com/documentation/publish/self-distribution/";
+
+ ok(
+ message.includes(moreInfoURL),
+ "Deprecation warning should include an url to self-distribution documentation"
+ );
+ }
+);
+
+async function testInstallTriggerDeprecationPrefs(expectedResults) {
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const promiseResults = page.spawn([], () => {
+ return {
+ uaDetectionResult: this.content.eval(
+ "typeof InstallTrigger !== 'undefined'"
+ ),
+ typeofInstallMethod: this.content.eval("typeof InstallTrigger?.install"),
+ };
+ });
+ if (expectedResults.error) {
+ await Assert.rejects(
+ promiseResults,
+ expectedResults.error,
+ "Got the expected error"
+ );
+ } else {
+ Assert.deepEqual(
+ await promiseResults,
+ expectedResults,
+ "Got the expected results"
+ );
+ }
+ await page.close();
+}
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", false],
+ ],
+ },
+ function testInstallTriggerImplDisabled() {
+ return testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "undefined",
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [["extensions.InstallTrigger.enabled", false]],
+ },
+ function testInstallTriggerDisabled() {
+ return testInstallTriggerDeprecationPrefs({
+ error: /ReferenceError: InstallTrigger is not defined/,
+ });
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.remoteSettings.disabled", false],
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ },
+ async function testInstallTriggerDeprecatedFromRemoteSettings() {
+ await AddonTestUtils.promiseStartupManager();
+
+ // InstallTrigger is expected to be initially enabled.
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "function",
+ });
+
+ info("Test remote settings update to hide InstallTrigger methods");
+
+ // InstallTrigger global is expected to still be enabled, the install method
+ // to have been hidden.
+ const unexpectedPrefsBranchName = "extensions.unexpectedPrefs";
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTriggerImpl.enabled": false,
+ // Unexpected preferences names would be just ignored.
+ [`${unexpectedPrefsBranchName}.fromProcessedEntry`]: true,
+ },
+ otherFakeFutureSetting: {
+ [`${unexpectedPrefsBranchName}.fromFakeFutureSetting`]: true,
+ },
+ // This entry is expected to always be processed when running this
+ // xpcshell test, the appInfo platformVersion is always set to 42
+ // by the call to AddonTestUtils's createAppInfo.
+ filter_expression: "env.appinfo.platformVersion >= 42",
+ },
+ {
+ // Entries entirely unexpected should be ignored even if they may be
+ // including a property named as the ones that AMRemoteSettings (e.g.
+ // it may be a new type of entry introduced for a new Firefox version,
+ // which a previous version of Firefox shouldn't try to process avoid
+ // undefined behaviors).
+ id: "AddonManagerSettings-fxFutureVersion",
+ // This entry is expected to always be filtered out by RemoteSettings,
+ // while running this xpcshell test the platformInfo version is always set
+ // to 42 by the call to AddonTestUtils's createAppInfo.
+ filter_expression: "env.appinfo.platformVersion >= 200",
+ installTriggerDeprecation: {
+ // If processed, it would fail the assertion that follows
+ // because it does change the same pref that the previous entry did
+ // set to false.
+ "extensions.InstallTriggerImpl.enabled": true,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "undefined",
+ });
+
+ const unexpectedPrefBranch = Services.prefs.getBranch(
+ unexpectedPrefsBranchName
+ );
+ equal(
+ unexpectedPrefBranch.getPrefType("fromFakeFutureSetting"),
+ unexpectedPrefBranch.PREF_INVALID,
+ "Preferences included in an unexpected entry property should not be set"
+ );
+ equal(
+ unexpectedPrefBranch.getPrefType("fromProcessedEntry"),
+ unexpectedPrefBranch.PREF_INVALID,
+ undefined,
+ "Unexpected pref included in the installTriggerDeprecation entry should not be set"
+ );
+
+ info("Test remote settings update to hide InstallTrigger global");
+ // InstallTrigger global is expected to still be enabled, the install method
+ // to have been hidden.
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": false,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ error: /ReferenceError: InstallTrigger is not defined/,
+ });
+
+ info("Test remote settings update to re-enable InstallTrigger global");
+ // InstallTrigger global is expected to still be enabled, the install method
+ // to have been hidden.
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": true,
+ "extensions.InstallTriggerImpl.enabled": false,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "undefined",
+ });
+
+ info("Test remote settings update to re-enable InstallTrigger methods");
+ // InstallTrigger global and method are both expected to be re-enabled.
+ await setAndEmitFakeRemoteSettingsData([
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": true,
+ "extensions.InstallTriggerImpl.enabled": true,
+ },
+ },
+ ]);
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "function",
+ });
+
+ info("Test remote settings ignored when AMRemoteSettings is disabled");
+ // RemoteSettings are expected to be ignored.
+ Services.prefs.setBoolPref("extensions.remoteSettings.disabled", true);
+ await setAndEmitFakeRemoteSettingsData(
+ [
+ {
+ id: "AddonManagerSettings",
+ installTriggerDeprecation: {
+ "extensions.InstallTrigger.enabled": false,
+ "extensions.InstallTriggerImpl.enabled": false,
+ },
+ },
+ ],
+ false /* expectClientInitialized */
+ );
+ await testInstallTriggerDeprecationPrefs({
+ uaDetectionResult: true,
+ typeofInstallMethod: "function",
+ });
+
+ info(
+ "Test previously synchronized are processed on AOM started when AMRemoteSettings are enabled"
+ );
+ // RemoteSettings previously stored on disk are expected to disable InstallTrigger global and methods.
+ await AddonTestUtils.promiseShutdownManager();
+ Services.prefs.setBoolPref("extensions.remoteSettings.disabled", false);
+ await AddonTestUtils.promiseStartupManager();
+ await testInstallTriggerDeprecationPrefs({
+ error: /ReferenceError: InstallTrigger is not defined/,
+ });
+
+ await AddonTestUtils.promiseShutdownManager();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js
new file mode 100644
index 0000000000..b219d2f55d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_installtrigger_schemes.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+createHttpServer({ hosts: ["example.com"] });
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+);
+
+async function assertInstallTriggetRejected(page, xpi_url, expectedError) {
+ await Assert.rejects(
+ page.spawn([xpi_url], async url => {
+ this.content.eval(`InstallTrigger.install({extension: '${url}'});`);
+ }),
+ expectedError,
+ `InstallTrigger.install expected to throw on xpi url "${xpi_url}"`
+ );
+}
+
+add_task(
+ {
+ // Once InstallTrigger is removed, this test should be removed as well.
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ async function test_InstallTriggerThrows_on_unsupported_xpi_schemes_blob() {
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const blob_url = await page.spawn([], () => {
+ return this.content.eval(`(function () {
+ const blob = new Blob(['fakexpicontent']);
+ return URL.createObjectURL(blob);
+ })()`);
+ });
+ await assertInstallTriggetRejected(page, blob_url, /Unsupported scheme/);
+ await page.close();
+ }
+);
+
+add_task(
+ {
+ // Once InstallTrigger is removed, this test should be removed as well.
+ pref_set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ async function test_InstallTriggerThrows_on_unsupported_xpi_schemes_data() {
+ const page = await ExtensionTestUtils.loadContentPage("http://example.com");
+ const data_url = "data:;,fakexpicontent";
+ // This is actually rejected by the checkLoadURIWithPrincipal, which fails with
+ // NS_ERROR_DOM_BAD_URI triggered by CheckLoadURIWithPrincipal's call to
+ //
+ // DenyAccessIfURIHasFlags(aTargetURI, nsIProtocolHandler::URI_INHERITS_SECURITY_CONTEXT)
+ //
+ // and so it is not a site permission that the user can actually grant, unlike the error
+ // raised may suggest.
+ await assertInstallTriggetRejected(
+ page,
+ data_url,
+ /Insufficient permissions to install/
+ );
+ await page.close();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js b/toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js
new file mode 100644
index 0000000000..b23e94e7bb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_isDebuggable.js
@@ -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/.
+ */
+
+var ID = "debuggable@tests.mozilla.org";
+
+add_task(async function () {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let addon = await AddonManager.getAddonByID(ID);
+ Assert.equal(addon.isDebuggable, true);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_isReady.js b/toolkit/mozapps/extensions/test/xpcshell/test_isReady.js
new file mode 100644
index 0000000000..e5e1649051
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_isReady.js
@@ -0,0 +1,71 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+add_task(async function () {
+ equal(AddonManager.isReady, false, "isReady should be false before startup");
+
+ let gotStartupEvent = false;
+ let gotShutdownEvent = false;
+ let listener = {
+ onStartup() {
+ gotStartupEvent = true;
+ },
+ onShutdown() {
+ gotShutdownEvent = true;
+ },
+ };
+ AddonManager.addManagerListener(listener);
+
+ info("Starting manager...");
+ await promiseStartupManager();
+ equal(AddonManager.isReady, true, "isReady should be true after startup");
+ equal(
+ gotStartupEvent,
+ true,
+ "Should have seen onStartup event after startup"
+ );
+ equal(
+ gotShutdownEvent,
+ false,
+ "Should not have seen onShutdown event before shutdown"
+ );
+
+ gotStartupEvent = false;
+ gotShutdownEvent = false;
+
+ info("Shutting down manager...");
+ await promiseShutdownManager();
+
+ equal(AddonManager.isReady, false, "isReady should be false after shutdown");
+ equal(
+ gotStartupEvent,
+ false,
+ "Should not have seen onStartup event after shutdown"
+ );
+ equal(
+ gotShutdownEvent,
+ true,
+ "Should have seen onShutdown event after shutdown"
+ );
+
+ AddonManager.addManagerListener(listener);
+ gotStartupEvent = false;
+ gotShutdownEvent = false;
+
+ info("Starting manager again...");
+ await promiseStartupManager();
+ equal(
+ AddonManager.isReady,
+ true,
+ "isReady should be true after repeat startup"
+ );
+ equal(
+ gotStartupEvent,
+ true,
+ "Should have seen onStartup event after repeat startup"
+ );
+ equal(
+ gotShutdownEvent,
+ false,
+ "Should not have seen onShutdown event before shutdown, following repeat startup"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js
new file mode 100644
index 0000000000..2e8190d57b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_loadManifest_isPrivileged.js
@@ -0,0 +1,233 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+// NOTE: Only constants can be extracted from XPIExports.
+const {
+ XPIInternal: {
+ KEY_APP_PROFILE,
+ KEY_APP_SYSTEM_DEFAULTS,
+ KEY_APP_SYSTEM_PROFILE,
+ },
+} = XPIExports;
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+// Disable "xpc::IsInAutomation()", since it would override the behavior
+// we're testing for.
+Services.prefs.setBoolPref(
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer",
+ false
+);
+
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ // SCOPE_PROFILE is enabled by default,
+ // SCOPE_APPLICATION is to enable KEY_APP_SYSTEM_PROFILE, which we need to
+ // test the combination (isSystem && !isBuiltin) in test_system_location.
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+// test_builtin_system_location tests the (isSystem && isBuiltin) combination
+// (i.e. KEY_APP_SYSTEM_DEFAULTS). That location only exists if this directory
+// is found:
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+function getInstallLocation({
+ isBuiltin = false,
+ isSystem = false,
+ isTemporary = false,
+}) {
+ if (isTemporary) {
+ // Temporary installation. Signatures will not be verified.
+ return XPIExports.XPIInternal.TemporaryInstallLocation; // KEY_APP_TEMPORARY
+ }
+ let location;
+ if (isSystem) {
+ if (isBuiltin) {
+ // System location. Signatures will not be verified.
+ location = XPIExports.XPIInternal.XPIStates.getLocation(
+ KEY_APP_SYSTEM_DEFAULTS
+ );
+ } else {
+ // Normandy installations. Signatures will be verified.
+ location = XPIExports.XPIInternal.XPIStates.getLocation(
+ KEY_APP_SYSTEM_PROFILE
+ );
+ }
+ } else if (isBuiltin) {
+ // Packaged with the application. Signatures will not be verified.
+ location = XPIExports.XPIInternal.BuiltInLocation; // KEY_APP_BUILTINS
+ } else {
+ // By default - The profile directory. Signatures will be verified.
+ location = XPIExports.XPIInternal.XPIStates.getLocation(KEY_APP_PROFILE);
+ }
+ // Sanity checks to make sure that the flags match the expected values.
+ if (location.isSystem !== isSystem) {
+ ok(false, `${location.name}, unexpected isSystem=${location.isSystem}`);
+ }
+ if (location.isBuiltin !== isBuiltin) {
+ ok(false, `${location.name}, unexpected isBuiltin=${location.isBuiltin}`);
+ }
+ return location;
+}
+
+async function testLoadManifest({ location, expectPrivileged }) {
+ location ??= getInstallLocation({});
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@with-privileged-perm" } },
+ permissions: ["mozillaAddons", "cookies"],
+ },
+ });
+ let actualPermissions;
+ let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+ if (location.isTemporary && !expectPrivileged) {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await Assert.rejects(
+ XPIExports.XPIInstall.loadManifestFromFile(xpi, location),
+ /Extension is invalid/,
+ "load manifest failed with privileged permission"
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ return;
+ }
+ let addon = await XPIExports.XPIInstall.loadManifestFromFile(xpi, location);
+ actualPermissions = addon.userPermissions;
+ equal(addon.isPrivileged, expectPrivileged, "addon.isPrivileged");
+ });
+ if (expectPrivileged) {
+ AddonTestUtils.checkMessages(messages, {
+ expected: [],
+ forbidden: [
+ {
+ message: /Reading manifest: Invalid extension permission/,
+ },
+ ],
+ });
+ Assert.deepEqual(
+ actualPermissions,
+ { origins: [], permissions: ["mozillaAddons", "cookies"] },
+ "Privileged permission should exist"
+ );
+ } else if (location.isTemporary) {
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Using the privileged permission 'mozillaAddons' requires a privileged add-on/,
+ },
+ ],
+ forbidden: [],
+ });
+ } else {
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ {
+ message:
+ /Reading manifest: Invalid extension permission: mozillaAddons/,
+ },
+ ],
+ forbidden: [],
+ });
+ Assert.deepEqual(
+ actualPermissions,
+ { origins: [], permissions: ["cookies"] },
+ "Privileged permission should be ignored"
+ );
+ }
+}
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_regular_addon() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: false,
+ });
+});
+
+add_task(async function test_privileged_signature() {
+ AddonTestUtils.usePrivilegedSignatures = true;
+ await testLoadManifest({
+ expectPrivileged: true,
+ });
+});
+
+add_task(async function test_system_signature() {
+ AddonTestUtils.usePrivilegedSignatures = "system";
+ await testLoadManifest({
+ expectPrivileged: true,
+ });
+});
+
+add_task(async function test_builtin_location() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: true,
+ location: getInstallLocation({ isBuiltin: true }),
+ });
+});
+
+add_task(async function test_system_location() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: false,
+ location: getInstallLocation({ isSystem: true }),
+ });
+});
+
+add_task(async function test_builtin_system_location() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ await testLoadManifest({
+ expectPrivileged: true,
+ location: getInstallLocation({ isSystem: true, isBuiltin: true }),
+ });
+});
+
+add_task(async function test_temporary_regular() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ Services.prefs.setBoolPref("extensions.experiments.enabled", false);
+ await testLoadManifest({
+ expectPrivileged: false,
+ location: getInstallLocation({ isTemporary: true }),
+ });
+});
+
+add_task(async function test_temporary_privileged_signature() {
+ AddonTestUtils.usePrivilegedSignatures = true;
+ Services.prefs.setBoolPref("extensions.experiments.enabled", false);
+ await testLoadManifest({
+ expectPrivileged: true,
+ location: getInstallLocation({ isTemporary: true }),
+ });
+});
+
+add_task(async function test_temporary_experiments_enabled() {
+ AddonTestUtils.usePrivilegedSignatures = false;
+ Services.prefs.setBoolPref("extensions.experiments.enabled", true);
+
+ // Experiments can only be used if AddonSettings.EXPERIMENTS_ENABLED is true.
+ // This is the condition behind the flag, minus Cu.isInAutomation. Currently
+ // that flag is false despite this being a test (see bug 1598804), but that
+ // is desired in this case because we want the test to confirm the real-world
+ // behavior instead of test-specific behavior.
+ const areTemporaryExperimentsAllowed =
+ !AppConstants.MOZ_REQUIRE_SIGNING ||
+ AppConstants.NIGHTLY_BUILD ||
+ AppConstants.MOZ_DEV_EDITION;
+
+ await testLoadManifest({
+ expectPrivileged: areTemporaryExperimentsAllowed,
+ location: getInstallLocation({ isTemporary: true }),
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_locale.js b/toolkit/mozapps/extensions/test/xpcshell/test_locale.js
new file mode 100644
index 0000000000..2824c489b4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_locale.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+add_task(async function setup() {
+ // Setup for test
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+// Tests that the localized properties are visible before installation
+add_task(async function test_1() {
+ await restartWithLocales(["fr-FR"]);
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ name: "__MSG_name__",
+ description: "__MSG_description__",
+ default_locale: "en",
+
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ },
+ },
+ },
+
+ files: {
+ "_locales/en/messages.json": {
+ name: {
+ message: "Fallback Name",
+ description: "name",
+ },
+ description: {
+ message: "Fallback Description",
+ description: "description",
+ },
+ },
+ "_locales/fr_FR/messages.json": {
+ name: {
+ message: "fr-FR Name",
+ description: "name",
+ },
+ description: {
+ message: "fr-FR Description",
+ description: "description",
+ },
+ },
+ "_locales/de-DE/messages.json": {
+ name: {
+ message: "de-DE Name",
+ description: "name",
+ },
+ },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+ Assert.equal(install.addon.name, "fr-FR Name");
+ Assert.equal(install.addon.description, "fr-FR Description");
+ await install.install();
+});
+
+// Tests that the localized properties are visible after installation
+add_task(async function test_2() {
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+
+ Assert.equal(addon.name, "fr-FR Name");
+ Assert.equal(addon.description, "fr-FR Description");
+
+ await addon.disable();
+});
+
+// Test that the localized properties are still there when disabled.
+add_task(async function test_3() {
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.name, "fr-FR Name");
+});
+
+// Test that changing locale works
+add_task(async function test_5() {
+ await restartWithLocales(["de-DE"]);
+
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+
+ Assert.equal(addon.name, "de-DE Name");
+ Assert.equal(addon.description, "Fallback Description");
+});
+
+// Test that missing locales use the fallbacks
+add_task(async function test_6() {
+ await restartWithLocales(["nl-NL"]);
+
+ let addon = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+
+ Assert.equal(addon.name, "Fallback Name");
+ Assert.equal(addon.description, "Fallback Description");
+
+ await addon.enable();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js b/toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js
new file mode 100644
index 0000000000..13ac8a1e57
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_moved_extension_metadata.js
@@ -0,0 +1,186 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test is disabled but is being kept around so it can eventualy
+// be modernized and re-enabled. But is uses obsolete test helpers that
+// fail lint, so just skip linting it for now.
+/* eslint-disable */
+
+// This verifies that moving an extension in the filesystem without any other
+// change still keeps updated compatibility information
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+// Enable loading extensions from the user and system scopes
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_USER
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "1.9.2");
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+var userDir = gProfD.clone();
+userDir.append("extensions2");
+userDir.append(gAppInfo.ID);
+
+var dirProvider = {
+ getFile(aProp, aPersistent) {
+ aPersistent.value = false;
+ if (aProp == "XREUSysExt") return userDir.parent;
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+};
+Services.dirsvc.registerProvider(dirProvider);
+
+var addon1 = {
+ id: "addon1@tests.mozilla.org",
+ version: "1.0",
+ name: "Test 1",
+ bootstrap: true,
+ updateURL: "http://example.com/data/test_bug655254.json",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+};
+
+const ADDONS = [
+ {
+ "install.rdf": {
+ id: "addon2@tests.mozilla.org",
+ name: "Test 2",
+
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "2",
+ maxVersion: "2",
+ },
+ ],
+ },
+ "bootstrap.js": `
+ /* exported startup, shutdown */
+ function startup(data, reason) {
+ Services.prefs.setIntPref("bootstraptest.active_version", 1);
+ }
+
+ function shutdown(data, reason) {
+ Services.prefs.setIntPref("bootstraptest.active_version", 0);
+ }
+ `,
+ },
+];
+
+const XPIS = ADDONS.map(addon => AddonTestUtils.createTempXPIFile(addon));
+
+add_task(async function test_1() {
+ var time = Date.now();
+ var dir = await promiseWriteInstallRDFForExtension(addon1, userDir);
+ setExtensionModifiedTime(dir, time);
+
+ await manuallyInstall(XPIS[0], userDir, "addon2@tests.mozilla.org");
+
+ await promiseStartupManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1, null);
+ Assert.ok(a1.appDisabled);
+ Assert.ok(!a1.isActive);
+ Assert.ok(!isExtensionInBootstrappedList(userDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(!a2.appDisabled);
+ Assert.ok(a2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a2.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 1);
+
+ await AddonTestUtils.promiseFindAddonUpdates(
+ a1,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+
+ await promiseRestartManager();
+
+ let a1_2 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(a1_2, null);
+ Assert.ok(!a1_2.appDisabled);
+ Assert.ok(a1_2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a1_2.id));
+
+ await promiseShutdownManager();
+
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 0);
+
+ userDir.parent.moveTo(gProfD, "extensions3");
+ userDir = gProfD.clone();
+ userDir.append("extensions3");
+ userDir.append(gAppInfo.ID);
+ Assert.ok(userDir.exists());
+
+ await promiseStartupManager();
+
+ let [a1_3, a2_3] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1_3, null);
+ Assert.ok(!a1_3.appDisabled);
+ Assert.ok(a1_3.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a1_3.id));
+
+ Assert.notEqual(a2_3, null);
+ Assert.ok(!a2_3.appDisabled);
+ Assert.ok(a2_3.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a2_3.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 1);
+});
+
+// Set up the profile
+add_task(async function test_2() {
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ Assert.notEqual(a2, null);
+ Assert.ok(!a2.appDisabled);
+ Assert.ok(a2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a2.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 1);
+
+ await a2.disable();
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 0);
+
+ await promiseShutdownManager();
+
+ userDir.parent.moveTo(gProfD, "extensions4");
+ userDir = gProfD.clone();
+ userDir.append("extensions4");
+ userDir.append(gAppInfo.ID);
+ Assert.ok(userDir.exists());
+
+ await promiseStartupManager();
+
+ let [a1_2, a2_2] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon2@tests.mozilla.org",
+ ]);
+ Assert.notEqual(a1_2, null);
+ Assert.ok(!a1_2.appDisabled);
+ Assert.ok(a1_2.isActive);
+ Assert.ok(isExtensionInBootstrappedList(userDir, a1_2.id));
+
+ Assert.notEqual(a2_2, null);
+ Assert.ok(a2_2.userDisabled);
+ Assert.ok(!a2_2.isActive);
+ Assert.ok(!isExtensionInBootstrappedList(userDir, a2_2.id));
+ Assert.equal(Services.prefs.getIntPref("bootstraptest.active_version"), 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js b/toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js
new file mode 100644
index 0000000000..14ac8a2c17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_no_addons.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test startup and restart when no add-ons are installed
+// bug 944006
+
+// Load XPI Provider to get schema version ID
+const {
+ XPIExports: {
+ XPIInternal: { DB_SCHEMA },
+ },
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Test for a preference to either exist with a specified value, or not exist at all
+function checkPending() {
+ try {
+ Assert.ok(!Services.prefs.getBoolPref("extensions.pendingOperations"));
+ } catch (e) {
+ // OK
+ }
+}
+
+// Make sure all our extension state is empty/nonexistent
+function check_empty_state() {
+ Assert.equal(
+ Services.prefs.getIntPref("extensions.databaseSchema"),
+ DB_SCHEMA
+ );
+ checkPending();
+}
+
+// After first run with no add-ons, we expect:
+// no extensions.json is created
+// no extensions.ini
+// database schema version preference is set
+// bootstrap add-ons preference is not found
+// add-on directory state preference is an empty array
+// no pending operations
+add_task(async function first_run() {
+ await promiseStartupManager();
+ check_empty_state();
+ await true;
+});
+
+// Now do something that causes a DB load, and re-check
+async function trigger_db_load() {
+ let addonList = await AddonManager.getAddonsByTypes(["extension"]);
+
+ Assert.equal(addonList.length, 0);
+ check_empty_state();
+
+ await true;
+}
+add_task(trigger_db_load);
+
+// Now restart the manager and check again
+add_task(async function restart_and_recheck() {
+ await promiseRestartManager();
+ check_empty_state();
+ await true;
+});
+
+// and reload the DB again
+add_task(trigger_db_load);
+
+// When we start up with no DB and an old database schema, we should update the
+// schema number but not create a database
+add_task(async function upgrade_schema_version() {
+ await promiseShutdownManager();
+ Services.prefs.setIntPref("extensions.databaseSchema", 1);
+
+ await promiseStartupManager();
+ Assert.equal(
+ Services.prefs.getIntPref("extensions.databaseSchema"),
+ DB_SCHEMA
+ );
+ check_empty_state();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js b/toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js
new file mode 100644
index 0000000000..e9f1a6626f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_nodisable_hidden.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test verifies that hidden add-ons cannot be user disabled.
+
+// for system add-ons
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+const NORMAL_ID = "normal@tests.mozilla.org";
+const SYSTEM_ID = "system@tests.mozilla.org";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+// normal add-ons can be user disabled.
+add_task(async function () {
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test disabling hidden add-ons, non-hidden add-on case.",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: NORMAL_ID } },
+ },
+ });
+
+ let addon = await promiseAddonByID(NORMAL_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(
+ addon.name,
+ "Test disabling hidden add-ons, non-hidden add-on case."
+ );
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ // normal add-ons can be disabled by the user.
+ await addon.disable();
+
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(
+ addon.name,
+ "Test disabling hidden add-ons, non-hidden add-on case."
+ );
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.userDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// system add-ons can never be user disabled.
+add_task(async function () {
+ let xpi = createTempWebExtensionFile({
+ manifest: {
+ name: "Test disabling hidden add-ons, hidden system add-on case.",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: SYSTEM_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${SYSTEM_ID}.xpi`);
+ await overrideBuiltIns({ system: [SYSTEM_ID] });
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(SYSTEM_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+ Assert.equal(
+ addon.name,
+ "Test disabling hidden add-ons, hidden system add-on case."
+ );
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+
+ // system add-ons cannot be disabled by the user.
+ await Assert.rejects(
+ addon.disable(),
+ err => err.message == `Cannot disable system add-on ${SYSTEM_ID}`,
+ "disable() on a hidden add-on should fail"
+ );
+
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.isActive);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js b/toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js
new file mode 100644
index 0000000000..7a7cd6543e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_onPropertyChanged_appDisabled.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ID = "addon1@tests.mozilla.org";
+add_task(async function run_test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ let xpi = createAddon({
+ id: ID,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ AddonManager.strictCompatibility = false;
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(addon, null);
+ await addon.disable();
+
+ Assert.ok(addon.userDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.ok(!addon.appDisabled);
+
+ let promise = promiseAddonEvent("onPropertyChanged");
+ AddonManager.strictCompatibility = true;
+ let [, properties] = await promise;
+
+ Assert.deepEqual(
+ properties,
+ ["appDisabled"],
+ "Got onPropertyChanged for appDisabled"
+ );
+ Assert.ok(addon.appDisabled);
+
+ promise = promiseAddonEvent("onPropertyChanged");
+ AddonManager.strictCompatibility = false;
+ [, properties] = await promise;
+
+ Assert.deepEqual(
+ properties,
+ ["appDisabled"],
+ "Got onPropertyChanged for appDisabled"
+ );
+ Assert.ok(!addon.appDisabled);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js b/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js
new file mode 100644
index 0000000000..30c9aa92b0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_permissions.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Checks that permissions set in preferences are correctly imported but can
+// be removed by the user.
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const XPI_MIMETYPE = "application/x-xpinstall";
+
+function newPrincipal(uri) {
+ return Services.scriptSecurityManager.createContentPrincipal(
+ NetUtil.newURI(uri),
+ {}
+ );
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add",
+ "https://test1.com,https://test2.com"
+ );
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.36",
+ "https://test3.com,https://www.test4.com"
+ );
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.test5",
+ "https://test5.com"
+ );
+
+ PermissionTestUtils.add(
+ "https://www.test9.com",
+ "install",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+
+ await promiseStartupManager();
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://test1.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test1.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test2.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test3.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test4.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test4.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://www.test5.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test5.com")
+ )
+ );
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://www.test6.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test6.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test7.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test8.com")
+ )
+ );
+
+ // This should remain unaffected
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("http://www.test9.com")
+ )
+ );
+ Assert.ok(
+ AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test9.com")
+ )
+ );
+
+ Services.perms.removeAll();
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test1.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test2.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test3.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test4.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test5.com")
+ )
+ );
+
+ // Upgrade the application and verify that the permissions are still not there
+ await promiseRestartManager("2");
+
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test1.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test2.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://test3.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test4.com")
+ )
+ );
+ Assert.ok(
+ !AddonManager.isInstallAllowed(
+ XPI_MIMETYPE,
+ newPrincipal("https://www.test5.com")
+ )
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js b/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js
new file mode 100644
index 0000000000..d7bcaa038c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_permissions_prefs.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that xpinstall.[whitelist|blacklist].add preferences are emptied when
+// converted into permissions.
+
+const PREF_XPI_WHITELIST_PERMISSIONS = "xpinstall.whitelist.add";
+const PREF_XPI_BLACKLIST_PERMISSIONS = "xpinstall.blacklist.add";
+
+const { PermissionsTestUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PermissionsUtils.sys.mjs"
+);
+
+function newPrincipal(uri) {
+ return Services.scriptSecurityManager.createContentPrincipal(
+ NetUtil.newURI(uri),
+ {}
+ );
+}
+
+function do_check_permission_prefs(preferences) {
+ // Check preferences were emptied
+ for (let pref of preferences) {
+ try {
+ Assert.equal(Services.prefs.getCharPref(pref), "");
+ } catch (e) {
+ // Successfully emptied
+ }
+ }
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+
+ // Create own preferences to test
+ Services.prefs.setCharPref("xpinstall.whitelist.add.EMPTY", "");
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST",
+ "http://whitelist.example.com"
+ );
+ Services.prefs.setCharPref("xpinstall.blacklist.add.EMPTY", "");
+ Services.prefs.setCharPref(
+ "xpinstall.blacklist.add.TEST",
+ "http://blacklist.example.com"
+ );
+
+ // Get list of preferences to check
+ var whitelistPreferences = Services.prefs.getChildList(
+ PREF_XPI_WHITELIST_PERMISSIONS
+ );
+ var blacklistPreferences = Services.prefs.getChildList(
+ PREF_XPI_BLACKLIST_PERMISSIONS
+ );
+ var preferences = whitelistPreferences.concat(blacklistPreferences);
+
+ await promiseStartupManager();
+
+ // Permissions are imported lazily - act as thought we're checking an install,
+ // to trigger on-deman importing of the permissions.
+ AddonManager.isInstallAllowed(
+ "application/x-xpinstall",
+ newPrincipal("http://example.com/file.xpi")
+ );
+ do_check_permission_prefs(preferences);
+
+ // Import can also be triggered by an observer notification by any other area
+ // of code, such as a permissions management UI.
+
+ // First, request to flush all permissions
+ PermissionsTestUtils.clearImportedPrefBranches();
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST2",
+ "https://whitelist2.example.com"
+ );
+ Services.obs.notifyObservers(null, "flush-pending-permissions", "install");
+ do_check_permission_prefs(preferences);
+
+ // Then, request to flush just install permissions
+ PermissionsTestUtils.clearImportedPrefBranches();
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST3",
+ "https://whitelist3.example.com"
+ );
+ Services.obs.notifyObservers(null, "flush-pending-permissions");
+ do_check_permission_prefs(preferences);
+
+ // And a request to flush some other permissions sholdn't flush install permissions
+ PermissionsTestUtils.clearImportedPrefBranches();
+ Services.prefs.setCharPref(
+ "xpinstall.whitelist.add.TEST4",
+ "https://whitelist4.example.com"
+ );
+ Services.obs.notifyObservers(null, "flush-pending-permissions", "lolcats");
+ Assert.equal(
+ Services.prefs.getCharPref("xpinstall.whitelist.add.TEST4"),
+ "https://whitelist4.example.com"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js b/toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js
new file mode 100644
index 0000000000..cb816dcd8d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_pref_properties.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests the preference-related properties of AddonManager
+// eg: AddonManager.checkCompatibility, AddonManager.updateEnabled, etc
+
+var gManagerEventsListener = {
+ seenEvents: [],
+ init() {
+ let events = [
+ "onCompatibilityModeChanged",
+ "onCheckUpdateSecurityChanged",
+ "onUpdateModeChanged",
+ ];
+ events.forEach(function (aEvent) {
+ this[aEvent] = function () {
+ info("Saw event " + aEvent);
+ this.seenEvents.push(aEvent);
+ };
+ }, this);
+ AddonManager.addManagerListener(this);
+ // Try to add twice, to test that the second time silently fails.
+ AddonManager.addManagerListener(this);
+ },
+ shutdown() {
+ AddonManager.removeManagerListener(this);
+ },
+ expect(aEvents) {
+ this.expectedEvents = aEvents;
+ },
+ checkExpected() {
+ info("Checking expected events...");
+ while (this.expectedEvents.length) {
+ let event = this.expectedEvents.pop();
+ info("Looking for expected event " + event);
+ let matchingEvents = this.seenEvents.filter(function (aSeenEvent) {
+ return aSeenEvent == event;
+ });
+ Assert.equal(matchingEvents.length, 1);
+ }
+ this.seenEvents = [];
+ },
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ Services.prefs.setBoolPref("extensions.update.enabled", true);
+ Services.prefs.setBoolPref("extensions.update.autoUpdateDefault", true);
+ Services.prefs.setBoolPref("extensions.strictCompatibility", true);
+ Services.prefs.setBoolPref("extensions.checkUpdatesecurity", true);
+
+ await promiseStartupManager();
+ gManagerEventsListener.init();
+
+ // AddonManager.updateEnabled
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.updateEnabled = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.updateEnabled);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.updateEnabled = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.updateEnabled);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.updateEnabled = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.updateEnabled);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.updateEnabled = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.updateEnabled);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.enabled"));
+
+ // AddonManager.autoUpdateDefault
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.autoUpdateDefault = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.autoUpdateDefault);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.autoUpdateDefault = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.autoUpdateDefault);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ gManagerEventsListener.expect(["onUpdateModeChanged"]);
+ AddonManager.autoUpdateDefault = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.autoUpdateDefault);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.autoUpdateDefault = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.autoUpdateDefault);
+ Assert.ok(Services.prefs.getBoolPref("extensions.update.autoUpdateDefault"));
+
+ // AddonManager.strictCompatibility
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.strictCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.strictCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.strictCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.strictCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.strictCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.strictCompatibility);
+ Assert.ok(Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.strictCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.strictCompatibility);
+ Assert.ok(Services.prefs.getBoolPref("extensions.strictCompatibility"));
+
+ // AddonManager.checkCompatibility
+ if (isNightlyChannel()) {
+ var version = "nightly";
+ } else {
+ version = Services.appinfo.version.replace(
+ /^([^\.]+\.[0-9]+[a-z]*).*/gi,
+ "$1"
+ );
+ }
+ const COMPATIBILITY_PREF = "extensions.checkCompatibility." + version;
+
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.checkCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref(COMPATIBILITY_PREF));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkCompatibility = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.getBoolPref(COMPATIBILITY_PREF));
+
+ gManagerEventsListener.expect(["onCompatibilityModeChanged"]);
+ AddonManager.checkCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.prefHasUserValue(COMPATIBILITY_PREF));
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkCompatibility = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkCompatibility);
+ Assert.ok(!Services.prefs.prefHasUserValue(COMPATIBILITY_PREF));
+
+ // AddonManager.checkUpdateSecurity
+ gManagerEventsListener.expect(["onCheckUpdateSecurityChanged"]);
+ AddonManager.checkUpdateSecurity = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkUpdateSecurity);
+ if (AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(!Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkUpdateSecurity = false;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(!AddonManager.checkUpdateSecurity);
+ if (AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(!Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.expect(["onCheckUpdateSecurityChanged"]);
+ AddonManager.checkUpdateSecurity = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkUpdateSecurity);
+ if (!AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.expect([]);
+ AddonManager.checkUpdateSecurity = true;
+ gManagerEventsListener.checkExpected();
+ Assert.ok(AddonManager.checkUpdateSecurity);
+ if (!AddonManager.checkUpdateSecurityDefault) {
+ Assert.ok(Services.prefs.getBoolPref("extensions.checkUpdateSecurity"));
+ } else {
+ Assert.ok(
+ !Services.prefs.prefHasUserValue("extensions.checkUpdateSecurity")
+ );
+ }
+
+ gManagerEventsListener.shutdown();
+
+ // After removing the listener, ensure we get no further events.
+ gManagerEventsListener.expect([]);
+ AddonManager.updateEnabled = false;
+ gManagerEventsListener.checkExpected();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js
new file mode 100644
index 0000000000..e8062a2caf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_markSafe.js
@@ -0,0 +1,43 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+function mockAddonProvider(name) {
+ let mockProvider = {
+ markSafe: false,
+ apiAccessed: false,
+
+ startup() {
+ if (this.markSafe) {
+ AddonManagerPrivate.markProviderSafe(this);
+ }
+
+ AddonManager.isInstallEnabled("made-up-mimetype");
+ },
+ supportsMimetype(mimetype) {
+ this.apiAccessed = true;
+ return false;
+ },
+
+ get name() {
+ return name;
+ },
+ };
+
+ return mockProvider;
+}
+
+add_task(async function testMarkSafe() {
+ info("Starting with provider normally");
+ let provider = mockAddonProvider("Mock1");
+ AddonManagerPrivate.registerProvider(provider);
+ await promiseStartupManager();
+ ok(!provider.apiAccessed, "Provider API should not have been accessed");
+ AddonManagerPrivate.unregisterProvider(provider);
+ await promiseShutdownManager();
+
+ info("Starting with provider that marks itself safe");
+ provider.apiAccessed = false;
+ provider.markSafe = true;
+ AddonManagerPrivate.registerProvider(provider);
+ await promiseStartupManager();
+ ok(provider.apiAccessed, "Provider API should have been accessed");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js
new file mode 100644
index 0000000000..498b28a0c9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_shutdown.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Verify that we report shutdown status for Addon Manager providers
+// and AddonRepository correctly.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Make a mock AddonRepository that just lets us hang shutdown.
+// Needs two promises - one to let us know that AM has called shutdown,
+// and one for us to let AM know that shutdown is done.
+function mockAddonProvider(aName) {
+ let mockProvider = {
+ donePromise: null,
+ doneResolve: null,
+ doneReject: null,
+ shutdownPromise: null,
+ shutdownResolve: null,
+
+ get name() {
+ return aName;
+ },
+
+ shutdown() {
+ this.shutdownResolve();
+ return this.donePromise;
+ },
+ };
+ mockProvider.donePromise = new Promise((resolve, reject) => {
+ mockProvider.doneResolve = resolve;
+ mockProvider.doneReject = reject;
+ });
+ mockProvider.shutdownPromise = new Promise((resolve, reject) => {
+ mockProvider.shutdownResolve = resolve;
+ });
+ return mockProvider;
+}
+
+// Helper to find a particular shutdown blocker's status in the JSON blob
+function findInStatus(aStatus, aName) {
+ for (let { name, state } of aStatus.state) {
+ if (name == aName) {
+ return state;
+ }
+ }
+ return null;
+}
+
+/*
+ * Make sure we report correctly when an add-on provider or AddonRepository block shutdown
+ */
+add_task(async function blockRepoShutdown() {
+ // the mock provider behaves enough like AddonRepository for the purpose of this test
+ let mockRepo = mockAddonProvider("Mock repo");
+ AddonManagerPrivate.overrideAddonRepository(mockRepo);
+
+ let mockProvider = mockAddonProvider("Mock provider");
+
+ await promiseStartupManager();
+ AddonManagerPrivate.registerProvider(mockProvider);
+
+ let { fetchState } =
+ MockAsyncShutdown.profileBeforeChange.blockers[0].options;
+
+ // Start shutting the manager down
+ let managerDown = promiseShutdownManager();
+
+ // Wait for manager to call provider shutdown.
+ await mockProvider.shutdownPromise;
+ // check AsyncShutdown state
+ let status = fetchState();
+ equal(findInStatus(status[1], "Mock provider"), "(none)");
+ equal(status[2].name, "AddonRepository: async shutdown");
+ equal(status[2].state, "pending");
+ // let the provider finish
+ mockProvider.doneResolve();
+
+ // Wait for manager to call repo shutdown and start waiting for it
+ await mockRepo.shutdownPromise;
+ // Check the shutdown state
+ status = fetchState();
+ equal(status[1].name, "AddonManager: Waiting for providers to shut down.");
+ equal(status[1].state, "Complete");
+ equal(status[2].name, "AddonRepository: async shutdown");
+ equal(status[2].state, "in progress");
+
+ // Now finish our shutdown, and wait for the manager to wrap up
+ mockRepo.doneResolve();
+ await managerDown;
+
+ // Check the shutdown state again
+ status = fetchState();
+ equal(status[0].name, "AddonRepository: async shutdown");
+ equal(status[0].state, "done");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js
new file mode 100644
index 0000000000..720ccaf0c4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_shutdown.js
@@ -0,0 +1,65 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+var shutdownOrder = [];
+
+function mockAddonProvider(name) {
+ let mockProvider = {
+ hasShutdown: false,
+ unsafeAccess: false,
+
+ shutdownCallback: null,
+
+ startup() {},
+ shutdown() {
+ this.hasShutdown = true;
+ shutdownOrder.push(this.name);
+ if (this.shutdownCallback) {
+ return this.shutdownCallback();
+ }
+ return undefined;
+ },
+ getAddonByID(id, callback) {
+ if (this.hasShutdown) {
+ this.unsafeAccess = true;
+ }
+ callback(null);
+ },
+
+ get name() {
+ return name;
+ },
+ };
+
+ return mockProvider;
+}
+
+add_task(async function unsafeProviderShutdown() {
+ let firstProvider = mockAddonProvider("Mock1");
+ AddonManagerPrivate.registerProvider(firstProvider);
+ let secondProvider = mockAddonProvider("Mock2");
+ AddonManagerPrivate.registerProvider(secondProvider);
+
+ await promiseStartupManager();
+
+ let shutdownPromise = null;
+ await new Promise(resolve => {
+ secondProvider.shutdownCallback = function () {
+ return AddonManager.getAddonByID("does-not-exist").then(() => {
+ resolve();
+ });
+ };
+
+ shutdownPromise = promiseShutdownManager();
+ });
+ await shutdownPromise;
+
+ equal(
+ shutdownOrder.join(","),
+ ["Mock1", "Mock2"].join(","),
+ "Mock providers should have shutdown in expected order"
+ );
+ ok(
+ !firstProvider.unsafeAccess,
+ "First registered mock provider should not have been accessed unsafely"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js
new file mode 100644
index 0000000000..8e066973f2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_provider_unsafe_access_startup.js
@@ -0,0 +1,59 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+var startupOrder = [];
+
+function mockAddonProvider(name) {
+ let mockProvider = {
+ hasStarted: false,
+ unsafeAccess: false,
+
+ startupCallback: null,
+
+ startup() {
+ this.hasStarted = true;
+ startupOrder.push(this.name);
+ if (this.startupCallback) {
+ this.startupCallback();
+ }
+ },
+ getAddonByID(id, callback) {
+ if (!this.hasStarted) {
+ this.unsafeAccess = true;
+ }
+ callback(null);
+ },
+
+ get name() {
+ return name;
+ },
+ };
+
+ return mockProvider;
+}
+
+add_task(async function unsafeProviderStartup() {
+ let secondProvider = null;
+
+ await new Promise(resolve => {
+ let firstProvider = mockAddonProvider("Mock1");
+ firstProvider.startupCallback = function () {
+ resolve(AddonManager.getAddonByID("does-not-exist"));
+ };
+ AddonManagerPrivate.registerProvider(firstProvider);
+
+ secondProvider = mockAddonProvider("Mock2");
+ AddonManagerPrivate.registerProvider(secondProvider);
+
+ promiseStartupManager();
+ });
+
+ equal(
+ startupOrder.join(","),
+ ["Mock1", "Mock2"].join(","),
+ "Mock providers should have hasStarted in expected order"
+ );
+ ok(
+ !secondProvider.unsafeAccess,
+ "Second registered mock provider should not have been accessed unsafely"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_proxies.js b/toolkit/mozapps/extensions/test/xpcshell/test_proxies.js
new file mode 100644
index 0000000000..2f40147f83
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_proxies.js
@@ -0,0 +1,235 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests the semantics of extension proxy files and symlinks
+
+var ADDONS = [
+ {
+ id: "proxy1@tests.mozilla.org",
+ dirId: "proxy1@tests.mozilla.com",
+ type: "proxy",
+ },
+ {
+ id: "proxy2@tests.mozilla.org",
+ type: "proxy",
+ },
+ {
+ id: "symlink1@tests.mozilla.org",
+ dirId: "symlink1@tests.mozilla.com",
+ type: "symlink",
+ },
+ {
+ id: "symlink2@tests.mozilla.org",
+ type: "symlink",
+ },
+];
+
+const gHaveSymlinks = AppConstants.platform != "win";
+
+function createSymlink(aSource, aDest) {
+ if (aSource instanceof Ci.nsIFile) {
+ aSource = aSource.path;
+ }
+ if (aDest instanceof Ci.nsIFile) {
+ aDest = aDest.path;
+ }
+
+ const ln = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ ln.initWithPath("/bin/ln");
+
+ const lnProcess = Cc["@mozilla.org/process/util;1"].createInstance(
+ Ci.nsIProcess
+ );
+ lnProcess.init(ln);
+
+ const args = ["-s", aSource, aDest];
+ lnProcess.run(true, args, args.length);
+ Assert.equal(lnProcess.exitValue, 0);
+}
+
+async function promiseWriteFile(aFile, aData) {
+ if (!(await IOUtils.exists(aFile.parent.path))) {
+ await IOUtils.makeDirectory(aFile.parent.path);
+ }
+
+ return IOUtils.writeUTF8(aFile.path, aData);
+}
+
+function checkAddonsExist() {
+ for (let addon of ADDONS) {
+ let file = addon.directory.clone();
+ file.append("manifest.json");
+ Assert.ok(file.exists());
+ }
+}
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+function run_test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2", "2");
+
+ // Unpacked extensions are never signed, so this can only run with
+ // signature checks disabled.
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+
+ add_task(run_proxy_tests);
+
+ if (gHaveSymlinks) {
+ add_task(run_symlink_tests);
+ }
+
+ run_next_test();
+}
+
+async function run_proxy_tests() {
+ if (!gHaveSymlinks) {
+ ADDONS = ADDONS.filter(a => a.type != "symlink");
+ }
+
+ for (let addon of ADDONS) {
+ addon.directory = gTmpD.clone();
+ addon.directory.append(addon.id);
+
+ addon.proxyFile = profileDir.clone();
+ addon.proxyFile.append(addon.dirId || addon.id);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ name: addon.id,
+ browser_specific_settings: { gecko: { id: addon.id } },
+ },
+ });
+ let path = PathUtils.join(gTmpD.path, addon.id);
+ await AddonTestUtils.promiseWriteFilesToDir(path, files);
+
+ if (addon.type == "proxy") {
+ await promiseWriteFile(addon.proxyFile, addon.directory.path);
+ } else if (addon.type == "symlink") {
+ await createSymlink(addon.directory, addon.proxyFile);
+ }
+ }
+
+ await promiseStartupManager();
+
+ // Check that all add-ons original sources still exist after invalid
+ // add-ons have been removed at startup.
+ checkAddonsExist();
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(addon => addon.id));
+ try {
+ for (let [i, addon] of addons.entries()) {
+ // Ensure that valid proxied add-ons were installed properly on
+ // platforms that support the installation method.
+ print(
+ ADDONS[i].id,
+ ADDONS[i].dirId,
+ ADDONS[i].dirId != null,
+ ADDONS[i].type == "symlink"
+ );
+ Assert.equal(addon == null, ADDONS[i].dirId != null);
+
+ if (addon != null) {
+ let fixURL = url => {
+ if (AppConstants.platform == "macosx") {
+ return url.replace(RegExp(`^file:///private/`), "file:///");
+ }
+ return url;
+ };
+
+ // Check that proxied add-ons do not have upgrade permissions.
+ Assert.equal(addon.permissions & AddonManager.PERM_CAN_UPGRADE, 0);
+
+ // Check that getResourceURI points to the right place.
+ Assert.equal(
+ Services.io.newFileURI(ADDONS[i].directory).spec,
+ fixURL(addon.getResourceURI().spec),
+ `Base resource URL resolves as expected`
+ );
+
+ let file = ADDONS[i].directory.clone();
+ file.append("manifest.json");
+
+ Assert.equal(
+ Services.io.newFileURI(file).spec,
+ fixURL(addon.getResourceURI("manifest.json").spec),
+ `Resource URLs resolve as expected`
+ );
+
+ await addon.uninstall();
+ }
+ }
+
+ // Check that original sources still exist after explicit uninstall.
+ await promiseRestartManager();
+ checkAddonsExist();
+
+ await promiseShutdownManager();
+
+ // Check that all of the proxy files have been removed and remove
+ // the original targets.
+ for (let addon of ADDONS) {
+ equal(
+ addon.proxyFile.exists(),
+ addon.dirId != null,
+ `Proxy file ${addon.proxyFile.path} should exist?`
+ );
+ addon.directory.remove(true);
+ try {
+ addon.proxyFile.remove(false);
+ } catch (e) {}
+ }
+ } catch (e) {
+ do_throw(e);
+ }
+}
+
+// Check that symlinks are not followed out of a directory tree
+// when deleting an add-on.
+async function run_symlink_tests() {
+ const ID = "unpacked@test.mozilla.org";
+
+ let tempDirectory = gTmpD.clone();
+ tempDirectory.append(ID);
+
+ let tempFile = tempDirectory.clone();
+ tempFile.append("test.txt");
+ tempFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
+
+ let addonDirectory = profileDir.clone();
+ addonDirectory.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: { browser_specific_settings: { gecko: { id: ID } } },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(addonDirectory.path, files);
+
+ let symlink = addonDirectory.clone();
+ symlink.append(tempDirectory.leafName);
+ await createSymlink(tempDirectory, symlink);
+
+ // Make sure that the symlink was created properly.
+ let file = symlink.clone();
+ file.append(tempFile.leafName);
+ file.normalize();
+ Assert.equal(file.path.replace(/^\/private\//, "/"), tempFile.path);
+
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ await addon.uninstall();
+
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ // Check that the install directory is gone.
+ Assert.ok(!addonDirectory.exists());
+
+ // Check that the temp file is not gone.
+ Assert.ok(tempFile.exists());
+
+ tempDirectory.remove(true);
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js b/toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js
new file mode 100644
index 0000000000..2ea7c0f77f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_recommendations.js
@@ -0,0 +1,707 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ Management: "resource://gre/modules/Extension.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+AddonTestUtils.usePrivilegedSignatures = false;
+
+const testStartTime = Date.now();
+const not_before = new Date(testStartTime - 3600000).toISOString();
+const not_after = new Date(testStartTime + 3600000).toISOString();
+const RECOMMENDATION_FILE_NAME = "mozilla-recommendation.json";
+
+const server = AddonTestUtils.createHttpServer();
+const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
+// Allow the test extensions to be updated from an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ `${SERVER_BASE_URL}/upgrade.json`
+);
+
+function createFileWithRecommendations(id, recommendation, version = "1.0.0") {
+ let files = {};
+ if (recommendation) {
+ files[RECOMMENDATION_FILE_NAME] = recommendation;
+ }
+ return AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ files,
+ });
+}
+
+async function installAddonWithRecommendations(id, recommendation) {
+ let xpi = createFileWithRecommendations(id, recommendation);
+ let install = await AddonTestUtils.promiseInstallFile(xpi);
+ return install.addon;
+}
+
+function checkRecommended(addon, recommended = true) {
+ equal(
+ addon.isRecommended,
+ recommended,
+ "The add-on isRecommended state is correct"
+ );
+ equal(
+ addon.recommendationStates.includes("recommended"),
+ recommended,
+ "The add-on recommendationStates is correct"
+ );
+}
+
+function waitForPendingExtension(extId) {
+ return new Promise(resolve => {
+ Management.on("startup", function startupListener() {
+ const pendingExtensionsMap =
+ Services.ppmm.sharedData.get("extensions/pending");
+ if (pendingExtensionsMap.has(extId)) {
+ Management.off("startup", startupListener);
+ resolve(pendingExtensionsMap.get(extId));
+ }
+ });
+ });
+}
+
+async function assertPendingExtensionIgnoreQuarantined({
+ addonId,
+ expectedIgnoreQuarantined,
+}) {
+ info(
+ `Reload ${addonId} and verify ignoreQuarantine in extensions/pending sharedData`
+ );
+ const promisePendingExtension = waitForPendingExtension(addonId);
+ const addon = await AddonManager.getAddonByID(addonId);
+ await addon.disable();
+ await addon.enable();
+ Assert.deepEqual(
+ (await promisePendingExtension).ignoreQuarantine,
+ expectedIgnoreQuarantined,
+ `Expect ignoreQuarantine to be true in pending/extensions details for ${addon.id}`
+ );
+}
+
+function assertQuarantinedFromURI({ domain, expected }) {
+ const { processType, PROCESS_TYPE_DEFAULT } = Services.appinfo;
+ const processTypeStr =
+ processType === PROCESS_TYPE_DEFAULT ? "Main Process" : "Child Process";
+ const testURI = Services.io.newURI(`https://${domain}/`);
+ for (const [addonId, expectedQuarantinedFromURI] of Object.entries(
+ expected
+ )) {
+ Assert.equal(
+ WebExtensionPolicy.getByID(addonId).quarantinedFromURI(testURI),
+ expectedQuarantinedFromURI,
+ `Expect ${addonId} to ${
+ expectedQuarantinedFromURI ? "not be" : "be"
+ } quarantined from ${domain} in ${processTypeStr}`
+ );
+ }
+}
+
+async function assertQuarantinedFromURIInChildProcessAsync({
+ domain,
+ expected,
+}) {
+ // Doesn't matter what content url we us here, as long as we are
+ // using a content url to be able to run the assertions from a
+ // child process.
+ const testUrl = SERVER_BASE_URL;
+ const page = await ExtensionTestUtils.loadContentPage(testUrl);
+ // TODO(rpl): look into Bug 1648545 changes and determine what
+ // would need to change to use page.spawn instead.
+ await page.legacySpawn({ domain, expected }, assertQuarantinedFromURI);
+ await page.close();
+}
+
+function getUpdatesJSONFor(id, version) {
+ return {
+ updates: [
+ {
+ version,
+ update_link: `${SERVER_BASE_URL}/addons/${id}.xpi`,
+ },
+ ],
+ };
+}
+
+function registerUpdateXPIFile({ id, version, recommendationStates }) {
+ const recommendation = {
+ addon_id: id,
+ states: recommendationStates,
+ validity: { not_before, not_after },
+ };
+ let xpi = createFileWithRecommendations(id, recommendation, version);
+ server.registerFile(`/addons/${id}.xpi`, xpi);
+}
+
+function waitForBootstrapUpdateMethod(addonId, newVersion) {
+ return new Promise(resolve => {
+ function listener(_evt, { method, params }) {
+ if (
+ method === "update" &&
+ params.id === addonId &&
+ params.newVersion === newVersion
+ ) {
+ AddonTestUtils.off("bootstrap-method", listener);
+ info(`Update bootstrap method called for ${addonId} ${newVersion}`);
+ resolve({ addonId, method, params });
+ }
+ }
+ AddonTestUtils.on("bootstrap-method", listener);
+ });
+}
+
+function assertUpdateBootstrapCall(detailsBootstrapUpdates, expected) {
+ const actualPerAddonId = detailsBootstrapUpdates
+ .map(({ addonId, params }) => {
+ return [addonId, params.recommendationState?.states];
+ })
+ .reduce((acc, [addonId, states]) => {
+ acc[addonId] = states;
+ return acc;
+ }, {});
+ Assert.deepEqual(
+ actualPerAddonId,
+ expected,
+ `Got the expected recommendation states in the update bootstrap calls`
+ );
+}
+
+add_setup(async () => {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function text_no_file() {
+ const id = "no-recommendations-file@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, null);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function text_malformed_file() {
+ const id = "no-recommendations-file@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, "This is not JSON");
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_valid_recommendation_file() {
+ const id = "recommended@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_multiple_valid_recommendation_file() {
+ const id = "recommended@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended", "something"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon);
+ ok(
+ addon.recommendationStates.includes("something"),
+ "The add-on recommendationStates contains something"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_unsigned() {
+ // Don't override the certificate, so that the test add-on is unsigned.
+ AddonTestUtils.useRealCertChecks = true;
+ // Allow unsigned add-on to be installed.
+ Services.prefs.setBoolPref("xpinstall.signatures.required", false);
+
+ const id = "unsigned@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+ AddonTestUtils.useRealCertChecks = false;
+ Services.prefs.setBoolPref("xpinstall.signatures.required", true);
+});
+
+add_task(async function test_temporary() {
+ const id = "temporary@test.web.extension";
+ let xpi = createFileWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+ let addon = await XPIExports.XPIInstall.installTemporaryAddon(xpi);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+// Tests that unpacked temporary add-ons are not recommended.
+add_task(async function test_temporary_directory() {
+ const id = "temporary-dir@test.web.extension";
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+ let extDir = await AddonTestUtils.promiseWriteFilesToExtension(
+ gTmpD.path,
+ id,
+ files,
+ true
+ );
+
+ let addon = await XPIExports.XPIInstall.installTemporaryAddon(extDir);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+ extDir.remove(true);
+});
+
+add_task(async function test_builtin() {
+ const id = "builtin@test.web.extension";
+ let extension = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ background: `browser.test.sendMessage("started");`,
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+ await extension.awaitMessage("started");
+
+ checkRecommended(extension.addon, false);
+
+ await extension.unload();
+});
+
+add_task(async function test_theme() {
+ const id = "theme@test.web.extension";
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ theme: {},
+ },
+ files: {
+ [RECOMMENDATION_FILE_NAME]: {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ },
+ },
+ });
+ let { addon } = await AddonTestUtils.promiseInstallFile(xpi);
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_not_recommended() {
+ const id = "not-recommended@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["something"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+ ok(
+ addon.recommendationStates.includes("something"),
+ "The add-on recommendationStates contains something"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_id_missing() {
+ const id = "no-id@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_expired() {
+ const id = "expired@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended", "something"],
+ validity: { not_before, not_after: not_before },
+ });
+
+ checkRecommended(addon, false);
+ ok(
+ !addon.recommendationStates.length,
+ "The add-on recommendationStates does not contain anything"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_not_valid_yet() {
+ const id = "expired@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before: not_after, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_states_missing() {
+ const id = "states-missing@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_validity_missing() {
+ const id = "validity-missing@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_not_before_missing() {
+ const id = "not-before-missing@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_bad_states() {
+ const id = "bad-states@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: { recommended: true },
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon, false);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_recommendation_persist_restart() {
+ const id = "persisted-recommendation@test.web.extension";
+ let addon = await installAddonWithRecommendations(id, {
+ addon_id: id,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ checkRecommended(addon);
+
+ await AddonTestUtils.promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID(id);
+
+ checkRecommended(addon);
+
+ await addon.uninstall();
+});
+
+add_task(async function test_isLineExtension_internal_svg_permission() {
+ async function assertLineExtensionStateAndPermission(
+ addonId,
+ expectLineExtension,
+ isRestart
+ ) {
+ const { extension } = WebExtensionPolicy.getByID(addonId);
+
+ const msgShould = expectLineExtension ? "should" : "should not";
+
+ equal(
+ extension.hasPermission("internal:svgContextPropertiesAllowed"),
+ expectLineExtension,
+ `"${addonId}" ${msgShould} have permission internal:svgContextPropertiesAllowed`
+ );
+ if (isRestart) {
+ const { permissions } = await ExtensionPermissions.get(addonId);
+ Assert.deepEqual(
+ permissions,
+ expectLineExtension ? ["internal:svgContextPropertiesAllowed"] : [],
+ `ExtensionPermission.get("${addonId}") result ${msgShould} include internal:svgContextPropertiesAllowed permission`
+ );
+ }
+ }
+
+ const idLineExt = "line-extension@test.web.extension";
+ await installAddonWithRecommendations(idLineExt, {
+ addon_id: idLineExt,
+ states: ["line"],
+ validity: { not_before, not_after },
+ });
+
+ info(`Test line extension ${idLineExt}`);
+ await assertLineExtensionStateAndPermission(idLineExt, true, false);
+ await AddonTestUtils.promiseRestartManager();
+ info(`Test ${idLineExt} again after AOM restart`);
+ await assertLineExtensionStateAndPermission(idLineExt, true, true);
+ let addon = await AddonManager.getAddonByID(idLineExt);
+ await addon.uninstall();
+
+ const idNonLineExt = "non-line-extension@test.web.extension";
+ await installAddonWithRecommendations(idNonLineExt, {
+ addon_id: idNonLineExt,
+ states: ["recommended"],
+ validity: { not_before, not_after },
+ });
+
+ info(`Test non line extension: ${idNonLineExt}`);
+ await assertLineExtensionStateAndPermission(idNonLineExt, false, false);
+ await AddonTestUtils.promiseRestartManager();
+ info(`Test ${idNonLineExt} again after AOM restart`);
+ await assertLineExtensionStateAndPermission(idNonLineExt, false, true);
+ addon = await AddonManager.getAddonByID(idNonLineExt);
+ await addon.uninstall();
+});
+
+add_task(
+ {
+ pref_set: [
+ ["extensions.quarantinedDomains.enabled", true],
+ ["extensions.quarantinedDomains.list", "quarantined.example.org"],
+ ],
+ },
+ async function test_recommended_exempt_from_quarantined() {
+ const invalidRecommendedId = "invalid-recommended@test.web.extension";
+ const validRecommendedId = "recommended@test.web.extension";
+ const validAndroidRecommendedId = "recommended-android@test.web.extension";
+ const lineExtensionId = "line@test.web.extension";
+ const validMultiRecommendedId = "recommended-multi@test.web.extension";
+ // NOTE: confirm that any future recommendation state that was considered
+ // valid and signed by AMO is also going to be exempt, which does also include
+ // recommendation states that we are not using anymore but are still technically
+ // supported by autograph (e.g. verified), see:
+ // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460
+ const validFutureRecStateId = "fake-future-valid-state@test.web.extension";
+
+ const recommendationStatesPerId = {
+ [invalidRecommendedId]: null,
+ [validRecommendedId]: ["recommended"],
+ [validAndroidRecommendedId]: ["recommended-android"],
+ [lineExtensionId]: ["line"],
+ [validFutureRecStateId]: ["fake-future-valid-state"],
+ [validMultiRecommendedId]: ["recommended", "recommended-android"],
+ };
+
+ for (const [extId, expectedRecStates] of Object.entries(
+ recommendationStatesPerId
+ )) {
+ const recommendationData = expectedRecStates
+ ? {
+ addon_id: extId,
+ states: expectedRecStates,
+ validity: { not_before, not_after },
+ }
+ : null;
+ await installAddonWithRecommendations(extId, recommendationData);
+ // Check that the expected recommendation states are reflected by the
+ // value returned by the AddonWrapper.recommendationStates getter.
+ const addon = await AddonManager.getAddonByID(extId);
+ Assert.deepEqual(
+ addon.recommendationStates,
+ expectedRecStates ?? [],
+ `Addon ${extId} has the expected recommendation states`
+ );
+ }
+
+ assertQuarantinedFromURI({
+ domain: "quarantined.example.org",
+ expected: {
+ [invalidRecommendedId]: true,
+ [validRecommendedId]: false,
+ [validAndroidRecommendedId]: false,
+ [lineExtensionId]: false,
+ [validFutureRecStateId]: false,
+ [validMultiRecommendedId]: false,
+ },
+ });
+
+ await assertQuarantinedFromURIInChildProcessAsync({
+ domain: "quarantined.example.org",
+ expected: {
+ [invalidRecommendedId]: true,
+ [validRecommendedId]: false,
+ [validAndroidRecommendedId]: false,
+ [lineExtensionId]: false,
+ [validFutureRecStateId]: false,
+ [validMultiRecommendedId]: false,
+ },
+ });
+
+ // NOTE: we only cover the 3 basic cases in the rest of this test case
+ // (we have verified that ignoreQuarantine is being set to the expected
+ // value and so the other cases shouldn't matter for the behaviors being
+ // explicitly covered by the remaining part of this test task).
+
+ // Make sure the ignoreQuarantine property is also propagated in the child
+ // processes while the extensions may still be not fully initialized (and
+ // so listed in the `extensions/pending` sharedData entry).
+ await assertPendingExtensionIgnoreQuarantined({
+ addonId: validRecommendedId,
+ expectedIgnoreQuarantined: true,
+ });
+ await assertPendingExtensionIgnoreQuarantined({
+ addonId: lineExtensionId,
+ expectedIgnoreQuarantined: true,
+ });
+ await assertPendingExtensionIgnoreQuarantined({
+ addonId: invalidRecommendedId,
+ expectedIgnoreQuarantined: false,
+ });
+
+ info("Verify ignoreQuarantine again after application restart");
+
+ await AddonTestUtils.promiseRestartManager();
+ assertQuarantinedFromURI({
+ domain: "quarantined.example.org",
+ expected: {
+ [invalidRecommendedId]: true,
+ [validRecommendedId]: false,
+ [lineExtensionId]: false,
+ },
+ });
+
+ info("Verify ignoreQuarantine again after addon updates");
+
+ AddonTestUtils.registerJSON(server, "/upgrade.json", {
+ addons: {
+ [invalidRecommendedId]: getUpdatesJSONFor(
+ invalidRecommendedId,
+ "2.0.0"
+ ),
+ [validRecommendedId]: getUpdatesJSONFor(validRecommendedId, "2.0.0"),
+ [lineExtensionId]: getUpdatesJSONFor(lineExtensionId, "2.0.0"),
+ },
+ });
+ registerUpdateXPIFile({
+ id: invalidRecommendedId,
+ version: "2.0.0",
+ recommendationStates: recommendationStatesPerId[invalidRecommendedId],
+ });
+ registerUpdateXPIFile({
+ id: validRecommendedId,
+ version: "2.0.0",
+ recommendationStates: recommendationStatesPerId[validRecommendedId],
+ });
+ registerUpdateXPIFile({
+ id: lineExtensionId,
+ version: "2.0.0",
+ recommendationStates: recommendationStatesPerId[lineExtensionId],
+ });
+
+ const promiseUpdatesInstalled = Promise.all([
+ waitForBootstrapUpdateMethod(invalidRecommendedId, "2.0.0"),
+ waitForBootstrapUpdateMethod(validRecommendedId, "2.0.0"),
+ waitForBootstrapUpdateMethod(lineExtensionId, "2.0.0"),
+ ]);
+
+ const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
+ "addons-background-updates-found"
+ );
+ let [
+ extensionInvalidRecommended,
+ extensionValidRecommended,
+ extensionLine,
+ ] = [
+ ExtensionTestUtils.expectExtension(invalidRecommendedId),
+ ExtensionTestUtils.expectExtension(validRecommendedId),
+ ExtensionTestUtils.expectExtension(lineExtensionId),
+ ];
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await promiseBackgroundUpdatesFound;
+
+ assertUpdateBootstrapCall(await promiseUpdatesInstalled, {
+ [invalidRecommendedId]: null,
+ [validRecommendedId]: ["recommended"],
+ [lineExtensionId]: ["line"],
+ });
+
+ // Wait the test extension to be fully started (prevents logspam
+ // due to the AOM trying to uninstall them while being started).
+ await Promise.all([
+ extensionInvalidRecommended.awaitStartup(),
+ extensionValidRecommended.awaitStartup(),
+ extensionLine.awaitStartup(),
+ ]);
+
+ // Uninstall all test extensions.
+ await Promise.all(
+ Object.keys(recommendationStatesPerId).map(async addonId => {
+ const addon = await AddonManager.getAddonByID(addonId);
+ await addon.uninstall();
+ })
+ );
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js b/toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js
new file mode 100644
index 0000000000..5d45a1a273
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_registerchrome.js
@@ -0,0 +1,88 @@
+"use strict";
+
+function getFileURI(path) {
+ let file = do_get_file(".");
+ file.append(path);
+ return Services.io.newFileURI(file);
+}
+
+add_task(async function () {
+ const registry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ let file1 = getFileURI("file1");
+ let file2 = getFileURI("file2");
+
+ let uri1 = getFileURI("chrome.manifest");
+ let uri2 = getFileURI("manifest.json");
+
+ let overrideURL = Services.io.newURI("chrome://global/content/foo");
+ let contentURL = Services.io.newURI("chrome://test/content/foo");
+ let localeURL = Services.io.newURI("chrome://global/locale/foo");
+
+ let origOverrideURL = registry.convertChromeURL(overrideURL);
+ let origLocaleURL = registry.convertChromeURL(localeURL);
+
+ let entry1 = aomStartup.registerChrome(uri1, [
+ ["override", "chrome://global/content/foo", file1.spec],
+ ["content", "test", file2.spec + "/"],
+ ["locale", "global", "en-US", file2.spec + "/"],
+ ]);
+
+ let entry2 = aomStartup.registerChrome(uri2, [
+ ["override", "chrome://global/content/foo", file2.spec],
+ ["content", "test", file1.spec + "/"],
+ ["locale", "global", "en-US", file1.spec + "/"],
+ ]);
+
+ // Initially, the second entry should override the first.
+ equal(registry.convertChromeURL(overrideURL).spec, file2.spec);
+ let file = file1.spec + "/foo";
+ equal(registry.convertChromeURL(contentURL).spec, file);
+ equal(registry.convertChromeURL(localeURL).spec, file);
+
+ // After destroying the second entry, the first entry should now take
+ // precedence.
+ entry2.destruct();
+ equal(registry.convertChromeURL(overrideURL).spec, file1.spec);
+ file = file2.spec + "/foo";
+ equal(registry.convertChromeURL(contentURL).spec, file);
+ equal(registry.convertChromeURL(localeURL).spec, file);
+
+ // After dropping the reference to the first entry and allowing it to
+ // be GCed, we should be back to the original entries.
+ entry1 = null; // eslint-disable-line no-unused-vars
+ Cu.forceGC();
+ Cu.forceCC();
+ equal(registry.convertChromeURL(overrideURL).spec, origOverrideURL.spec);
+ equal(registry.convertChromeURL(localeURL).spec, origLocaleURL.spec);
+ Assert.throws(
+ () => registry.convertChromeURL(contentURL),
+ e => e.result == Cr.NS_ERROR_FILE_NOT_FOUND,
+ "chrome://test/ should no longer be registered"
+ );
+});
+
+add_task(async function () {
+ const INVALID_VALUES = [
+ {},
+ "foo",
+ ["foo"],
+ [{}],
+ [[]],
+ [["locale", "global"]],
+ [["locale", "global", "en", "foo", "foo"]],
+ [["override", "en"]],
+ [["override", "en", "US", "OR"]],
+ ];
+
+ let uri = getFileURI("chrome.manifest");
+ for (let arg of INVALID_VALUES) {
+ Assert.throws(
+ () => aomStartup.registerChrome(uri, arg),
+ e => e.result == Cr.NS_ERROR_INVALID_ARG,
+ `Arg ${uneval(arg)} should throw`
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_registry.js b/toolkit/mozapps/extensions/test/xpcshell/test_registry.js
new file mode 100644
index 0000000000..22909e6362
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_registry.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that extensions installed through the registry work as expected
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Enable loading extensions from the user and system scopes
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE +
+ AddonManager.SCOPE_USER +
+ AddonManager.SCOPE_SYSTEM
+);
+
+Services.prefs.setIntPref("extensions.sideloadScopes", AddonManager.SCOPE_ALL);
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+let xpi1, xpi2;
+
+let registry;
+
+add_task(async function setup() {
+ xpi1 = await createTempWebExtensionFile({
+ manifest: { browser_specific_settings: { gecko: { id: ID1 } } },
+ });
+
+ xpi2 = await createTempWebExtensionFile({
+ manifest: { browser_specific_settings: { gecko: { id: ID2 } } },
+ });
+
+ registry = new MockRegistry();
+ registerCleanupFunction(() => {
+ registry.shutdown();
+ });
+});
+
+// Tests whether basic registry install works
+add_task(async function test_1() {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ xpi1.path
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ xpi2.path
+ );
+
+ await promiseStartupManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ notEqual(a1, null);
+ ok(a1.isActive);
+ ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ equal(a1.scope, AddonManager.SCOPE_SYSTEM);
+
+ notEqual(a2, null);
+ ok(a2.isActive);
+ ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ equal(a2.scope, AddonManager.SCOPE_USER);
+});
+
+// Tests whether uninstalling from the registry works
+add_task(async function test_2() {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ null
+ );
+
+ await promiseRestartManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+});
+
+// Checks that the ID in the registry must match that in the install manifest
+add_task(async function test_3() {
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ xpi2.path
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ xpi1.path
+ );
+
+ await promiseRestartManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ equal(a2, null);
+});
+
+// Tests whether an extension's ID can change without its directory changing
+add_task(async function test_4() {
+ // Restarting with bad items in the registry should not force an EM restart
+ await promiseRestartManager();
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ null
+ );
+
+ await promiseRestartManager();
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ xpi1.path
+ );
+
+ await promiseShutdownManager();
+
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID1,
+ null
+ );
+ registry.setValue(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "SOFTWARE\\Mozilla\\XPCShell\\Extensions",
+ ID2,
+ xpi1.path
+ );
+ xpi2.copyTo(xpi1.parent, xpi1.leafName);
+
+ await promiseStartupManager();
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ equal(a1, null);
+ notEqual(a2, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js b/toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js
new file mode 100644
index 0000000000..f231397072
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_reinstall_disabled_addon.js
@@ -0,0 +1,213 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "test_addon@tests.mozilla.org";
+
+const ADDONS = {
+ test_install1_1: {
+ name: "Test 1 Addon",
+ description: "Test 1 addon description",
+ manifest_version: 2,
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ test_install1_2: {
+ name: "Test 1 Addon",
+ description: "Test 1 addon description",
+ manifest_version: 2,
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+// User intentionally reinstalls existing disabled addon of the same version.
+// No onInstalling nor onInstalled are fired.
+add_task(async function reinstallExistingDisabledAddonSameVersion() {
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onInstalling" }, { event: "onInstalled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_1,
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+ await install.install();
+ }
+ );
+
+ let addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(addon.isActive);
+ ok(!addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onDisabling" }, { event: "onDisabled" }],
+ },
+ },
+ () => addon.disable()
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(!addon.isActive);
+ ok(addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onEnabling" }, { event: "onEnabled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ const xpi2 = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_1,
+ });
+ let install = await AddonManager.getInstallForFile(xpi2);
+ await install.install();
+ }
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(addon.isActive);
+ ok(!addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onUninstalling" }, { event: "onUninstalled" }],
+ },
+ },
+ () => addon.uninstall()
+ );
+
+ addon = await promiseAddonByID(ID);
+ equal(addon, null);
+
+ await promiseRestartManager();
+});
+
+// User intentionally reinstalls existing disabled addon of different version,
+// but addon *still should be disabled*.
+add_task(async function reinstallExistingDisabledAddonDifferentVersion() {
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onInstalling" }, { event: "onInstalled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ const xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_1,
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ await install.install();
+ }
+ );
+
+ let addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(addon.isActive);
+ ok(!addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onDisabling" }, { event: "onDisabled" }],
+ },
+ },
+ () => addon.disable()
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(!addon.isActive);
+ ok(addon.userDisabled);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onInstalling" }, { event: "onInstalled" }],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ async () => {
+ let xpi2 = AddonTestUtils.createTempWebExtensionFile({
+ manifest: ADDONS.test_install1_2,
+ });
+ let install = await AddonManager.getInstallForFile(xpi2);
+ await install.install();
+ }
+ );
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ ok(!addon.isActive);
+ ok(addon.userDisabled);
+ equal(addon.version, "2.0");
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ [ID]: [{ event: "onUninstalling" }, { event: "onUninstalled" }],
+ },
+ },
+ () => addon.uninstall()
+ );
+
+ addon = await promiseAddonByID(ID);
+ equal(addon, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_reload.js b/toolkit/mozapps/extensions/test/xpcshell/test_reload.js
new file mode 100644
index 0000000000..993c4a9c53
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_reload.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const ID = "webextension1@tests.mozilla.org";
+
+const ADDONS = {
+ webextension_1: {
+ "manifest.json": {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ },
+ "chrome.manifest": "content webex ./\n",
+ },
+};
+
+async function tearDownAddon(addon) {
+ await addon.uninstall();
+ await promiseShutdownManager();
+}
+
+add_task(async function test_reloading_a_temp_addon() {
+ await promiseRestartManager();
+ let xpi = AddonTestUtils.createTempXPIFile(ADDONS.webextension_1);
+ const addon = await AddonManager.installTemporaryAddon(xpi);
+
+ var receivedOnUninstalled = false;
+ var receivedOnUninstalling = false;
+ var receivedOnInstalled = false;
+ var receivedOnInstalling = false;
+
+ const onReload = new Promise(resolve => {
+ const listener = {
+ onUninstalling: addonObj => {
+ if (addonObj.id === ID) {
+ receivedOnUninstalling = true;
+ }
+ },
+ onUninstalled: addonObj => {
+ if (addonObj.id === ID) {
+ receivedOnUninstalled = true;
+ }
+ },
+ onInstalling: addonObj => {
+ receivedOnInstalling = true;
+ equal(addonObj.id, ID);
+ },
+ onInstalled: addonObj => {
+ receivedOnInstalled = true;
+ equal(addonObj.id, ID);
+ // This should be the last event called.
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addAddonListener(listener);
+ });
+
+ await addon.reload();
+ await onReload;
+
+ // Make sure reload() doesn't trigger uninstall events.
+ equal(
+ receivedOnUninstalled,
+ false,
+ "reload should not trigger onUninstalled"
+ );
+ equal(
+ receivedOnUninstalling,
+ false,
+ "reload should not trigger onUninstalling"
+ );
+
+ // Make sure reload() triggers install events, like an upgrade.
+ equal(receivedOnInstalling, true, "reload should trigger onInstalling");
+ equal(receivedOnInstalled, true, "reload should trigger onInstalled");
+
+ await tearDownAddon(addon);
+});
+
+add_task(async function test_can_reload_permanent_addon() {
+ await promiseRestartManager();
+ const { addon } = await AddonTestUtils.promiseInstallXPI(
+ ADDONS.webextension_1
+ );
+
+ let disabledCalled = false;
+ let enabledCalled = false;
+ AddonManager.addAddonListener({
+ onDisabled: aAddon => {
+ Assert.ok(!enabledCalled);
+ disabledCalled = true;
+ },
+ onEnabled: aAddon => {
+ Assert.ok(disabledCalled);
+ enabledCalled = true;
+ },
+ });
+
+ await addon.reload();
+
+ Assert.ok(disabledCalled);
+ Assert.ok(enabledCalled);
+
+ notEqual(addon, null);
+ equal(addon.appDisabled, false);
+ equal(addon.userDisabled, false);
+
+ await tearDownAddon(addon);
+});
+
+add_task(async function test_reload_to_invalid_version_fails() {
+ await promiseRestartManager();
+ let tempdir = gTmpD.clone();
+
+ // The initial version of the add-on will be compatible, and will therefore load
+ const addonId = "invalid_version_cannot_be_reloaded@tests.mozilla.org";
+ let manifest = {
+ name: "invalid_version_cannot_be_reloaded",
+ description: "test invalid_version_cannot_be_reloaded",
+ manifest_version: 2,
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ },
+ },
+ };
+
+ let addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ tempdir,
+ "invalid_version"
+ );
+ await AddonManager.installTemporaryAddon(addonDir);
+
+ let addon = await promiseAddonByID(addonId);
+ notEqual(addon, null);
+ equal(addon.id, addonId);
+ equal(addon.version, "1.0");
+ equal(addon.appDisabled, false);
+ equal(addon.userDisabled, false);
+ addonDir.remove(true);
+
+ // update the manifest to make the add-on version incompatible, so the reload will reject
+ manifest.browser_specific_settings.gecko.strict_min_version = "1";
+ manifest.browser_specific_settings.gecko.strict_max_version = "1";
+ manifest.version = "2.0";
+
+ addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ tempdir,
+ "invalid_version",
+ false
+ );
+ let expectedMsg = new RegExp(
+ "Add-on invalid_version_cannot_be_reloaded@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 1. add-on maxVersion: 1."
+ );
+
+ await Assert.rejects(
+ addon.reload(),
+ expectedMsg,
+ "Reload rejects when application version does not fall between minVersion and maxVersion"
+ );
+
+ let reloadedAddon = await promiseAddonByID(addonId);
+ notEqual(reloadedAddon, null);
+ equal(reloadedAddon.id, addonId);
+ equal(reloadedAddon.version, "1.0");
+ equal(reloadedAddon.appDisabled, false);
+ equal(reloadedAddon.userDisabled, false);
+
+ await tearDownAddon(reloadedAddon);
+ addonDir.remove(true);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_remote_pref_telemetry.js b/toolkit/mozapps/extensions/test/xpcshell/test_remote_pref_telemetry.js
new file mode 100644
index 0000000000..aeeb368aa7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_remote_pref_telemetry.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+AddonTestUtils.init(this);
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+// Need a profile dir to initialize Glean.
+add_setup(async () => {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+add_task(async function test_remote_extensions_pref_telemetry() {
+ let original = Services.prefs.getBoolPref("extensions.webextensions.remote");
+ await AddonTestUtils.promiseStartupManager();
+
+ equal(
+ original,
+ Glean.extensions.useRemotePref.testGetValue(),
+ "useRemotePref flag in glean is correct."
+ );
+ equal(
+ original,
+ Glean.extensions.useRemotePolicy.testGetValue(),
+ "useRemotePolicy flag in glean is correct."
+ );
+
+ // Change the pref to simulate nimbus doing so after startup.
+ Services.prefs.setBoolPref("extensions.webextensions.remote", !original);
+
+ equal(
+ !original,
+ Glean.extensions.useRemotePref.testGetValue(),
+ "useRemotePref flag reflects the changed pref."
+ );
+ // EPS::UseRemoteExtensions() only reads the pref once, for consistency.
+ equal(
+ original,
+ Glean.extensions.useRemotePolicy.testGetValue(),
+ "useRemotePolicy flag still equal to original pref value."
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_safemode.js b/toolkit/mozapps/extensions/test/xpcshell/test_safemode.js
new file mode 100644
index 0000000000..30f4564e09
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_safemode.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Tests that extensions behave correctly in safe mode
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ID = "addon1@tests.mozilla.org";
+const BUILTIN_ID = "builtin@tests.mozilla.org";
+const VERSION = "1.0";
+
+// Sets up the profile by installing an add-on.
+add_task(async function setup() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+ );
+ gAppInfo.inSafeMode = true;
+
+ await promiseStartupManager();
+
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.equal(a1, null);
+ do_check_not_in_crash_annotation(ID, VERSION);
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test 1",
+ version: VERSION,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ let wrapper = await installBuiltinExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: BUILTIN_ID } },
+ },
+ });
+
+ let builtin = await AddonManager.getAddonByID(BUILTIN_ID);
+ Assert.notEqual(builtin, null, "builtin extension is installed");
+
+ await promiseRestartManager();
+
+ a1 = await AddonManager.getAddonByID(ID);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_DISABLE));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_ENABLE));
+ do_check_not_in_crash_annotation(ID, VERSION);
+
+ builtin = await AddonManager.getAddonByID(BUILTIN_ID);
+ Assert.notEqual(builtin, null, "builtin extension is installed");
+ Assert.ok(builtin.isActive, "builtin extension is active");
+ await wrapper.unload();
+});
+
+// Disabling an add-on should work
+add_task(async function test_disable() {
+ let a1 = await AddonManager.getAddonByID(ID);
+ Assert.ok(
+ !hasFlag(
+ a1.operationsRequiringRestart,
+ AddonManager.OP_NEEDS_RESTART_DISABLE
+ )
+ );
+ await a1.disable();
+ Assert.ok(!a1.isActive);
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_DISABLE));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_ENABLE));
+ do_check_not_in_crash_annotation(ID, VERSION);
+});
+
+// Enabling an add-on should happen but not become active.
+add_task(async function test_enable() {
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ await a1.enable();
+ Assert.ok(!a1.isActive);
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_DISABLE));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_ENABLE));
+
+ do_check_not_in_crash_annotation(ID, VERSION);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js b/toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js
new file mode 100644
index 0000000000..591bf6eb56
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_schema_change.js
@@ -0,0 +1,157 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const PREF_DB_SCHEMA = "extensions.databaseSchema";
+const PREF_IS_EMBEDDED = "extensions.isembedded";
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(PREF_IS_EMBEDDED);
+});
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+add_task(async function test_setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function run_tests() {
+ // Fake installTelemetryInfo used in the addon installation,
+ // to verify that they are preserved after the DB is updated
+ // from the addon manifests.
+ const fakeInstallTelemetryInfo = { source: "amo", method: "amWebAPI" };
+
+ const ID = "schema-change@tests.mozilla.org";
+
+ const xpi1 = createTempWebExtensionFile({
+ manifest: {
+ name: "Test Add-on",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ const xpi2 = createTempWebExtensionFile({
+ manifest: {
+ name: "Test Add-on 2",
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let xpiPath = PathUtils.join(profileDir.path, `${ID}.xpi`);
+
+ const TESTS = [
+ {
+ what: "Schema change with no application update reloads metadata.",
+ expectedVersion: "2.0",
+ action() {
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, 0);
+ },
+ },
+ {
+ what: "Application update with no schema change does not reload metadata.",
+ expectedVersion: "1.0",
+ action() {
+ gAppInfo.version = "2";
+ },
+ },
+ {
+ what: "App update and a schema change causes a reload of the manifest.",
+ expectedVersion: "2.0",
+ action() {
+ gAppInfo.version = "3";
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, 0);
+ },
+ },
+ {
+ what: "No schema change, no manifest reload.",
+ expectedVersion: "1.0",
+ action() {},
+ },
+ {
+ what: "Modified timestamp on the XPI causes a reload of the manifest.",
+ expectedVersion: "2.0",
+ async action() {
+ let stat = await IOUtils.stat(xpiPath);
+ let newLastModTime = stat.lastModified + 60 * 1000;
+ await IOUtils.setModificationTime(xpiPath, newLastModTime);
+ },
+ },
+ ];
+
+ for (let test of TESTS) {
+ info(test.what);
+ await promiseInstallFile(xpi1, false, fakeInstallTelemetryInfo);
+
+ let addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Got an addon object as expected");
+ equal(addon.version, "1.0", "Got the expected version");
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ fakeInstallTelemetryInfo,
+ "Got the expected installTelemetryInfo after installing the addon"
+ );
+
+ await promiseShutdownManager();
+
+ let fileInfo = await IOUtils.stat(xpiPath);
+
+ xpi2.copyTo(profileDir, `${ID}.xpi`);
+
+ // Make sure the timestamp of the extension is unchanged, so it is not
+ // re-scanned for that reason.
+ await IOUtils.setModificationTime(xpiPath, fileInfo.lastModified);
+
+ await test.action();
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Got an addon object as expected");
+ equal(addon.version, test.expectedVersion, "Got the expected version");
+ Assert.deepEqual(
+ addon.installTelemetryInfo,
+ fakeInstallTelemetryInfo,
+ "Got the expected installTelemetryInfo after rebuilding the DB"
+ );
+
+ await addon.uninstall();
+ }
+});
+
+add_task(async function embedder_disabled_stays_disabled() {
+ Services.prefs.setBoolPref(PREF_IS_EMBEDDED, true);
+
+ const ID = "embedder-disabled@tests.mozilla.org";
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Add-on",
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let addon = await promiseAddonByID(ID);
+
+ equal(addon.embedderDisabled, false);
+
+ await addon.setEmbedderDisabled(true);
+ equal(addon.embedderDisabled, true);
+
+ await promiseShutdownManager();
+
+ // Change db schema to force reload
+ Services.prefs.setIntPref(PREF_DB_SCHEMA, 0);
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ equal(addon.embedderDisabled, true);
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_seen.js b/toolkit/mozapps/extensions/test/xpcshell/test_seen.js
new file mode 100644
index 0000000000..fbf43f5cc0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_seen.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "addon@tests.mozilla.org";
+
+let profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+// By default disable add-ons from the profile and the system-wide scope
+const SCOPES = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_SYSTEM;
+Services.prefs.setIntPref("extensions.enabledScopes", SCOPES);
+Services.prefs.setIntPref("extensions.autoDisableScopes", SCOPES);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+const XPIS = {};
+
+// Installing an add-on through the API should mark it as seen
+add_task(async function () {
+ await promiseStartupManager();
+
+ for (let n of [1, 2]) {
+ XPIS[n] = await createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: `${n}.0`,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ }
+
+ await promiseInstallFile(XPIS[1]);
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ // Installing an update should retain that
+ await promiseInstallFile(XPIS[2]);
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// Sideloading an add-on in the systemwide location should mark it as unseen
+add_task(async function () {
+ let savedStartupScanScopes = Services.prefs.getIntPref(
+ "extensions.startupScanScopes"
+ );
+ Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+ Services.prefs.setIntPref(
+ "extensions.sideloadScopes",
+ AddonManager.SCOPE_ALL
+ );
+
+ let systemParentDir = gTmpD.clone();
+ systemParentDir.append("systemwide-extensions");
+ registerDirectory("XRESysSExtPD", systemParentDir.clone());
+ registerCleanupFunction(() => {
+ systemParentDir.remove(true);
+ });
+
+ let systemDir = systemParentDir.clone();
+ systemDir.append(Services.appinfo.ID);
+
+ let path = await manuallyInstall(XPIS[1], systemDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+ await AddonManagerPrivate.getNewSideloads();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseShutdownManager();
+ Services.obs.notifyObservers(path, "flush-cache-entry");
+ path.remove(true);
+
+ Services.prefs.setIntPref(
+ "extensions.startupScanScopes",
+ savedStartupScanScopes
+ );
+ Services.prefs.clearUserPref("extensions.sideloadScopes");
+});
+
+// Sideloading an add-on in the profile should mark it as unseen and it should
+// remain unseen after an update is sideloaded.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseShutdownManager();
+
+ // Sideloading an update shouldn't change the state
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(XPIS[2], profileDir, ID);
+ setExtensionModifiedTime(path, Date.now());
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// Sideloading an add-on in the profile should mark it as unseen and it should
+// remain unseen after a regular update.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ // Updating through the API shouldn't change the state
+ let install = await promiseInstallFile(XPIS[2]);
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.ok(
+ !hasFlag(install.addon.pendingOperations, AddonManager.PENDING_INSTALL)
+ );
+
+ addon = install.addon;
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// After a sideloaded addon has been seen, sideloading an update should
+// not reset it to unseen.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+ addon.markAsSeen();
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseShutdownManager();
+
+ // Sideloading an update shouldn't change the state
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(XPIS[2], profileDir, ID);
+ setExtensionModifiedTime(path, Date.now());
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// After a sideloaded addon has been seen, manually applying an update should
+// not reset it to unseen.
+add_task(async function () {
+ let path = await manuallyInstall(XPIS[1], profileDir, ID);
+ // Make sure the startup code will detect sideloaded updates
+ setExtensionModifiedTime(path, Date.now() - 10000);
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "1.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.seen);
+ addon.markAsSeen();
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ // Updating through the API shouldn't change the state
+ let install = await promiseInstallFile(XPIS[2]);
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.ok(
+ !hasFlag(install.addon.pendingOperations, AddonManager.PENDING_INSTALL)
+ );
+
+ addon = install.addon;
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon.version, "2.0");
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(addon.seen);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
new file mode 100644
index 0000000000..d6fe082666
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Verify that API functions fail if the Add-ons Manager isn't initialised.
+
+const IGNORE = [
+ "getPreferredIconURL",
+ "escapeAddonURI",
+ "shouldAutoUpdate",
+ "getStartupChanges",
+ "addAddonListener",
+ "removeAddonListener",
+ "addInstallListener",
+ "removeInstallListener",
+ "addManagerListener",
+ "removeManagerListener",
+ "addExternalExtensionLoader",
+ "beforeShutdown",
+ "init",
+ "stateToString",
+ "errorToString",
+ "getUpgradeListener",
+ "addUpgradeListener",
+ "removeUpgradeListener",
+ "getInstallSourceFromHost",
+ "stageLangpacksForAppUpdate",
+];
+
+const IGNORE_PRIVATE = [
+ "AddonAuthor",
+ "AddonScreenshot",
+ "startup",
+ "shutdown",
+ "addonIsActive",
+ "registerProvider",
+ "unregisterProvider",
+ "addStartupChange",
+ "removeStartupChange",
+ "getNewSideloads",
+ "finalShutdown",
+ "recordTimestamp",
+ "recordSimpleMeasure",
+ "recordException",
+ "getSimpleMeasures",
+ "simpleTimer",
+ "setTelemetryDetails",
+ "getTelemetryDetails",
+ "callNoUpdateListeners",
+ "backgroundUpdateTimerHandler",
+ "hasUpgradeListener",
+ "getUpgradeListener",
+ "isDBLoaded",
+ "recordTiming",
+ "BOOTSTRAP_REASONS",
+ "notifyAddonChanged",
+ "overrideAddonRepository",
+ "overrideAsyncShutdown",
+];
+
+async function test_functions() {
+ for (let prop in AddonManager) {
+ if (IGNORE.includes(prop)) {
+ continue;
+ }
+ if (typeof AddonManager[prop] != "function") {
+ continue;
+ }
+
+ let args = [];
+
+ // Getter functions need a callback and in some cases not having one will
+ // throw before checking if the add-ons manager is initialized so pass in
+ // an empty one.
+ if (prop.startsWith("get")) {
+ // For now all getter functions with more than one argument take the
+ // callback in the second argument.
+ if (AddonManager[prop].length > 1) {
+ args.push(undefined, () => {});
+ } else {
+ args.push(() => {});
+ }
+ }
+
+ // Clean this up in bug 1365720
+ if (prop == "getActiveAddons") {
+ args = [];
+ }
+
+ try {
+ info("AddonManager." + prop);
+ await AddonManager[prop](...args);
+ do_throw(prop + " did not throw an exception");
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ do_throw(prop + " threw an unexpected exception: " + e);
+ }
+ }
+ }
+
+ for (let prop in AddonManagerPrivate) {
+ if (IGNORE_PRIVATE.includes(prop)) {
+ continue;
+ }
+ if (typeof AddonManagerPrivate[prop] != "function") {
+ continue;
+ }
+
+ try {
+ info("AddonManagerPrivate." + prop);
+ AddonManagerPrivate[prop]();
+ do_throw(prop + " did not throw an exception");
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ do_throw(prop + " threw an unexpected exception: " + e);
+ }
+ }
+ }
+}
+
+add_task(async function () {
+ await test_functions();
+ await promiseStartupManager();
+ await promiseShutdownManager();
+ await test_functions();
+});
+
+function run_test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ run_next_test();
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js
new file mode 100644
index 0000000000..9fd06bb5ee
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_barriers.js
@@ -0,0 +1,215 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Management: "resource://gre/modules/Extension.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+add_task(async function test_shutdown_barriers() {
+ await promiseStartupManager();
+
+ const ID = "thing@xpcshell.addons.mozilla.org";
+ const VERSION = "1.42";
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: VERSION,
+ },
+ });
+
+ await AddonManager.installTemporaryAddon(xpi);
+
+ let blockersComplete = 0;
+ AddonManager.beforeShutdown.addBlocker("Before shutdown", async () => {
+ equal(blockersComplete, 0, "No blockers have run yet");
+
+ // Delay a bit to make sure this doesn't succeed because of a timing
+ // fluke.
+ await delay(1000);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ checkAddon(ID, addon, {
+ version: VERSION,
+ userDisabled: false,
+ appDisabled: false,
+ });
+
+ await addon.disable();
+
+ // Delay again for the same reasons.
+ await delay(1000);
+
+ addon = await AddonManager.getAddonByID(ID);
+ checkAddon(ID, addon, {
+ version: VERSION,
+ userDisabled: true,
+ appDisabled: false,
+ });
+
+ blockersComplete++;
+ });
+ AddonManagerPrivate.finalShutdown.addBlocker("Before shutdown", async () => {
+ equal(blockersComplete, 1, "Before shutdown blocker has run");
+
+ // Should probably try to access XPIDatabase here to make sure it
+ // doesn't work correctly, but it's a bit hairy.
+
+ // Delay a bit to make sure this doesn't succeed because of a timing
+ // fluke.
+ await delay(1000);
+
+ blockersComplete++;
+ });
+
+ await promiseShutdownManager();
+ equal(
+ blockersComplete,
+ 2,
+ "Both shutdown blockers ran before manager shutdown completed"
+ );
+});
+
+// Regression test for Bug 1814104.
+add_task(async function test_wait_addons_startup_before_granting_quit() {
+ await promiseStartupManager();
+
+ const extensions = [];
+ for (let i = 0; i < 20; i++) {
+ extensions.push(
+ ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ background() {},
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: `test-extension-${i}@mozilla` },
+ },
+ },
+ })
+ );
+ }
+
+ info("Wait for all test extension to have been started once");
+ await Promise.all(extensions.map(ext => ext.startup()));
+ await promiseShutdownManager();
+
+ info("Test early shutdown while enabled addons are still being started");
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ function listener(_evt, extension) {
+ ok(
+ !XPIExports.XPIProvider._closing,
+ `Unxpected addon startup for "${extension.id}" after XPIProvider have been closed and shutting down`
+ );
+ }
+ Management.on("startup", listener);
+ promiseStartupManager();
+ await promiseShutdownManager();
+
+ info("Uninstall test extensions");
+ Management.off("startup", listener);
+ await promiseStartupManager();
+ await Promise.all(extensions.map(ext => ext.awaitStartup));
+ await Promise.all(
+ extensions.map(ext => {
+ return AddonManager.getAddonByID(ext.id).then(addon =>
+ addon?.uninstall()
+ );
+ })
+ );
+ await promiseShutdownManager();
+});
+
+// Regression test for Bug 1799421.
+add_task(async function test_late_XPIDB_load_rejected() {
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+ const sandbox = sinon.createSandbox();
+ await AddonTestUtils.promiseStartupManager();
+
+ // Mock a late XPIDB load and expect to be rejected.
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+
+ const resolveDBReadySpy = sinon.spy(XPIExports.XPIInternal, "resolveDBReady");
+ XPIExports.XPIProvider._closing = true;
+ XPIExports.XPIDatabase._dbPromise = null;
+
+ Assert.equal(
+ await XPIExports.XPIDatabase.getAddonByID("test@addon"),
+ null,
+ "Expect a late getAddonByID call to be sucessfully resolved to null"
+ );
+
+ await Assert.rejects(
+ XPIExports.XPIDatabase._dbPromise,
+ /XPIDatabase.asyncLoadDB attempt after XPIProvider shutdown/,
+ "Expect XPIDatebase._dbPromise to be set to the expected rejected promise"
+ );
+
+ Assert.equal(
+ resolveDBReadySpy.callCount,
+ 1,
+ "Expect resolveDBReadySpy to have been called once"
+ );
+
+ Assert.equal(
+ resolveDBReadySpy.getCall(0).args[0],
+ XPIExports.XPIDatabase._dbPromise,
+ "Got the expected promise instance passed to the XPIProvider.resolveDBReady call"
+ );
+
+ // Cleanup sinon spy, AOM mocked status and shutdown AOM before exit test.
+ sandbox.restore();
+ XPIExports.XPIProvider._closing = false;
+ XPIExports.XPIDatabase._dbPromise = null;
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Regression test for Bug 1799421.
+//
+// NOTE: this test calls Services.startup.advanceShutdownPhase
+// with SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED to mock the scenario
+// and so using promiseStartupManager/promiseShutdownManager will
+// fail to bring the AddonManager back into a working status
+// because it would be detected as too late on the shutdown path.
+add_task(async function test_late_bootstrapscope_startup_rejected() {
+ await AddonTestUtils.promiseStartupManager();
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "test@addon" },
+ },
+ },
+ });
+
+ await extension.startup();
+ const { addon } = extension;
+ await addon.disable();
+ // Mock a shutdown which already got to shutdown confirmed
+ // and expect a rejection from trying to startup the BootstrapScope
+ // too late on shutdown.
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED
+ );
+
+ await Assert.rejects(
+ addon.enable(),
+ /XPIProvider can't start bootstrap scope for test@addon after shutdown was already granted/,
+ "Got the expected rejection on trying to enable the extension after shutdown granted"
+ );
+
+ info("Cleanup mocked late bootstrap scope before exit test");
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js
new file mode 100644
index 0000000000..2af6d42365
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown_early.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { TelemetryEnvironment } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryEnvironment.sys.mjs"
+);
+
+// Regression test for bug 1665568: verifies that AddonManager unblocks shutdown
+// when startup is interrupted very early.
+add_task(async function test_shutdown_immediately_after_startup() {
+ // Set as migrated to prevent sync DB load at startup.
+ Services.prefs.setCharPref("extensions.lastAppVersion", "42");
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42");
+
+ Cc["@mozilla.org/addons/integration;1"]
+ .getService(Ci.nsIObserver)
+ .observe(null, "addons-startup", null);
+
+ // Above, we have configured the runtime to avoid a forced synchronous load
+ // of the database. Confirm that this is indeed the case.
+ equal(AddonManagerPrivate.isDBLoaded(), false, "DB not loaded synchronously");
+
+ let shutdownCount = 0;
+ AddonManager.beforeShutdown.addBlocker("count", async () => ++shutdownCount);
+
+ let databaseLoaded = false;
+ AddonManagerPrivate.databaseReady.then(() => {
+ databaseLoaded = true;
+ });
+
+ // Accessing TelemetryEnvironment.currentEnvironment triggers initialization
+ // of TelemetryEnvironment / EnvironmentAddonBuilder, which registers a
+ // shutdown blocker.
+ equal(
+ TelemetryEnvironment.currentEnvironment.addons,
+ undefined,
+ "TelemetryEnvironment.currentEnvironment.addons is uninitialized"
+ );
+
+ info("Immediate exit at startup, without quit-application-granted");
+ Services.startup.advanceShutdownPhase(
+ Services.startup.SHUTDOWN_PHASE_APPSHUTDOWN
+ );
+ let shutdownPromise = MockAsyncShutdown.profileBeforeChange.trigger();
+ equal(shutdownCount, 1, "AddonManager.beforeShutdown has started");
+
+ // Note: Until now everything ran in the same tick of the event loop.
+
+ // Waiting for AddonManager to have shut down.
+ await shutdownPromise;
+
+ ok(databaseLoaded, "Addon DB loaded for use by TelemetryEnvironment");
+ equal(AddonManagerPrivate.isDBLoaded(), false, "DB unloaded after shutdown");
+
+ Assert.deepEqual(
+ TelemetryEnvironment.currentEnvironment.addons.activeAddons,
+ {},
+ "TelemetryEnvironment.currentEnvironment.addons is initialized"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js b/toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js
new file mode 100644
index 0000000000..6631fa47c5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideload_scopes.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+// We refer to addons that were sideloaded prior to disabling sideloading as legacy. We
+// determine that they are legacy because they are in a SCOPE that is not included
+// in AddonSettings.SCOPES_SIDELOAD.
+//
+// test_startup.js tests the legacy sideloading functionality still works as it should
+// for ESR and some 3rd party distributions, which is to allow sideloading in all scopes.
+//
+// This file tests that locking down the sideload functionality works as we expect it to, which
+// is to allow sideloading only in SCOPE_APPLICATION and SCOPE_PROFILE. We also allow the legacy
+// sideloaded addons to be updated or removed.
+//
+// We first change the sideload scope so we can sideload some addons into locations outside
+// the profile (ie. we create some legacy sideloads). We then reset it to test new sideloads.
+//
+// We expect new sideloads to only work in profile.
+// We expect new sideloads to fail elsewhere.
+// We expect to be able to change/uninstall legacy sideloads.
+
+// This test uses add-on versions that follow the toolkit version but we
+// started to encourage the use of a simpler format in Bug 1793925. We disable
+// the pref below to avoid install errors.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+// IDs for scopes that should sideload when sideloading
+// is not disabled.
+let legacyIDs = [
+ getID(`legacy-global`),
+ getID(`legacy-user`),
+ getID(`legacy-app`),
+ getID(`legacy-profile`),
+];
+
+add_task(async function test_sideloads_legacy() {
+ let IDs = [];
+
+ // Create a "legacy" addon for each scope.
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ let id = getID(`legacy-${name}`);
+ IDs.push(id);
+ await createWebExtension(id, initialVersion(name), dir);
+ }
+
+ await promiseStartupManager();
+
+ // SCOPE_APPLICATION will never sideload, so we expect 3 addons.
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+ Assert.equal(sideloaded.length, 4, "four sideloaded addons");
+ let sideloadedIds = sideloaded.map(a => a.id);
+ for (let id of legacyIDs) {
+ Assert.ok(sideloadedIds.includes(id), `${id} is sideloaded`);
+ }
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+
+ await promiseShutdownManager();
+});
+
+// Test that a sideload install in SCOPE_PROFILE is allowed, all others are
+// disallowed.
+add_task(async function test_sideloads_disabled() {
+ // First, reset our scope pref to disable sideloading. head_sideload.js set this to ALL.
+ Services.prefs.setIntPref(
+ "extensions.sideloadScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // Create 4 new addons, only one of these, "profile" should
+ // sideload.
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ await createWebExtension(getID(name), initialVersion(name), dir);
+ }
+
+ await promiseStartupManager();
+
+ // Test that the "profile" addon has been sideloaded.
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+ Assert.equal(sideloaded.length, 1, "one sideloaded addon");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, [
+ getID("profile"),
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+
+ for (let [name] of Object.entries(scopeDirectories)) {
+ let id = getID(name);
+ let addon = await promiseAddonByID(id);
+ if (name === "profile") {
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.id, id);
+ Assert.ok(addon.foreignInstall);
+ Assert.equal(addon.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(addon.userDisabled);
+ Assert.ok(!addon.seen);
+ } else {
+ Assert.equal(addon, null, `addon ${id} is not installed`);
+ }
+ }
+
+ // Test that we still have the 3 legacy addons from the prior test, plus
+ // the new "profile" addon from this test.
+ let extensionAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(
+ extensionAddons.length,
+ 5,
+ "five addons expected to be installed"
+ );
+ let IDs = extensionAddons.map(ext => ext.id);
+ for (let id of [getID("profile"), ...legacyIDs]) {
+ Assert.ok(IDs.includes(id), `${id} is installed`);
+ }
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_sideloads_changed() {
+ // Upgrade the manifest version
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ let id = getID(name);
+ await createWebExtension(id, `${name}.1`, dir);
+
+ id = getID(`legacy-${name}`);
+ await createWebExtension(id, `${name}.1`, dir);
+ }
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 5, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [
+ getID("profile"),
+ ...legacyIDs,
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+
+ await promiseShutdownManager();
+});
+
+// Remove one just to test the startup changes
+add_task(async function test_sideload_removal() {
+ let id = getID(`legacy-profile`);
+ let file = AddonTestUtils.getFileForAddon(profileDir, id);
+ file.remove(false);
+ Assert.ok(!file.exists());
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [id]);
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_sideload_uninstall() {
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 4, "addons installed");
+ for (let addon of addons) {
+ let file = AddonTestUtils.getFileForAddon(
+ scopeToDir.get(addon.scope),
+ addon.id
+ );
+ await addon.uninstall();
+ // Addon file should still exist in non-profile directories.
+ Assert.equal(
+ addon.scope !== AddonManager.SCOPE_PROFILE,
+ file.exists(),
+ `file remains after uninstall for non-profile sideloads, scope ${addon.scope}`
+ );
+ }
+ addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 0, "addons left");
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js
new file mode 100644
index 0000000000..f2ffe8e855
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+const ID3 = "addon3@tests.mozilla.org";
+
+async function createWebExtension(details) {
+ let options = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: details.id } },
+
+ name: details.name,
+
+ permissions: details.permissions,
+ },
+ };
+
+ if (details.iconURL) {
+ options.manifest.icons = { 64: details.iconURL };
+ }
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+
+ await AddonTestUtils.manuallyInstall(xpi);
+}
+
+add_task(async function test_sideloading() {
+ Services.prefs.setIntPref("extensions.autoDisableScopes", 15);
+ Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+
+ await createWebExtension({
+ id: ID1,
+ name: "Test 1",
+ userDisabled: true,
+ permissions: ["tabs", "https://*/*"],
+ iconURL: "foo-icon.png",
+ });
+
+ await createWebExtension({
+ id: ID2,
+ name: "Test 2",
+ permissions: ["<all_urls>"],
+ });
+
+ await createWebExtension({
+ id: ID3,
+ name: "Test 3",
+ permissions: ["<all_urls>"],
+ });
+
+ await promiseStartupManager();
+
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+
+ sideloaded.sort((a, b) => a.id.localeCompare(b.id));
+
+ deepEqual(
+ sideloaded.map(a => a.id),
+ [ID1, ID2, ID3],
+ "Got the correct sideload add-ons"
+ );
+
+ deepEqual(
+ sideloaded.map(a => a.userDisabled),
+ [true, true, true],
+ "All sideloaded add-ons are disabled"
+ );
+});
+
+add_task(async function test_getNewSideload_on_invalid_extension() {
+ let destDir = AddonTestUtils.profileExtensions.clone();
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "@invalid-extension" } },
+ name: "Invalid Extension",
+ },
+ });
+
+ // Create an invalid sideload by creating a file name that doesn't match the
+ // actual extension id.
+ await IOUtils.copy(
+ xpi.path,
+ PathUtils.join(destDir.path, "@wrong-extension-filename.xpi")
+ );
+
+ // Verify that getNewSideloads does not reject or throw when one of the sideloaded extensions
+ // is invalid.
+ const newSideloads = await AddonManagerPrivate.getNewSideloads();
+
+ const sideloadsInfo = newSideloads
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .map(({ id, seen, userDisabled, permissions }) => {
+ return {
+ id,
+ seen,
+ userDisabled,
+ canEnable: Boolean(permissions & AddonManager.PERM_CAN_ENABLE),
+ };
+ });
+
+ const expectedInfo = { seen: false, userDisabled: true, canEnable: true };
+
+ Assert.deepEqual(
+ sideloadsInfo,
+ [
+ { id: ID1, ...expectedInfo },
+ { id: ID2, ...expectedInfo },
+ { id: ID3, ...expectedInfo },
+ ],
+ "Got the expected sideloaded extensions"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js
new file mode 100644
index 0000000000..24aa8b228a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads_after_rebuild.js
@@ -0,0 +1,149 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/publicdomain/zero/1.0/
+
+"use strict";
+
+// This test uses add-on versions that follow the toolkit version but we
+// started to encourage the use of a simpler format in Bug 1793925. We disable
+// the pref below to avoid install errors.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+// IDs for scopes that should sideload when sideloading
+// is not disabled.
+let legacyIDs = [
+ getID(`legacy-global`),
+ getID(`legacy-user`),
+ getID(`legacy-app`),
+ getID(`legacy-profile`),
+];
+
+// This tests that, on a rebuild after addonStartup.json and extensions.json
+// are lost, we only sideload from the profile.
+add_task(async function test_sideloads_after_rebuild() {
+ let IDs = [];
+
+ // Create a sideloaded addon for each scope before the restriction is put
+ // in place (by updating the sideloadScopes preference).
+ for (let [name, dir] of Object.entries(scopeDirectories)) {
+ let id = getID(`legacy-${name}`);
+ IDs.push(id);
+ await createWebExtension(id, initialVersion(name), dir);
+ }
+
+ await promiseStartupManager();
+
+ // SCOPE_APPLICATION will never sideload, so we expect 3
+ let sideloaded = await AddonManagerPrivate.getNewSideloads();
+ Assert.equal(sideloaded.length, 4, "four sideloaded addon");
+ let sideloadedIds = sideloaded.map(a => a.id);
+ for (let id of legacyIDs) {
+ Assert.ok(sideloadedIds.includes(id));
+ }
+
+ // After a restart that causes a database rebuild, we should have
+ // the same addons available
+ await promiseShutdownManager();
+ // Reset our scope pref so the scope limitation works.
+ Services.prefs.setIntPref(
+ "extensions.sideloadScopes",
+ AddonManager.SCOPE_PROFILE
+ );
+
+ // Try to sideload from a non-profile directory.
+ await createWebExtension(
+ getID(`sideload-global-1`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+
+ await promiseStartupManager("2");
+
+ // We should still only have 4 addons.
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 4, "addons remain installed");
+
+ await promiseShutdownManager();
+
+ // Install a sideload that will not load because it is not in
+ // appStartup.json and is not in a sideloadScope.
+ await createWebExtension(
+ getID(`sideload-global-2`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+ await createWebExtension(
+ getID(`sideload-app-2`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+ // Install a sideload that will load. We cannot currently prevent
+ // this situation.
+ await createWebExtension(
+ getID(`sideload-profile`),
+ initialVersion("sideload-profile"),
+ profileDir
+ );
+
+ // Replace the extensions.json with something bogus so we lose our xpidatabase.
+ // On AOM startup, addons are restored with help from XPIState. Existing
+ // sideloads should all remain. One new sideloaded addon should be added from
+ // the profile.
+ await IOUtils.writeJSON(gExtensionsJSON.path, {
+ not: "what we expect to find",
+ });
+ info(`**** restart AOM and rebuild XPI database`);
+ await promiseStartupManager();
+
+ addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 5, "addons installed");
+
+ await promiseShutdownManager();
+
+ // Install a sideload that will not load.
+ await createWebExtension(
+ getID(`sideload-global-3`),
+ initialVersion("sideload-global"),
+ globalDir
+ );
+ // Install a sideload that will load. We cannot currently prevent
+ // this situation.
+ await createWebExtension(
+ getID(`sideload-profile-2`),
+ initialVersion("sideload-profile"),
+ profileDir
+ );
+
+ // Replace the extensions.json with something bogus so we lose our xpidatabase.
+ await IOUtils.writeJSON(gExtensionsJSON.path, {
+ not: "what we expect to find",
+ });
+ // Delete our appStartup/XPIState data. Now we should only be able to
+ // restore extensions in the profile.
+ gAddonStartup.remove(true);
+ info(`**** restart AOM and rebuild XPI database`);
+
+ await promiseStartupManager();
+
+ addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 3, "addons installed");
+
+ let [a1, a2, a3] = await promiseAddonsByIDs([
+ getID(`legacy-profile`),
+ getID(`sideload-profile`),
+ getID(`sideload-profile-2`),
+ ]);
+
+ Assert.notEqual(a1, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a2.id));
+
+ Assert.notEqual(a3, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js
new file mode 100644
index 0000000000..10bf7402d6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_inject.js
@@ -0,0 +1,429 @@
+// Enable signature checks for these tests
+gUseRealCertChecks = true;
+// Disable update security
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const DATA = "data/signing_checks/";
+const ADDONS = {
+ bootstrap: {
+ unsigned: "unsigned_bootstrap_2.xpi",
+ badid: "signed_bootstrap_badid_2.xpi",
+ signed: "signed_bootstrap_2.xpi",
+ preliminary: "preliminary_bootstrap_2.xpi",
+ },
+ nonbootstrap: {
+ unsigned: "unsigned_nonbootstrap_2.xpi",
+ badid: "signed_nonbootstrap_badid_2.xpi",
+ signed: "signed_nonbootstrap_2.xpi",
+ },
+};
+const ID = "test@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+// Deletes a file from the test add-on in the profile
+function breakAddon(file) {
+ if (TEST_UNPACKED) {
+ let f = file.clone();
+ f.append("test.txt");
+ f.remove(true);
+
+ f = file.clone();
+ f.append("install.rdf");
+ f.lastModifiedTime = Date.now();
+ } else {
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(file, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.removeEntry("test.txt", false);
+ zipW.close();
+ }
+}
+
+function resetPrefs() {
+ Services.prefs.setIntPref("bootstraptest.active_version", -1);
+ Services.prefs.setIntPref("bootstraptest.installed_version", -1);
+ Services.prefs.setIntPref("bootstraptest.startup_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.shutdown_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.install_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.uninstall_reason", -1);
+ Services.prefs.setIntPref("bootstraptest.startup_oldversion", -1);
+ Services.prefs.setIntPref("bootstraptest.shutdown_newversion", -1);
+ Services.prefs.setIntPref("bootstraptest.install_oldversion", -1);
+ Services.prefs.setIntPref("bootstraptest.uninstall_newversion", -1);
+}
+
+function clearCache(file) {
+ if (TEST_UNPACKED) {
+ return;
+ }
+
+ Services.obs.notifyObservers(file, "flush-cache-entry");
+}
+
+function getActiveVersion() {
+ return Services.prefs.getIntPref("bootstraptest.active_version");
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+
+ // Start and stop the manager to initialise everything in the profile before
+ // actual testing
+ await promiseStartupManager();
+ await promiseShutdownManager();
+ resetPrefs();
+});
+
+// Injecting into profile (bootstrap)
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.unsigned),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+ Assert.equal(getActiveVersion(), -1);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.signed),
+ profileDir,
+ ID
+ );
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+ Assert.equal(getActiveVersion(), -1);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.badid),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+ Assert.equal(getActiveVersion(), -1);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Installs a signed add-on then modifies it in place breaking its signing
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.signed),
+ profileDir,
+ ID
+ );
+
+ // Make it appear to come from the past so when we modify it later it is
+ // detected during startup. Obviously malware can bypass this method of
+ // detection but the periodic scan will catch that
+ await promiseSetExtensionModifiedTime(file.path, Date.now() - 600000);
+
+ await promiseStartupManager();
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+ Assert.equal(getActiveVersion(), 2);
+
+ await promiseShutdownManager();
+ Assert.equal(getActiveVersion(), 0);
+
+ clearCache(file);
+ breakAddon(file);
+ resetPrefs();
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+ Assert.equal(getActiveVersion(), -1);
+
+ let ids = AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_DISABLED
+ );
+ Assert.equal(ids.length, 1);
+ Assert.equal(ids[0], ID);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Injecting into profile (non-bootstrap)
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.unsigned),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.signed),
+ profileDir,
+ ID
+ );
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.badid),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ // Currently we leave the sideloaded add-on there but just don't run it
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Installs a signed add-on then modifies it in place breaking its signing
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.nonbootstrap.signed),
+ profileDir,
+ ID
+ );
+
+ // Make it appear to come from the past so when we modify it later it is
+ // detected during startup. Obviously malware can bypass this method of
+ // detection but the periodic scan will catch that
+ await promiseSetExtensionModifiedTime(file.path, Date.now() - 60000);
+
+ await promiseStartupManager();
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ await promiseShutdownManager();
+
+ clearCache(file);
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_BROKEN);
+
+ let ids = AddonManager.getStartupChanges(
+ AddonManager.STARTUP_CHANGE_DISABLED
+ );
+ Assert.equal(ids.length, 1);
+ Assert.equal(ids[0], ID);
+
+ await addon.uninstall();
+ await promiseRestartManager();
+ await promiseShutdownManager();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Stage install then modify before startup (non-bootstrap)
+add_task(async function () {
+ await promiseStartupManager();
+ await promiseInstallAllFiles([
+ do_get_file(DATA + ADDONS.nonbootstrap.signed),
+ ]);
+ await promiseShutdownManager();
+
+ let staged = profileDir.clone();
+ staged.append("staged");
+ staged.append(do_get_expected_addon_name(ID));
+ Assert.ok(staged.exists());
+
+ breakAddon(staged);
+ await promiseStartupManager();
+
+ // Should have refused to install the broken staged version
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ clearCache(staged);
+
+ await promiseShutdownManager();
+});
+
+// Manufacture staged install (bootstrap)
+add_task(async function () {
+ let stage = profileDir.clone();
+ stage.append("staged");
+
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.signed),
+ stage,
+ ID
+ );
+ breakAddon(file);
+
+ await promiseStartupManager();
+
+ // Should have refused to install the broken staged version
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+ Assert.equal(getActiveVersion(), -1);
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+
+ await promiseShutdownManager();
+ resetPrefs();
+});
+
+// Preliminarily-signed sideloaded add-ons should work
+add_task(async function () {
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.preliminary),
+ profileDir,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_PRELIMINARY);
+ Assert.equal(getActiveVersion(), 2);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
+
+// Preliminarily-signed sideloaded add-ons should work via staged install
+add_task(async function () {
+ let stage = profileDir.clone();
+ stage.append("staged");
+
+ let file = await manuallyInstall(
+ do_get_file(DATA + ADDONS.bootstrap.preliminary),
+ stage,
+ ID
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_PRELIMINARY);
+ Assert.equal(getActiveVersion(), 2);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ resetPrefs();
+
+ Assert.ok(!file.exists());
+ clearCache(file);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js
new file mode 100644
index 0000000000..065463864d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_install.js
@@ -0,0 +1,337 @@
+// Enable signature checks for these tests
+gUseRealCertChecks = true;
+
+const DATA = "data/signing_checks/";
+const ADDONS = {
+ unsigned: "unsigned.xpi",
+ signed1: "signed1.xpi",
+ signed2: "signed2.xpi",
+ privileged: "privileged.xpi",
+
+ // Bug 1509093
+ // sha256Signed: "signed_bootstrap_sha256_1.xpi",
+};
+
+// The ID in signed1.xpi and signed2.xpi
+const ID = "test@somewhere.com";
+const PR_USEC_PER_MSEC = 1000;
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://example.com/update.json"
+);
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+// Creates an add-on with a broken signature by changing an existing file
+function createBrokenAddonModify(file) {
+ let brokenFile = gTmpD.clone();
+ brokenFile.append("broken.xpi");
+ file.copyTo(brokenFile.parent, brokenFile.leafName);
+
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData("FOOBAR", -1);
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(brokenFile, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.removeEntry("test.txt", false);
+ zipW.addEntryStream(
+ "test.txt",
+ new Date() * PR_USEC_PER_MSEC,
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ zipW.close();
+
+ return brokenFile;
+}
+
+// Creates an add-on with a broken signature by adding a new file
+function createBrokenAddonAdd(file) {
+ let brokenFile = gTmpD.clone();
+ brokenFile.append("broken.xpi");
+ file.copyTo(brokenFile.parent, brokenFile.leafName);
+
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData("FOOBAR", -1);
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(brokenFile, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.addEntryStream(
+ "test2.txt",
+ new Date() * PR_USEC_PER_MSEC,
+ Ci.nsIZipWriter.COMPRESSION_NONE,
+ stream,
+ false
+ );
+ zipW.close();
+
+ return brokenFile;
+}
+
+// Creates an add-on with a broken signature by removing an existing file
+function createBrokenAddonRemove(file) {
+ let brokenFile = gTmpD.clone();
+ brokenFile.append("broken.xpi");
+ file.copyTo(brokenFile.parent, brokenFile.leafName);
+
+ var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setData("FOOBAR", -1);
+ var zipW = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter);
+ zipW.open(brokenFile, FileUtils.MODE_RDWR | FileUtils.MODE_APPEND);
+ zipW.removeEntry("test.txt", false);
+ zipW.close();
+
+ return brokenFile;
+}
+
+function serveUpdate(filename) {
+ const RESPONSE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: `http://example.com/${filename}`,
+ applications: {
+ gecko: {
+ strict_min_version: "4",
+ advisory_max_version: "6",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+ AddonTestUtils.registerJSON(testserver, "/update.json", RESPONSE);
+}
+
+async function test_install_broken(
+ file,
+ expectedError,
+ expectNullAddon = true
+) {
+ let install = await AddonManager.getInstallForFile(file);
+ await Assert.rejects(
+ install.install(),
+ /Install failed/,
+ "Install of an improperly signed extension should throw"
+ );
+
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, expectedError);
+
+ if (expectNullAddon) {
+ Assert.equal(install.addon, null);
+ }
+}
+
+async function test_install_working(file, expectedSignedState) {
+ let install = await AddonManager.getInstallForFile(file);
+ await install.install();
+
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.notEqual(install.addon, null);
+ Assert.equal(install.addon.signedState, expectedSignedState);
+
+ await install.addon.uninstall();
+}
+
+async function test_update_broken(file1, file2, expectedError) {
+ // First install the older version
+ await Promise.all([
+ promiseInstallFile(file1),
+ promiseWebExtensionStartup(ID),
+ ]);
+
+ testserver.registerFile("/" + file2.leafName, file2);
+ serveUpdate(file2.leafName);
+
+ let addon = await promiseAddonByID(ID);
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await Assert.rejects(
+ install.install(),
+ /Install failed/,
+ "Update to an improperly signed extension should throw"
+ );
+
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, expectedError);
+ Assert.equal(install.addon, null);
+
+ testserver.registerFile("/" + file2.leafName, null);
+ testserver.registerPathHandler("/update.json", null);
+
+ await addon.uninstall();
+}
+
+async function test_update_working(file1, file2, expectedSignedState) {
+ // First install the older version
+ await promiseInstallFile(file1);
+
+ testserver.registerFile("/" + file2.leafName, file2);
+ serveUpdate(file2.leafName);
+
+ let addon = await promiseAddonByID(ID);
+ let update = await promiseFindAddonUpdates(addon);
+ let install = update.updateAvailable;
+ await Promise.all([install.install(), promiseWebExtensionStartup(ID)]);
+
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.notEqual(install.addon, null);
+ Assert.equal(install.addon.signedState, expectedSignedState);
+
+ testserver.registerFile("/" + file2.leafName, null);
+ testserver.registerPathHandler("/update.json", null);
+
+ await install.addon.uninstall();
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+ await promiseStartupManager();
+});
+
+// Try to install a broken add-on
+add_task(async function test_install_invalid_modified() {
+ let file = createBrokenAddonModify(do_get_file(DATA + ADDONS.signed1));
+ await test_install_broken(file, AddonManager.ERROR_CORRUPT_FILE);
+ file.remove(true);
+});
+
+add_task(async function test_install_invalid_added() {
+ let file = createBrokenAddonAdd(do_get_file(DATA + ADDONS.signed1));
+ await test_install_broken(file, AddonManager.ERROR_CORRUPT_FILE);
+ file.remove(true);
+});
+
+add_task(async function test_install_invalid_removed() {
+ let file = createBrokenAddonRemove(do_get_file(DATA + ADDONS.signed1));
+ await test_install_broken(file, AddonManager.ERROR_CORRUPT_FILE);
+ file.remove(true);
+});
+
+// Try to install an unsigned add-on
+add_task(async function test_install_invalid_unsigned() {
+ let file = do_get_file(DATA + ADDONS.unsigned);
+ await test_install_broken(file, AddonManager.ERROR_SIGNEDSTATE_REQUIRED);
+});
+
+// Try to install a signed add-on
+add_task(async function test_install_valid() {
+ let file = do_get_file(DATA + ADDONS.signed1);
+ await test_install_working(file, AddonManager.SIGNEDSTATE_SIGNED);
+});
+
+add_task(
+ {
+ pref_set: [["xpinstall.signatures.dev-root", true]],
+ // `xpinstall.signatures.dev-root` is not taken into account on release
+ // builds because `MOZ_REQUIRE_SIGNING` is set to `true`.
+ skip_if: () => AppConstants.MOZ_REQUIRE_SIGNING,
+ },
+ async function test_install_valid_file_with_different_root_cert() {
+ const TEST_CASES = [
+ {
+ title: "XPI without ID in manifest",
+ xpi: "data/webext-implicit-id.xpi",
+ expectedMessage:
+ /Cannot find id for addon .+ Preference xpinstall.signatures.dev-root is set/,
+ },
+ {
+ title: "XPI with ID in manifest",
+ xpi: DATA + ADDONS.signed1,
+ expectedMessage: /Add-on test@somewhere.com is not correctly signed/,
+ },
+ ];
+
+ for (const { title, xpi, expectedMessage } of TEST_CASES) {
+ info(`test_install_valid_file_with_different_root_cert: ${title}`);
+
+ const file = do_get_file(xpi);
+
+ const awaitConsole = new Promise(resolve => {
+ Services.console.registerListener(function listener(message) {
+ if (expectedMessage.test(message.message)) {
+ Services.console.unregisterListener(listener);
+ resolve();
+ }
+ });
+ });
+
+ await test_install_broken(
+ file,
+ AddonManager.ERROR_CORRUPT_FILE,
+ // We don't expect the `addon` property on the `install` object to be
+ // `null` because that seems to happen later (when the signature is
+ // checked).
+ false
+ );
+
+ await awaitConsole;
+ }
+ }
+);
+
+// Try to install an add-on signed with SHA-256
+add_task(async function test_install_valid_sha256() {
+ // Bug 1509093
+ // let file = do_get_file(DATA + ADDONS.sha256Signed);
+ // await test_install_working(file, AddonManager.SIGNEDSTATE_SIGNED);
+});
+
+// Try to install an add-on with the "Mozilla Extensions" OU
+add_task(async function test_install_valid_privileged() {
+ let file = do_get_file(DATA + ADDONS.privileged);
+ await test_install_working(file, AddonManager.SIGNEDSTATE_PRIVILEGED);
+});
+
+// Try to update to a broken add-on
+add_task(async function test_update_invalid_modified() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = createBrokenAddonModify(do_get_file(DATA + ADDONS.signed2));
+ await test_update_broken(file1, file2, AddonManager.ERROR_CORRUPT_FILE);
+ file2.remove(true);
+});
+
+add_task(async function test_update_invalid_added() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = createBrokenAddonAdd(do_get_file(DATA + ADDONS.signed2));
+ await test_update_broken(file1, file2, AddonManager.ERROR_CORRUPT_FILE);
+ file2.remove(true);
+});
+
+add_task(async function test_update_invalid_removed() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = createBrokenAddonRemove(do_get_file(DATA + ADDONS.signed2));
+ await test_update_broken(file1, file2, AddonManager.ERROR_CORRUPT_FILE);
+ file2.remove(true);
+});
+
+// Try to update to an unsigned add-on
+add_task(async function test_update_invalid_unsigned() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = do_get_file(DATA + ADDONS.unsigned);
+ await test_update_broken(
+ file1,
+ file2,
+ AddonManager.ERROR_SIGNEDSTATE_REQUIRED
+ );
+});
+
+// Try to update to a signed add-on
+add_task(async function test_update_valid() {
+ let file1 = do_get_file(DATA + ADDONS.signed1);
+ let file2 = do_get_file(DATA + ADDONS.signed2);
+ await test_update_working(file1, file2, AddonManager.SIGNEDSTATE_SIGNED);
+});
+
+add_task(() => promiseShutdownManager());
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js
new file mode 100644
index 0000000000..8ad83b2ecb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_langpack.js
@@ -0,0 +1,67 @@
+const PREF_SIGNATURES_GENERAL = "xpinstall.signatures.required";
+const PREF_SIGNATURES_LANGPACKS = "extensions.langpacks.signatures.required";
+
+// Disable "xpc::IsInAutomation()", since it would override the behavior
+// we're testing for.
+Services.prefs.setBoolPref(
+ "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer",
+ false
+);
+
+// Try to install the given XPI file, and assert that the install
+// succeeds. Uninstalls before returning.
+async function installShouldSucceed(file) {
+ let install = await promiseInstallFile(file);
+ Assert.equal(install.state, AddonManager.STATE_INSTALLED);
+ Assert.notEqual(install.addon, null);
+ await install.addon.uninstall();
+}
+
+// Try to install the given XPI file, assert that the install fails
+// due to lack of signing.
+async function installShouldFail(file) {
+ let install;
+ try {
+ install = await AddonManager.getInstallForFile(file);
+ } catch (err) {}
+ Assert.equal(install.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install.error, AddonManager.ERROR_SIGNEDSTATE_REQUIRED);
+ Assert.equal(install.addon, null);
+}
+
+// Test that the preference controlling langpack signing works properly
+// (and that the general preference for addon signing does not affect
+// language packs).
+add_task(async function () {
+ AddonTestUtils.useRealCertChecks = true;
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+
+ Services.prefs.setBoolPref(PREF_SIGNATURES_GENERAL, true);
+ Services.prefs.setBoolPref(PREF_SIGNATURES_LANGPACKS, true);
+
+ // The signed langpack should always install.
+ let signedXPI = do_get_file("data/signing_checks/langpack_signed.xpi");
+ await installShouldSucceed(signedXPI);
+
+ // With signatures required, unsigned langpack should not install.
+ let unsignedXPI = do_get_file("data/signing_checks/langpack_unsigned.xpi");
+ await installShouldFail(unsignedXPI);
+
+ // Even with the general xpi signing pref off, an unsigned langapck
+ // should not install.
+ Services.prefs.setBoolPref(PREF_SIGNATURES_GENERAL, false);
+ await installShouldFail(unsignedXPI);
+
+ // But with the langpack signing pref off, unsigned langpack should
+ // install only on non-release builds.
+ Services.prefs.setBoolPref(PREF_SIGNATURES_LANGPACKS, false);
+ if (AppConstants.MOZ_REQUIRE_SIGNING) {
+ await installShouldFail(unsignedXPI);
+ } else {
+ await installShouldSucceed(unsignedXPI);
+ }
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js
new file mode 100644
index 0000000000..2aa76e8ff8
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_long.js
@@ -0,0 +1,23 @@
+gUseRealCertChecks = true;
+
+const ID = "123456789012345678901234567890123456789012345678901@somewhere.com";
+
+// Tests that signature verification works correctly on an extension with
+// an ID that does not fit into a certificate CN field.
+add_task(async function test_long_id() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ Assert.greater(ID.length, 64, "ID is > 64 characters");
+
+ await promiseInstallFile(do_get_file("data/signing_checks/long.xpi"));
+ let addon = await promiseAddonByID(ID);
+
+ Assert.notEqual(addon, null, "Addon install properly");
+ Assert.ok(
+ addon.signedState > AddonManager.SIGNEDSTATE_MISSING,
+ "Signature verification worked properly"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js
new file mode 100644
index 0000000000..39abab438d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_updatepref.js
@@ -0,0 +1,130 @@
+// Disable update security
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+gUseRealCertChecks = true;
+
+const DATA = "data/signing_checks/";
+const ID = "test@somewhere.com";
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+AddonTestUtils.registerJSON(testserver, "/update.json", {
+ addons: {
+ [ID]: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: {
+ strict_min_version: "4",
+ strict_max_version: "6",
+ },
+ },
+ },
+ },
+});
+
+Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://example.com/update.json"
+);
+
+function verifySignatures() {
+ return new Promise(resolve => {
+ let observer = (subject, topic, data) => {
+ Services.obs.removeObserver(observer, "xpi-signature-changed");
+ resolve(JSON.parse(data));
+ };
+ Services.obs.addObserver(observer, "xpi-signature-changed");
+
+ info("Verifying signatures");
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ XPIExports.XPIDatabase.verifySignatures();
+ });
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+});
+
+// Updating the pref without changing the app version won't disable add-ons
+// immediately but will after a signing check
+add_task(async function () {
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+ await promiseStartupManager();
+
+ // Install an unsigned add-on
+ await promiseInstallFile(do_get_file(DATA + "unsigned.xpi"));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await promiseShutdownManager();
+
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, true);
+
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ // Update checks shouldn't affect the add-on
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ let changes = await verifySignatures();
+
+ Assert.equal(changes.disabled.length, 1);
+ Assert.equal(changes.disabled[0], ID);
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// Updating the pref with changing the app version will disable add-ons
+// immediately
+add_task(async function () {
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, false);
+ await promiseStartupManager();
+
+ // Install an unsigned add-on
+ await promiseInstallFile(do_get_file(DATA + "unsigned.xpi"));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await promiseShutdownManager();
+
+ Services.prefs.setBoolPref(PREF_XPI_SIGNATURES_REQUIRED, true);
+ gAppInfo.version = 5.0;
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js b/toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js
new file mode 100644
index 0000000000..c17cb941cb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_signed_verify.js
@@ -0,0 +1,109 @@
+// Enable signature checks for these tests
+gUseRealCertChecks = true;
+
+const DATA = "data/signing_checks";
+const ID = "test@somewhere.com";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+function verifySignatures() {
+ return new Promise(resolve => {
+ let observer = (subject, topic, data) => {
+ Services.obs.removeObserver(observer, "xpi-signature-changed");
+ resolve(JSON.parse(data));
+ };
+ Services.obs.addObserver(observer, "xpi-signature-changed");
+
+ info("Verifying signatures");
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ XPIExports.XPIDatabase.verifySignatures();
+ });
+}
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "4", "4");
+
+add_task(async function test_no_change() {
+ await promiseStartupManager();
+
+ // Install the first add-on
+ await promiseInstallFile(do_get_file(`${DATA}/signed1.xpi`));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.appDisabled, false);
+ Assert.equal(addon.isActive, true);
+ Assert.equal(addon.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ // Swap in the files from the next add-on
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(do_get_file(`${DATA}/signed2.xpi`), profileDir, ID);
+
+ let listener = {
+ onPropetyChanged(_addon, properties) {
+ Assert.ok(false, `Got unexpected onPropertyChanged for ${_addon.id}`);
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+
+ // Trigger the check
+ let changes = await verifySignatures();
+ Assert.equal(changes.enabled.length, 0);
+ Assert.equal(changes.disabled.length, 0);
+
+ Assert.equal(addon.appDisabled, false);
+ Assert.equal(addon.isActive, true);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ await addon.uninstall();
+ AddonManager.removeAddonListener(listener);
+});
+
+add_task(async function test_diable() {
+ // Install the first add-on
+ await promiseInstallFile(do_get_file(`${DATA}/signed1.xpi`));
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED);
+
+ // Swap in the files from the next add-on
+ manuallyUninstall(profileDir, ID);
+ await manuallyInstall(do_get_file(`${DATA}/unsigned.xpi`), profileDir, ID);
+
+ let changedProperties = [];
+ let listener = {
+ onPropertyChanged(_, properties) {
+ changedProperties.push(...properties);
+ },
+ };
+ AddonManager.addAddonListener(listener);
+
+ // Trigger the check
+ let [changes] = await Promise.all([
+ verifySignatures(),
+ promiseAddonEvent("onDisabling"),
+ ]);
+
+ Assert.equal(changes.enabled.length, 0);
+ Assert.equal(changes.disabled.length, 1);
+ Assert.equal(changes.disabled[0], ID);
+
+ Assert.deepEqual(
+ changedProperties,
+ ["signedState", "appDisabled"],
+ "Got onPropertyChanged events for signedState and appDisabled"
+ );
+
+ Assert.ok(addon.appDisabled);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_MISSING);
+
+ await addon.uninstall();
+ AddonManager.removeAddonListener(listener);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js b/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js
new file mode 100644
index 0000000000..51272509da
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js
@@ -0,0 +1,967 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ addGatedPermissionTypesForXpcShellTests,
+ SITEPERMS_ADDON_PROVIDER_PREF,
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ SITEPERMS_ADDON_TYPE,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const addonsBundle = new Localization(["toolkit/about/aboutAddons.ftl"], true);
+
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+ "https://example.com"
+);
+const PRINCIPAL_ORG = ssm.createContentPrincipalFromOrigin(
+ "https://example.org"
+);
+const PRINCIPAL_GITHUB =
+ ssm.createContentPrincipalFromOrigin("https://github.io");
+const PRINCIPAL_UNSECURE =
+ ssm.createContentPrincipalFromOrigin("http://example.net");
+const PRINCIPAL_IP = ssm.createContentPrincipalFromOrigin(
+ "https://18.154.122.194"
+);
+const PRINCIPAL_PRIVATEBROWSING = ssm.createContentPrincipal(
+ Services.io.newURI("https://example.withprivatebrowsing.com"),
+ { privateBrowsingId: 1 }
+);
+const PRINCIPAL_USERCONTEXT = ssm.createContentPrincipal(
+ Services.io.newURI("https://example.withusercontext.com"),
+ { userContextId: 2 }
+);
+const PRINCIPAL_NULL = ssm.createNullPrincipal({});
+
+const URI_USED_IN_MULTIPLE_CONTEXTS = Services.io.newURI(
+ "https://multiplecontexts.com"
+);
+const PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR = ssm.createContentPrincipal(
+ URI_USED_IN_MULTIPLE_CONTEXTS,
+ {}
+);
+const PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING = ssm.createContentPrincipal(
+ URI_USED_IN_MULTIPLE_CONTEXTS,
+ { privateBrowsingId: 1 }
+);
+const PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT = ssm.createContentPrincipal(
+ URI_USED_IN_MULTIPLE_CONTEXTS,
+ { userContextId: 3 }
+);
+
+const BLOCKED_DOMAIN = "malicious.com";
+const BLOCKED_DOMAIN2 = "someothermalicious.com";
+const BLOCKED_PRINCIPAL = ssm.createContentPrincipalFromOrigin(
+ `https://${BLOCKED_DOMAIN}`
+);
+const BLOCKED_PRINCIPAL2 = ssm.createContentPrincipalFromOrigin(
+ `https://${BLOCKED_DOMAIN2}`
+);
+
+const GATED_SITE_PERM1 = "test/gatedSitePerm";
+const GATED_SITE_PERM2 = "test/anotherGatedSitePerm";
+addGatedPermissionTypesForXpcShellTests([GATED_SITE_PERM1, GATED_SITE_PERM2]);
+const NON_GATED_SITE_PERM = "test/nonGatedPerm";
+
+// Leave it to throw if the pref doesn't exist anymore, so that a test failure
+// will make us notice and confirm if we have enabled it by default or not.
+const PERMS_ISOLATE_USERCONTEXT_ENABLED = Services.prefs.getBoolPref(
+ "permissions.isolateBy.userContext"
+);
+
+// Observers to bypass panels and continue install.
+const expectAndHandleInstallPrompts = () => {
+ TestUtils.topicObserved("addon-install-blocked").then(([subject]) => {
+ let installInfo = subject.wrappedJSObject;
+ info("==== test got addon-install-blocked, calling `install`");
+ installInfo.install();
+ });
+ TestUtils.topicObserved("webextension-permission-prompt").then(
+ ([subject]) => {
+ info("==== test webextension-permission-prompt, calling `resolve`");
+ subject.wrappedJSObject.info.resolve();
+ }
+ );
+};
+
+add_setup(async () => {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ AddonTestUtils.init(this);
+ await promiseStartupManager();
+});
+
+add_task(
+ {
+ pref_set: [[SITEPERMS_ADDON_PROVIDER_PREF, false]],
+ },
+ async function test_sitepermsaddon_provider_disabled() {
+ // The SitePermsAddonProvider does not register until the first content process
+ // is launched, so we simulate that by firing this notification.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+
+ ok(
+ !AddonManager.hasProvider("SitePermsAddonProvider"),
+ "Expect no SitePermsAddonProvider to be registered"
+ );
+ }
+);
+
+add_task(
+ {
+ pref_set: [
+ [SITEPERMS_ADDON_PROVIDER_PREF, true],
+ [
+ SITEPERMS_ADDON_BLOCKEDLIST_PREF,
+ `${BLOCKED_DOMAIN},${BLOCKED_DOMAIN2}`,
+ ],
+ ],
+ },
+ async function test_sitepermsaddon_provider_enabled() {
+ // The SitePermsAddonProvider does not register until the first content process
+ // is launched, so we simulate that by firing this notification.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+
+ ok(
+ AddonManager.hasProvider("SitePermsAddonProvider"),
+ "Expect SitePermsAddonProvider to be registered"
+ );
+
+ let addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "There's no addons");
+
+ info("Add a gated permission");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "A siteperm addon is now available");
+ const comAddon = await promiseAddonByID(addons[0].id);
+ Assert.equal(
+ addons[0],
+ comAddon,
+ "getAddonByID returns the expected addon"
+ );
+
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1],
+ "addon has expected sitePermissions"
+ );
+ Assert.equal(
+ comAddon.type,
+ SITEPERMS_ADDON_TYPE,
+ "addon has expected type"
+ );
+
+ const localizedExtensionName = await addonsBundle.formatValue(
+ "addon-sitepermission-host",
+ {
+ host: PRINCIPAL_COM.host,
+ }
+ );
+ Assert.ok(!!localizedExtensionName, "retrieved addonName is not falsy");
+ Assert.equal(
+ comAddon.name,
+ localizedExtensionName,
+ "addon has expected name"
+ );
+
+ info("Add another gated permission");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ GATED_SITE_PERM2,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "There is no new siteperm addon after adding a permission to the same principal..."
+ );
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1, GATED_SITE_PERM2],
+ "...but the new permission is reported by addon.sitePermissions"
+ );
+
+ info("Add a non-gated permission");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ NON_GATED_SITE_PERM,
+ Services.perms.ALLOW_ACTION
+ );
+ Assert.equal(
+ addons.length,
+ 1,
+ "There is no new siteperm addon after adding a non gated permission to the same principal..."
+ );
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1, GATED_SITE_PERM2],
+ "...and the new permission is not reported by addon.sitePermissions"
+ );
+
+ info("Adding a gated permission to another principal");
+ PermissionTestUtils.add(
+ PRINCIPAL_ORG,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 2, "A new siteperm addon is now available");
+ const orgAddon = await promiseAddonByID(addons[1].id);
+ Assert.equal(
+ addons[1],
+ orgAddon,
+ "getAddonByID returns the expected addon"
+ );
+
+ Assert.deepEqual(
+ orgAddon.sitePermissions,
+ [GATED_SITE_PERM1],
+ "new addon only has a single sitePermission"
+ );
+
+ info(
+ "Passing null or undefined to getAddonsByTypes returns all the addons"
+ );
+ addons = await promiseAddonsByTypes(null);
+ // We can't do an exact check on all the returned addons as we get other type
+ // of addons from other providers.
+ Assert.deepEqual(
+ addons.filter(a => a.type == SITEPERMS_ADDON_TYPE).map(a => a.id),
+ [comAddon.id, orgAddon.id],
+ "Got site perms addons when passing null"
+ );
+
+ addons = await promiseAddonsByTypes();
+ Assert.deepEqual(
+ addons.filter(a => a.type == SITEPERMS_ADDON_TYPE).map(a => a.id),
+ [comAddon.id, orgAddon.id],
+ "Got site perms addons when passing undefined"
+ );
+
+ info("Remove a gated permission");
+ PermissionTestUtils.remove(PRINCIPAL_COM, GATED_SITE_PERM2);
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "Removing a permission did not removed the addon has it has another permission"
+ );
+ Assert.deepEqual(
+ comAddon.sitePermissions,
+ [GATED_SITE_PERM1],
+ "addon has expected sitePermissions"
+ );
+
+ info("Remove last gated permission on PRINCIPAL_COM");
+ const promisePrincipalComUninstalling = AddonTestUtils.promiseAddonEvent(
+ "onUninstalling",
+ addon => {
+ return addon.id === comAddon.id;
+ }
+ );
+ const promisePrincipalComUninstalled = AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => {
+ return addon.id === comAddon.id;
+ }
+ );
+ PermissionTestUtils.remove(PRINCIPAL_COM, GATED_SITE_PERM1);
+ info("Wait for onUninstalling addon listener call");
+ await promisePrincipalComUninstalling;
+ info("Wait for onUninstalled addon listener call");
+ await promisePrincipalComUninstalled;
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "Removing the last gated permission removed the addon"
+ );
+ Assert.equal(addons[0], orgAddon);
+
+ info("Uninstall org addon");
+ orgAddon.uninstall();
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "org addon is removed");
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_ORG, GATED_SITE_PERM1),
+ false,
+ "Permission was removed when the addon was uninstalled"
+ );
+
+ info("Add gated permissions");
+ PermissionTestUtils.add(
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ PRINCIPAL_ORG,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 2, "2 addons are now available");
+
+ info("Clear permissions");
+ const onAddon1Uninstall = AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === addons[0].id
+ );
+ const onAddon2Uninstall = AddonTestUtils.promiseAddonEvent(
+ "onUninstalled",
+ addon => addon.id === addons[1].id
+ );
+ Services.perms.removeAll();
+
+ await Promise.all([onAddon1Uninstall, onAddon2Uninstall]);
+ ok("Addons were properly uninstalled…");
+ Assert.equal(
+ (await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE])).length,
+ 0,
+ "… and getAddonsByTypes does not return them anymore"
+ );
+
+ info("Adding a permission to a public etld");
+ PermissionTestUtils.add(
+ PRINCIPAL_GITHUB,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "Adding a gated permission to a public etld shouldn't add a new addon"
+ );
+ // Cleanup
+ PermissionTestUtils.remove(PRINCIPAL_GITHUB, GATED_SITE_PERM1);
+
+ info("Adding a permission to a non secure principal");
+ PermissionTestUtils.add(
+ PRINCIPAL_UNSECURE,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "Adding a gated permission to an unsecure principal shouldn't add a new addon"
+ );
+
+ info("Adding a permission to a blocked principal");
+ PermissionTestUtils.add(
+ BLOCKED_PRINCIPAL,
+ GATED_SITE_PERM1,
+ Services.perms.ALLOW_ACTION
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "Adding a gated permission to a blocked principal shouldn't add a new addon"
+ );
+ // Cleanup
+ PermissionTestUtils.remove(BLOCKED_PRINCIPAL, GATED_SITE_PERM1);
+
+ info("Call installSitePermsAddonFromWebpage without proper principal");
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ // principal
+ null,
+ GATED_SITE_PERM1
+ ),
+ /aInstallingPrincipal must be a nsIPrincipal/,
+ "installSitePermsAddonFromWebpage rejected when called without a principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage with non-null, non-element browser"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ "browser",
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ ),
+ /aBrowser must be an Element, or null/,
+ "installSitePermsAddonFromWebpage rejected when called with a non-null, non-element browser"
+ );
+
+ info("Call installSitePermsAddonFromWebpage with unsecure principal");
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_UNSECURE,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can only be installed from secure origins/,
+ "installSitePermsAddonFromWebpage rejected when called with unsecure principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for public principal and gated permission"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_GITHUB,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddon can\'t be installed from public eTLDs/,
+ "installSitePermsAddonFromWebpage rejected when called with public principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for a NullPrincipal as installingPrincipal"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_NULL,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can\'t be installed from sandboxed subframes/,
+ "installSitePermsAddonFromWebpage rejected when called with a NullPrincipal as installing principal"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "there was no added addon...");
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_GITHUB,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "...and no new permission either"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for plain-ip principal and gated permission"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_IP,
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons install disallowed when the host is an IP address/,
+ "installSitePermsAddonFromWebpage rejected when called with plain-ip principal"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "there was no added addon...");
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_IP, GATED_SITE_PERM1),
+ false,
+ "...and no new permission either"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for authorized principal and non-gated permission"
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ NON_GATED_SITE_PERM
+ ),
+ new RegExp(`"${NON_GATED_SITE_PERM}" is not a gated permission`),
+ "installSitePermsAddonFromWebpage rejected for non-gated permission"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 0,
+ "installSitePermsAddonFromWebpage with non-gated permission should not add the addon"
+ );
+
+ info("Call installSitePermsAddonFromWebpage blocked principals");
+ const blockedDomainsPrincipals = {
+ [BLOCKED_DOMAIN]: BLOCKED_PRINCIPAL,
+ [BLOCKED_DOMAIN2]: BLOCKED_PRINCIPAL2,
+ };
+ for (const blockedDomain of [BLOCKED_DOMAIN, BLOCKED_DOMAIN2]) {
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ blockedDomainsPrincipals[blockedDomain],
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can\'t be installed/,
+ `installSitePermsAddonFromWebpage rejected when called with blocked domain principal: ${blockedDomainsPrincipals[blockedDomain].URI.spec}`
+ );
+ await Assert.rejects(
+ AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ ssm.createContentPrincipalFromOrigin(`https://sub.${blockedDomain}`),
+ GATED_SITE_PERM1
+ ),
+ /SitePermsAddons can\'t be installed/,
+ `installSitePermsAddonFromWebpage rejected when called with blocked subdomain principal: https://sub.${blockedDomain}`
+ );
+ }
+
+ info(
+ "Call installSitePermsAddonFromWebpage for authorized principal and gated permission"
+ );
+ expectAndHandleInstallPrompts();
+ const onAddonInstalled = AddonTestUtils.promiseInstallEvent(
+ "onInstallEnded"
+ ).then(installs => installs?.[0]?.addon);
+
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_COM, GATED_SITE_PERM1),
+ true,
+ "...and set the permission"
+ );
+
+ // The addon we get here is a SitePermsAddonInstalling instance, and we want to assert
+ // that its permissions are correct as it may impact addon uninstall later on
+ Assert.deepEqual(
+ (await onAddonInstalled).sitePermissions,
+ [GATED_SITE_PERM1],
+ "Addon has expected sitePermissions"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for private browsing principal and gated permission"
+ );
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ const [addonWithPrivateBrowsing] = addons.filter(
+ addon => addon.siteOrigin === PRINCIPAL_PRIVATEBROWSING.siteOriginNoSuffix
+ );
+ Assert.equal(
+ addonWithPrivateBrowsing?.siteOrigin,
+ PRINCIPAL_PRIVATEBROWSING.siteOriginNoSuffix,
+ "Got an addon for the expected siteOriginNoSuffix value"
+ );
+ await addonWithPrivateBrowsing.uninstall();
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Uninstalling the addon should clear the permission for the private browsing principal"
+ );
+
+ info(
+ "Call installSitePermsAddonFromWebpage for user context isolated principal and gated permission"
+ );
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_USERCONTEXT,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ const [addonWithUserContextId] = addons.filter(
+ addon => addon.siteOrigin === PRINCIPAL_USERCONTEXT.siteOriginNoSuffix
+ );
+ Assert.equal(
+ addonWithUserContextId?.siteOrigin,
+ PRINCIPAL_USERCONTEXT.siteOriginNoSuffix,
+ "Got an addon for the expected siteOriginNoSuffix value"
+ );
+ await addonWithUserContextId.uninstall();
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Uninstalling the addon should clear the permission for the user context isolated principal"
+ );
+
+ info(
+ "Check calling installSitePermsAddonFromWebpage for same gated permission and same origin on different contexts"
+ );
+ info("First call installSitePermsAddonFromWebpage on regular context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ // We expect the permission to not be set for user context if
+ // perms.isolateBy.userContext is set to true.
+ PERMS_ISOLATE_USERCONTEXT_ENABLED
+ ? Services.perms.UNKNOWN_ACTION
+ : Services.perms.ALLOW_ACTION,
+ `...and ${
+ PERMS_ISOLATE_USERCONTEXT_ENABLED ? "not allowed" : "allowed"
+ } on the context user specific principal`
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "...but not on the private browsing principal"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ const [addonMultipleContexts] = addons.filter(
+ addon =>
+ addon.siteOrigin ===
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR.siteOriginNoSuffix
+ );
+ Assert.equal(
+ addonMultipleContexts?.siteOrigin,
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR.siteOriginNoSuffix,
+ "Got an addon for the expected siteOriginNoSuffix value"
+ );
+ info("Then call installSitePermsAddonFromWebpage on private context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "Permission is set for the private context"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and still set on the regular principal"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ // We expect the permission to not be set for user context if
+ // perms.isolateBy.userContext is set to true.
+ PERMS_ISOLATE_USERCONTEXT_ENABLED
+ ? Services.perms.UNKNOWN_ACTION
+ : Services.perms.ALLOW_ACTION,
+ `...and ${
+ PERMS_ISOLATE_USERCONTEXT_ENABLED ? "not allowed" : "allowed"
+ } on the context user specific principal`
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage did not add a new addon"
+ );
+ info("Then call installSitePermsAddonFromWebpage on specific user context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "Permission is set for the user context"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and still set on the regular principal"
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and on the private principal"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage did not add a new addon"
+ );
+
+ info(
+ "Uninstalling the addon should remove the permission on the different contexts"
+ );
+ await addonMultipleContexts.uninstall();
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Uninstalling the addon should clear the permission for regular principal..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "... as well as the private browsing one..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_USERCONTEXT,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "... and the user context one"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "addon was properly uninstalled");
+
+ info("Install the addon for the multiple context origin again");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "...and set the permission"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+
+ info("Then call installSitePermsAddonFromWebpage on private context");
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ true,
+ "Permission is set for the private context"
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 2,
+ "installSitePermsAddonFromWebpage did not add a new addon"
+ );
+
+ info("Remove the permission for the private context");
+ PermissionTestUtils.remove(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_PRIVATEBROWSING,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Permission is removed for the private context..."
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 2, "... but it didn't uninstall the addon");
+
+ info("Remove the permission for the regular context");
+ PermissionTestUtils.remove(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ PRINCIPAL_MULTIPLE_CONTEXTS_REGULAR,
+ GATED_SITE_PERM1
+ ),
+ false,
+ "Permission is removed for the regular context..."
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "...and addon is uninstalled");
+
+ await addons[0]?.uninstall();
+ }
+);
+
+add_task(
+ {
+ pref_set: [[SITEPERMS_ADDON_PROVIDER_PREF, true]],
+ },
+ async function test_salted_hash_addon_id() {
+ // Make sure the test will also be able to run if it is the only one executed.
+ Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+ ok(
+ AddonManager.hasProvider("SitePermsAddonProvider"),
+ "Expect SitePermsAddonProvider to be registered"
+ );
+ // Make sure no sitepermission addon is already installed.
+ let addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 0, "There's no addons");
+
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ );
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(
+ addons.length,
+ 1,
+ "installSitePermsAddonFromWebpage should add the addon..."
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(PRINCIPAL_COM, GATED_SITE_PERM1),
+ true,
+ "...and set the permission"
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "There is an addon installed");
+
+ const firstSaltedAddonId = addons[0].id;
+ ok(firstSaltedAddonId, "Got the first addon id");
+
+ info("Verify addon id after mocking new browsing session");
+
+ const { generateSalt } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/SitePermsAddonProvider.sys.mjs"
+ );
+ generateSalt();
+
+ await promiseRestartManager();
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "There is an addon installed");
+
+ const secondSaltedAddonId = addons[0].id;
+ ok(
+ secondSaltedAddonId,
+ "Got the second addon id after mocking new browsing session"
+ );
+
+ Assert.notEqual(
+ firstSaltedAddonId,
+ secondSaltedAddonId,
+ "The two addon ids are different"
+ );
+
+ // Confirm that new installs from the same siteOrigin will still
+ // belong to the existing addon entry while the salt isn't expected
+ // to have changed.
+ expectAndHandleInstallPrompts();
+ await AddonManager.installSitePermsAddonFromWebpage(
+ null,
+ PRINCIPAL_COM,
+ GATED_SITE_PERM1
+ );
+
+ addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+ Assert.equal(addons.length, 1, "There is still a single addon installed");
+
+ await addons[0]?.uninstall();
+ }
+);
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup.js
new file mode 100644
index 0000000000..ba9e04c7bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup.js
@@ -0,0 +1,648 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies startup detection of added/removed/changed items and install
+// location priorities
+
+Services.prefs.setIntPref("extensions.autoDisableScopes", 0);
+
+const ID1 = getID(1);
+const ID2 = getID(2);
+const ID3 = getID(3);
+const ID4 = getID(4);
+
+function createWebExtensionXPI(id, version) {
+ return createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+}
+
+// Bug 1554703: Verify that the extensions.lastAppBuildId pref is updated properly
+// to avoid a full scan on second startup in XPIStates.scanForChanges.
+add_task(async function test_scan_app_build_id_updated() {
+ const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId";
+ Assert.equal(
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, ""),
+ "",
+ "fresh version with no saved build ID"
+ );
+ Assert.ok(Services.appinfo.appBuildID, "build ID is set before a startup");
+
+ await promiseStartupManager();
+ Assert.equal(
+ Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, ""),
+ Services.appinfo.appBuildID,
+ "build ID is correct after a startup"
+ );
+
+ await promiseShutdownManager();
+});
+
+// Try to install all the items into the profile
+add_task(async function test_scan_profile() {
+ await promiseStartupManager();
+
+ let ids = [];
+ for (let n of [1, 2, 3]) {
+ let id = getID(n);
+ ids.push(id);
+ await createWebExtension(id, initialVersion(n), profileDir);
+ }
+
+ await promiseRestartManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 3, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, ids);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ info("Checking for " + gAddonStartup.path);
+ Assert.ok(gAddonStartup.exists());
+
+ for (let n of [1, 2, 3]) {
+ let id = getID(n);
+ let addon = await promiseAddonByID(id);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.id, id);
+ Assert.notEqual(addon.syncGUID, null);
+ Assert.ok(addon.syncGUID.length >= 9);
+ Assert.equal(addon.version, initialVersion(n));
+ Assert.ok(isExtensionInBootstrappedList(profileDir, id));
+ Assert.ok(hasFlag(addon.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(addon.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(id, initialVersion(n));
+ Assert.equal(addon.scope, AddonManager.SCOPE_PROFILE);
+ Assert.equal(addon.sourceURI, null);
+ Assert.ok(addon.foreignInstall);
+ Assert.ok(!addon.userDisabled);
+ Assert.ok(addon.seen);
+ }
+
+ let extensionAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(extensionAddons.length, 3);
+
+ await promiseShutdownManager();
+});
+
+// Test that modified items are detected and items in other install locations
+// are ignored
+add_task(async function test_modify() {
+ await createWebExtension(ID1, "1.1", userDir);
+ await createWebExtension(ID2, "2.1", profileDir);
+ await createWebExtension(ID2, "2.2", globalDir);
+ await createWebExtension(ID2, "2.3", userDir);
+
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID3}.xpi`));
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 2, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [ID3]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ Assert.ok(gAddonStartup.exists());
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, "1.0");
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, "1.0");
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(a1.foreignInstall);
+
+ // The version in the profile should take precedence.
+ const VERSION2 = "2.1";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_PROFILE);
+ Assert.ok(a2.foreignInstall);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+ do_check_not_in_crash_annotation(ID3, "3.0");
+
+ await promiseShutdownManager();
+});
+
+// Check that removing items from the profile reveals their hidden versions.
+add_task(async function test_reveal() {
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID1}.xpi`));
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID2}.xpi`));
+
+ // XPI with wrong name (basename doesn't match the id)
+ let xpi = await createWebExtensionXPI(ID3, "3.0");
+ xpi.copyTo(profileDir, `${ID4}.xpi`);
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 2, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID1, ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3, a4] = await AddonManager.getAddonsByIDs([
+ ID1,
+ ID2,
+ ID3,
+ ID4,
+ ]);
+
+ // Copy of addon1 in the per-user directory is now revealed.
+ const VERSION1 = "1.1";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ // Likewise with addon2
+ const VERSION2 = "2.3";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_USER);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+
+ Assert.equal(a4, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID4));
+
+ let addon4Exists = await IOUtils.exists(
+ PathUtils.join(profileDir.path, `${ID4}.xpi`)
+ );
+ Assert.ok(!addon4Exists, "Misnamed xpi should be removed from profile");
+
+ await promiseShutdownManager();
+});
+
+// Test that disabling an install location works
+add_task(async function test_disable_location() {
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_SYSTEM
+ );
+
+ await promiseStartupManager();
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 1, "addons installed");
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [ID1]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+ Assert.equal(a1, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+
+ // System-wide copy of addon2 is now revealed
+ const VERSION2 = "2.2";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_SYSTEM);
+
+ await promiseShutdownManager();
+});
+
+// Switching disabled locations works
+add_task(async function test_disable_location2() {
+ Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_USER
+ );
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, [ID1]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ const VERSION1 = "1.1";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ const VERSION2 = "2.3";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_USER);
+
+ await promiseShutdownManager();
+});
+
+// Resetting the pref makes everything visible again
+add_task(async function test_enable_location() {
+ Services.prefs.clearUserPref("extensions.enabledScopes");
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ const VERSION1 = "1.1";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ const VERSION2 = "2.3";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_USER);
+
+ await promiseShutdownManager();
+});
+
+// Check that items in the profile hide the others again.
+add_task(async function test_profile_hiding() {
+ const VERSION1 = "1.2";
+ await createWebExtension(ID1, VERSION1, profileDir);
+
+ await IOUtils.remove(PathUtils.join(userDir.path, `${ID2}.xpi`));
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID1, ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+
+ const VERSION2 = "2.2";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID2, VERSION2);
+ Assert.equal(a2.scope, AddonManager.SCOPE_SYSTEM);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+
+ await promiseShutdownManager();
+});
+
+// Disabling all locations still leaves the profile working
+add_task(async function test_disable3() {
+ Services.prefs.setIntPref("extensions.enabledScopes", 0);
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [
+ "2@tests.mozilla.org",
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ const VERSION1 = "1.2";
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ do_check_in_crash_annotation(ID1, VERSION1);
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+
+ Assert.equal(a2, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+
+ await promiseShutdownManager();
+});
+
+// More hiding and revealing
+add_task(async function test_reval() {
+ Services.prefs.clearUserPref("extensions.enabledScopes");
+
+ await IOUtils.remove(PathUtils.join(userDir.path, `${ID1}.xpi`));
+ await IOUtils.remove(PathUtils.join(globalDir.path, `${ID2}.xpi`));
+
+ const VERSION2 = "2.4";
+ await createWebExtension(ID2, VERSION2, profileDir);
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, [
+ "2@tests.mozilla.org",
+ ]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, "1.2");
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a1.scope, AddonManager.SCOPE_PROFILE);
+
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a2.scope, AddonManager.SCOPE_PROFILE);
+
+ Assert.equal(a3, null);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+
+ await promiseShutdownManager();
+});
+
+// Checks that a removal from one location and an addition in another location
+// for the same item is handled
+add_task(async function test_move() {
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID1}.xpi`));
+ const VERSION1 = "1.3";
+ await createWebExtension(ID1, VERSION1, userDir);
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, [ID1]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2] = await AddonManager.getAddonsByIDs([ID1, ID2]);
+
+ Assert.notEqual(a1, null);
+ Assert.equal(a1.id, ID1);
+ Assert.equal(a1.version, VERSION1);
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(!hasFlag(a1.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a1.scope, AddonManager.SCOPE_USER);
+
+ const VERSION2 = "2.4";
+ Assert.notEqual(a2, null);
+ Assert.equal(a2.id, ID2);
+ Assert.equal(a2.version, VERSION2);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UNINSTALL));
+ Assert.ok(hasFlag(a2.permissions, AddonManager.PERM_CAN_UPGRADE));
+ Assert.equal(a2.scope, AddonManager.SCOPE_PROFILE);
+
+ await promiseShutdownManager();
+});
+
+// This should remove any remaining items
+add_task(async function test_remove() {
+ await IOUtils.remove(PathUtils.join(userDir.path, `${ID1}.xpi`));
+ await IOUtils.remove(PathUtils.join(profileDir.path, `${ID2}.xpi`));
+
+ await promiseStartupManager();
+
+ check_startup_changes(AddonManager.STARTUP_CHANGE_INSTALLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_CHANGED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_UNINSTALLED, [ID1, ID2]);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_DISABLED, []);
+ check_startup_changes(AddonManager.STARTUP_CHANGE_ENABLED, []);
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.equal(a1, null);
+ Assert.equal(a2, null);
+ Assert.equal(a3, null);
+
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID3));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(profileDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID3));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(userDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID1));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID2));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID3));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID4));
+ Assert.ok(!isExtensionInBootstrappedList(globalDir, ID4));
+
+ await promiseShutdownManager();
+});
+
+// Test that auto-disabling for specific scopes works
+add_task(async function test_autoDisable() {
+ Services.prefs.setIntPref(
+ "extensions.autoDisableScopes",
+ AddonManager.SCOPE_USER
+ );
+
+ async function writeAll() {
+ return Promise.all([
+ createWebExtension(ID1, "1.0", profileDir),
+ createWebExtension(ID2, "2.0", userDir),
+ createWebExtension(ID3, "3.0", globalDir),
+ ]);
+ }
+
+ async function removeAll() {
+ return Promise.all([
+ IOUtils.remove(PathUtils.join(profileDir.path, `${ID1}.xpi`)),
+ IOUtils.remove(PathUtils.join(userDir.path, `${ID2}.xpi`)),
+ IOUtils.remove(PathUtils.join(globalDir.path, `${ID3}.xpi`)),
+ ]);
+ }
+
+ await writeAll();
+
+ await promiseStartupManager();
+
+ let [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(a1.seen);
+ Assert.ok(a1.isActive);
+
+ Assert.notEqual(a2, null);
+ Assert.ok(a2.userDisabled);
+ Assert.ok(!a2.seen);
+ Assert.ok(!a2.isActive);
+
+ Assert.notEqual(a3, null);
+ Assert.ok(!a3.userDisabled);
+ Assert.ok(a3.seen);
+ Assert.ok(a3.isActive);
+
+ await promiseShutdownManager();
+
+ await removeAll();
+
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ Services.prefs.setIntPref(
+ "extensions.autoDisableScopes",
+ AddonManager.SCOPE_SYSTEM
+ );
+
+ await writeAll();
+
+ await promiseStartupManager();
+
+ [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(a1.seen);
+ Assert.ok(a1.isActive);
+
+ Assert.notEqual(a2, null);
+ Assert.ok(!a2.userDisabled);
+ Assert.ok(a2.seen);
+ Assert.ok(a2.isActive);
+
+ Assert.notEqual(a3, null);
+ Assert.ok(a3.userDisabled);
+ Assert.ok(!a3.seen);
+ Assert.ok(!a3.isActive);
+
+ await promiseShutdownManager();
+
+ await removeAll();
+
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ Services.prefs.setIntPref(
+ "extensions.autoDisableScopes",
+ AddonManager.SCOPE_USER + AddonManager.SCOPE_SYSTEM
+ );
+
+ await writeAll();
+
+ await promiseStartupManager();
+
+ [a1, a2, a3] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3]);
+ Assert.notEqual(a1, null);
+ Assert.ok(!a1.userDisabled);
+ Assert.ok(a1.seen);
+ Assert.ok(a1.isActive);
+
+ Assert.notEqual(a2, null);
+ Assert.ok(a2.userDisabled);
+ Assert.ok(!a2.seen);
+ Assert.ok(!a2.isActive);
+
+ Assert.notEqual(a3, null);
+ Assert.ok(a3.userDisabled);
+ Assert.ok(!a3.seen);
+ Assert.ok(!a3.isActive);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js
new file mode 100644
index 0000000000..a3259b599f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup_enable.js
@@ -0,0 +1,47 @@
+createAppInfo("xpcshell@tessts.mozilla.org", "XPCShell", "1", "1");
+BootstrapMonitor.init();
+
+// Test that enabling an extension during startup generates the
+// proper reason for startup().
+add_task(async function test_startup_enable() {
+ const ID = "compat@tests.mozilla.org";
+
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ });
+
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+ let { reason } = BootstrapMonitor.started.get(ID);
+ equal(
+ reason,
+ BOOTSTRAP_REASONS.ADDON_INSTALL,
+ "Startup reason is ADDON_INSTALL at install"
+ );
+
+ gAppInfo.platformVersion = "2";
+ await promiseRestartManager("2");
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ gAppInfo.platformVersion = "1";
+ await promiseRestartManager("1");
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+ ({ reason } = BootstrapMonitor.started.get(ID));
+ equal(
+ reason,
+ BOOTSTRAP_REASONS.ADDON_ENABLE,
+ "Startup reason is ADDON_ENABLE when re-enabled at startup"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js
new file mode 100644
index 0000000000..eb9cc34938
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup_isPrivileged.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ADDON_ID_PRIVILEGED = "@privileged-addon-id";
+const ADDON_ID_NO_PRIV = "@addon-without-privileges";
+AddonTestUtils.usePrivilegedSignatures = id => id === ADDON_ID_PRIVILEGED;
+
+function isExtensionPrivileged(addonId) {
+ const { extension } = WebExtensionPolicy.getByID(addonId);
+ return extension.isPrivileged;
+}
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function isPrivileged_at_install() {
+ {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ permissions: ["mozillaAddons"],
+ browser_specific_settings: { gecko: { id: ADDON_ID_PRIVILEGED } },
+ },
+ });
+ ok(addon.isPrivileged, "Add-on is privileged");
+ ok(isExtensionPrivileged(ADDON_ID_PRIVILEGED), "Extension is privileged");
+ }
+
+ {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ permissions: ["mozillaAddons"],
+ browser_specific_settings: { gecko: { id: ADDON_ID_NO_PRIV } },
+ },
+ });
+ ok(!addon.isPrivileged, "Add-on is not privileged");
+ ok(!isExtensionPrivileged(ADDON_ID_NO_PRIV), "Extension is not privileged");
+ }
+});
+
+// When the Add-on Manager is restarted, the extension is started using data
+// from XPIState. This test verifies that `extension.isPrivileged` is correctly
+// set in that scenario.
+add_task(async function isPrivileged_at_restart() {
+ await promiseRestartManager();
+ {
+ let addon = await AddonManager.getAddonByID(ADDON_ID_PRIVILEGED);
+ ok(addon.isPrivileged, "Add-on is privileged");
+ ok(isExtensionPrivileged(ADDON_ID_PRIVILEGED), "Extension is privileged");
+ }
+ {
+ let addon = await AddonManager.getAddonByID(ADDON_ID_NO_PRIV);
+ ok(!addon.isPrivileged, "Add-on is not privileged");
+ ok(!isExtensionPrivileged(ADDON_ID_NO_PRIV), "Extension is not privileged");
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js b/toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js
new file mode 100644
index 0000000000..67c75cbd17
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_startup_scan.js
@@ -0,0 +1,125 @@
+"use strict";
+
+// Turn off startup scanning.
+Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+
+createAppInfo("xpcshell@tessts.mozilla.org", "XPCShell", "42", "42");
+// Prevent XPIStates.scanForChanges from seeing this as an update and forcing a
+// full scan.
+Services.prefs.setCharPref(
+ "extensions.lastAppBuildId",
+ Services.appinfo.appBuildID
+);
+
+// A small bootstrap calls monitor targeting a single extension (created to avoid introducing a workaround
+// in BootstrapMonitor to be able to test Bug 1664144 fix).
+let Monitor = {
+ extensionId: undefined,
+ collected: [],
+ init() {
+ const bootstrapCallListener = (_evtName, data) => {
+ if (data.params.id == this.extensionId) {
+ this.collected.push(data);
+ }
+ };
+ AddonTestUtils.on("bootstrap-method", bootstrapCallListener);
+ registerCleanupFunction(() => {
+ AddonTestUtils.off("bootstrap-method", bootstrapCallListener);
+ });
+ },
+ startCollecting(extensionId) {
+ this.extensionId = extensionId;
+ },
+ stopCollecting() {
+ this.extensionId = undefined;
+ },
+ getCollected() {
+ const collected = this.collected;
+ this.collected = [];
+ return collected;
+ },
+};
+
+Monitor.init();
+
+// Bug 1664144: Test that during startup scans, updating an addon
+// that has already started is restarted.
+add_task(async function test_startup_sideload_updated() {
+ const ID = "sideload@tests.mozilla.org";
+
+ await createWebExtension(ID, initialVersion("1"), profileDir);
+ await promiseStartupManager();
+
+ // Ensure the sideload is enabled and running.
+ let addon = await promiseAddonByID(ID);
+
+ Monitor.startCollecting(ID);
+ await addon.enable();
+ Monitor.stopCollecting();
+
+ let events = Monitor.getCollected();
+ ok(events.length, "bootstrap methods called");
+ equal(
+ events[0].reason,
+ BOOTSTRAP_REASONS.ADDON_ENABLE,
+ "Startup reason is ADDON_ENABLE at install"
+ );
+
+ await promiseShutdownManager();
+ // Touch the addon on disk before startup.
+ await createWebExtension(ID, initialVersion("1.1"), profileDir);
+ Monitor.startCollecting(ID);
+ await promiseStartupManager();
+ await AddonManagerPrivate.getNewSideloads();
+ Monitor.stopCollecting();
+
+ events = Monitor.getCollected().map(({ method, reason, params }) => {
+ const { version } = params;
+ return { method, reason, version };
+ });
+
+ const updatedVersion = "1.1.0";
+ const expectedUpgradeParams = {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ version: updatedVersion,
+ };
+
+ const expectedCalls = [
+ {
+ method: "startup",
+ reason: BOOTSTRAP_REASONS.APP_STARTUP,
+ version: "1.0",
+ },
+ // Shutdown call has version 1.1 because the file was already
+ // updated on disk and got the new version as part of the startup.
+ { method: "shutdown", ...expectedUpgradeParams },
+ { method: "update", ...expectedUpgradeParams },
+ { method: "startup", ...expectedUpgradeParams },
+ ];
+
+ for (let i = 0; i < expectedCalls.length; i++) {
+ Assert.deepEqual(
+ events[i],
+ expectedCalls[i],
+ "Got the expected sequence of bootstrap method calls"
+ );
+ }
+
+ equal(
+ events.length,
+ expectedCalls.length,
+ "Got the expected number of bootstrap method calls"
+ );
+
+ // flush addonStartup.json
+ await AddonTestUtils.loadAddonsList(true);
+ // verify startupData is correct
+ let startupData = aomStartup.readStartupData();
+ Assert.equal(
+ startupData["app-profile"].addons[ID].version,
+ updatedVersion,
+ "startup data is correct in cache"
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js b/toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js
new file mode 100644
index 0000000000..98318ceef6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_strictcompatibility.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests AddonManager.strictCompatibility and it's related preference,
+// extensions.strictCompatibility, and the strictCompatibility option in
+// install.rdf
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /IOUtils: Shutting down and refusing additional I\/O tasks/
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /IOUtils\.profileBeforeChange getter: IOUtils: profileBeforeChange phase has already finished/
+);
+
+// The `compatbile` array defines which of the tests below the add-on
+// should be compatible in. It's pretty gross.
+const ADDONS = [
+ // Always compatible
+ {
+ manifest: {
+ id: "addon1@tests.mozilla.org",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: true,
+ strict: true,
+ },
+ },
+
+ // Incompatible in strict compatibility mode
+ {
+ manifest: {
+ id: "addon2@tests.mozilla.org",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.7",
+ maxVersion: "0.8",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: true,
+ strict: false,
+ },
+ },
+
+ // Opt-in to strict compatibility - always incompatible
+ {
+ manifest: {
+ id: "addon3@tests.mozilla.org",
+ strictCompatibility: true,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.8",
+ maxVersion: "0.9",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: false,
+ strict: false,
+ },
+ },
+
+ // Addon from the future - would be marked as compatibile-by-default,
+ // but minVersion is higher than the app version
+ {
+ manifest: {
+ id: "addon4@tests.mozilla.org",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "3",
+ maxVersion: "5",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: false,
+ strict: false,
+ },
+ },
+
+ // Dictionary - compatible even in strict compatibility mode
+ {
+ manifest: {
+ id: "addon5@tests.mozilla.org",
+ type: "dictionary",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.8",
+ maxVersion: "0.9",
+ },
+ ],
+ },
+ compatible: {
+ nonStrict: true,
+ strict: true,
+ },
+ },
+];
+
+async function checkCompatStatus(strict, index) {
+ info(`Checking compat status for test ${index}\n`);
+
+ equal(AddonManager.strictCompatibility, strict);
+
+ for (let test of ADDONS) {
+ let { id } = test.manifest;
+ let addon = await promiseAddonByID(id);
+ checkAddon(id, addon, {
+ isCompatible: test.compatible[index],
+ appDisabled: !test.compatible[index],
+ });
+ }
+}
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ for (let addon of ADDONS) {
+ let xpi = await createAddon(addon.manifest);
+ await manuallyInstall(
+ xpi,
+ AddonTestUtils.profileExtensions,
+ addon.manifest.id
+ );
+ }
+
+ await promiseStartupManager();
+});
+
+add_task(async function test_1() {
+ info("Test 1");
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+ await checkCompatStatus(false, "nonStrict");
+ await promiseRestartManager();
+ await checkCompatStatus(false, "nonStrict");
+});
+
+add_task(async function test_2() {
+ info("Test 2");
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, true);
+ await checkCompatStatus(true, "strict");
+ await promiseRestartManager();
+ await checkCompatStatus(true, "strict");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js b/toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js
new file mode 100644
index 0000000000..5ac50bd5d4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_syncGUID.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+const UUID_PATTERN =
+ /^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$/i;
+
+const ADDONS = [
+ {
+ id: "addon1@tests.mozilla.org",
+ name: "Test 1",
+ },
+ {
+ id: "addon2@tests.mozilla.org",
+ name: "Real Test 2",
+ },
+];
+
+let xpis;
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9");
+ await promiseStartupManager();
+
+ xpis = await Promise.all(
+ ADDONS.map(info =>
+ createTempWebExtensionFile({
+ manifest: {
+ name: info.name,
+ browser_specific_settings: { gecko: { id: info.id } },
+ },
+ })
+ )
+ );
+});
+
+add_task(async function test_getter_and_setter() {
+ await promiseInstallFile(xpis[0]);
+
+ let addon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(addon, null);
+ Assert.notEqual(addon.syncGUID, null);
+ Assert.ok(UUID_PATTERN.test(addon.syncGUID));
+
+ let newGUID = "foo";
+
+ addon.syncGUID = newGUID;
+ Assert.equal(newGUID, addon.syncGUID);
+
+ // Verify change made it to DB.
+ let newAddon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(newAddon, null);
+ Assert.equal(newGUID, newAddon.syncGUID);
+});
+
+add_task(async function test_fetch_by_guid_unknown_guid() {
+ let addon = await XPIExports.XPIProvider.getAddonBySyncGUID("XXXX");
+ Assert.equal(null, addon);
+});
+
+// Ensure setting an extension to an existing syncGUID results in error.
+add_task(async function test_error_on_duplicate_syncguid_insert() {
+ await promiseInstallAllFiles(xpis);
+
+ let addons = await AddonManager.getAddonsByIDs(ADDONS.map(a => a.id));
+ let initialGUID = addons[1].syncGUID;
+
+ Assert.throws(
+ () => {
+ addons[1].syncGUID = addons[0].syncGUID;
+ },
+ /Addon sync GUID conflict/,
+ "Assigning conflicting sync guids throws"
+ );
+
+ await promiseRestartManager();
+
+ let addon = await AddonManager.getAddonByID(ADDONS[1].id);
+ Assert.equal(initialGUID, addon.syncGUID);
+});
+
+add_task(async function test_fetch_by_guid_known_guid() {
+ let addon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(null, addon);
+ Assert.notEqual(null, addon.syncGUID);
+
+ let syncGUID = addon.syncGUID;
+
+ let newAddon = await XPIExports.XPIProvider.getAddonBySyncGUID(syncGUID);
+ Assert.notEqual(null, newAddon);
+ Assert.equal(syncGUID, newAddon.syncGUID);
+});
+
+add_task(async function test_addon_manager_get_by_sync_guid() {
+ let addon = await AddonManager.getAddonByID(ADDONS[0].id);
+ Assert.notEqual(null, addon.syncGUID);
+
+ let syncGUID = addon.syncGUID;
+
+ let newAddon = await AddonManager.getAddonBySyncGUID(syncGUID);
+ Assert.notEqual(null, newAddon);
+ Assert.equal(addon.id, newAddon.id);
+ Assert.equal(syncGUID, newAddon.syncGUID);
+
+ let missing = await AddonManager.getAddonBySyncGUID("DOES_NOT_EXIST");
+ Assert.equal(undefined, missing);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js
new file mode 100644
index 0000000000..996f334382
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_allowed.js
@@ -0,0 +1,55 @@
+// Tests that only allowed built-in system add-ons are loaded on startup.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "0");
+
+// Ensure that only allowed add-ons are loaded.
+add_task(async function test_allowed_addons() {
+ // Build the test set
+ var distroDir = FileUtils.getDir("ProfD", ["sysfeatures"]);
+ distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(2, "1.0");
+ xpi.copyTo(distroDir, "system2@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(distroDir, "system3@tests.mozilla.org.xpi");
+
+ registerDirectory("XREAppFeat", distroDir);
+
+ // 1 and 2 are allowed, 3 is not.
+ let validAddons = {
+ system: ["system1@tests.mozilla.org", "system2@tests.mozilla.org"],
+ };
+ await overrideBuiltIns(validAddons);
+
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID("system1@tests.mozilla.org");
+ notEqual(addon, null);
+
+ addon = await AddonManager.getAddonByID("system2@tests.mozilla.org");
+ notEqual(addon, null);
+
+ addon = await AddonManager.getAddonByID("system3@tests.mozilla.org");
+ Assert.equal(addon, null);
+ equal(addon, null);
+
+ // 3 is now allowed, 1 and 2 are not.
+ validAddons = { system: ["system3@tests.mozilla.org"] };
+ await overrideBuiltIns(validAddons);
+
+ await promiseRestartManager();
+
+ addon = await AddonManager.getAddonByID("system1@tests.mozilla.org");
+ equal(addon, null);
+
+ addon = await AddonManager.getAddonByID("system2@tests.mozilla.org");
+ equal(addon, null);
+
+ addon = await AddonManager.getAddonByID("system3@tests.mozilla.org");
+ notEqual(addon, null);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js
new file mode 100644
index 0000000000..4490ec065b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_delay_update.js
@@ -0,0 +1,486 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that delaying a system add-on update works.
+
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const IGNORE_ID = "system_delay_ignore@tests.mozilla.org";
+const COMPLETE_ID = "system_delay_complete@tests.mozilla.org";
+const DEFER_ID = "system_delay_defer@tests.mozilla.org";
+const DEFER2_ID = "system_delay_defer2@tests.mozilla.org";
+const DEFER_ALSO_ID = "system_delay_defer_also@tests.mozilla.org";
+const NORMAL_ID = "system1@tests.mozilla.org";
+
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+registerCleanupFunction(() => {
+ distroDir.remove(true);
+});
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function promiseInstallPostponed(addonID1, addonID2) {
+ return new Promise((resolve, reject) => {
+ let seen = [];
+ let listener = {
+ onInstallFailed: () => {
+ AddonManager.removeInstallListener(listener);
+ reject("extension installation should not have failed");
+ },
+ onInstallEnded: install => {
+ AddonManager.removeInstallListener(listener);
+ reject(
+ `extension installation should not have ended for ${install.addon.id}`
+ );
+ },
+ onInstallPostponed: install => {
+ seen.push(install.addon.id);
+ if (seen.includes(addonID1) && seen.includes(addonID2)) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+function promiseInstallResumed(addonID1, addonID2) {
+ return new Promise((resolve, reject) => {
+ let seenPostponed = [];
+ let seenEnded = [];
+ let listener = {
+ onInstallFailed: () => {
+ AddonManager.removeInstallListener(listener);
+ reject("extension installation should not have failed");
+ },
+ onInstallEnded: install => {
+ seenEnded.push(install.addon.id);
+ if (
+ seenEnded.includes(addonID1) &&
+ seenEnded.includes(addonID2) &&
+ seenPostponed.includes(addonID1) &&
+ seenPostponed.includes(addonID2)
+ ) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ onInstallPostponed: install => {
+ seenPostponed.push(install.addon.id);
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+function promiseInstallDeferred(addonID1, addonID2) {
+ return new Promise((resolve, reject) => {
+ let seenEnded = [];
+ let listener = {
+ onInstallFailed: () => {
+ AddonManager.removeInstallListener(listener);
+ reject("extension installation should not have failed");
+ },
+ onInstallEnded: install => {
+ seenEnded.push(install.addon.id);
+ if (seenEnded.includes(addonID1) && seenEnded.includes(addonID2)) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+async function checkAddon(addonID, { version }) {
+ let addon = await promiseAddonByID(addonID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, version);
+ Assert.ok(addon.isCompatible);
+ Assert.ok(!addon.appDisabled);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.type, "extension");
+}
+
+// Tests below have webextension background scripts inline.
+/* globals browser */
+
+// add-on registers upgrade listener, and ignores update.
+add_task(async function test_addon_upgrade_on_restart() {
+ // discard system addon updates
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, `${NORMAL_ID}.xpi`);
+
+ // Version 1.0 of an extension that ignores updates.
+ function background() {
+ browser.runtime.onUpdateAvailable.addListener(() => {
+ browser.test.sendMessage("got-update");
+ });
+ }
+
+ xpi = await createTempWebExtensionFile({
+ background,
+
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: IGNORE_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${IGNORE_ID}.xpi`);
+
+ // Version 2.0 of the same extension.
+ let xpi2 = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: IGNORE_ID } },
+ },
+ });
+
+ await overrideBuiltIns({ system: [IGNORE_ID, NORMAL_ID] });
+
+ let extension = ExtensionTestUtils.expectExtension(IGNORE_ID);
+
+ await Promise.all([promiseStartupManager(), extension.awaitStartup()]);
+
+ let updateList = [
+ {
+ id: IGNORE_ID,
+ version: "2.0",
+ path: "system_delay_ignore_2.xpi",
+ xpi: xpi2,
+ },
+ {
+ id: NORMAL_ID,
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi: await getSystemAddonXPI(1, "2.0"),
+ },
+ ];
+
+ await Promise.all([
+ promiseInstallPostponed(IGNORE_ID, NORMAL_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ extension.awaitMessage("got-update"),
+ ]);
+
+ // addon upgrade has been delayed.
+ await checkAddon(IGNORE_ID, { version: "1.0" });
+ // other addons in the set are delayed as well.
+ await checkAddon(NORMAL_ID, { version: "1.0" });
+
+ // restarting allows upgrades to proceed
+ await Promise.all([promiseRestartManager(), extension.awaitStartup()]);
+
+ await checkAddon(IGNORE_ID, { version: "2.0" });
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
+
+// add-on registers upgrade listener, and allows update.
+add_task(async function test_addon_upgrade_on_reload() {
+ // discard system addon updates
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, `${NORMAL_ID}.xpi`);
+
+ // Version 1.0 of an extension that listens for and immediately
+ // applies updates.
+ function background() {
+ browser.runtime.onUpdateAvailable.addListener(function listener() {
+ browser.runtime.onUpdateAvailable.removeListener(listener);
+ browser.test.sendMessage("got-update");
+ browser.runtime.reload();
+ });
+ }
+
+ xpi = await createTempWebExtensionFile({
+ background,
+
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: COMPLETE_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${COMPLETE_ID}.xpi`);
+
+ // Version 2.0 of the same extension.
+ let xpi2 = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: COMPLETE_ID } },
+ },
+ });
+
+ await overrideBuiltIns({ system: [COMPLETE_ID, NORMAL_ID] });
+
+ let extension = ExtensionTestUtils.expectExtension(COMPLETE_ID);
+
+ await Promise.all([promiseStartupManager(), extension.awaitStartup()]);
+
+ let updateList = [
+ {
+ id: COMPLETE_ID,
+ version: "2.0",
+ path: "system_delay_complete_2.xpi",
+ xpi: xpi2,
+ },
+ {
+ id: NORMAL_ID,
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi: await getSystemAddonXPI(1, "2.0"),
+ },
+ ];
+
+ // initial state
+ await checkAddon(COMPLETE_ID, { version: "1.0" });
+ await checkAddon(NORMAL_ID, { version: "1.0" });
+
+ // We should see that the onUpdateListener executed, then see the
+ // update resume.
+ await Promise.all([
+ extension.awaitMessage("got-update"),
+ promiseInstallResumed(COMPLETE_ID, NORMAL_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ ]);
+ await extension.awaitStartup();
+
+ // addon upgrade has been allowed
+ await checkAddon(COMPLETE_ID, { version: "2.0" });
+ // other upgrades in the set are allowed as well
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ // restarting changes nothing
+ await Promise.all([promiseRestartManager(), extension.awaitStartup()]);
+
+ await checkAddon(COMPLETE_ID, { version: "2.0" });
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
+
+function delayBackground() {
+ browser.test.onMessage.addListener(msg => {
+ if (msg !== "reload") {
+ browser.test.fail(`Got unexpected test message: ${msg}`);
+ }
+ browser.runtime.reload();
+ });
+ browser.runtime.onUpdateAvailable.addListener(async function listener() {
+ browser.runtime.onUpdateAvailable.removeListener(listener);
+ browser.test.sendMessage("got-update");
+ });
+}
+
+// Upgrade listener initially defers then proceeds after a pause.
+add_task(async function test_addon_upgrade_after_pause() {
+ // discard system addon updates
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, `${NORMAL_ID}.xpi`);
+
+ // Version 1.0 of an extension that delays upgrades.
+ xpi = await createTempWebExtensionFile({
+ background: delayBackground,
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: DEFER_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${DEFER_ID}.xpi`);
+
+ // Version 2.0 of the same xtension.
+ let xpi2 = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: DEFER_ID } },
+ },
+ });
+
+ await overrideBuiltIns({ system: [DEFER_ID, NORMAL_ID] });
+
+ let extension = ExtensionTestUtils.expectExtension(DEFER_ID);
+
+ await Promise.all([promiseStartupManager(), extension.awaitStartup()]);
+
+ let updateList = [
+ {
+ id: DEFER_ID,
+ version: "2.0",
+ path: "system_delay_defer_2.xpi",
+ xpi: xpi2,
+ },
+ {
+ id: NORMAL_ID,
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi: await getSystemAddonXPI(1, "2.0"),
+ },
+ ];
+
+ await Promise.all([
+ promiseInstallPostponed(DEFER_ID, NORMAL_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ extension.awaitMessage("got-update"),
+ ]);
+
+ // upgrade is initially postponed
+ await checkAddon(DEFER_ID, { version: "1.0" });
+ // other addons in the set are postponed as well.
+ await checkAddon(NORMAL_ID, { version: "1.0" });
+
+ let deferred = promiseInstallDeferred(DEFER_ID, NORMAL_ID);
+
+ // Tell the extension to proceed with the update.
+ extension.setRestarting();
+ extension.sendMessage("reload");
+
+ await Promise.all([deferred, extension.awaitStartup()]);
+
+ // addon upgrade has been allowed
+ await checkAddon(DEFER_ID, { version: "2.0" });
+ // other addons in the set are allowed as well.
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ // restarting changes nothing
+ await promiseRestartManager();
+ await extension.awaitStartup();
+
+ await checkAddon(DEFER_ID, { version: "2.0" });
+ await checkAddon(NORMAL_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
+
+// Multiple add-ons register update listeners, initially defers then
+// each unblock in turn.
+add_task(async function test_multiple_addon_upgrade_postpone() {
+ // discard system addon updates.
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "");
+
+ let updateList = [];
+
+ let xpi = await createTempWebExtensionFile({
+ background: delayBackground,
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: DEFER2_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${DEFER2_ID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: DEFER2_ID } },
+ },
+ });
+ updateList.push({
+ id: DEFER2_ID,
+ version: "2.0",
+ path: "system_delay_defer_2.xpi",
+ xpi,
+ });
+
+ xpi = await createTempWebExtensionFile({
+ background: delayBackground,
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id: DEFER_ALSO_ID } },
+ },
+ });
+ xpi.copyTo(distroDir, `${DEFER_ALSO_ID}.xpi`);
+
+ xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: DEFER_ALSO_ID } },
+ },
+ });
+ updateList.push({
+ id: DEFER_ALSO_ID,
+ version: "2.0",
+ path: "system_delay_defer_also_2.xpi",
+ xpi,
+ });
+
+ await overrideBuiltIns({ system: [DEFER2_ID, DEFER_ALSO_ID] });
+
+ let extension1 = ExtensionTestUtils.expectExtension(DEFER2_ID);
+ let extension2 = ExtensionTestUtils.expectExtension(DEFER_ALSO_ID);
+
+ await Promise.all([
+ promiseStartupManager(),
+ extension1.awaitStartup(),
+ extension2.awaitStartup(),
+ ]);
+
+ await Promise.all([
+ promiseInstallPostponed(DEFER2_ID, DEFER_ALSO_ID),
+ installSystemAddons(buildSystemAddonUpdates(updateList)),
+ extension1.awaitMessage("got-update"),
+ extension2.awaitMessage("got-update"),
+ ]);
+
+ // upgrade is initially postponed
+ await checkAddon(DEFER2_ID, { version: "1.0" });
+ // other addons in the set are postponed as well.
+ await checkAddon(DEFER_ALSO_ID, { version: "1.0" });
+
+ let deferred = promiseInstallDeferred(DEFER2_ID, DEFER_ALSO_ID);
+
+ // Let one extension request that the update proceed.
+ extension1.setRestarting();
+ extension1.sendMessage("reload");
+
+ // Upgrade blockers still present.
+ await checkAddon(DEFER2_ID, { version: "1.0" });
+ await checkAddon(DEFER_ALSO_ID, { version: "1.0" });
+
+ // Let the second extension allow the update to proceed.
+ extension2.setRestarting();
+ extension2.sendMessage("reload");
+
+ await Promise.all([
+ deferred,
+ extension1.awaitStartup(),
+ extension2.awaitStartup(),
+ ]);
+
+ // addon upgrade has been allowed
+ await checkAddon(DEFER2_ID, { version: "2.0" });
+ await checkAddon(DEFER_ALSO_ID, { version: "2.0" });
+
+ // restarting changes nothing
+ await Promise.all([
+ promiseRestartManager(),
+ extension1.awaitStartup(),
+ extension2.awaitStartup(),
+ ]);
+
+ await checkAddon(DEFER2_ID, { version: "2.0" });
+ await checkAddon(DEFER_ALSO_ID, { version: "2.0" });
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js
new file mode 100644
index 0000000000..9dbf8f7070
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_profile_location.js
@@ -0,0 +1,204 @@
+"use strict";
+
+/* globals browser */
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1"
+);
+
+AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("system");
+
+async function promiseInstallSystemProfileExtension(id, hidden) {
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ hidden,
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ let wrapper = ExtensionTestUtils.expectExtension(id);
+
+ const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, {
+ useSystemLocation: true, // KEY_APP_SYSTEM_PROFILE
+ });
+
+ install.install();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ return wrapper;
+}
+
+async function promiseUninstall(id) {
+ await AddonManager.uninstallSystemProfileAddon(id);
+
+ let addon = await promiseAddonByID(id);
+ equal(addon, null, "Addon is gone after uninstall");
+}
+
+// Tests installing an extension into the app-system-profile location.
+add_task(async function test_system_profile_location() {
+ let id = "system@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ let wrapper = await promiseInstallSystemProfileExtension(id);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(wrapper.extension.isAppProvided, "Addon is app provided");
+ ok(!addon.hidden, "Addon is not hidden");
+ equal(
+ addon.signedState,
+ AddonManager.SIGNEDSTATE_PRIVILEGED,
+ "Addon is system signed"
+ );
+
+ // After a restart, the extension should start up normally.
+ await promiseRestartManager();
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in app-system-profile location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that causes a database rebuild, it should still work
+ await promiseRestartManager("2");
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in app-system-profile location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ // After a restart that changes the schema version, it should still work
+ await promiseShutdownManager();
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+ ok(true, "Extension in app-system-profile location ran after restart");
+
+ addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+
+ await promiseUninstall(id);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+// Tests installing a hidden extension in the app-system-profile location.
+add_task(async function test_system_profile_location_hidden() {
+ let id = "system-hidden@tests.mozilla.org";
+ await AddonTestUtils.promiseStartupManager();
+ await promiseInstallSystemProfileExtension(id, true);
+
+ let addon = await promiseAddonByID(id);
+ notEqual(addon, null, "Addon is installed");
+ ok(addon.isActive, "Addon is active");
+ ok(addon.isPrivileged, "Addon is privileged");
+ ok(addon.hidden, "Addon is hidden");
+
+ await promiseUninstall(id);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_system_profile_location_installFile() {
+ await AddonTestUtils.promiseStartupManager();
+ let id = "system-fileinstall@test";
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ background() {
+ browser.test.sendMessage("started");
+ },
+ });
+ let wrapper = ExtensionTestUtils.expectExtension(id);
+
+ const install = await AddonManager.getInstallForFile(xpi, null, null, true);
+ install.install();
+
+ await wrapper.awaitStartup();
+ await wrapper.awaitMessage("started");
+
+ await promiseUninstall(id);
+
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_system_profile_location_overridden() {
+ await AddonTestUtils.promiseStartupManager();
+ let id = "system-fileinstall@test";
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ let install = await AddonManager.getInstallForFile(xpi, null, null, true);
+ await install.install();
+
+ let addon = await promiseAddonByID(id);
+ equal(addon.version, "1.0", "Addon is installed");
+
+ // Install a user profile version on top of the system profile location.
+ xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ install = await AddonManager.getInstallForFile(xpi);
+ await install.install();
+
+ addon = await promiseAddonByID(id);
+ equal(addon.version, "2.0", "Addon is upgraded");
+
+ // Uninstall the system profile addon.
+ await AddonManager.uninstallSystemProfileAddon(id);
+
+ addon = await promiseAddonByID(id);
+ equal(addon.version, "2.0", "Addon is still active");
+
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+});
+
+add_task(async function test_system_profile_location_require_system_cert() {
+ await AddonTestUtils.promiseStartupManager();
+ let id = "fail@test";
+ let xpi = await AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, {
+ useSystemLocation: true, // KEY_APP_SYSTEM_PROFILE
+ });
+
+ await install.install();
+
+ let addon = await promiseAddonByID(id);
+ ok(!addon.isPrivileged, "Addon is not privileged");
+ ok(!addon.isActive, "Addon is not active");
+ equal(addon.signedState, AddonManager.SIGNEDSTATE_SIGNED, "Addon is signed");
+
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js
new file mode 100644
index 0000000000..dc1c96e7bc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_repository.js
@@ -0,0 +1,69 @@
+// Tests that AddonRepository doesn't download results for system add-ons
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+var gServer = new HttpServer();
+gServer.start(-1);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "0");
+
+// Test with a missing features directory
+add_task(async function test_app_addons() {
+ // Build the test set
+ var distroDir = FileUtils.getDir("ProfD", ["sysfeatures"]);
+ distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(distroDir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(2, "1.0");
+ xpi.copyTo(distroDir, "system2@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(distroDir, "system3@tests.mozilla.org.xpi");
+
+ registerDirectory("XREAppFeat", distroDir);
+
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ `http://localhost:${gServer.identity.primaryPort}/get?%IDS%`
+ );
+
+ gServer.registerPathHandler("/get", (request, response) => {
+ do_throw("Unexpected request to server.");
+ });
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ ],
+ });
+
+ await promiseStartupManager();
+
+ await AddonRepository.cacheAddons([
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ ]);
+
+ let cached = await AddonRepository.getCachedAddonByID(
+ "system1@tests.mozilla.org"
+ );
+ Assert.equal(cached, null);
+
+ cached = await AddonRepository.getCachedAddonByID(
+ "system2@tests.mozilla.org"
+ );
+ Assert.equal(cached, null);
+
+ cached = await AddonRepository.getCachedAddonByID(
+ "system3@tests.mozilla.org"
+ );
+ Assert.equal(cached, null);
+
+ await promiseShutdownManager();
+ await new Promise(resolve => gServer.stop(resolve));
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js
new file mode 100644
index 0000000000..93e4c516fa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_reset.js
@@ -0,0 +1,539 @@
+// Tests that we reset to the default system add-ons correctly when switching
+// application versions
+
+const updatesDir = FileUtils.getDir("ProfD", ["features"]);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(async function setup() {
+ // Build the test sets
+ let dir = FileUtils.getDir("ProfD", ["sysfeatures", "app1"]);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(dir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(2, "1.0");
+ xpi.copyTo(dir, "system2@tests.mozilla.org.xpi");
+
+ dir = FileUtils.getDir("ProfD", ["sysfeatures", "app2"]);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ xpi = await getSystemAddonXPI(1, "2.0");
+ xpi.copyTo(dir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(dir, "system3@tests.mozilla.org.xpi");
+
+ dir = FileUtils.getDir("ProfD", ["sysfeatures", "app3"]);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ xpi = await getSystemAddonXPI(1, "1.0");
+ xpi.copyTo(dir, "system1@tests.mozilla.org.xpi");
+
+ xpi = await getSystemAddonXPI(3, "1.0");
+ xpi.copyTo(dir, "system3@tests.mozilla.org.xpi");
+});
+
+const distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "app0"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "0");
+
+function makeUUID() {
+ let uuidGen = Services.uuid;
+ return uuidGen.generateUUID().toString();
+}
+
+async function check_installed(conditions) {
+ for (let i = 0; i < conditions.length; i++) {
+ let condition = conditions[i];
+ let id = "system" + (i + 1) + "@tests.mozilla.org";
+ let addon = await promiseAddonByID(id);
+
+ if (!("isUpgrade" in condition) || !("version" in condition)) {
+ throw Error("condition must contain isUpgrade and version");
+ }
+ let isUpgrade = conditions[i].isUpgrade;
+ let version = conditions[i].version;
+
+ let expectedDir = isUpgrade ? updatesDir : distroDir;
+
+ if (version) {
+ // Add-on should be installed
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, version);
+ Assert.ok(addon.isActive);
+ Assert.ok(!addon.foreignInstall);
+ Assert.ok(addon.hidden);
+ Assert.ok(addon.isSystem);
+ Assert.ok(!hasFlag(addon.permissions, AddonManager.PERM_CAN_UPGRADE));
+ if (isUpgrade) {
+ Assert.ok(
+ hasFlag(addon.permissions, AddonManager.PERM_API_CAN_UNINSTALL)
+ );
+ } else {
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_API_CAN_UNINSTALL)
+ );
+ }
+
+ // Verify the add-ons file is in the right place
+ let file = expectedDir.clone();
+ file.append(id + ".xpi");
+ Assert.ok(file.exists());
+ Assert.ok(file.isFile());
+
+ Assert.equal(getAddonFile(addon).path, file.path);
+
+ if (isUpgrade) {
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_SYSTEM);
+ }
+ } else if (isUpgrade) {
+ // Add-on should not be installed
+ Assert.equal(addon, null);
+ } else {
+ // Either add-on should not be installed or it shouldn't be active
+ Assert.ok(!addon || !addon.isActive);
+ }
+ }
+}
+
+// Test with a missing features directory
+add_task(async function test_missing_app_dir() {
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Add some features in a new version
+add_task(async function test_new_version() {
+ gAppInfo.version = "1";
+ distroDir.leafName = "app1";
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Another new version swaps one feature and upgrades another
+add_task(async function test_upgrade() {
+ gAppInfo.version = "2";
+ distroDir.leafName = "app2";
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "1.0" },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Downgrade
+add_task(async function test_downgrade() {
+ gAppInfo.version = "1";
+ distroDir.leafName = "app1";
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ Assert.ok(!updatesDir.exists());
+
+ await promiseShutdownManager();
+});
+
+// Fake a mid-cycle install
+add_task(async function test_updated() {
+ // Create a random dir to install into
+ let dirname = makeUUID();
+ let dir = FileUtils.getDir("ProfD", ["features", dirname]);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ updatesDir.append(dirname);
+
+ // Copy in the system add-ons
+ let file = await getSystemAddonXPI(2, "2.0");
+ file.copyTo(updatesDir, "system2@tests.mozilla.org.xpi");
+ file = await getSystemAddonXPI(3, "2.0");
+ file.copyTo(updatesDir, "system3@tests.mozilla.org.xpi");
+
+ // Inject it into the system set
+ let addonSet = {
+ schema: 1,
+ directory: updatesDir.leafName,
+ addons: {
+ "system2@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system3@tests.mozilla.org": {
+ version: "2.0",
+ },
+ },
+ };
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(addonSet));
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Entering safe mode should disable the updated system add-ons and use the
+// default system add-ons
+add_task(async function safe_mode_disabled() {
+ gAppInfo.inSafeMode = true;
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Leaving safe mode should re-enable the updated system add-ons
+add_task(async function normal_mode_enabled() {
+ gAppInfo.inSafeMode = false;
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// An additional add-on in the directory should be ignored
+add_task(async function test_skips_additional() {
+ // Copy in the system add-ons
+ let file = await getSystemAddonXPI(4, "1.0");
+ file.copyTo(updatesDir, "system4@tests.mozilla.org.xpi");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Missing add-on should revert to the default set
+add_task(async function test_revert() {
+ manuallyUninstall(updatesDir, "system2@tests.mozilla.org");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ // With system add-on 2 gone the updated set is now invalid so it reverts to
+ // the default set which is system add-ons 1 and 2.
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Putting it back will make the set work again
+add_task(async function test_reuse() {
+ let file = await getSystemAddonXPI(2, "2.0");
+ file.copyTo(updatesDir, "system2@tests.mozilla.org.xpi");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Making the pref corrupt should revert to the default set
+add_task(async function test_corrupt_pref() {
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, "foo");
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// An add-on with a bad certificate should cause us to use the default set
+add_task(async function test_bad_profile_cert() {
+ let file = await getSystemAddonXPI(1, "1.0");
+ file.copyTo(updatesDir, "system1@tests.mozilla.org.xpi");
+
+ // Inject it into the system set
+ let addonSet = {
+ schema: 1,
+ directory: updatesDir.leafName,
+ addons: {
+ "system1@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system2@tests.mozilla.org": {
+ version: "1.0",
+ },
+ "system3@tests.mozilla.org": {
+ version: "1.0",
+ },
+ },
+ };
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(addonSet));
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
+
+// Switching to app defaults that contain a bad certificate should still work
+add_task(async function test_bad_app_cert() {
+ gAppInfo.version = "3";
+ distroDir.leafName = "app3";
+
+ AddonTestUtils.usePrivilegedSignatures = id => {
+ return id === "system1@tests.mozilla.org" ? false : "system";
+ };
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ // Since we updated the app version, the system addon set should be reset as well.
+ let addonSet = Services.prefs.getCharPref(PREF_SYSTEM_ADDON_SET);
+ Assert.equal(addonSet, `{"schema":1,"addons":{}}`);
+
+ // Add-on will still be present
+ let addon = await promiseAddonByID("system1@tests.mozilla.org");
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.signedState, AddonManager.SIGNEDSTATE_NOT_REQUIRED);
+
+ let conditions = [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "1.0" },
+ ];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+
+ AddonTestUtils.usePrivilegedSignatures = id => "system";
+});
+
+// A failed upgrade should revert to the default set.
+add_task(async function test_updated_bad_update_set() {
+ // Create a random dir to install into
+ let dirname = makeUUID();
+ let dir = FileUtils.getDir("ProfD", ["features", dirname]);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ updatesDir.append(dirname);
+
+ // Copy in the system add-ons
+ let file = await getSystemAddonXPI(2, "2.0");
+ file.copyTo(updatesDir, "system2@tests.mozilla.org.xpi");
+ file = await getSystemAddonXPI("failed_update", "1.0");
+ file.copyTo(updatesDir, "system_failed_update@tests.mozilla.org.xpi");
+
+ // Inject it into the system set
+ let addonSet = {
+ schema: 1,
+ directory: updatesDir.leafName,
+ addons: {
+ "system2@tests.mozilla.org": {
+ version: "2.0",
+ },
+ "system_failed_update@tests.mozilla.org": {
+ version: "1.0",
+ },
+ },
+ };
+ Services.prefs.setCharPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(addonSet));
+
+ await overrideBuiltIns({
+ system: [
+ "system1@tests.mozilla.org",
+ "system2@tests.mozilla.org",
+ "system3@tests.mozilla.org",
+ "system5@tests.mozilla.org",
+ ],
+ });
+ await promiseStartupManager();
+
+ let conditions = [{ isUpgrade: false, version: "1.0" }];
+
+ await check_installed(conditions);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js
new file mode 100644
index 0000000000..56d10436c7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_blank.js
@@ -0,0 +1,118 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Test that a blank response does nothing
+ blank: {
+ updateList: null,
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js
new file mode 100644
index 0000000000..f9ac09255a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_checkSizeHash.js
@@ -0,0 +1,182 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Correct sizes and hashes should work
+ checkSizeHash: {
+ // updateList is populated in setup()
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ await initSystemAddonDirs();
+
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.checkSizeHash.updateList;
+ let xpi = await getSystemAddonXPI(2, "3.0");
+ list.push({
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi,
+ size: xpi.fileSize,
+ });
+
+ xpi = await getSystemAddonXPI(3, "3.0");
+ let [hashFunction, hashValue] = do_get_file_hash(xpi, "sha256").split(":");
+ list.push({
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ xpi,
+ hashFunction,
+ hashValue,
+ });
+
+ xpi = await getSystemAddonXPI(5, "1.0");
+ [hashFunction, hashValue] = do_get_file_hash(xpi, "sha256").split(":");
+ list.push({
+ id: "system5@tests.mozilla.org",
+ version: "1.0",
+ path: "system5_1.xpi",
+ size: xpi.fileSize,
+ xpi,
+ hashFunction,
+ hashValue,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js
new file mode 100644
index 0000000000..b0310d3ceb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_custom.js
@@ -0,0 +1,492 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(initSystemAddonDirs);
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+// Test that the update check is performed as part of the regular add-on update
+// check
+add_task(async function test_addon_update() {
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, true);
+ await setupSystemAddonConditions(TEST_CONDITIONS.blank, distroDir);
+
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+
+ const promiseInstallsEnded = createInstallsEndedPromise(2);
+
+ await Promise.all([
+ updateAllSystemAddons(updateXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ ]);
+
+ await promiseInstallsEnded;
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.blank.initialState,
+ [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Disabling app updates should block system add-on updates
+add_task(async function test_app_update_disabled() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.blank, distroDir);
+
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, false);
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await updateAllSystemAddons(updateXML);
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.blank.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Safe mode should block system add-on updates
+add_task(async function test_safe_mode() {
+ gAppInfo.inSafeMode = true;
+
+ await setupSystemAddonConditions(TEST_CONDITIONS.blank, distroDir);
+
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, false);
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await updateAllSystemAddons(updateXML);
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.blank.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+
+ gAppInfo.inSafeMode = false;
+});
+
+// Tests that a set that matches the default set does nothing
+add_task(async function test_match_default() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withAppSet, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await installSystemAddons(installXML);
+
+ // Shouldn't have installed an updated set
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withAppSet.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a set that matches the hidden default set works
+add_task(async function test_match_default_revert() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system1@tests.mozilla.org",
+ version: "1.0",
+ path: "system1_1.xpi",
+ xpi: await getSystemAddonXPI(1, "1.0"),
+ },
+ {
+ id: "system2@tests.mozilla.org",
+ version: "1.0",
+ path: "system2_1.xpi",
+ xpi: await getSystemAddonXPI(2, "1.0"),
+ },
+ ]);
+ await installSystemAddons(installXML);
+
+ // This should revert to the default set instead of installing new versions
+ // into an updated set.
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a set that matches the current set works
+add_task(async function test_match_current() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ]);
+ await installSystemAddons(installXML);
+
+ // This should remain with the current set instead of creating a new copy
+ let set = JSON.parse(Services.prefs.getCharPref(PREF_SYSTEM_ADDON_SET));
+ Assert.equal(set.directory, "prefilled");
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a set with a minor change doesn't re-download existing files
+add_task(async function test_no_download() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ // The missing file here is unneeded since there is a local version already
+ let installXML = buildSystemAddonUpdates([
+ { id: "system2@tests.mozilla.org", version: "2.0", path: "missing.xpi" },
+ {
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi: await getSystemAddonXPI(4, "1.0"),
+ },
+ ]);
+
+ const promiseInstallsEnded = createInstallsEndedPromise(2);
+
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system4@tests.mozilla.org"),
+ ]);
+
+ // NOTE: verifySystemAddonState does call AddonTestUtils.promiseShutdownManager
+ // internally, and so we need to wait for the system addons to be fully
+ // installed and the system addon location's stating dir to have been released.
+ info("Wait for system addon installs to be completed");
+ await promiseInstallsEnded;
+ info("Wait for system addons stating dir to be released");
+ await waitForSystemAddonStagingDirReleased();
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// Tests that a second update before a restart works
+add_task(async function test_double_update() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withAppSet, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "1.0",
+ path: "system3_1.xpi",
+ xpi: await getSystemAddonXPI(3, "1.0"),
+ },
+ ]);
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ ]);
+
+ installXML = buildSystemAddonUpdates([
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ {
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi: await getSystemAddonXPI(4, "1.0"),
+ },
+ ]);
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ promiseWebExtensionStartup("system4@tests.mozilla.org"),
+ ]);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withAppSet.initialState,
+ [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ true,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
+
+// A second update after a restart will delete the original unused set
+add_task(async function test_update_purges() {
+ await setupSystemAddonConditions(TEST_CONDITIONS.withBothSets, distroDir);
+
+ let installXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "1.0",
+ path: "system3_1.xpi",
+ xpi: await getSystemAddonXPI(3, "1.0"),
+ },
+ ]);
+ await Promise.all([
+ installSystemAddons(installXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ promiseWebExtensionStartup("system3@tests.mozilla.org"),
+ ]);
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.withBothSets.initialState,
+ [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ await installSystemAddons(buildSystemAddonUpdates(null));
+
+ let dirs = await getSystemAddonDirectories();
+ Assert.equal(dirs.length, 1);
+
+ await promiseShutdownManager();
+});
+
+function createInstallsEndedPromise(expectedCount) {
+ // Addon installation triggers the execution of an un-awaited promise. We need
+ // to keep track of addon installs so that we can tell when these async
+ // processes have finished.
+
+ return new Promise(resolve => {
+ let installsEnded = 0;
+
+ const listener = {
+ onInstallStarted() {},
+ onInstallEnded() {
+ installsEnded++;
+
+ if (installsEnded === expectedCount) {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ }
+ },
+ onInstallCancelled() {},
+ onInstallFailed() {},
+ };
+
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+async function waitForSystemAddonStagingDirReleased() {
+ // Wait for the staging dir to be released, which prevents unexpected test
+ // failure due to AddonTestUtils.promiseShutdownManager being mocking an
+ // AddonManager shutdown by using testing functions to re-import XPIProvider,
+ // XPIDatabase, and XPIInstall modules, which would hit unexpected failures
+ // if done while system addon updates are still running in the background.
+
+ const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+ );
+ let systemAddonLocation = XPIExports.XPIInternal.XPIStates.getLocation(
+ XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS
+ );
+ await TestUtils.waitForCondition(() => {
+ return systemAddonLocation.installer._stagingDirLock == 0;
+ }, "Wait for staging dir to be released");
+}
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js
new file mode 100644
index 0000000000..3fae4272be
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_empty.js
@@ -0,0 +1,142 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Test that an empty list removes existing updates, leaving defaults.
+ empty: {
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ // Set this to `true` to so `verifySystemAddonState()` expects a blank profile dir
+ { isUpgrade: true, version: null },
+ ],
+ },
+ },
+};
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js
new file mode 100644
index 0000000000..d36b97a3cc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_enterprisepolicy.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// This test verifies that system addon updates are correctly blocked by the
+// DisableSystemAddonUpdate enterprise policy.
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+Services.policies; // Load policy engine
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run the test against.
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ *
+ * These conditions run tests with no updated or default system add-ons
+ * initially installed
+ */
+const TEST_CONDITIONS = {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+};
+
+add_task(async function test_update_disabled_by_policy() {
+ await setupSystemAddonConditions(TEST_CONDITIONS, distroDir);
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ DisableSystemAddonUpdate: true,
+ },
+ });
+
+ await updateAllSystemAddons(
+ buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi: await getSystemAddonXPI(2, "2.0"),
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "2.0",
+ path: "system3_2.xpi",
+ xpi: await getSystemAddonXPI(3, "2.0"),
+ },
+ ])
+ );
+
+ await verifySystemAddonState(
+ TEST_CONDITIONS.initialState,
+ undefined,
+ false,
+ distroDir
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js
new file mode 100644
index 0000000000..377c30395a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_fail.js
@@ -0,0 +1,186 @@
+// Tests that system add-on upgrades fail to upgrade in expected cases.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: regex to test error in Assert.rejects.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Specifying an incorrect version should stop us updating anything
+ badVersion: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ updateList: [
+ {
+ id: "system2@tests.mozilla.org",
+ version: "4.0",
+ path: "system2_3.xpi",
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ },
+ ],
+ },
+
+ // Specifying an invalid size should stop us updating anything
+ badSize: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ updateList: [
+ {
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ size: 2,
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ },
+ ],
+ },
+
+ // Specifying an incorrect hash should stop us updating anything
+ badHash: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ updateList: [
+ {
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ hashFunction: "sha256",
+ hashValue: "205a4c49bd513ebd30594e380c19e86bba1f83e2",
+ },
+ ],
+ },
+
+ // A bad certificate should stop updates
+ badCert: {
+ fails:
+ /Error: Rejecting updated system add-on set that either could not be downloaded or contained unusable add-ons./,
+ // true is not system addon signed
+ usePrivilegedSignatures: true,
+ updateList: [
+ {
+ id: "system1@tests.mozilla.org",
+ version: "1.0",
+ path: "system1_1_badcert.xpi",
+ },
+ {
+ id: "system3@tests.mozilla.org",
+ version: "1.0",
+ path: "system3_1.xpi",
+ },
+ ],
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js
new file mode 100644
index 0000000000..f67894289d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_installTelemetryInfo.js
@@ -0,0 +1,95 @@
+// Test that the expected installTelemetryInfo are being set on the system addon
+// installed/updated through Balrog.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+add_task(async function test_addon_update() {
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, true);
+
+ // Define the set of initial conditions to run each test against:
+ // - setup: A task to setup the profile into the initial state.
+ // - initialState: The initial expected system add-on state after setup has run.
+ const initialSetup = {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ ],
+ };
+
+ await setupSystemAddonConditions(initialSetup, distroDir);
+
+ const newlyInstalledSystemAddonId = "system1@tests.mozilla.org";
+ const updatedSystemAddonId = "system2@tests.mozilla.org";
+
+ const updateXML = buildSystemAddonUpdates([
+ // Newly installed system addon entry.
+ {
+ id: newlyInstalledSystemAddonId,
+ version: "1.0",
+ path: "system1_1.xpi",
+ xpi: await getSystemAddonXPI(1, "1.0"),
+ },
+ // Updated system addon entry.
+ {
+ id: updatedSystemAddonId,
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi: await getSystemAddonXPI(2, "3.0"),
+ },
+ ]);
+
+ await Promise.all([
+ updateAllSystemAddons(updateXML),
+ promiseWebExtensionStartup(newlyInstalledSystemAddonId),
+ promiseWebExtensionStartup(updatedSystemAddonId),
+ ]);
+
+ await verifySystemAddonState(
+ initialSetup.initialState,
+ [
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ false,
+ distroDir
+ );
+
+ const newlyInstalledSystemAddon = await AddonManager.getAddonByID(
+ newlyInstalledSystemAddonId
+ );
+ Assert.deepEqual(
+ newlyInstalledSystemAddon.installTelemetryInfo,
+ // For addons installed for the first time through the product addon checker
+ // we set a `method` in the telemetryInfo.
+ { source: "system-addon", method: "product-updates" },
+ "Got the expected telemetry info on balrog system addon installed addon"
+ );
+
+ const updatedSystemAddon = await AddonManager.getAddonByID(
+ updatedSystemAddonId
+ );
+ Assert.deepEqual(
+ updatedSystemAddon.installTelemetryInfo,
+ // For addons that are distributed in Firefox, then updated through the product
+ // addon checker, `method` will not be set.
+ { source: "system-addon" },
+ "Got the expected telemetry info on balrog system addon updated addon"
+ );
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js
new file mode 100644
index 0000000000..fd93ba5d38
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_newset.js
@@ -0,0 +1,166 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Tests that a new set of system add-ons gets installed
+ newset: {
+ // updateList is populated in setup() below
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: true, version: "1.0" },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.newset.updateList;
+ let xpi = await getSystemAddonXPI(4, "1.0");
+ list.push({
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(5, "1.0");
+ list.push({
+ id: "system5@tests.mozilla.org",
+ version: "1.0",
+ path: "system5_1.xpi",
+ xpi,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js
new file mode 100644
index 0000000000..a6c5bc905c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_overlapping.js
@@ -0,0 +1,181 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+add_task(() => initSystemAddonDirs());
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Tests that a set of system add-ons, some new, some existing gets installed
+ overlapping: {
+ // updateList is populated in setup() below
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ withAppSet: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ withProfileSet: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ withBothSets: [
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "1.0" },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.overlapping.updateList;
+ let xpi = await getSystemAddonXPI(1, "2.0");
+ list.push({
+ id: "system1@tests.mozilla.org",
+ version: "2.0",
+ path: "system1_2.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(2, "2.0");
+ list.push({
+ id: "system2@tests.mozilla.org",
+ version: "2.0",
+ path: "system2_2.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(3, "3.0");
+ list.push({
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(4, "1.0");
+ list.push({
+ id: "system4@tests.mozilla.org",
+ version: "1.0",
+ path: "system4_1.xpi",
+ xpi,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js
new file mode 100644
index 0000000000..bf2dd85772
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_uninstall_check.js
@@ -0,0 +1,57 @@
+// Tests that system add-on doesnt uninstall while update.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = "system";
+
+add_task(() => initSystemAddonDirs());
+
+const initialSetup = {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ ],
+};
+
+add_task(async function test_systems_update_uninstall_check() {
+ Services.prefs.setBoolPref(PREF_SYSTEM_ADDON_UPDATE_ENABLED, true);
+
+ await setupSystemAddonConditions(initialSetup, distroDir);
+
+ let updateXML = buildSystemAddonUpdates([
+ {
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi: await getSystemAddonXPI(2, "3.0"),
+ },
+ ]);
+
+ const listener = (msg, { method, params, reason }) => {
+ if (params.id === "system2@tests.mozilla.org" && method === "uninstall") {
+ Assert.ok(
+ false,
+ "Should not see uninstall call for system2@tests.mozilla.org"
+ );
+ }
+ };
+
+ AddonTestUtils.on("bootstrap-method", listener);
+
+ await Promise.all([
+ updateAllSystemAddons(updateXML),
+ promiseWebExtensionStartup("system2@tests.mozilla.org"),
+ ]);
+
+ AddonTestUtils.off("bootstrap-method", listener);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js
new file mode 100644
index 0000000000..d270f33190
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_update_upgrades.js
@@ -0,0 +1,166 @@
+// Tests that system add-on upgrades work.
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+add_task(() => initSystemAddonDirs());
+
+/**
+ * Defines the set of initial conditions to run each test against. Each should
+ * define the following properties:
+ *
+ * setup: A task to setup the profile into the initial state.
+ * initialState: The initial expected system add-on state after setup has run.
+ */
+const TEST_CONDITIONS = {
+ // Runs tests with no updated or default system add-ons initially installed
+ blank: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ // Runs tests with default system add-ons installed
+ withAppSet: {
+ setup() {
+ clearSystemAddonUpdatesDir();
+ distroDir.leafName = "prefilled";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with updated system add-ons installed
+ withProfileSet: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+
+ // Runs tests with both default and updated system add-ons installed
+ withBothSets: {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "hidden";
+ },
+ initialState: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: true, version: "2.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+};
+
+/**
+ * The tests to run. Each test must define an updateList or test. The following
+ * properties are used:
+ *
+ * updateList: The set of add-ons the server should respond with.
+ * test: A function to run to perform the update check (replaces
+ * updateList)
+ * fails: An optional property, if true the update check is expected to
+ * fail.
+ * finalState: An optional property, the expected final state of system add-ons,
+ * if missing the test condition's initialState is used.
+ */
+const TESTS = {
+ // Tests that an upgraded set of system add-ons gets installed
+ upgrades: {
+ // populated in setup() below
+ updateList: [],
+ finalState: {
+ blank: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withAppSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withProfileSet: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ withBothSets: [
+ { isUpgrade: false, version: "1.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: true, version: "3.0" },
+ { isUpgrade: false, version: null },
+ { isUpgrade: false, version: null },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // Initialise the profile
+ await overrideBuiltIns({ system: [] });
+ await promiseStartupManager();
+ await promiseShutdownManager();
+
+ let list = TESTS.upgrades.updateList;
+ let xpi = await getSystemAddonXPI(2, "3.0");
+ list.push({
+ id: "system2@tests.mozilla.org",
+ version: "3.0",
+ path: "system2_3.xpi",
+ xpi,
+ });
+
+ xpi = await getSystemAddonXPI(3, "3.0");
+ list.push({
+ id: "system3@tests.mozilla.org",
+ version: "3.0",
+ path: "system3_3.xpi",
+ xpi,
+ });
+});
+
+add_task(async function () {
+ for (let setupName of Object.keys(TEST_CONDITIONS)) {
+ for (let testName of Object.keys(TESTS)) {
+ info("Running test " + setupName + " " + testName);
+
+ let setup = TEST_CONDITIONS[setupName];
+ let test = TESTS[testName];
+
+ await execSystemAddonTest(setupName, setup, test, distroDir);
+ }
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js b/toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js
new file mode 100644
index 0000000000..f02003805c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_system_upgrades.js
@@ -0,0 +1,417 @@
+"use strict";
+
+// Enable SCOPE_APPLICATION for builtin testing. Default in tests is only SCOPE_PROFILE.
+let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+BootstrapMonitor.init();
+
+// A test directory for default/builtin system addons.
+const systemDefaults = FileUtils.getDir("ProfD", [
+ "app-system-defaults",
+ "features",
+]);
+systemDefaults.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", systemDefaults);
+
+AddonTestUtils.usePrivilegedSignatures = id => "system";
+
+const ADDON_ID = "updates@test";
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let server = createHttpServer();
+
+server.registerPathHandler("/upgrade.json", (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "ok");
+ response.write(
+ JSON.stringify({
+ addons: {
+ [ADDON_ID]: {
+ updates: [
+ {
+ version: "4.0",
+ update_link: `http://localhost:${server.identity.primaryPort}/${ADDON_ID}.xpi`,
+ },
+ ],
+ },
+ },
+ })
+ );
+});
+
+function createWebExtensionFile(id, version, update_url) {
+ return AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version,
+ browser_specific_settings: {
+ gecko: { id, update_url },
+ },
+ },
+ });
+}
+
+let xpiUpdate = createWebExtensionFile(ADDON_ID, "4.0");
+
+server.registerFile(`/${ADDON_ID}.xpi`, xpiUpdate);
+
+async function promiseInstallDefaultSystemAddon(id, version) {
+ let xpi = createWebExtensionFile(id, version);
+ await AddonTestUtils.manuallyInstall(xpi, systemDefaults);
+ return xpi;
+}
+
+async function promiseInstallProfileExtension(id, version, update_url) {
+ return promiseInstallWebExtension({
+ manifest: {
+ version,
+ browser_specific_settings: {
+ gecko: { id, update_url },
+ },
+ },
+ });
+}
+
+async function promiseInstallSystemProfileAddon(id, version) {
+ let xpi = createWebExtensionFile(id, version);
+ const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, {
+ useSystemLocation: true, // KEY_APP_SYSTEM_PROFILE
+ });
+
+ return install.install();
+}
+
+async function promiseUpdateSystemAddon(id, version, waitForStartup = true) {
+ let xpi = createWebExtensionFile(id, version);
+ let xml = buildSystemAddonUpdates([
+ {
+ id: ADDON_ID,
+ version,
+ path: xpi.leafName,
+ xpi,
+ },
+ ]);
+ // If we're not expecting a startup we need to wait for install to end.
+ let promises = [];
+ if (!waitForStartup) {
+ promises.push(AddonTestUtils.promiseAddonEvent("onInstalled"));
+ }
+ promises.push(installSystemAddons(xml, waitForStartup ? [ADDON_ID] : []));
+ return Promise.all(promises);
+}
+
+async function promiseClearSystemAddons() {
+ let xml = buildSystemAddonUpdates([]);
+ return installSystemAddons(xml, []);
+}
+
+const builtInOverride = { system: [ADDON_ID] };
+
+async function checkAddon(version, reason, startReason = reason) {
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ Assert.equal(addons.length, 1, "one addon expected to be installed");
+ Assert.equal(addons[0].version, version, `addon ${version} is installed`);
+ Assert.ok(addons[0].isActive, `addon ${version} is active`);
+ Assert.ok(!addons[0].disabled, `addon ${version} is enabled`);
+
+ let installInfo = BootstrapMonitor.checkInstalled(ADDON_ID, version);
+ equal(
+ installInfo.reason,
+ reason,
+ `bootstrap monitor verified install reason for ${version}`
+ );
+ let started = BootstrapMonitor.checkStarted(ADDON_ID, version);
+ equal(
+ started.reason,
+ startReason,
+ `bootstrap monitor verified started reason for ${version}`
+ );
+
+ return addons[0];
+}
+
+/**
+ * This test function starts after a 1.0 version of an addon is installed
+ * either as a builtin ("app-builtin") or as a builtin system addon ("app-system-defaults").
+ *
+ * This tests the precedence chain works as expected through upgrades and
+ * downgrades.
+ *
+ * Upgrade to a system addon in the profile location, "app-system-addons"
+ * Upgrade to a temporary addon
+ * Uninstalling the temporary addon downgrades to the system addon in "app-system-addons".
+ * Upgrade to a system addon in the "app-system-profile" location.
+ * Uninstalling the "app-system-profile" addon downgrades to the one in "app-system-addons".
+ * Upgrade to a user-installed addon
+ * Upgrade the addon in "app-system-addons", verify user-install is still active
+ * Uninstall the addon in "app-system-addons", verify user-install is active
+ * Test that the update_url upgrades the user-install and becomes active
+ * Disable the user-install, verify the disabled addon retains precedence
+ * Uninstall the disabled user-install, verify system addon in "app-system-defaults" is active and enabled
+ * Upgrade the system addon again, then user-install a lower version, verify downgrade happens.
+ * Uninstall user-install, verify upgrade happens when the system addon in "app-system-addons" is activated.
+ */
+async function _test_builtin_addon_override() {
+ /////
+ // Upgrade to a system addon in the profile location, "app-system-addons"
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.0");
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Upgrade to a temporary addon
+ /////
+ let tmpAddon = createWebExtensionFile(ADDON_ID, "2.1");
+ await Promise.all([
+ AddonManager.installTemporaryAddon(tmpAddon),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ let addon = await checkAddon("2.1", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Downgrade back to the system addon
+ /////
+ await addon.uninstall();
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Install then uninstall an system profile addon
+ /////
+ info("Install an System Profile Addon, then uninstall it.");
+ await Promise.all([
+ promiseInstallSystemProfileAddon(ADDON_ID, "2.2"),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ addon = await checkAddon("2.2", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+ await addon.uninstall();
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Upgrade to a user-installed addon
+ /////
+ await Promise.all([
+ promiseInstallProfileExtension(
+ ADDON_ID,
+ "3.0",
+ `http://localhost:${server.identity.primaryPort}/upgrade.json`
+ ),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("3.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Upgrade the system addon, verify user-install is still active
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.2", false);
+ await checkAddon("3.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Uninstall the system addon, verify user-install is active
+ /////
+ await Promise.all([
+ promiseClearSystemAddons(),
+ AddonTestUtils.promiseAddonEvent("onUninstalled"),
+ ]);
+ addon = await checkAddon("3.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Test that the update_url upgrades the user-install and becomes active
+ /////
+ let update = await promiseFindAddonUpdates(addon);
+ await Promise.all([
+ promiseCompleteAllInstalls([update.updateAvailable]),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ addon = await checkAddon("4.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Disable the user-install, verify the disabled addon retains precedence
+ /////
+ await addon.disable();
+
+ await AddonManager.getAddonByID(ADDON_ID);
+ Assert.ok(!addon.isActive, "4.0 is disabled");
+ Assert.equal(
+ addon.version,
+ "4.0",
+ "version 4.0 is still the visible version"
+ );
+
+ /////
+ // Uninstall the disabled user-install, verify system addon is active and enabled
+ /////
+ await Promise.all([
+ addon.uninstall(),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ // We've downgraded all the way to 1.0 from a user-installed addon
+ addon = await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Upgrade the system addon again, then user-install a lower version, verify downgrade happens.
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "5.1");
+ addon = await checkAddon("5.1", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ // user-install a lower version, downgrade happens
+ await Promise.all([
+ promiseInstallProfileExtension(ADDON_ID, "5.0"),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ addon = await checkAddon("5.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ /////
+ // Uninstall user-install, verify upgrade happens when system addon is activated.
+ /////
+ await Promise.all([
+ addon.uninstall(),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ // the "system add-on upgrade" is now revealed
+ addon = await checkAddon("5.1", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ await Promise.all([
+ addon.uninstall(),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_DOWNGRADE);
+
+ // Downgrading from an installed system addon to a default system
+ // addon also requires the removal of the file on disk, and removing
+ // the addon from the pref.
+ Services.prefs.clearUserPref(PREF_SYSTEM_ADDON_SET);
+}
+
+add_task(async function test_system_addon_upgrades() {
+ await AddonTestUtils.overrideBuiltIns(builtInOverride);
+ await promiseInstallDefaultSystemAddon(ADDON_ID, "1.0");
+ await AddonTestUtils.promiseStartupManager();
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ await _test_builtin_addon_override();
+
+ // cleanup the system addon in the default location
+ await AddonTestUtils.manuallyUninstall(systemDefaults, ADDON_ID);
+ // If we don't restart (to clean up the uninstall above) and we
+ // clear BootstrapMonitor, BootstrapMonitor asserts on the next AOM startup.
+ await AddonTestUtils.promiseRestartManager();
+
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
+
+// Run the test again, but starting from a "builtin" addon location
+add_task(async function test_builtin_addon_upgrades() {
+ builtInOverride.system = [];
+
+ await AddonTestUtils.promiseStartupManager();
+ await Promise.all([
+ installBuiltinExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: { id: ADDON_ID },
+ },
+ },
+ }),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ await _test_builtin_addon_override();
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
+
+add_task(async function test_system_addon_precedence() {
+ builtInOverride.system = [ADDON_ID];
+ await AddonTestUtils.overrideBuiltIns(builtInOverride);
+ await promiseInstallDefaultSystemAddon(ADDON_ID, "1.0");
+ await AddonTestUtils.promiseStartupManager();
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ /////
+ // Upgrade to a system addon in the profile location, "app-system-addons"
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.0");
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Builtin system addon is changed, it has precedence because when this
+ // happens we remove all prior system addon upgrades.
+ /////
+ await AddonTestUtils.promiseShutdownManager();
+ await AddonTestUtils.overrideBuiltIns(builtInOverride);
+ await promiseInstallDefaultSystemAddon(ADDON_ID, "1.5");
+ await AddonTestUtils.promiseStartupManager("2");
+ await checkAddon(
+ "1.5",
+ BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ BOOTSTRAP_REASONS.APP_STARTUP
+ );
+
+ // cleanup the system addon in the default location
+ await AddonTestUtils.manuallyUninstall(systemDefaults, ADDON_ID);
+ await AddonTestUtils.promiseRestartManager();
+
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
+
+add_task(async function test_builtin_addon_version_precedence() {
+ builtInOverride.system = [];
+
+ await AddonTestUtils.promiseStartupManager();
+ await Promise.all([
+ installBuiltinExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: { id: ADDON_ID },
+ },
+ },
+ }),
+ AddonTestUtils.promiseWebExtensionStartup(ADDON_ID),
+ ]);
+ await checkAddon("1.0", BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ /////
+ // Upgrade to a system addon in the profile location, "app-system-addons"
+ /////
+ await promiseUpdateSystemAddon(ADDON_ID, "2.0");
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ /////
+ // Builtin addon is changed, the system addon should still have precedence.
+ /////
+ await Promise.all([
+ installBuiltinExtension(
+ {
+ manifest: {
+ version: "1.5",
+ browser_specific_settings: {
+ gecko: { id: ADDON_ID },
+ },
+ },
+ },
+ false
+ ),
+ AddonTestUtils.promiseAddonEvent("onInstalled"),
+ ]);
+ await checkAddon("2.0", BOOTSTRAP_REASONS.ADDON_UPGRADE);
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ await addon.uninstall();
+ await AddonTestUtils.promiseShutdownManager();
+ BootstrapMonitor.clear(ADDON_ID);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js b/toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js
new file mode 100644
index 0000000000..46a53917f9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_systemaddomstartupprefs.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that system addon about:config prefs
+// are honored during startup/restarts/upgrades.
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = "system";
+
+add_task(initSystemAddonDirs);
+
+BootstrapMonitor.init();
+
+add_task(async function setup() {
+ let xpi = await getSystemAddonXPI(1, "1.0");
+ await AddonTestUtils.manuallyInstall(xpi, distroDir);
+});
+
+add_task(async function systemAddonPreffedOff() {
+ const id = "system1@tests.mozilla.org";
+ Services.prefs.setBoolPref("extensions.system1.enabled", false);
+
+ await overrideBuiltIns({ system: [id] });
+
+ await promiseStartupManager();
+
+ BootstrapMonitor.checkInstalled(id);
+ BootstrapMonitor.checkNotStarted(id);
+
+ await promiseRestartManager();
+
+ BootstrapMonitor.checkNotStarted(id);
+
+ await promiseShutdownManager({ clearOverrides: false });
+});
+
+add_task(async function systemAddonPreffedOn() {
+ const id = "system1@tests.mozilla.org";
+ Services.prefs.setBoolPref("extensions.system1.enabled", true);
+
+ await promiseStartupManager("2.0");
+
+ BootstrapMonitor.checkInstalled(id);
+ BootstrapMonitor.checkStarted(id);
+
+ await promiseRestartManager();
+
+ BootstrapMonitor.checkStarted(id);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_temporary.js b/toolkit/mozapps/extensions/test/xpcshell/test_temporary.js
new file mode 100644
index 0000000000..80faa57fc1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_temporary.js
@@ -0,0 +1,765 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "addon@tests.mozilla.org";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+function waitForBootstrapEvent(expectedEvent, addonId) {
+ return new Promise(resolve => {
+ function listener(msg, { method, params, reason }) {
+ if (params.id === addonId && method === expectedEvent) {
+ resolve({ params, method, reason });
+ AddonTestUtils.off("bootstrap-method", listener);
+ } else {
+ info(`Ignoring bootstrap event: ${method} for ${params.id}`);
+ }
+ }
+ AddonTestUtils.on("bootstrap-method", listener);
+ });
+}
+
+async function checkEvent(promise, { reason, params }) {
+ let event = await promise;
+ info(`Checking bootstrap event ${event.method} for ${event.params.id}`);
+
+ equal(
+ event.reason,
+ reason,
+ `Expected bootstrap reason ${getReasonName(reason)} got ${getReasonName(
+ event.reason
+ )}`
+ );
+
+ for (let [param, value] of Object.entries(params)) {
+ equal(event.params[param], value, `Expected value for params.${param}`);
+ }
+}
+
+BootstrapMonitor.init();
+
+const XPIS = {};
+
+add_task(async function setup() {
+ for (let n of [1, 2]) {
+ XPIS[n] = await createTempWebExtensionFile({
+ manifest: {
+ name: "Test",
+ version: `${n}.0`,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+ }
+});
+
+// Install a temporary add-on with no existing add-on present.
+// Restart and make sure it has gone away.
+add_task(async function test_new_temporary() {
+ await promiseStartupManager();
+
+ let extInstallCalled = false;
+ AddonManager.addInstallListener({
+ onExternalInstall: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "1.0");
+ extInstallCalled = true;
+ },
+ });
+
+ let installingCalled = false;
+ let installedCalled = false;
+ AddonManager.addAddonListener({
+ onInstalling: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "1.0");
+ installingCalled = true;
+ },
+ onInstalled: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "1.0");
+ installedCalled = true;
+ },
+ onInstallStarted: aInstall => {
+ do_throw("onInstallStarted called unexpectedly");
+ },
+ });
+
+ await AddonManager.installTemporaryAddon(XPIS[1]);
+
+ Assert.ok(extInstallCalled);
+ Assert.ok(installingCalled);
+ Assert.ok(installedCalled);
+
+ const install = BootstrapMonitor.checkInstalled(ID, "1.0");
+ equal(install.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ BootstrapMonitor.checkStarted(ID, "1.0");
+
+ let info = BootstrapMonitor.started.get(ID);
+ Assert.equal(info.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ let addon = await promiseAddonByID(ID);
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: true,
+ });
+
+ let onShutdown = waitForBootstrapEvent("shutdown", ID);
+ let onUninstall = waitForBootstrapEvent("uninstall", ID);
+
+ await promiseRestartManager();
+
+ let shutdown = await onShutdown;
+ equal(shutdown.reason, BOOTSTRAP_REASONS.ADDON_UNINSTALL);
+
+ let uninstall = await onUninstall;
+ equal(uninstall.reason, BOOTSTRAP_REASONS.ADDON_UNINSTALL);
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on over the top of an existing add-on.
+// Restart and make sure the existing add-on comes back.
+add_task(async function test_replace_temporary() {
+ await promiseInstallFile(XPIS[2]);
+ let addon = await promiseAddonByID(ID);
+
+ BootstrapMonitor.checkInstalled(ID, "2.0");
+ BootstrapMonitor.checkStarted(ID, "2.0");
+
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ let tempdir = gTmpD.clone();
+
+ for (let newversion of ["1.0", "3.0"]) {
+ for (let packed of [false, true]) {
+ // ugh, file locking issues with xpis on windows
+ if (packed && AppConstants.platform == "win") {
+ continue;
+ }
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ name: "Test",
+ version: newversion,
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let target = await AddonTestUtils.promiseWriteFilesToExtension(
+ tempdir.path,
+ ID,
+ files,
+ !packed
+ );
+
+ let onShutdown = waitForBootstrapEvent("shutdown", ID);
+ let onUpdate = waitForBootstrapEvent("update", ID);
+ let onStartup = waitForBootstrapEvent("startup", ID);
+
+ await AddonManager.installTemporaryAddon(target);
+
+ let reason =
+ Services.vc.compare(newversion, "2.0") < 0
+ ? BOOTSTRAP_REASONS.ADDON_DOWNGRADE
+ : BOOTSTRAP_REASONS.ADDON_UPGRADE;
+
+ await checkEvent(onShutdown, {
+ reason,
+ params: {
+ version: "2.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason,
+ params: {
+ version: newversion,
+ oldVersion: "2.0",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason,
+ params: {
+ version: newversion,
+ oldVersion: "2.0",
+ },
+ });
+
+ addon = await promiseAddonByID(ID);
+
+ let signedState = packed
+ ? AddonManager.SIGNEDSTATE_PRIVILEGED
+ : AddonManager.SIGNEDSTATE_UNKNOWN;
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: newversion,
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState,
+ temporarilyInstalled: true,
+ });
+
+ // Now restart, the temporary addon will go away which should
+ // be the opposite action (ie, if the temporary addon was an
+ // upgrade, then removing it is a downgrade and vice versa)
+ reason =
+ reason == BOOTSTRAP_REASONS.ADDON_UPGRADE
+ ? BOOTSTRAP_REASONS.ADDON_DOWNGRADE
+ : BOOTSTRAP_REASONS.ADDON_UPGRADE;
+
+ onShutdown = waitForBootstrapEvent("shutdown", ID);
+ onUpdate = waitForBootstrapEvent("update", ID);
+ onStartup = waitForBootstrapEvent("startup", ID);
+
+ await promiseRestartManager();
+
+ await checkEvent(onShutdown, {
+ reason,
+ params: {
+ version: newversion,
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason,
+ params: {
+ version: "2.0",
+ oldVersion: newversion,
+ },
+ });
+
+ await checkEvent(onStartup, {
+ // We don't actually propagate the upgrade/downgrade reason across
+ // the browser restart when a temporary addon is removed. See
+ // bug 1359558 for detailed reasoning.
+ reason: BOOTSTRAP_REASONS.APP_STARTUP,
+ params: {
+ version: "2.0",
+ },
+ });
+
+ BootstrapMonitor.checkInstalled(ID, "2.0");
+ BootstrapMonitor.checkStarted(ID, "2.0");
+
+ addon = await promiseAddonByID(ID);
+
+ // existing add-on is back
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ Services.obs.notifyObservers(target, "flush-cache-entry");
+ target.remove(true);
+ }
+ }
+
+ // remove original add-on
+ await addon.uninstall();
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ await promiseRestartManager();
+});
+
+// Test that loading from the same path multiple times work
+add_task(async function test_samefile() {
+ // File locking issues on Windows, ugh
+ if (AppConstants.platform == "win") {
+ return;
+ }
+
+ // test that a webextension works
+ let webext = createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ });
+
+ let addon = await AddonManager.installTemporaryAddon(webext);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: true,
+ });
+
+ Services.obs.notifyObservers(webext, "flush-cache-entry");
+ webext.remove(false);
+ webext = createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ });
+
+ addon = await AddonManager.installTemporaryAddon(webext);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ isWebExtension: true,
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: true,
+ });
+
+ await addon.uninstall();
+});
+
+// Install a temporary add-on over the top of an existing add-on.
+// Uninstall it and make sure the existing add-on comes back.
+add_task(async function test_replace_permanent() {
+ await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ name: "Test Bootstrap 1",
+ },
+ });
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+
+ let unpacked_addon = gTmpD.clone();
+ unpacked_addon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "2.0",
+ name: "Test Bootstrap 1 (temporary)",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpacked_addon.path, files);
+
+ let extInstallCalled = false;
+ AddonManager.addInstallListener({
+ onExternalInstall: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "2.0");
+ extInstallCalled = true;
+ },
+ });
+
+ let installingCalled = false;
+ let installedCalled = false;
+ AddonManager.addAddonListener({
+ onInstalling: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ if (!installingCalled) {
+ Assert.equal(aInstall.version, "2.0");
+ }
+ installingCalled = true;
+ },
+ onInstalled: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ if (!installedCalled) {
+ Assert.equal(aInstall.version, "2.0");
+ }
+ installedCalled = true;
+ },
+ onInstallStarted: aInstall => {
+ do_throw("onInstallStarted called unexpectedly");
+ },
+ });
+
+ let addon = await AddonManager.installTemporaryAddon(unpacked_addon);
+
+ Assert.ok(extInstallCalled);
+ Assert.ok(installingCalled);
+ Assert.ok(installedCalled);
+
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test Bootstrap 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_UNKNOWN,
+ temporarilyInstalled: true,
+ });
+
+ await addon.uninstall();
+
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkStarted(ID);
+
+ addon = await promiseAddonByID(ID);
+
+ // existing add-on is back
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test Bootstrap 1",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ unpacked_addon.remove(true);
+ await addon.uninstall();
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on as a version upgrade over the top of an
+// existing temporary add-on.
+add_task(async function test_replace_temporary() {
+ const unpackedAddon = gTmpD.clone();
+ unpackedAddon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ // Increment the version number, re-install it, and make sure it
+ // gets marked as an upgrade.
+ files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "2.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ const onShutdown = waitForBootstrapEvent("shutdown", ID);
+ const onUpdate = waitForBootstrapEvent("update", ID);
+ const onStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onShutdown, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "2.0",
+ oldVersion: "1.0",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "2.0",
+ oldVersion: "1.0",
+ },
+ });
+
+ const addon = await promiseAddonByID(ID);
+ await addon.uninstall();
+
+ unpackedAddon.remove(true);
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on as a version downgrade over the top of an
+// existing temporary add-on.
+add_task(async function test_replace_temporary_downgrade() {
+ const unpackedAddon = gTmpD.clone();
+ unpackedAddon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ // Decrement the version number, re-install, and make sure
+ // it gets marked as a downgrade.
+ files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "0.8",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ const onShutdown = waitForBootstrapEvent("shutdown", ID);
+ const onUpdate = waitForBootstrapEvent("update", ID);
+ const onStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onShutdown, {
+ reason: BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason: BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ params: {
+ oldVersion: "1.0",
+ version: "0.8",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_DOWNGRADE,
+ params: {
+ version: "0.8",
+ },
+ });
+
+ const addon = await promiseAddonByID(ID);
+ await addon.uninstall();
+
+ unpackedAddon.remove(true);
+ await promiseRestartManager();
+});
+
+// Installing a temporary add-on over an existing add-on with the same
+// version number should be installed as an upgrade.
+add_task(async function test_replace_same_version() {
+ const unpackedAddon = gTmpD.clone();
+ unpackedAddon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ version: "1.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpackedAddon.path, files);
+
+ const onInitialInstall = waitForBootstrapEvent("install", ID);
+ const onInitialStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onInitialInstall, {
+ reason: BOOTSTRAP_REASONS.ADDON_INSTALL,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onInitialStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_INSTALL,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ let info = BootstrapMonitor.started.get(ID);
+ Assert.equal(info.reason, BOOTSTRAP_REASONS.ADDON_INSTALL);
+
+ // Install it again.
+ const onShutdown = waitForBootstrapEvent("shutdown", ID);
+ const onUpdate = waitForBootstrapEvent("update", ID);
+ const onStartup = waitForBootstrapEvent("startup", ID);
+ await AddonManager.installTemporaryAddon(unpackedAddon);
+
+ await checkEvent(onShutdown, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onUpdate, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ oldVersion: "1.0",
+ version: "1.0",
+ },
+ });
+
+ await checkEvent(onStartup, {
+ reason: BOOTSTRAP_REASONS.ADDON_UPGRADE,
+ params: {
+ version: "1.0",
+ },
+ });
+
+ const addon = await promiseAddonByID(ID);
+ await addon.uninstall();
+
+ unpackedAddon.remove(true);
+ await promiseRestartManager();
+});
+
+// Install a temporary add-on over the top of an existing disabled add-on.
+// After restart, the existing add-on should continue to be installed and disabled.
+add_task(async function test_replace_permanent_disabled() {
+ await promiseInstallFile(XPIS[1]);
+ let addon = await promiseAddonByID(ID);
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+
+ await addon.disable();
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkNotStarted(ID);
+
+ let unpacked_addon = gTmpD.clone();
+ unpacked_addon.append(ID);
+
+ let files = ExtensionTestCommon.generateFiles({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ name: "Test",
+ version: "2.0",
+ },
+ });
+ await AddonTestUtils.promiseWriteFilesToDir(unpacked_addon.path, files);
+
+ let extInstallCalled = false;
+ AddonManager.addInstallListener({
+ onExternalInstall: aInstall => {
+ Assert.equal(aInstall.id, ID);
+ Assert.equal(aInstall.version, "2.0");
+ extInstallCalled = true;
+ },
+ });
+
+ let tempAddon = await AddonManager.installTemporaryAddon(unpacked_addon);
+
+ Assert.ok(extInstallCalled);
+
+ BootstrapMonitor.checkInstalled(ID, "2.0");
+ BootstrapMonitor.checkStarted(ID);
+
+ // temporary add-on is installed and started
+ checkAddon(ID, tempAddon, {
+ version: "2.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_UNKNOWN,
+ temporarilyInstalled: true,
+ });
+
+ await tempAddon.uninstall();
+ unpacked_addon.remove(true);
+
+ await addon.enable();
+ await new Promise(executeSoon);
+ addon = await promiseAddonByID(ID);
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID);
+
+ // existing add-on is back
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ temporarilyInstalled: false,
+ });
+
+ await addon.uninstall();
+
+ BootstrapMonitor.checkNotInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+
+ await promiseRestartManager();
+});
+
+// Tests that XPIs with a .zip extension work when loaded temporarily.
+add_task(async function test_zip_extension() {
+ let xpi = createTempWebExtensionFile({
+ background() {
+ /* globals browser */
+ browser.test.sendMessage("started", "Hello.");
+ },
+ });
+ xpi.moveTo(null, xpi.leafName.replace(/\.xpi$/, ".zip"));
+
+ let extension = ExtensionTestUtils.loadExtensionXPI(xpi);
+ await extension.startup();
+
+ let msg = await extension.awaitMessage("started");
+ equal(msg, "Hello.", "Got expected background script message");
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js b/toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js
new file mode 100644
index 0000000000..2c0540e399
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_trash_directory.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { BasePromiseWorker } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseWorker.sys.mjs"
+);
+
+// Test that an open file inside the trash directory does not cause
+// unrelated installs to break (see bug 1180901 for more background).
+add_task(async function test() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+
+ let profileDir = PathUtils.profileDir;
+ let trashDir = PathUtils.join(profileDir, "extensions", "trash");
+ let testFile = PathUtils.join(trashDir, "test.txt");
+
+ await IOUtils.makeDirectory(trashDir);
+
+ let trashDirExists = await IOUtils.exists(trashDir);
+ ok(trashDirExists, "trash directory should have been created");
+
+ await IOUtils.writeUTF8(testFile, "");
+
+ // Use a worker to keep the testFile open.
+ const worker = new BasePromiseWorker(
+ "resource://test/data/test_trash_directory.worker.js"
+ );
+ await worker.post("open", [testFile]);
+
+ let fileExists = await IOUtils.exists(testFile);
+ ok(fileExists, "test.txt should have been created in " + trashDir);
+
+ await promiseInstallWebExtension({});
+
+ // The testFile should still exist at this point because we have not
+ // yet closed the file handle and as a result, Windows cannot remove it.
+ fileExists = await IOUtils.exists(testFile);
+ ok(fileExists, "test.txt should still exist");
+
+ // Cleanup
+ await promiseShutdownManager();
+ await worker.post("close", []);
+ await IOUtils.remove(testFile);
+ await IOUtils.remove(trashDir, { recursive: true });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_types.js b/toolkit/mozapps/extensions/test/xpcshell/test_types.js
new file mode 100644
index 0000000000..b9a0d3987e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_types.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that custom types can be defined and undefined
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+add_task(async function setup() {
+ await promiseStartupManager();
+});
+
+add_task(async function test_new_addonType() {
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+
+ // The dumbest provider possible
+ const provider = {};
+
+ AddonManagerPrivate.registerProvider(provider, ["test"]);
+
+ Assert.equal(true, AddonManager.hasAddonType("test"));
+ Assert.equal(false, AddonManager.hasAddonType("t$e%st"));
+ Assert.equal(false, AddonManager.hasAddonType(null));
+ Assert.equal(false, AddonManager.hasAddonType(undefined));
+
+ AddonManagerPrivate.unregisterProvider(provider);
+
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+});
+
+add_task(async function test_bad_addonType() {
+ const provider = {};
+ Assert.throws(
+ () => AddonManagerPrivate.registerProvider(provider, /* addonTypes =*/ {}),
+ /aTypes must be an array or null/
+ );
+
+ Assert.throws(
+ () => AddonManagerPrivate.registerProvider(provider, new Set()),
+ /aTypes must be an array or null/
+ );
+});
+
+add_task(async function test_addonTypes_should_be_immutable() {
+ const provider = {};
+ const addonTypes = [];
+
+ addonTypes.push("test");
+ AddonManagerPrivate.registerProvider(provider, addonTypes);
+ addonTypes.pop();
+ addonTypes.push("test_added");
+ // Modifications to addonTypes should not affect AddonManager.
+ Assert.equal(true, AddonManager.hasAddonType("test"));
+ Assert.equal(false, AddonManager.hasAddonType("test_added"));
+ AddonManagerPrivate.unregisterProvider(provider);
+
+ AddonManagerPrivate.registerProvider(provider, addonTypes);
+ // After re-registering the provider, the type change should have been processed.
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+ Assert.equal(true, AddonManager.hasAddonType("test_added"));
+ AddonManagerPrivate.unregisterProvider(provider);
+});
+
+add_task(async function test_missing_addonType() {
+ const dummyAddon = {
+ id: "some dummy addon from provider without .addonTypes property",
+ };
+ const provider = {
+ // addonTypes Set is missing. This only happens in unit tests, but let's
+ // verify that the implementation behaves reasonably.
+ // A provider without an explicitly registered type may still return an
+ // entry when getAddonsByTypes is called.
+ async getAddonsByTypes(types) {
+ Assert.equal(null, types);
+ return [dummyAddon];
+ },
+ };
+
+ AddonManagerPrivate.registerProvider(provider); // addonTypes not set.
+ Assert.equal(false, AddonManager.hasAddonType("test"));
+ Assert.equal(false, AddonManager.hasAddonType(null));
+ Assert.equal(false, AddonManager.hasAddonType(undefined));
+
+ const addons = await AddonManager.getAddonsByTypes(null);
+ Assert.equal(1, addons.length);
+ Assert.equal(dummyAddon, addons[0]);
+
+ AddonManagerPrivate.unregisterProvider(provider);
+});
+
+add_task(async function test_getAddonTypesByProvider() {
+ let defaultTypes = AddonManagerPrivate.getAddonTypesByProvider("XPIProvider");
+ Assert.ok(defaultTypes.includes("extension"), `extension in ${defaultTypes}`);
+ Assert.throws(
+ () => AddonManagerPrivate.getAddonTypesByProvider(),
+ /No addonTypes found for provider: undefined/
+ );
+ Assert.throws(
+ () => AddonManagerPrivate.getAddonTypesByProvider("MaybeExistent"),
+ /No addonTypes found for provider: MaybeExistent/
+ );
+
+ const provider = { name: "MaybeExistent" };
+ AddonManagerPrivate.registerProvider(provider, ["test"]);
+ Assert.deepEqual(
+ AddonManagerPrivate.getAddonTypesByProvider("MaybeExistent"),
+ ["test"],
+ "Newly registered type returned by getAddonTypesByProvider"
+ );
+
+ AddonManagerPrivate.unregisterProvider(provider);
+
+ Assert.throws(
+ () => AddonManagerPrivate.getAddonTypesByProvider("MaybeExistent"),
+ /No addonTypes found for provider: MaybeExistent/
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js b/toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js
new file mode 100644
index 0000000000..af07fb1e34
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_undouninstall.js
@@ -0,0 +1,584 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that forcing undo for uninstall works
+
+const APP_STARTUP = 1;
+const APP_SHUTDOWN = 2;
+const ADDON_DISABLE = 4;
+const ADDON_INSTALL = 5;
+const ADDON_UNINSTALL = 6;
+const ADDON_UPGRADE = 7;
+
+const ID = "undouninstall1@tests.mozilla.org";
+const INCOMPAT_ID = "incompatible@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = {
+ test_undoincompatible: {
+ manifest: {
+ name: "Incompatible Addon",
+ browser_specific_settings: {
+ gecko: {
+ id: "incompatible@tests.mozilla.org",
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ },
+ },
+ test_undouninstall1: {
+ manifest: {
+ name: "Test Bootstrap 1",
+ browser_specific_settings: {
+ gecko: {
+ id: "undouninstall1@tests.mozilla.org",
+ },
+ },
+ },
+ },
+};
+
+const XPIS = {};
+
+BootstrapMonitor.init();
+
+function getStartupReason(id) {
+ let info = BootstrapMonitor.started.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getShutdownReason(id) {
+ let info = BootstrapMonitor.stopped.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getInstallReason(id) {
+ let info = BootstrapMonitor.installed.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getUninstallReason(id) {
+ let info = BootstrapMonitor.uninstalled.get(id);
+ return info ? info.reason : undefined;
+}
+
+function getShutdownNewVersion(id) {
+ let info = BootstrapMonitor.stopped.get(id);
+ return info ? info.params.newVersion : undefined;
+}
+
+// Sets up the profile by installing an add-on.
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+ registerCleanupFunction(promiseShutdownManager);
+
+ for (let [name, files] of Object.entries(ADDONS)) {
+ XPIS[name] = await AddonTestUtils.createTempWebExtensionFile(files);
+ }
+});
+
+// Tests that an enabled restartless add-on can be uninstalled and goes away
+// when the uninstall is committed
+add_task(async function uninstallRestartless() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID(ID);
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall(true);
+
+ a1 = await promiseAddonByID(ID);
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_UNINSTALL);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall();
+
+ a1 = await promiseAddonByID(ID);
+
+ Assert.equal(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+});
+
+// Tests that an enabled restartless add-on can be uninstalled and then cancelled
+add_task(async function cancelUninstallOfRestartless() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+ let a1 = await promiseAddonByID(ID);
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_UNINSTALL);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ let promises = [
+ promiseAddonEvent("onOperationCancelled"),
+ promiseWebExtensionStartup(ID),
+ ];
+ a1.cancelUninstall();
+ await Promise.all(promises);
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await promiseShutdownManager();
+
+ Assert.equal(getShutdownReason(ID), APP_SHUTDOWN);
+ Assert.equal(getShutdownNewVersion(ID), undefined);
+
+ await promiseStartupManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getStartupReason(ID), APP_STARTUP);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Tests that reinstalling an enabled restartless add-on waiting to be
+// uninstalled aborts the uninstall and leaves the add-on enabled
+add_task(async function reinstallAddonAwaitingUninstall() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_UNINSTALL);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => promiseInstallFile(XPIS.test_undouninstall1)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_UPGRADE);
+ Assert.equal(getStartupReason(ID), ADDON_UPGRADE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await promiseShutdownManager();
+
+ Assert.equal(getShutdownReason(ID), APP_SHUTDOWN);
+
+ await promiseStartupManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getStartupReason(ID), APP_STARTUP);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Tests that a disabled restartless add-on can be uninstalled and goes away
+// when the uninstall is committed
+add_task(async function uninstallDisabledRestartless() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.disable();
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_DISABLE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ // commit the uninstall
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalled" }],
+ },
+ },
+ () => a1.uninstall()
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.equal(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkNotInstalled(ID);
+ Assert.equal(getUninstallReason(ID), ADDON_UNINSTALL);
+});
+
+// Tests that a disabled restartless add-on can be uninstalled and then cancelled
+add_task(async function cancelUninstallDisabledRestartless() {
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => promiseInstallFile(XPIS.test_undouninstall1)
+ );
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onDisabling" },
+ { event: "onDisabled" },
+ ],
+ },
+ },
+ () => a1.disable()
+ );
+
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_DISABLE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkInstalled(ID);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onOperationCancelled" }],
+ },
+ },
+ async () => {
+ a1.cancelUninstall();
+ }
+ );
+
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkInstalled(ID);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await promiseRestartManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ BootstrapMonitor.checkInstalled(ID);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Tests that reinstalling a disabled restartless add-on waiting to be
+// uninstalled aborts the uninstall and leaves the add-on disabled
+add_task(async function reinstallDisabledAddonAwaitingUninstall() {
+ await promiseInstallFile(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await a1.disable();
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.equal(getShutdownReason(ID), ADDON_DISABLE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => promiseInstallFile(XPIS.test_undouninstall1)
+ );
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkNotStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_UPGRADE);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await promiseRestartManager();
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(ID, "1.0");
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(!a1.isActive);
+ Assert.ok(a1.userDisabled);
+
+ await a1.uninstall();
+});
+
+// Test that uninstalling a temporary addon can be canceled
+add_task(async function cancelUninstallTemporary() {
+ await AddonManager.installTemporaryAddon(XPIS.test_undouninstall1);
+
+ let a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkInstalled(ID, "1.0");
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(getInstallReason(ID), ADDON_INSTALL);
+ Assert.equal(getStartupReason(ID), ADDON_INSTALL);
+ Assert.equal(a1.pendingOperations, AddonManager.PENDING_NONE);
+ Assert.ok(a1.isActive);
+ Assert.ok(!a1.userDisabled);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "undouninstall1@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ BootstrapMonitor.checkNotStarted(ID, "1.0");
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+
+ let promises = [
+ promiseAddonEvent("onOperationCancelled"),
+ promiseWebExtensionStartup("undouninstall1@tests.mozilla.org"),
+ ];
+ a1.cancelUninstall();
+ await Promise.all(promises);
+
+ a1 = await promiseAddonByID("undouninstall1@tests.mozilla.org");
+
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkStarted(ID, "1.0");
+ Assert.equal(a1.pendingOperations, 0);
+
+ await promiseRestartManager();
+});
+
+// Tests that cancelling the uninstall of an incompatible restartless addon
+// does not start the addon
+add_task(async function cancelUninstallIncompatibleRestartless() {
+ await promiseInstallFile(XPIS.test_undoincompatible);
+
+ let a1 = await promiseAddonByID(INCOMPAT_ID);
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(INCOMPAT_ID);
+ Assert.ok(!a1.isActive);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "incompatible@tests.mozilla.org": [{ event: "onUninstalling" }],
+ },
+ },
+ () => a1.uninstall(true)
+ );
+
+ a1 = await promiseAddonByID(INCOMPAT_ID);
+ Assert.notEqual(a1, null);
+ Assert.ok(hasFlag(AddonManager.PENDING_UNINSTALL, a1.pendingOperations));
+ Assert.ok(!a1.isActive);
+
+ await expectEvents(
+ {
+ addonEvents: {
+ "incompatible@tests.mozilla.org": [{ event: "onOperationCancelled" }],
+ },
+ },
+ async () => {
+ a1.cancelUninstall();
+ }
+ );
+
+ a1 = await promiseAddonByID(INCOMPAT_ID);
+ Assert.notEqual(a1, null);
+ BootstrapMonitor.checkNotStarted(INCOMPAT_ID);
+ Assert.equal(a1.pendingOperations, 0);
+ Assert.ok(!a1.isActive);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update.js b/toolkit/mozapps/extensions/test/xpcshell/test_update.js
new file mode 100644
index 0000000000..1bd41e8d71
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update.js
@@ -0,0 +1,834 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update checks work
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+// This test uses add-on versions that follow the toolkit version but we
+// started to encourage the use of a simpler format in Bug 1793925. We disable
+// the pref below to avoid install errors.
+Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+);
+
+const updateFile = "test_update.json";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = {
+ test_update: {
+ id: "addon1@tests.mozilla.org",
+ version: "2.0",
+ name: "Test 1",
+ },
+ test_update8: {
+ id: "addon8@tests.mozilla.org",
+ version: "2.0",
+ name: "Test 8",
+ },
+ test_update12: {
+ id: "addon12@tests.mozilla.org",
+ version: "2.0",
+ name: "Test 12",
+ },
+ test_install2_1: {
+ id: "addon2@tests.mozilla.org",
+ version: "2.0",
+ name: "Real Test 2",
+ },
+ test_install2_2: {
+ id: "addon2@tests.mozilla.org",
+ version: "3.0",
+ name: "Real Test 3",
+ },
+};
+
+var testserver = createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+const XPIS = {};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+ Services.locale.requestedLocales = ["fr-FR"];
+
+ for (let [name, info] of Object.entries(ADDONS)) {
+ XPIS[name] = createTempWebExtensionFile({
+ manifest: {
+ name: info.name,
+ version: info.version,
+ browser_specific_settings: { gecko: { id: info.id } },
+ },
+ });
+ testserver.registerFile(`/addons/${name}.xpi`, XPIS[name]);
+ }
+
+ AddonTestUtils.updateReason = AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ await promiseStartupManager();
+});
+
+// Verify that an update is available and can be installed.
+add_task(async function test_apply_update() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ },
+ },
+ },
+ });
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ notEqual(a1, null);
+ equal(a1.version, "1.0");
+ equal(a1.applyBackgroundUpdates, AddonManager.AUTOUPDATE_DEFAULT);
+ equal(a1.releaseNotesURI, null);
+ notEqual(a1.syncGUID, null);
+
+ let originalSyncGUID = a1.syncGUID;
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ {
+ event: "onPropertyChanged",
+ properties: ["applyBackgroundUpdates"],
+ },
+ ],
+ },
+ },
+ async () => {
+ a1.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+ }
+ );
+
+ a1.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+
+ let install;
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ installEvents: [{ event: "onNewInstall" }],
+ },
+ async () => {
+ ({ updateAvailable: install } =
+ await AddonTestUtils.promiseFindAddonUpdates(a1));
+ }
+ );
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 1);
+ equal(installs[0], install);
+
+ equal(install.name, a1.name);
+ equal(install.version, "2.0");
+ equal(install.state, AddonManager.STATE_AVAILABLE);
+ equal(install.existingAddon, a1);
+ equal(install.releaseNotesURI.spec, "http://example.com/updateInfo.xhtml");
+
+ // Verify that another update check returns the same AddonInstall
+ let { updateAvailable: install2 } =
+ await AddonTestUtils.promiseFindAddonUpdates(a1);
+
+ installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 1);
+ equal(installs[0], install);
+ equal(install2, install);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ installEvents: [
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", returnValue: false },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ equal(install.state, AddonManager.STATE_DOWNLOADED);
+
+ // Continue installing the update.
+ // Verify that another update check returns no new update
+ let { updateAvailable } = await AddonTestUtils.promiseFindAddonUpdates(
+ install.existingAddon
+ );
+
+ ok(
+ !updateAvailable,
+ "Should find no available updates when one is already downloading"
+ );
+
+ installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 1);
+ equal(installs[0], install);
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ install.install();
+ }
+ );
+
+ await AddonTestUtils.loadAddonsList(true);
+
+ // Grab the current time so we can check the mtime of the add-on below
+ // without worrying too much about how long other tests take.
+ let startupTime = Date.now();
+
+ ok(isExtensionInBootstrappedList(profileDir, "addon1@tests.mozilla.org"));
+
+ a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ notEqual(a1, null);
+ equal(a1.version, "2.0");
+ ok(isExtensionInBootstrappedList(profileDir, a1.id));
+ equal(a1.applyBackgroundUpdates, AddonManager.AUTOUPDATE_DISABLE);
+ equal(a1.releaseNotesURI.spec, "http://example.com/updateInfo.xhtml");
+ notEqual(a1.syncGUID, null);
+ equal(originalSyncGUID, a1.syncGUID);
+
+ // Make sure that the extension lastModifiedTime was updated.
+ let testFile = getAddonFile(a1);
+ let difference = testFile.lastModifiedTime - startupTime;
+ Assert.less(Math.abs(difference), MAX_TIME_DIFFERENCE);
+
+ await a1.uninstall();
+});
+
+// Check that an update check finds compatibility updates and applies them
+add_task(async function test_compat_update() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 2",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon2@tests.mozilla.org",
+ update_url: "http://example.com/data/" + updateFile,
+ strict_max_version: "0",
+ },
+ },
+ },
+ });
+
+ let a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ notEqual(a2, null);
+ ok(a2.isActive);
+ ok(a2.isCompatible);
+ ok(!a2.appDisabled);
+ ok(a2.isCompatibleWith("0", "0"));
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(a2);
+ ok(result.compatibilityUpdate, "Should have seen a compatibility update");
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ ok(a2.isCompatible);
+ ok(!a2.appDisabled);
+ ok(a2.isActive);
+
+ await promiseRestartManager();
+
+ a2 = await AddonManager.getAddonByID("addon2@tests.mozilla.org");
+ notEqual(a2, null);
+ ok(a2.isActive);
+ ok(a2.isCompatible);
+ ok(!a2.appDisabled);
+ await a2.uninstall();
+});
+
+// Checks that we see no compatibility information when there is none.
+add_task(async function test_no_compat() {
+ gAppInfo.platformVersion = "5";
+ await promiseRestartManager("5");
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 3",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ strict_min_version: "5",
+ },
+ },
+ },
+ });
+
+ gAppInfo.platformVersion = "1";
+ await promiseRestartManager("1");
+
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ notEqual(a3, null);
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+ ok(a3.isCompatibleWith("5", "5"));
+ ok(!a3.isCompatibleWith("2", "2"));
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(a3);
+ ok(
+ !result.compatibilityUpdate,
+ "Should not have seen a compatibility update"
+ );
+ ok(!result.updateAvailable, "Should not have seen a version update");
+});
+
+// Checks that compatibility info for future apps are detected but don't make
+// the item compatibile.
+add_task(async function test_future_compat() {
+ let a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ notEqual(a3, null);
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+ ok(a3.isCompatibleWith("5", "5"));
+ ok(!a3.isCompatibleWith("2", "2"));
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(
+ a3,
+ undefined,
+ "3.0",
+ "3.0"
+ );
+ ok(result.compatibilityUpdate, "Should have seen a compatibility update");
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+
+ await promiseRestartManager();
+
+ a3 = await AddonManager.getAddonByID("addon3@tests.mozilla.org");
+ notEqual(a3, null);
+ ok(!a3.isActive);
+ ok(!a3.isCompatible);
+ ok(a3.appDisabled);
+
+ await a3.uninstall();
+});
+
+// Test that background update checks work
+add_task(async function test_background_update() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ });
+
+ function checkInstall(install) {
+ notEqual(install.existingAddon, null);
+ equal(install.existingAddon.id, "addon1@tests.mozilla.org");
+ }
+
+ await expectEvents(
+ {
+ ignorePlugins: true,
+ addonEvents: {
+ "addon1@tests.mozilla.org": [
+ { event: "onInstalling" },
+ { event: "onInstalled" },
+ ],
+ },
+ installEvents: [
+ { event: "onNewInstall" },
+ { event: "onDownloadStarted" },
+ { event: "onDownloadEnded", callback: checkInstall },
+ { event: "onInstallStarted" },
+ { event: "onInstallEnded" },
+ ],
+ },
+ () => {
+ AddonManagerPrivate.backgroundUpdateCheck();
+ }
+ );
+
+ let a1 = await AddonManager.getAddonByID("addon1@tests.mozilla.org");
+ notEqual(a1, null);
+ equal(a1.version, "2.0");
+ equal(a1.releaseNotesURI.spec, "http://example.com/updateInfo.xhtml");
+
+ await a1.uninstall();
+});
+
+const STATE_BLOCKED = Ci.nsIBlocklistService.STATE_BLOCKED;
+
+const PARAMS =
+ "?" +
+ [
+ "req_version=%REQ_VERSION%",
+ "item_id=%ITEM_ID%",
+ "item_version=%ITEM_VERSION%",
+ "item_maxappversion=%ITEM_MAXAPPVERSION%",
+ "item_status=%ITEM_STATUS%",
+ "app_id=%APP_ID%",
+ "app_version=%APP_VERSION%",
+ "current_app_version=%CURRENT_APP_VERSION%",
+ "app_os=%APP_OS%",
+ "app_abi=%APP_ABI%",
+ "app_locale=%APP_LOCALE%",
+ "update_type=%UPDATE_TYPE%",
+ ].join("&");
+
+const PARAM_ADDONS = {
+ "addon1@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 1",
+ version: "5.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "2",
+ },
+ },
+ },
+ params: {
+ item_version: "5.0",
+ item_maxappversion: "2",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "97",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_USER_REQUESTED],
+ },
+
+ "addon2@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 2",
+ version: "67.0.5b1",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon2@tests.mozilla.org",
+ update_url: "http://example.com/data/param_test.json" + PARAMS,
+ strict_min_version: "0",
+ strict_max_version: "3",
+ },
+ },
+ },
+ initialState: {
+ userDisabled: true,
+ },
+ params: {
+ item_version: "67.0.5b1",
+ item_maxappversion: "3",
+ item_status: "userDisabled",
+ app_version: "1",
+ update_type: "49",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_ADDON_INSTALLED],
+ compatOnly: true,
+ },
+
+ "addon3@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 3",
+ version: "1.3+",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon3@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ },
+ },
+ },
+ params: {
+ item_version: "1.3+",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "112",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_PERIODIC_UPDATE],
+ },
+
+ "addon4@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 4",
+ version: "0.5ab6",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "5",
+ },
+ },
+ },
+ params: {
+ item_version: "0.5ab6",
+ item_maxappversion: "5",
+ item_status: "userEnabled",
+ app_version: "2",
+ update_type: "98",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_NEW_APP_DETECTED, "2"],
+ },
+
+ "addon5@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 5",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon5@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ params: {
+ item_version: "1.0",
+ item_maxappversion: "1",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "35",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED],
+ compatOnly: true,
+ },
+
+ "addon6@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 6",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon6@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ params: {
+ item_version: "1.0",
+ item_maxappversion: "1",
+ item_status: "userEnabled",
+ app_version: "1",
+ update_type: "99",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED],
+ },
+
+ "blocklist2@tests.mozilla.org": {
+ manifest: {
+ name: "Test Addon 1",
+ version: "5.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "blocklist2@tests.mozilla.org",
+ update_url: `http://example.com/data/param_test.json${PARAMS}`,
+ strict_min_version: "1",
+ strict_max_version: "2",
+ },
+ },
+ },
+ params: {
+ item_version: "5.0",
+ item_maxappversion: "2",
+ item_status: "userEnabled,blocklisted",
+ app_version: "1",
+ update_type: "97",
+ },
+ updateType: [AddonManager.UPDATE_WHEN_USER_REQUESTED],
+ blocklistState: STATE_BLOCKED,
+ },
+};
+
+const PARAM_IDS = Object.keys(PARAM_ADDONS);
+
+// Verify the parameter escaping in update urls.
+add_task(async function test_params() {
+ let blocked = [];
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ if (options.blocklistState == STATE_BLOCKED) {
+ blocked.push(`${id}:${options.manifest.version}`);
+ }
+ }
+ let extensionsMLBF = [{ stash: { blocked, unblocked: [] }, stash_time: 0 }];
+ await AddonTestUtils.loadBlocklistRawData({ extensionsMLBF });
+
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ await promiseInstallWebExtension({ manifest: options.manifest });
+
+ if (options.initialState) {
+ let addon = await AddonManager.getAddonByID(id);
+ await setInitialState(addon, options.initialState);
+ }
+ }
+
+ let resultsPromise = new Promise(resolve => {
+ let results = new Map();
+
+ testserver.registerPathHandler(
+ "/data/param_test.json",
+ function (request, response) {
+ let params = new URLSearchParams(request.queryString);
+ let itemId = params.get("item_id");
+ ok(
+ !results.has(itemId),
+ `Should not see a duplicate request for item ${itemId}`
+ );
+
+ results.set(itemId, params);
+
+ if (results.size === PARAM_IDS.length) {
+ resolve(results);
+ }
+
+ request.setStatusLine(null, 500, "Server Error");
+ }
+ );
+ });
+
+ let addons = await getAddons(PARAM_IDS);
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ // Having an onUpdateAvailable callback in the listener automagically adds
+ // UPDATE_TYPE_NEWVERSION to the update type flags in the request.
+ let listener = options.compatOnly ? {} : { onUpdateAvailable() {} };
+
+ addons.get(id).findUpdates(listener, ...options.updateType);
+ }
+
+ let baseParams = {
+ req_version: "2",
+ app_id: "xpcshell@tests.mozilla.org",
+ current_app_version: "1",
+ app_os: "XPCShell",
+ app_abi: "noarch-spidermonkey",
+ app_locale: "fr-FR",
+ };
+
+ let results = await resultsPromise;
+ for (let [id, options] of Object.entries(PARAM_ADDONS)) {
+ info(`Checking update params for ${id}`);
+
+ let expected = Object.assign({}, baseParams, options.params);
+ let params = results.get(id);
+
+ for (let [prop, value] of Object.entries(expected)) {
+ equal(params.get(prop), value, `Expected value for ${prop}`);
+ }
+ }
+
+ for (let [, addon] of await getAddons(PARAM_IDS)) {
+ await addon.uninstall();
+ }
+});
+
+// Tests that if a manifest claims compatibility then the add-on will be
+// seen as compatible regardless of what the update payload says.
+add_task(async function test_manifest_compat() {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "5.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon4@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ strict_min_version: "0",
+ strict_max_version: "1",
+ },
+ },
+ },
+ });
+
+ let a4 = await AddonManager.getAddonByID("addon4@tests.mozilla.org");
+ ok(a4.isActive, "addon4 is active");
+ ok(a4.isCompatible, "addon4 is compatible");
+
+ // Test that a normal update check won't decrease a targetApplication's
+ // maxVersion but an update check for a new application will.
+ await AddonTestUtils.promiseFindAddonUpdates(
+ a4,
+ AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+ );
+ ok(a4.isActive, "addon4 is active");
+ ok(a4.isCompatible, "addon4 is compatible");
+
+ await AddonTestUtils.promiseFindAddonUpdates(
+ a4,
+ AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED
+ );
+ ok(!a4.isActive, "addon4 is not active");
+ ok(!a4.isCompatible, "addon4 is not compatible");
+
+ await a4.uninstall();
+});
+
+// Test that the background update check doesn't update an add-on that isn't
+// allowed to update automatically.
+add_task(async function test_no_auto_update() {
+ // Have an add-on there that will be updated so we see some events from it
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ },
+ },
+ },
+ });
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 8",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon8@tests.mozilla.org",
+ update_url: `http://example.com/data/${updateFile}`,
+ },
+ },
+ },
+ });
+
+ let a8 = await AddonManager.getAddonByID("addon8@tests.mozilla.org");
+ a8.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DISABLE;
+
+ // The background update check will find updates for both add-ons but only
+ // proceed to install one of them.
+ let listener;
+ await new Promise(resolve => {
+ listener = {
+ onNewInstall(aInstall) {
+ let id = aInstall.existingAddon.id;
+ ok(
+ id == "addon1@tests.mozilla.org" || id == "addon8@tests.mozilla.org",
+ "Saw unexpected onNewInstall for " + id
+ );
+ },
+
+ onDownloadStarted(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+ },
+
+ onDownloadEnded(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+ },
+
+ onDownloadFailed(aInstall) {
+ ok(false, "Should not have seen onDownloadFailed event");
+ },
+
+ onDownloadCancelled(aInstall) {
+ ok(false, "Should not have seen onDownloadCancelled event");
+ },
+
+ onInstallStarted(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+ },
+
+ onInstallEnded(aInstall) {
+ equal(aInstall.existingAddon.id, "addon1@tests.mozilla.org");
+
+ resolve();
+ },
+
+ onInstallFailed(aInstall) {
+ ok(false, "Should not have seen onInstallFailed event");
+ },
+
+ onInstallCancelled(aInstall) {
+ ok(false, "Should not have seen onInstallCancelled event");
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ AddonManagerPrivate.backgroundUpdateCheck();
+ });
+ AddonManager.removeInstallListener(listener);
+
+ let a1;
+ [a1, a8] = await AddonManager.getAddonsByIDs([
+ "addon1@tests.mozilla.org",
+ "addon8@tests.mozilla.org",
+ ]);
+ notEqual(a1, null);
+ equal(a1.version, "2.0");
+ await a1.uninstall();
+
+ notEqual(a8, null);
+ equal(a8.version, "1.0");
+ await a8.uninstall();
+});
+
+// Test that the update check returns nothing for addons in locked install
+// locations.
+add_task(async function run_test_locked_install() {
+ const lockedDir = gProfD.clone();
+ lockedDir.append("locked_extensions");
+ registerDirectory("XREAppFeat", lockedDir);
+
+ await promiseShutdownManager();
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ name: "Test Addon 13",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon13@tests.mozilla.org",
+ update_url: "http://example.com/data/test_update.json",
+ },
+ },
+ },
+ });
+ xpi.copyTo(lockedDir, "addon13@tests.mozilla.org.xpi");
+
+ let validAddons = { system: ["addon13@tests.mozilla.org"] };
+ await overrideBuiltIns(validAddons);
+
+ await promiseStartupManager();
+
+ let a13 = await AddonManager.getAddonByID("addon13@tests.mozilla.org");
+ notEqual(a13, null);
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(a13);
+ ok(
+ !result.compatibilityUpdate,
+ "Should not have seen a compatibility update"
+ );
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ let installs = await AddonManager.getAllInstalls();
+ equal(installs.length, 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js b/toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js
new file mode 100644
index 0000000000..ac201f434c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updateCancel.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Test cancelling add-on update checks while in progress (bug 925389)
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+// Install one extension
+// Start download of update check (but delay HTTP response)
+// Cancel update check
+// - ensure we get cancel notification
+// complete HTTP response
+// - ensure no callbacks after cancel
+// - ensure update is gone
+
+// Create an addon update listener containing a promise
+// that resolves when the update is cancelled
+function makeCancelListener() {
+ let resolve, reject;
+ let promise = new Promise((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+
+ return {
+ onUpdateAvailable(addon, install) {
+ reject("Should not have seen onUpdateAvailable notification");
+ },
+
+ onUpdateFinished(aAddon, aError) {
+ info("onUpdateCheckFinished: " + aAddon.id + " " + aError);
+ resolve(aError);
+ },
+ promise,
+ };
+}
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+// Set up the HTTP server so that we can control when it responds
+let _httpResolve;
+function resetUpdateListener() {
+ return new Promise(resolve => {
+ _httpResolve = resolve;
+ });
+}
+
+testserver.registerPathHandler("/data/test_update.json", (req, resp) => {
+ resp.processAsync();
+ _httpResolve([req, resp]);
+});
+
+const UPDATE_RESPONSE = {
+ addons: {
+ "addon1@tests.mozilla.org": {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addons/test_update.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+add_task(async function cancel_during_check() {
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: "http://example.com/data/test_update.json",
+ },
+ },
+ },
+ });
+
+ let a1 = await promiseAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(a1, null);
+
+ let requestPromise = resetUpdateListener();
+ let listener = makeCancelListener();
+ a1.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+
+ // Wait for the http request to arrive
+ let [, /* request */ response] = await requestPromise;
+
+ // cancelUpdate returns true if there is an update check in progress
+ Assert.ok(a1.cancelUpdate());
+
+ let updateResult = await listener.promise;
+ Assert.equal(AddonManager.UPDATE_STATUS_CANCELLED, updateResult);
+
+ // Now complete the HTTP request
+ response.write(JSON.stringify(UPDATE_RESPONSE));
+ response.finish();
+
+ // trying to cancel again should return false, i.e. nothing to cancel
+ Assert.ok(!a1.cancelUpdate());
+});
+
+// Test that update check is cancelled if the XPI provider shuts down while
+// the update check is in progress
+add_task(async function shutdown_during_check() {
+ // Reset our HTTP listener
+ let requestPromise = resetUpdateListener();
+
+ let a1 = await promiseAddonByID("addon1@tests.mozilla.org");
+ Assert.notEqual(a1, null);
+
+ let listener = makeCancelListener();
+ a1.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+
+ // Wait for the http request to arrive
+ let [, /* request */ response] = await requestPromise;
+
+ await promiseShutdownManager();
+
+ let updateResult = await listener.promise;
+ Assert.equal(AddonManager.UPDATE_STATUS_CANCELLED, updateResult);
+
+ // Now complete the HTTP request
+ response.write(JSON.stringify(UPDATE_RESPONSE));
+ response.finish();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js
new file mode 100644
index 0000000000..ca324cf4ef
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_addontype.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_update_theme_to_extension() {
+ const THEME_ID = "theme@tests.mozilla.org";
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ theme: {},
+ browser_specific_settings: {
+ gecko: {
+ id: THEME_ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id: THEME_ID } },
+ },
+ });
+
+ server.registerFile("/addon.xpi", xpi);
+ AddonTestUtils.registerJSON(server, "/update.json", {
+ addons: {
+ [THEME_ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addon.xpi",
+ },
+ ],
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID(THEME_ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.type, "theme");
+ Assert.equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ Assert.notEqual(install, null, "Found available update");
+ // Although the downloaded xpi is an "extension", install.type is "theme"
+ // because install.type reflects the type of the add-on that is being updated.
+ Assert.equal(install.type, "theme");
+ Assert.equal(install.version, "2.0");
+ Assert.equal(install.state, AddonManager.STATE_AVAILABLE);
+ Assert.equal(install.existingAddon, addon);
+
+ await Assert.rejects(
+ install.install(),
+ err => install.error == AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
+ "Refusing to change addon type from theme to extension"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js
new file mode 100644
index 0000000000..85ec556f95
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_compatmode.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update check correctly fills in the
+// %COMPATIBILITY_MODE% token in the update URL.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+let testserver = createHttpServer({ hosts: ["example.com"] });
+
+let lastMode;
+testserver.registerPathHandler("/update.json", (request, response) => {
+ let params = new URLSearchParams(request.queryString);
+ lastMode = params.get("mode");
+
+ response.setHeader("content-type", "application/json", true);
+ response.write(JSON.stringify({ addons: {} }));
+});
+
+const ID_NORMAL = "compatmode@tests.mozilla.org";
+const ID_STRICT = "compatmode-strict@tests.mozilla.org";
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ let xpi = await createAddon({
+ id: ID_NORMAL,
+ updateURL: "http://example.com/update.json?mode=%COMPATIBILITY_MODE%",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID_NORMAL);
+
+ xpi = await createAddon({
+ id: ID_STRICT,
+ updateURL: "http://example.com/update.json?mode=%COMPATIBILITY_MODE%",
+ strictCompatibility: true,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID_STRICT);
+
+ await promiseStartupManager();
+});
+
+// Strict compatibility checking disabled.
+add_task(async function test_strict_disabled() {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+ let addon = await AddonManager.getAddonByID(ID_NORMAL);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "normal",
+ "COMPATIBIILITY_MODE normal was set correctly"
+ );
+});
+
+// Strict compatibility checking enabled.
+add_task(async function test_strict_enabled() {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, true);
+ let addon = await AddonManager.getAddonByID(ID_NORMAL);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "strict",
+ "COMPATIBILITY_MODE strict was set correctly"
+ );
+});
+
+// Strict compatibility checking opt-in.
+add_task(async function test_strict_optin() {
+ Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+ let addon = await AddonManager.getAddonByID(ID_STRICT);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "normal",
+ "COMPATIBILITY_MODE is normal even for an addon with strictCompatibility"
+ );
+});
+
+// Compatibility checking disabled.
+add_task(async function test_compat_disabled() {
+ AddonManager.checkCompatibility = false;
+ let addon = await AddonManager.getAddonByID(ID_NORMAL);
+ Assert.notEqual(addon, null);
+
+ await promiseFindAddonUpdates(addon, AddonManager.UPDATE_WHEN_USER_REQUESTED);
+ Assert.equal(
+ lastMode,
+ "ignore",
+ "COMPATIBILITY_MODE ignore was set correctly"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js
new file mode 100644
index 0000000000..7ac01afc53
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_ignorecompat.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test is disabled but is being kept around so it can eventualy
+// be modernized and re-enabled. But is uses obsolete test helpers that
+// fail lint, so just skip linting it for now.
+/* eslint-disable */
+
+// This verifies that add-on update checks work correctly when compatibility
+// check is disabled.
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+const updateFile = "test_update.json";
+const appId = "toolkit@mozilla.org";
+
+// Test that the update check correctly observes the
+// extensions.strictCompatibility pref.
+add_test(async function () {
+ await promiseWriteInstallRDFForExtension(
+ {
+ id: "addon9@tests.mozilla.org",
+ version: "1.0",
+ updateURL: "http://example.com/data/" + updateFile,
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ name: "Test Addon 9",
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ AddonManager.addInstallListener({
+ onNewInstall(aInstall) {
+ if (aInstall.existingAddon.id != "addon9@tests.mozilla.org")
+ do_throw(
+ "Saw unexpected onNewInstall for " + aInstall.existingAddon.id
+ );
+ Assert.equal(aInstall.version, "4.0");
+ },
+ onDownloadFailed(aInstall) {
+ run_next_test();
+ },
+ });
+
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ `http://example.com/data/test_update_addons.json`
+ );
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+
+ AddonManagerInternal.backgroundUpdateCheck();
+});
+
+// Test that the update check correctly observes when an addon opts-in to
+// strict compatibility checking.
+add_test(async function () {
+ await promiseWriteInstallRDFForExtension(
+ {
+ id: "addon11@tests.mozilla.org",
+ version: "1.0",
+ updateURL: "http://example.com/data/" + updateFile,
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ name: "Test Addon 11",
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ let a11 = await AddonManager.getAddonByID("addon11@tests.mozilla.org");
+ Assert.notEqual(a11, null);
+
+ a11.findUpdates(
+ {
+ onCompatibilityUpdateAvailable() {
+ do_throw("Should not have seen compatibility information");
+ },
+
+ onUpdateAvailable() {
+ do_throw("Should not have seen an available update");
+ },
+
+ onUpdateFinished() {
+ run_next_test();
+ },
+ },
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js
new file mode 100644
index 0000000000..525dbfa25a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_isPrivileged.js
@@ -0,0 +1,181 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+AddonTestUtils.usePrivilegedSignatures = id => id === "privileged@ext";
+
+const EXTENSION_API_IMPL = `
+this.extensionApiImpl = class extends ExtensionAPI {
+ onStartup() {
+ extensions.emit("test-ExtensionAPI-onStartup", {
+ extensionId: this.extension.id,
+ version: this.extension.manifest.version,
+ });
+ }
+ static onUpdate(id, manifest) {
+ extensions.emit("test-ExtensionAPI-onUpdate", {
+ extensionId: id,
+ version: manifest.version,
+ });
+ }
+};`;
+
+function setupTestExtensionAPI() {
+ // The EXTENSION_API_IMPL script is going to be loaded in the main process,
+ // where only safe loads are permitted. So we generate a resource:-URL, to
+ // avoid the use of security.allow_parent_unrestricted_js_loads.
+ let resProto = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+ resProto.setSubstitution(
+ "extensionApiImplJs",
+ Services.io.newURI(`data:,${encodeURIComponent(EXTENSION_API_IMPL)}`)
+ );
+ registerCleanupFunction(() => {
+ resProto.setSubstitution("extensionApiImplJs", null);
+ });
+
+ const modules = {
+ extensionApiImpl: {
+ url: "resource://extensionApiImplJs",
+ events: ["startup", "update"],
+ },
+ };
+
+ Services.catMan.addCategoryEntry(
+ "webextension-modules",
+ "test-register-extensionApiImpl",
+ `data:,${JSON.stringify(modules)}`,
+ false,
+ false
+ );
+}
+
+async function runInstallAndUpdate({
+ extensionId,
+ expectPrivileged,
+ installExtensionData,
+}) {
+ let extensionData = {
+ useAddonManager: "permanent",
+ manifest: {
+ browser_specific_settings: { gecko: { id: extensionId } },
+ version: "1.1",
+ },
+ };
+ let events = [];
+ function onUpdated(type, params) {
+ params = { type, ...params };
+ // resourceURI cannot be serialized for use with deepEqual.
+ delete params.resourceURI;
+ events.push(params);
+ }
+ function onExtensionAPI(type, params) {
+ events.push({ type, ...params });
+ }
+ ExtensionParent.apiManager.on("update", onUpdated);
+ ExtensionParent.apiManager.on("test-ExtensionAPI-onStartup", onExtensionAPI);
+ ExtensionParent.apiManager.on("test-ExtensionAPI-onUpdate", onExtensionAPI);
+
+ let { addon } = await installExtensionData(extensionData);
+ equal(addon.isPrivileged, expectPrivileged, "Expected isPrivileged");
+
+ extensionData.manifest.version = "2.22";
+ extensionData.manifest.permissions = ["mozillaAddons"];
+ // May warn about invalid permissions when the extension is not privileged.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ let extension = await installExtensionData(extensionData);
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ await extension.unload();
+
+ ExtensionParent.apiManager.off("update", onUpdated);
+ ExtensionParent.apiManager.off("test-ExtensionAPI-onStartup", onExtensionAPI);
+ ExtensionParent.apiManager.off("test-ExtensionAPI-onUpdate", onExtensionAPI);
+
+ // Verify that we have (1) installed and (2) updated the extension.
+ Assert.deepEqual(
+ events,
+ [
+ { type: "test-ExtensionAPI-onStartup", extensionId, version: "1.1" },
+ // The next two events show that ExtensionParent has run the onUpdate
+ // handler, during which ExtensionData has supposedly been constructed.
+ { type: "update", id: extensionId, isPrivileged: expectPrivileged },
+ { type: "test-ExtensionAPI-onUpdate", extensionId, version: "2.22" },
+ { type: "test-ExtensionAPI-onStartup", extensionId, version: "2.22" },
+ ],
+ "Expected startup and update events"
+ );
+}
+
+add_task(async function setup() {
+ setupTestExtensionAPI();
+ await ExtensionTestUtils.startAddonManager();
+});
+
+// Tests that privileged extensions (e.g builtins) are always parsed with the
+// correct isPrivileged flag.
+add_task(async function test_install_and_update_builtin() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runInstallAndUpdate({
+ extensionId: "builtin@ext",
+ expectPrivileged: true,
+ async installExtensionData(extData) {
+ return installBuiltinExtension(extData);
+ },
+ });
+ });
+
+ AddonTestUtils.checkMessages(messages, {
+ expected: [{ message: /Addon with ID builtin@ext already installed,/ }],
+ forbidden: [{ message: /Invalid extension permission: mozillaAddons/ }],
+ });
+});
+
+add_task(async function test_install_and_update_regular_ext() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runInstallAndUpdate({
+ extensionId: "regular@ext",
+ expectPrivileged: false,
+ async installExtensionData(extData) {
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ await extension.startup();
+ return extension;
+ },
+ });
+ });
+ let errPattern =
+ /Loading extension 'regular@ext': Reading manifest: Invalid extension permission: mozillaAddons/;
+ let permissionWarnings = messages.filter(msg => errPattern.test(msg.message));
+ // Expected number of warnings after triggering the update:
+ // 1. Generated when the loaded by the Addons manager (ExtensionData).
+ // 2. Generated when read again before ExtensionAPI.onUpdate (ExtensionData).
+ // 3. Generated when the extension actually runs (Extension).
+ equal(permissionWarnings.length, 3, "Expected number of permission warnings");
+});
+
+add_task(async function test_install_and_update_privileged_ext() {
+ let { messages } = await promiseConsoleOutput(async () => {
+ await runInstallAndUpdate({
+ extensionId: "privileged@ext",
+ expectPrivileged: true,
+ async installExtensionData(extData) {
+ let extension = ExtensionTestUtils.loadExtension(extData);
+ await extension.startup();
+ return extension;
+ },
+ });
+ });
+ AddonTestUtils.checkMessages(messages, {
+ expected: [
+ // First installation.
+ { message: /Starting install of privileged@ext / },
+ // Second installation (update).
+ { message: /Starting install of privileged@ext / },
+ ],
+ forbidden: [{ message: /Invalid extension permission: mozillaAddons/ }],
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js
new file mode 100644
index 0000000000..f13187ab33
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_noSystemAddonUpdate.js
@@ -0,0 +1,43 @@
+// Tests that system add-on doesnt request update while normal backgroundUpdateCheck
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "2");
+
+let distroDir = FileUtils.getDir("ProfD", ["sysfeatures", "empty"]);
+distroDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+registerDirectory("XREAppFeat", distroDir);
+
+AddonTestUtils.usePrivilegedSignatures = "system";
+
+add_task(() => initSystemAddonDirs());
+
+const initialSetup = {
+ async setup() {
+ await buildPrefilledUpdatesDir();
+ distroDir.leafName = "empty";
+ },
+ initialState: [
+ { isUpgrade: false, version: null },
+ { isUpgrade: true, version: "2.0" },
+ ],
+};
+
+add_task(async function test_systems_update_uninstall_check() {
+ await setupSystemAddonConditions(initialSetup, distroDir);
+
+ const testserver = createHttpServer({ hosts: ["example.com"] });
+ testserver.registerPathHandler("/update.json", (request, response) => {
+ Assert.ok(
+ !request._queryString.includes("system2@tests.mozilla.org"),
+ "System addon should not request update from normal update process"
+ );
+ });
+
+ Services.prefs.setCharPref(
+ "extensions.update.background.url",
+ "http://example.com/update.json?id=%ITEM_ID%"
+ );
+
+ await AddonManagerPrivate.backgroundUpdateCheck();
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js
new file mode 100644
index 0000000000..14192657dd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_strictcompat.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update checks work in conjunction with
+// strict compatibility settings.
+
+const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled";
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+const appId = "toolkit@mozilla.org";
+
+testserver = createHttpServer({ hosts: ["example.com"] });
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ AddonTestUtils.updateReason = AddonManager.UPDATE_WHEN_USER_REQUESTED;
+
+ Services.prefs.setCharPref(
+ PREF_GETADDONS_BYIDS,
+ "http://example.com/data/test_update_addons.json"
+ );
+ Services.prefs.setBoolPref(PREF_GETADDONS_CACHE_ENABLED, true);
+});
+
+// Test that the update check correctly observes the
+// extensions.strictCompatibility pref.
+add_task(async function test_update_strict() {
+ const ID = "addon9@tests.mozilla.org";
+ let xpi = await createAddon({
+ id: ID,
+ updateURL: "http://example.com/update.json",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ let UPDATE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addons/test_update9_2.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ advisory_max_version: "1",
+ },
+ },
+ },
+
+ // Incompatible when strict compatibility is enabled
+ {
+ version: "3.0",
+ update_link: "http://example.com/addons/test_update9_3.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "0.9",
+ advisory_max_version: "0.9",
+ },
+ },
+ },
+
+ // Addon for future version of app
+ {
+ version: "4.0",
+ update_link: "http://example.com/addons/test_update9_5.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "5",
+ advisory_max_version: "6",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ let { updateAvailable } = await promiseFindAddonUpdates(addon);
+
+ Assert.notEqual(updateAvailable, null, "Got update");
+ Assert.equal(
+ updateAvailable.version,
+ "3.0",
+ "The correct update was selected"
+ );
+ await addon.uninstall();
+
+ await promiseShutdownManager();
+});
+
+// Tests that compatibility updates are applied to addons when the updated
+// compatibility data wouldn't match with strict compatibility enabled.
+add_task(async function test_update_strict2() {
+ const ID = "addon10@tests.mozilla.org";
+ let xpi = createAddon({
+ id: ID,
+ updateURL: "http://example.com/update.json",
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+ await AddonRepository.backgroundUpdateCheck();
+
+ const UPDATE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "1.0",
+ update_link: "http://example.com/addons/test_update10.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "0.1",
+ advisory_max_version: "0.4",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ notEqual(addon, null);
+
+ let result = await promiseFindAddonUpdates(addon);
+ ok(result.compatibilityUpdate, "Should have seen a compatibility update");
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// Test that the update check correctly observes when an addon opts-in to
+// strict compatibility checking.
+add_task(async function test_update_strict_optin() {
+ const ID = "addon11@tests.mozilla.org";
+ let xpi = await createAddon({
+ id: ID,
+ updateURL: "http://example.com/update.json",
+ targetApplications: [
+ {
+ id: appId,
+ minVersion: "0.1",
+ maxVersion: "0.2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+
+ await AddonRepository.backgroundUpdateCheck();
+
+ const UPDATE = {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addons/test_update11.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "0.1",
+ strict_max_version: "0.2",
+ },
+ },
+ },
+ ],
+ },
+ },
+ };
+
+ AddonTestUtils.registerJSON(testserver, "/update.json", UPDATE);
+
+ let addon = await AddonManager.getAddonByID(ID);
+ notEqual(addon, null);
+
+ let result = await AddonTestUtils.promiseFindAddonUpdates(addon);
+ ok(
+ !result.compatibilityUpdate,
+ "Should not have seen a compatibility update"
+ );
+ ok(!result.updateAvailable, "Should not have seen a version update");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js
new file mode 100644
index 0000000000..7d26f23981
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js
@@ -0,0 +1,121 @@
+"use strict";
+
+ChromeUtils.defineLazyGetter(this, "Management", () => {
+ const { ExtensionParent } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+ );
+ return ExtensionParent.apiManager;
+});
+
+add_task(async function setup() {
+ let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
+ Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+ await promiseStartupManager();
+});
+
+// Verify that a theme can be updated without issues.
+add_task(async function test_update_of_disabled_theme() {
+ const id = "theme-only@test";
+ async function installTheme(version) {
+ // Upon installing a theme, it is disabled by default. Because of this,
+ // ExtensionTestUtils.loadExtension cannot be used because it awaits the
+ // startup of a test extension.
+ let xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ version,
+ theme: {},
+ },
+ });
+ let install = await AddonManager.getInstallForFile(xpi);
+ let addon = await install.install();
+ ok(addon.userDisabled, "Theme is expected to be disabled by default");
+ equal(addon.userPermissions, null, "theme has no userPermissions");
+ }
+
+ await installTheme("1.0");
+
+ let updatePromise = new Promise(resolve => {
+ Management.on("update", function listener(name, { id: updatedId }) {
+ Management.off("update", listener);
+ equal(updatedId, id, "expected theme update");
+ resolve();
+ });
+ });
+ await installTheme("2.0");
+ await updatePromise;
+ let addon = await promiseAddonByID(id);
+ equal(addon.version, "2.0", "Theme should be updated");
+ ok(addon.userDisabled, "Theme is still disabled after an update");
+ await addon.uninstall();
+});
+
+add_task(async function test_builtin_location_migration() {
+ const ADDON_ID = "mytheme@mozilla.org";
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ version: "1.0",
+ theme: {},
+ },
+ };
+
+ await setupBuiltinExtension(themeDef, "first-loc", false);
+ await AddonManager.maybeInstallBuiltinAddon(
+ ADDON_ID,
+ "1.0",
+ "resource://first-loc/"
+ );
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ await addon.enable();
+ Assert.ok(!addon.userDisabled, "Add-on should be enabled.");
+
+ Assert.equal(
+ Services.prefs.getCharPref("extensions.activeThemeID", ""),
+ ADDON_ID,
+ "Pref should be set."
+ );
+
+ let { addons: activeThemes } = await AddonManager.getActiveAddons(["theme"]);
+ Assert.equal(activeThemes.length, 1, "Should have 1 theme.");
+ Assert.equal(activeThemes[0].id, ADDON_ID, "Should have enabled the theme.");
+
+ // If we restart and update, and install a newer version of the theme,
+ // it should be activated.
+ await promiseShutdownManager();
+
+ // Force schema change and restart
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ // Set up a new version of the builtin add-on.
+ let newDef = { manifest: Object.assign({}, themeDef.manifest) };
+ newDef.manifest.version = "1.1";
+ await setupBuiltinExtension(newDef, "second-loc");
+ await AddonManager.maybeInstallBuiltinAddon(
+ ADDON_ID,
+ "1.1",
+ "resource://second-loc/"
+ );
+
+ let newAddon = await AddonManager.getAddonByID(ADDON_ID);
+ Assert.ok(!newAddon.userDisabled, "Add-on should be enabled.");
+
+ ({ addons: activeThemes } = await AddonManager.getActiveAddons(["theme"]));
+ Assert.equal(activeThemes.length, 1, "Should still have 1 theme.");
+ Assert.equal(
+ activeThemes[0].id,
+ ADDON_ID,
+ "Should still have the theme enabled."
+ );
+ Assert.equal(
+ Services.prefs.getCharPref("extensions.activeThemeID", ""),
+ ADDON_ID,
+ "Pref should still be set."
+ );
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js b/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
new file mode 100644
index 0000000000..dbe944013f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_update_webextensions.js
@@ -0,0 +1,209 @@
+"use strict";
+
+// We don't have an easy way to serve update manifests from a secure URL.
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+var testserver = createHttpServer();
+gPort = testserver.identity.primaryPort;
+
+const uuidGenerator = Services.uuid;
+
+const extensionsDir = gProfD.clone();
+extensionsDir.append("extensions");
+
+const addonsDir = gTmpD.clone();
+addonsDir.append("addons");
+addonsDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+
+registerCleanupFunction(() => addonsDir.remove(true));
+
+testserver.registerDirectory("/addons/", addonsDir);
+
+let gUpdateManifests = {};
+
+function mapManifest(aPath, aManifestData) {
+ gUpdateManifests[aPath] = aManifestData;
+ testserver.registerPathHandler(aPath, serveManifest);
+}
+
+function serveManifest(request, response) {
+ let manifest = gUpdateManifests[request.path];
+
+ response.setHeader("Content-Type", manifest.contentType, false);
+ response.write(manifest.data);
+}
+
+async function promiseInstallWebExtension(aData) {
+ let addonFile = createTempWebExtensionFile(aData);
+
+ let { addon } = await promiseInstallFile(addonFile);
+ Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+ return addon;
+}
+
+var checkUpdates = async function (
+ aData,
+ aReason = AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
+) {
+ function provide(obj, path, value) {
+ path = path.split(".");
+ let prop = path.pop();
+
+ for (let key of path) {
+ if (!(key in obj)) {
+ obj[key] = {};
+ }
+ obj = obj[key];
+ }
+
+ if (!(prop in obj)) {
+ obj[prop] = value;
+ }
+ }
+
+ let id = uuidGenerator.generateUUID().number;
+ provide(aData, "addon.id", id);
+ provide(aData, "addon.manifest.browser_specific_settings.gecko.id", id);
+
+ let updatePath = `/updates/${id}.json`.replace(/[{}]/g, "");
+ let updateUrl = `http://localhost:${gPort}${updatePath}`;
+
+ let addonData = { updates: [] };
+ let manifestJSON = {
+ addons: { [id]: addonData },
+ };
+
+ provide(
+ aData,
+ "addon.manifest.browser_specific_settings.gecko.update_url",
+ updateUrl
+ );
+ let awaitInstall = promiseInstallWebExtension(aData.addon);
+
+ for (let version of Object.keys(aData.updates)) {
+ let update = aData.updates[version];
+ update.version = version;
+
+ // Create an add-on manifest based on what's in the current `update`.
+ provide(update, "addon.id", id);
+ provide(update, "addon.manifest.browser_specific_settings.gecko.id", id);
+ let addon = update.addon;
+
+ delete update.addon;
+
+ provide(addon, "manifest.version", version);
+ let file = createTempWebExtensionFile(addon);
+ file.moveTo(addonsDir, `${id}-${version}.xpi`.replace(/[{}]/g, ""));
+
+ let path = `/addons/${file.leafName}`;
+ provide(update, "update_link", `http://localhost:${gPort}${path}`);
+
+ addonData.updates.push(update);
+ }
+
+ mapManifest(updatePath, {
+ data: JSON.stringify(manifestJSON),
+ contentType: "application/json",
+ });
+
+ let addon = await awaitInstall;
+
+ let updates = await promiseFindAddonUpdates(addon, aReason);
+ updates.addon = addon;
+
+ return updates;
+};
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0", "42.0");
+
+ await promiseStartupManager();
+ registerCleanupFunction(promiseShutdownManager);
+});
+
+// Check that compatibility updates are applied.
+add_task(async function checkUpdateMetadata() {
+ let update = await checkUpdates({
+ addon: {
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { strict_max_version: "45" } },
+ },
+ },
+ updates: {
+ "1.0": {
+ applications: {
+ gecko: { strict_min_version: "40", strict_max_version: "48" },
+ },
+ },
+ },
+ });
+
+ ok(update.compatibilityUpdate, "have compat update");
+ ok(!update.updateAvailable, "have no add-on update");
+
+ ok(update.addon.isCompatibleWith("40", "40"), "compatible min");
+ ok(update.addon.isCompatibleWith("48", "48"), "compatible max");
+ ok(!update.addon.isCompatibleWith("49", "49"), "not compatible max");
+
+ await update.addon.uninstall();
+});
+
+// Check that updates from web extensions to web extensions succeed.
+add_task(async function checkUpdateToWebExt() {
+ let update = await checkUpdates({
+ addon: { manifest: { version: "1.0" } },
+ updates: {
+ 1.1: {},
+ 1.2: {},
+ 1.3: { applications: { gecko: { strict_min_version: "48" } } },
+ },
+ });
+
+ ok(!update.compatibilityUpdate, "have no compat update");
+ ok(update.updateAvailable, "have add-on update");
+
+ equal(update.addon.version, "1.0", "add-on version");
+
+ await update.updateAvailable.install();
+
+ let addon = await promiseAddonByID(update.addon.id);
+ equal(addon.version, "1.2", "new add-on version");
+
+ await addon.uninstall();
+});
+
+// Check that illegal update URLs are rejected.
+add_task(async function checkIllegalUpdateURL() {
+ const URLS = [
+ "chrome://browser/content/",
+ "data:text/json,...",
+ "javascript:;",
+ "/",
+ ];
+
+ for (let url of URLS) {
+ let { messages } = await promiseConsoleOutput(() => {
+ let addonFile = createTempWebExtensionFile({
+ manifest: { browser_specific_settings: { gecko: { update_url: url } } },
+ });
+
+ return AddonManager.getInstallForFile(addonFile).then(install => {
+ Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+
+ if (!install || install.state != AddonManager.STATE_DOWNLOAD_FAILED) {
+ throw new Error("Unexpected state: " + (install && install.state));
+ }
+ });
+ });
+
+ ok(
+ messages.some(msg =>
+ /Access denied for URL|may not load or link to|is not a valid URL/.test(
+ msg
+ )
+ ),
+ "Got checkLoadURI error"
+ );
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js
new file mode 100644
index 0000000000..9ddaf82bf3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that AddonUpdateChecker works correctly
+
+const { AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+
+var testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+testserver.registerDirectory("/data/", do_get_file("data"));
+
+function checkUpdates(aId) {
+ return new Promise((resolve, reject) => {
+ AddonUpdateChecker.checkForUpdates(
+ aId,
+ `http://example.com/data/test_updatecheck.json`,
+ {
+ onUpdateCheckComplete: resolve,
+
+ onUpdateCheckError(status) {
+ let error = new Error("Update check failed with status " + status);
+ error.status = status;
+ reject(error);
+ },
+ }
+ );
+ });
+}
+
+// Test that a basic update check returns the expected available updates
+add_task(async function test_basic_update() {
+ let updates = await checkUpdates("updatecheck1@tests.mozilla.org");
+
+ equal(updates.length, 5);
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(updates, {});
+ notEqual(update, null);
+ equal(update.version, "3.0");
+ update = AddonUpdateChecker.getCompatibilityUpdate(updates, "2");
+ notEqual(update, null);
+ equal(update.version, "2.0");
+ equal(update.targetApplications[0].minVersion, "1");
+ equal(update.targetApplications[0].maxVersion, "2");
+});
+
+// Test that only newer versions are considered.
+add_task(async function test_update_newer_versions_only() {
+ let updates = await checkUpdates("updatecheck1@tests.mozilla.org");
+
+ // This should be an AddonWrapper instance, but for the purpose of this test,
+ // an object with the version property suffices.
+ let addon = { version: "2.0" };
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(
+ updates,
+ addon
+ );
+ notEqual(update, null);
+ equal(update.version, "3.0");
+
+ addon = { version: "3.0" };
+ update = await AddonUpdateChecker.getNewestCompatibleUpdate(updates, addon);
+ equal(update, null);
+});
+
+/*
+ * Tests that the security checks are applied correctly
+ *
+ * Test updateHash updateLink expected
+ *--------------------------------------------
+ * 4 absent http no update
+ * 5 sha1 http update
+ * 6 absent https update
+ * 7 sha1 https update
+ * 8 md2 http no update
+ * 9 md2 https update
+ */
+
+add_task(async function test_4() {
+ let updates = await checkUpdates("test_bug378216_8@tests.mozilla.org");
+ equal(updates.length, 1);
+ ok(!("updateURL" in updates[0]));
+});
+
+add_task(async function test_5() {
+ let updates = await checkUpdates("test_bug378216_9@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_6() {
+ let updates = await checkUpdates("test_bug378216_10@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_7() {
+ let updates = await checkUpdates("test_bug378216_11@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_8() {
+ let updates = await checkUpdates("test_bug378216_12@tests.mozilla.org");
+ equal(updates.length, 1);
+ ok(!("updateURL" in updates[0]));
+});
+
+add_task(async function test_9() {
+ let updates = await checkUpdates("test_bug378216_13@tests.mozilla.org");
+ equal(updates.length, 1);
+ equal(updates[0].version, "2.0");
+ ok("updateURL" in updates[0]);
+});
+
+add_task(async function test_no_update_data() {
+ let updates = await checkUpdates("test_bug378216_14@tests.mozilla.org");
+ equal(updates.length, 0);
+});
+
+add_task(async function test_invalid_json() {
+ await checkUpdates("test_bug378216_15@tests.mozilla.org")
+ .then(() => {
+ ok(false, "Expected the update check to fail");
+ })
+ .catch(e => {
+ equal(
+ e.status,
+ AddonManager.ERROR_PARSE_ERROR,
+ "expected AddonManager.ERROR_PARSE_ERROR"
+ );
+ });
+});
+
+add_task(async function test_ignore_compat() {
+ let updates = await checkUpdates("ignore-compat@tests.mozilla.org");
+ equal(updates.length, 3);
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(
+ updates,
+ {}, // dummy value instead of addon.
+ null,
+ null,
+ true
+ );
+ notEqual(update, null);
+ equal(update.version, 2);
+});
+
+add_task(async function test_strict_compat() {
+ let updates = await checkUpdates("compat-strict-optin@tests.mozilla.org");
+ equal(updates.length, 1);
+ let update = await AddonUpdateChecker.getNewestCompatibleUpdate(
+ updates,
+ {}, // dummy value instead of addon.
+ null,
+ null,
+ true,
+ false
+ );
+ equal(update, null);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js
new file mode 100644
index 0000000000..5bbd723f42
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_errors.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that add-on update check failures are propagated correctly
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+var testserver;
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // Create and configure the HTTP server.
+ testserver = createHttpServer({ hosts: ["example.com"] });
+ testserver.registerDirectory("/data/", do_get_file("data"));
+
+ await promiseStartupManager();
+});
+
+// Verify that an update check returns the correct errors.
+add_task(async function () {
+ await promiseInstallWebExtension({
+ manifest: {
+ name: "Test Addon 1",
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: "addon1@tests.mozilla.org",
+ update_url: "http://example.com/data/test_missing.json",
+ },
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID("addon1@tests.mozilla.org");
+ equal(addon.version, "1.0");
+
+ // We're expecting an error, so resolve when the promise is rejected.
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ ).catch(e => e);
+
+ ok(!update.compatibilityUpdate, "not expecting a compatibility update");
+ ok(!update.updateAvailable, "not expecting a compatibility update");
+
+ equal(update.error, AddonManager.UPDATE_STATUS_DOWNLOAD_ERROR);
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js
new file mode 100644
index 0000000000..8186ea44a6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updatecheck_json.js
@@ -0,0 +1,423 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+// This verifies that AddonUpdateChecker works correctly for JSON
+// update manifests, particularly for behavior which does not
+// cleanly overlap with RDF manifests.
+
+const TOOLKIT_ID = "toolkit@mozilla.org";
+const TOOLKIT_MINVERSION = "42.0a1";
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42.0a2", "42.0a2");
+
+const { AddonUpdateChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs"
+);
+
+let testserver = createHttpServer();
+gPort = testserver.identity.primaryPort;
+
+let gUpdateManifests = {};
+
+function mapManifest(aPath, aManifestData) {
+ gUpdateManifests[aPath] = aManifestData;
+ testserver.registerPathHandler(aPath, serveManifest);
+}
+
+function serveManifest(request, response) {
+ let manifest = gUpdateManifests[request.path];
+
+ response.setHeader("Content-Type", manifest.contentType, false);
+ response.write(manifest.data);
+}
+
+const extensionsDir = gProfD.clone();
+extensionsDir.append("extensions");
+
+function checkUpdates(aData) {
+ // Registers JSON update manifest for it with the testing server,
+ // checks for updates, and yields the list of updates on
+ // success.
+
+ let extension = aData.manifestExtension || "json";
+
+ let path = `/updates/${aData.id}.${extension}`;
+ let updateUrl = `http://localhost:${gPort}${path}`;
+
+ let addonData = {};
+ if ("updates" in aData) {
+ addonData.updates = aData.updates;
+ }
+
+ let manifestJSON = {
+ addons: {
+ [aData.id]: addonData,
+ },
+ };
+
+ mapManifest(path.replace(/\?.*/, ""), {
+ data: JSON.stringify(manifestJSON),
+ contentType: aData.contentType || "application/json",
+ });
+
+ return new Promise((resolve, reject) => {
+ AddonUpdateChecker.checkForUpdates(aData.id, updateUrl, {
+ onUpdateCheckComplete: resolve,
+
+ onUpdateCheckError(status) {
+ reject(new Error("Update check failed with status " + status));
+ },
+ });
+ });
+}
+
+add_task(async function test_default_values() {
+ // Checks that the appropriate defaults are used for omitted values.
+
+ await promiseStartupManager();
+
+ let updates = await checkUpdates({
+ id: "updatecheck-defaults@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ },
+ ],
+ });
+
+ equal(updates.length, 1);
+ let update = updates[0];
+
+ equal(update.targetApplications.length, 2);
+ let targetApp = update.targetApplications[0];
+
+ equal(targetApp.id, TOOLKIT_ID);
+ equal(targetApp.minVersion, TOOLKIT_MINVERSION);
+ equal(targetApp.maxVersion, "*");
+
+ equal(update.version, "0.2");
+ equal(update.strictCompatibility, false, "inferred strictConpatibility flag");
+ equal(update.updateURL, null, "updateURL");
+ equal(update.updateHash, null, "updateHash");
+ equal(update.updateInfoURL, null, "updateInfoURL");
+
+ // If there's no applications property, we default to using one
+ // containing "gecko". If there is an applications property, but
+ // it doesn't contain "gecko", the update is skipped.
+ updates = await checkUpdates({
+ id: "updatecheck-defaults@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ applications: { foo: {} },
+ },
+ ],
+ });
+
+ equal(updates.length, 0);
+
+ // Updates property is also optional. No updates, but also no error.
+ updates = await checkUpdates({
+ id: "updatecheck-defaults@tests.mozilla.org",
+ version: "0.1",
+ });
+
+ equal(updates.length, 0);
+});
+
+add_task(async function test_explicit_values() {
+ // Checks that the appropriate explicit values are used when
+ // provided.
+
+ let updates = await checkUpdates({
+ id: "updatecheck-explicit@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ update_link: "https://example.com/foo.xpi",
+ update_hash: "sha256:0",
+ update_info_url: "https://example.com/update_info.html",
+ applications: {
+ gecko: {
+ strict_min_version: "42.0a2.xpcshell",
+ strict_max_version: "43.xpcshell",
+ },
+ },
+ },
+ ],
+ });
+
+ equal(updates.length, 1);
+ let update = updates[0];
+
+ equal(update.targetApplications.length, 2);
+ let targetApp = update.targetApplications[0];
+
+ equal(targetApp.id, TOOLKIT_ID);
+ equal(targetApp.minVersion, "42.0a2.xpcshell");
+ equal(targetApp.maxVersion, "43.xpcshell");
+
+ equal(update.version, "0.2");
+ equal(update.strictCompatibility, true, "inferred strictCompatibility flag");
+ equal(update.updateURL, "https://example.com/foo.xpi", "updateURL");
+ equal(update.updateHash, "sha256:0", "updateHash");
+ equal(
+ update.updateInfoURL,
+ "https://example.com/update_info.html",
+ "updateInfoURL"
+ );
+});
+
+add_task(async function test_secure_hashes() {
+ // Checks that only secure hash functions are accepted for
+ // non-secure update URLs.
+
+ let hashFunctions = ["sha512", "sha256", "sha1", "md5", "md4", "xxx"];
+
+ let updateItems = hashFunctions.map((hash, idx) => ({
+ version: `0.${idx}`,
+ update_link: `http://localhost:${gPort}/updates/${idx}-${hash}.xpi`,
+ update_hash: `${hash}:08ac852190ecd81f40a514ea9299fe9143d9ab5e296b97e73fb2a314de49648a`,
+ }));
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdates({
+ id: "updatecheck-hashes@tests.mozilla.org",
+ version: "0.1",
+ updates: updateItems,
+ });
+ });
+
+ equal(updates.length, hashFunctions.length);
+
+ updates = updates.filter(update => update.updateHash || update.updateURL);
+ equal(updates.length, 2, "expected number of update hashes were accepted");
+
+ ok(updates[0].updateHash.startsWith("sha512:"), "sha512 hash is present");
+ ok(updates[0].updateURL);
+
+ ok(updates[1].updateHash.startsWith("sha256:"), "sha256 hash is present");
+ ok(updates[1].updateURL);
+
+ messages = messages.filter(msg =>
+ /Update link.*not secure.*strong enough hash \(needs to be sha256 or sha512\)/.test(
+ msg.message
+ )
+ );
+ equal(
+ messages.length,
+ hashFunctions.length - 2,
+ "insecure hashes generated the expected warning"
+ );
+});
+
+add_task(async function test_strict_compat() {
+ // Checks that strict compatibility is enabled for strict max
+ // versions other than "*", but not for advisory max versions.
+ // Also, ensure that strict max versions take precedence over
+ // advisory versions.
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdates({
+ id: "updatecheck-strict@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ applications: { gecko: { strict_max_version: "*" } },
+ },
+ {
+ version: "0.3",
+ applications: { gecko: { strict_max_version: "43" } },
+ },
+ {
+ version: "0.4",
+ applications: { gecko: { advisory_max_version: "43" } },
+ },
+ {
+ version: "0.5",
+ applications: {
+ gecko: { advisory_max_version: "43", strict_max_version: "44" },
+ },
+ },
+ ],
+ });
+ });
+
+ equal(updates.length, 4, "all update items accepted");
+
+ equal(updates[0].targetApplications[0].maxVersion, "*");
+ equal(updates[0].strictCompatibility, false);
+
+ equal(updates[1].targetApplications[0].maxVersion, "43");
+ equal(updates[1].strictCompatibility, true);
+
+ equal(updates[2].targetApplications[0].maxVersion, "43");
+ equal(updates[2].strictCompatibility, false);
+
+ equal(updates[3].targetApplications[0].maxVersion, "44");
+ equal(updates[3].strictCompatibility, true);
+
+ messages = messages.filter(msg =>
+ /Ignoring 'advisory_max_version'.*'strict_max_version' also present/.test(
+ msg.message
+ )
+ );
+ equal(
+ messages.length,
+ 1,
+ "mix of advisory_max_version and strict_max_version generated the expected warning"
+ );
+});
+
+add_task(async function test_update_url_security() {
+ // Checks that update links to privileged URLs are not accepted.
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdates({
+ id: "updatecheck-security@tests.mozilla.org",
+ version: "0.1",
+ updates: [
+ {
+ version: "0.2",
+ update_link: "chrome://browser/content/browser.xhtml",
+ update_hash:
+ "sha256:08ac852190ecd81f40a514ea9299fe9143d9ab5e296b97e73fb2a314de49648a",
+ },
+ {
+ version: "0.3",
+ update_link: "http://example.com/update.xpi",
+ update_hash:
+ "sha256:18ac852190ecd81f40a514ea9299fe9143d9ab5e296b97e73fb2a314de49648a",
+ },
+ ],
+ });
+ });
+
+ equal(updates.length, 2, "both updates were processed");
+ equal(updates[0].updateURL, null, "privileged update URL was removed");
+ equal(
+ updates[1].updateURL,
+ "http://example.com/update.xpi",
+ "safe update URL was accepted"
+ );
+
+ messages = messages.filter(msg =>
+ /http:\/\/localhost.*\/updates\/.*may not load or link to chrome:/.test(
+ msg.message
+ )
+ );
+ equal(
+ messages.length,
+ 1,
+ "privileged update URL generated the expected console message"
+ );
+});
+
+add_task(async function test_type_detection() {
+ // Checks that JSON update manifests are detected correctly
+ // regardless of extension or MIME type.
+
+ let tests = [
+ { contentType: "application/json", extension: "json", valid: true },
+ { contentType: "application/json", extension: "php", valid: true },
+ { contentType: "text/plain", extension: "json", valid: true },
+ { contentType: "application/octet-stream", extension: "json", valid: true },
+ { contentType: "text/plain", extension: "json?foo=bar", valid: true },
+ { contentType: "text/plain", extension: "php", valid: true },
+ { contentType: "text/plain", extension: "rdf", valid: true },
+ { contentType: "application/json", extension: "rdf", valid: true },
+ { contentType: "text/xml", extension: "json", valid: true },
+ { contentType: "application/rdf+xml", extension: "json", valid: true },
+ ];
+
+ for (let [i, test] of tests.entries()) {
+ let { messages } = await promiseConsoleOutput(async function () {
+ let id = `updatecheck-typedetection-${i}@tests.mozilla.org`;
+ let updates;
+ try {
+ updates = await checkUpdates({
+ id,
+ version: "0.1",
+ contentType: test.contentType,
+ manifestExtension: test.extension,
+ updates: [{ version: "0.2" }],
+ });
+ } catch (e) {
+ ok(!test.valid, "update manifest correctly detected as RDF");
+ return;
+ }
+
+ ok(test.valid, "update manifest correctly detected as JSON");
+ equal(updates.length, 1, "correct number of updates");
+ equal(updates[0].id, id, "update is for correct extension");
+ });
+
+ if (test.valid) {
+ // Make sure we don't get any XML parsing errors from the
+ // XMLHttpRequest machinery.
+ ok(
+ !messages.some(msg => /not well-formed/.test(msg.message)),
+ "expect XMLHttpRequest not to attempt XML parsing"
+ );
+ }
+
+ messages = messages.filter(msg =>
+ /Update manifest was not valid XML/.test(msg.message)
+ );
+ equal(
+ messages.length,
+ !test.valid,
+ "expected number of XML parsing errors"
+ );
+ }
+});
+
+add_task(async function test_empty_manifest() {
+ function checkUpdatesForUnlistedAddon(aData) {
+ // Registers an empty JSON update manifest with the test server to simulate
+ // the update server's actual response in the case of an unlisted add-on.
+
+ let path = `/updates/${aData.id}.json`;
+ let updateUrl = `http://localhost:${gPort}${path}`;
+
+ let manifestJSON = {};
+
+ mapManifest(path.replace(/\?.*/, ""), {
+ data: JSON.stringify(manifestJSON),
+ contentType: "application/json",
+ });
+
+ return new Promise((resolve, reject) => {
+ AddonUpdateChecker.checkForUpdates(aData.id, updateUrl, {
+ onUpdateCheckComplete: resolve,
+
+ onUpdateCheckError(status) {
+ reject(new Error("Update check failed with status " + status));
+ },
+ });
+ });
+ }
+
+ let { messages, result: updates } = await promiseConsoleOutput(() => {
+ return checkUpdatesForUnlistedAddon({
+ id: "unlisted@example.org",
+ });
+ });
+
+ equal(updates.length, 0, "no update could be found");
+
+ messages = messages.filter(msg =>
+ /Received empty update manifest for .*/.test(msg.message)
+ );
+ equal(
+ messages.length,
+ 1,
+ "unlisted addon generated the expected console message"
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updateid.js b/toolkit/mozapps/extensions/test/xpcshell/test_updateid.js
new file mode 100644
index 0000000000..c88c8e637b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updateid.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that updating an add-on to a new ID does not work.
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let testserver = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+const ID = "updateid@tests.mozilla.org";
+
+// Verify that an update to an add-on with a new ID fails
+add_task(async function test_update_new_id() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: {
+ gecko: { id: "differentid@tests.mozilla.org" },
+ },
+ },
+ });
+
+ testserver.registerFile("/addon.xpi", xpi);
+ AddonTestUtils.registerJSON(testserver, "/update.json", {
+ addons: {
+ [ID]: {
+ updates: [
+ {
+ version: "2.0",
+ update_link: "http://example.com/addon.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "1",
+ strict_max_version: "10",
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ Assert.notEqual(install, null, "Found available update");
+ Assert.equal(install.name, addon.name);
+ Assert.equal(install.version, "2.0");
+ Assert.equal(install.state, AddonManager.STATE_AVAILABLE);
+ Assert.equal(install.existingAddon, addon);
+
+ await Assert.rejects(
+ install.install(),
+ err => install.error == AddonManager.ERROR_INCORRECT_ID,
+ "Upgrade to a different ID fails"
+ );
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js b/toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js
new file mode 100644
index 0000000000..4d1510c40f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_updateversion.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// The test extension uses an insecure update url.
+Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
+
+let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+
+async function serverRegisterUpdate({ id, version, actualVersion }) {
+ let xpi = await createTempWebExtensionFile({
+ manifest: {
+ version: actualVersion,
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ server.registerFile("/addon.xpi", xpi);
+ AddonTestUtils.registerJSON(server, "/update.json", {
+ addons: {
+ [id]: {
+ updates: [{ version, update_link: "http://example.com/addon.xpi" }],
+ },
+ },
+ });
+}
+
+add_task(async function setup() {
+ await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_update_version_mismatch() {
+ const ID = "updateversion@tests.mozilla.org";
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+
+ await serverRegisterUpdate({
+ id: ID,
+ version: "2.0",
+ actualVersion: "2.0.0",
+ });
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "1.0");
+
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ let install = update.updateAvailable;
+ Assert.notEqual(install, false, "Found available update");
+ Assert.equal(install.version, "2.0");
+ Assert.equal(install.state, AddonManager.STATE_AVAILABLE);
+ Assert.equal(install.existingAddon, addon);
+
+ await Assert.rejects(
+ install.install(),
+ err => install.error == AddonManager.ERROR_UNEXPECTED_ADDON_VERSION,
+ "Should refuse installation when downloaded version does not match"
+ );
+
+ await addon.uninstall();
+});
+
+add_task(async function test_update_version_empty() {
+ const ID = "updateversionempty@tests.mozilla.org";
+ await serverRegisterUpdate({ id: ID, version: "", actualVersion: "1.0" });
+
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "0",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ update_url: "http://example.com/update.json",
+ },
+ },
+ },
+ });
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+ Assert.equal(addon.version, "0");
+ let update = await promiseFindAddonUpdates(
+ addon,
+ AddonManager.UPDATE_WHEN_USER_REQUESTED
+ );
+ // The only item in the updates array has version "" (empty). This should not
+ // be offered as an available update because it is certainly not newer.
+ Assert.equal(update.updateAvailable, false, "No update found");
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js
new file mode 100644
index 0000000000..6e5f221625
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This verifies that app upgrades produce the expected behaviours,
+// with strict compatibility checking disabled.
+
+Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, false);
+
+// Enable loading extensions from the application scope
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+Services.prefs.setIntPref("extensions.sideloadScopes", AddonManager.SCOPE_ALL);
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const globalDir = Services.dirsvc.get("XREAddonAppDir", Ci.nsIFile);
+globalDir.append("extensions");
+
+var gGlobalExisted = globalDir.exists();
+var gInstallTime = Date.now();
+
+const ID1 = "addon1@tests.mozilla.org";
+const ID2 = "addon2@tests.mozilla.org";
+const ID3 = "addon3@tests.mozilla.org";
+const ID4 = "addon4@tests.mozilla.org";
+const PATH4 = PathUtils.join(globalDir.path, `${ID4}.xpi`);
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ // Will be compatible in the first version and incompatible in subsequent versions
+ let xpi = await createAddon({
+ id: ID1,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ targetPlatforms: [{ os: "XPCShell" }, { os: "WINNT_x86" }],
+ });
+ await manuallyInstall(xpi, profileDir, ID1);
+
+ // Works in all tested versions
+ xpi = await createAddon({
+ id: ID2,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "2",
+ },
+ ],
+ targetPlatforms: [
+ {
+ os: "XPCShell",
+ abi: "noarch-spidermonkey",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, profileDir, ID2);
+
+ // Will be disabled in the first version and enabled in the second.
+ xpi = createAddon({
+ id: ID3,
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "2",
+ maxVersion: "2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, profileDir, ID3);
+
+ // Will be compatible in both versions but will change version in between
+ xpi = await createAddon({
+ id: ID4,
+ version: "1.0",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "1",
+ maxVersion: "1",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, globalDir, ID4);
+ await promiseSetExtensionModifiedTime(PATH4, gInstallTime);
+});
+
+registerCleanupFunction(function end_test() {
+ if (!gGlobalExisted) {
+ globalDir.remove(true);
+ } else {
+ globalDir.append(do_get_expected_addon_name(ID4));
+ globalDir.remove(true);
+ }
+});
+
+// Test that the test extensions are all installed
+add_task(async function test_1() {
+ await promiseStartupManager();
+
+ let [a1, a2, a3, a4] = await promiseAddonsByIDs([ID1, ID2, ID3, ID4]);
+ Assert.notEqual(a1, null, "Found extension 1");
+ Assert.equal(a1.isActive, true, "Extension 1 is active");
+
+ Assert.notEqual(a2, null, "Found extension 2");
+ Assert.equal(a2.isActive, true, "Extension 2 is active");
+
+ Assert.notEqual(a3, null, "Found extension 3");
+ Assert.equal(a3.isActive, false, "Extension 3 is not active");
+
+ Assert.notEqual(a4, null);
+ Assert.equal(a4.isActive, true);
+ Assert.equal(a4.version, "1.0");
+});
+
+// Test that upgrading the application doesn't disable now incompatible add-ons
+add_task(async function test_2() {
+ await promiseShutdownManager();
+
+ // Upgrade the extension
+ let xpi = createAddon({
+ id: ID4,
+ version: "2.0",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "2",
+ maxVersion: "2",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, globalDir, ID4);
+ await promiseSetExtensionModifiedTime(PATH4, gInstallTime);
+
+ await promiseStartupManager("2");
+ let [a1, a2, a3, a4] = await promiseAddonsByIDs([ID1, ID2, ID3, ID4]);
+ Assert.notEqual(a1, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a2.id));
+
+ Assert.notEqual(a3, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ Assert.notEqual(a4, null);
+ Assert.ok(isExtensionInBootstrappedList(globalDir, a4.id));
+ Assert.equal(a4.version, "2.0");
+});
+
+// Test that nothing changes when only the build ID changes.
+add_task(async function test_3() {
+ await promiseShutdownManager();
+
+ // Upgrade the extension
+ let xpi = createAddon({
+ id: ID4,
+ version: "3.0",
+ targetApplications: [
+ {
+ id: "xpcshell@tests.mozilla.org",
+ minVersion: "3",
+ maxVersion: "3",
+ },
+ ],
+ });
+ await manuallyInstall(xpi, globalDir, ID4);
+ await promiseSetExtensionModifiedTime(PATH4, gInstallTime);
+
+ // Simulates a simple Build ID change
+ gAddonStartup.remove(true);
+ await promiseStartupManager();
+
+ let [a1, a2, a3, a4] = await promiseAddonsByIDs([ID1, ID2, ID3, ID4]);
+
+ Assert.notEqual(a1, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a1.id));
+
+ Assert.notEqual(a2, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a2.id));
+
+ Assert.notEqual(a3, null);
+ Assert.ok(isExtensionInBootstrappedList(profileDir, a3.id));
+
+ Assert.notEqual(a4, null);
+ Assert.ok(isExtensionInBootstrappedList(globalDir, a4.id));
+ Assert.equal(a4.version, "2.0");
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js
new file mode 100644
index 0000000000..b9eb0f31e1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_upgrade_incompatible.js
@@ -0,0 +1,73 @@
+// Tests that when an extension manifest that was previously valid becomes
+// unparseable after an application update, the extension becomes
+// disabled. (See bug 1439600 for a concrete example of a situation where
+// this happened).
+add_task(async function test_upgrade_incompatible() {
+ const ID = "incompatible-upgrade@tests.mozilla.org";
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseStartupManager();
+
+ let file = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ let { addon } = await promiseInstallFile(file);
+
+ notEqual(addon, null);
+ equal(addon.appDisabled, false);
+
+ await promiseShutdownManager();
+
+ // Create a new, incompatible extension
+ let newfile = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ manifest_version: 1,
+ },
+ });
+
+ // swap the incompatible extension in for the original
+ let path = PathUtils.join(gProfD.path, "extensions", `${ID}.xpi`);
+ let fileInfo = await IOUtils.stat(path);
+ let timestamp = fileInfo.lastModified;
+
+ await IOUtils.move(newfile.path, path);
+ await promiseSetExtensionModifiedTime(path, timestamp);
+ Services.obs.notifyObservers(new FileUtils.File(path), "flush-cache-entry");
+
+ // Restart. With the change to the DB schema we recompute compatibility.
+ // With an unparseable manifest the addon should become disabled.
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.appDisabled, true);
+
+ await promiseShutdownManager();
+
+ file = createTempWebExtensionFile({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID } },
+ },
+ });
+
+ // swap the old extension back in and check that we don't persist the disabled state forever.
+ await IOUtils.move(file.path, path);
+ await promiseSetExtensionModifiedTime(path, timestamp);
+ Services.obs.notifyObservers(new FileUtils.File(path), "flush-cache-entry");
+
+ // Restart. With the change to the DB schema we recompute compatibility.
+ Services.prefs.setIntPref("extensions.databaseSchema", 0);
+ await promiseStartupManager();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null);
+ equal(addon.appDisabled, false);
+
+ await promiseShutdownManager();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
new file mode 100644
index 0000000000..cd4b376117
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js
@@ -0,0 +1,676 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "webextension1@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+const ADDONS = {
+ webextension_1: {
+ "manifest.json": {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: "webextension1@tests.mozilla.org",
+ },
+ },
+ icons: {
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ },
+ "chrome.manifest": "content webex ./\n",
+ },
+ webextension_3: {
+ "manifest.json": {
+ name: "Web Extensiøn __MSG_name__",
+ description: "Descriptïon __MSG_desc__ of add-on",
+ version: "1.0",
+ manifest_version: 2,
+ default_locale: "en",
+ browser_specific_settings: {
+ gecko: {
+ id: "webextension3@tests.mozilla.org",
+ },
+ },
+ },
+ "_locales/en/messages.json": {
+ name: {
+ message: "foo ☹",
+ description: "foo",
+ },
+ desc: {
+ message: "bar ☹",
+ description: "bar",
+ },
+ },
+ "_locales/fr/messages.json": {
+ name: {
+ message: "le foo ☺",
+ description: "foo",
+ },
+ desc: {
+ message: "le bar ☺",
+ description: "bar",
+ },
+ },
+ },
+};
+
+let chromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+const {
+ ExtensionParent: { GlobalManager },
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs"
+);
+
+add_task(async function test_1() {
+ await promiseStartupManager();
+
+ equal(GlobalManager.extensionMap.size, 0);
+
+ let { addon } = await AddonTestUtils.promiseInstallXPI(ADDONS.webextension_1);
+
+ equal(GlobalManager.extensionMap.size, 1);
+ ok(GlobalManager.extensionMap.has(ID));
+
+ Assert.throws(
+ () =>
+ chromeReg.convertChromeURL(
+ Services.io.newURI("chrome://webex/content/webex.xul")
+ ),
+ error => error.result == Cr.NS_ERROR_FILE_NOT_FOUND,
+ "Chrome manifest should not have been registered"
+ );
+
+ let uri = do_get_addon_root_uri(profileDir, ID);
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ isSystem: false,
+ type: "extension",
+ isWebExtension: true,
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ iconURL: `${uri}icon48.png`,
+ });
+
+ // Should persist through a restart
+ await promiseShutdownManager();
+
+ equal(GlobalManager.extensionMap.size, 0);
+
+ await promiseStartupManager();
+
+ equal(GlobalManager.extensionMap.size, 1);
+ ok(GlobalManager.extensionMap.has(ID));
+
+ addon = await promiseAddonByID(ID);
+
+ uri = do_get_addon_root_uri(profileDir, ID);
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ isSystem: false,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ iconURL: `${uri}icon48.png`,
+ });
+
+ await addon.disable();
+
+ equal(GlobalManager.extensionMap.size, 0);
+
+ await addon.enable();
+
+ equal(GlobalManager.extensionMap.size, 1);
+ ok(GlobalManager.extensionMap.has(ID));
+
+ await addon.uninstall();
+
+ equal(GlobalManager.extensionMap.size, 0);
+ Assert.ok(!GlobalManager.extensionMap.has(ID));
+
+ await promiseShutdownManager();
+});
+
+// Writing the manifest direct to the profile should work
+add_task(async function test_2() {
+ await promiseWriteWebManifestForExtension(
+ {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseStartupManager();
+
+ let addon = await promiseAddonByID(ID);
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ isSystem: false,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ });
+
+ await addon.uninstall();
+
+ await promiseRestartManager();
+});
+
+add_task(async function test_manifest_localization() {
+ const extensionId = "webextension3@tests.mozilla.org";
+
+ let { addon } = await AddonTestUtils.promiseInstallXPI(ADDONS.webextension_3);
+
+ await addon.disable();
+
+ checkAddon(ID, addon, {
+ name: "Web Extensiøn foo ☹",
+ description: "Descriptïon bar ☹ of add-on",
+ });
+
+ await restartWithLocales(["fr-FR"]);
+
+ addon = await promiseAddonByID(extensionId);
+ checkAddon(ID, addon, {
+ name: "Web Extensiøn le foo ☺",
+ description: "Descriptïon le bar ☺ of add-on",
+ });
+
+ await restartWithLocales(["de"]);
+
+ addon = await promiseAddonByID(extensionId);
+ checkAddon(ID, addon, {
+ name: "Web Extensiøn foo ☹",
+ description: "Descriptïon bar ☹ of add-on",
+ });
+
+ await addon.uninstall();
+});
+
+// Missing version should cause a failure
+add_task(async function test_3() {
+ await promiseWriteWebManifestForExtension(
+ {
+ name: "Web Extension Name",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ let file = getFileForAddon(profileDir, ID);
+ Assert.ok(!file.exists());
+
+ await promiseRestartManager();
+});
+
+// Incorrect manifest version should cause a failure
+add_task(async function test_4() {
+ await promiseWriteWebManifestForExtension(
+ {
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 1,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+
+ let file = getFileForAddon(profileDir, ID);
+ Assert.ok(!file.exists());
+
+ await promiseRestartManager();
+});
+
+// Test that the "options_ui" manifest section is processed correctly.
+add_task(async function test_options_ui() {
+ let OPTIONS_RE =
+ /^moz-extension:\/\/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}\/options\.html$/;
+
+ const extensionId = "webextension@tests.mozilla.org";
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: extensionId } },
+ options_ui: {
+ page: "options.html",
+ },
+ },
+ });
+
+ checkAddon(extensionId, addon, {
+ optionsType: AddonManager.OPTIONS_TYPE_INLINE_BROWSER,
+ });
+
+ ok(
+ OPTIONS_RE.test(addon.optionsURL),
+ "Addon should have a moz-extension: options URL for /options.html"
+ );
+
+ await addon.uninstall();
+
+ const ID2 = "webextension2@tests.mozilla.org";
+ addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: ID2 } },
+ options_ui: {
+ page: "options.html",
+ open_in_tab: true,
+ },
+ },
+ });
+
+ checkAddon(ID2, addon, {
+ optionsType: AddonManager.OPTIONS_TYPE_TAB,
+ });
+
+ ok(
+ OPTIONS_RE.test(addon.optionsURL),
+ "Addon should have a moz-extension: options URL for /options.html"
+ );
+
+ await addon.uninstall();
+});
+
+// Test that experiments permissions add the appropriate dependencies.
+add_task(async function test_experiments_dependencies() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "meh@experiment" } },
+ permissions: ["experiments.meh"],
+ },
+ });
+
+ checkAddon(addon.id, addon, {
+ dependencies: ["meh@experiments.addons.mozilla.org"],
+ // Add-on should be app disabled due to missing dependencies
+ appDisabled: true,
+ });
+
+ await addon.uninstall();
+});
+
+add_task(async function developerShouldOverride() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ default_locale: "en",
+ developer: {
+ name: "__MSG_name__",
+ url: "__MSG_url__",
+ },
+ author: "Will be overridden by developer",
+ homepage_url: "https://will.be.overridden",
+ },
+ files: {
+ "_locales/en/messages.json": `{
+ "name": {
+ "message": "en name"
+ },
+ "url": {
+ "message": "https://example.net/en"
+ }
+ }`,
+ },
+ });
+
+ checkAddon(ID, addon, {
+ creator: "en name",
+ homepageURL: "https://example.net/en",
+ });
+
+ await addon.uninstall();
+});
+
+add_task(async function test_invalid_developer_does_not_override() {
+ for (const { type, manifestProps, files } of [
+ {
+ type: "dictionary",
+ manifestProps: {
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ },
+ {
+ type: "theme",
+ manifestProps: {
+ theme: {
+ colors: {
+ frame: "#FFF",
+ tab_background_text: "#000",
+ },
+ },
+ },
+ },
+ {
+ type: "locale",
+ manifestProps: {
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global",
+ },
+ version: "20190326174300",
+ },
+ },
+ },
+ },
+ ]) {
+ const id = `${type}@mozilla.com`;
+ const creator = "Some author";
+ const homepageURL = "https://example.net";
+
+ info(`== loading add-on with id=${id} ==`);
+
+ for (let developer of [{}, null, { name: null, url: null }]) {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ author: creator,
+ homepage_url: homepageURL,
+ developer,
+ browser_specific_settings: { gecko: { id } },
+ ...manifestProps,
+ },
+ files,
+ });
+
+ checkAddon(id, addon, { type, creator, homepageURL });
+
+ await addon.uninstall();
+ }
+ }
+});
+
+add_task(async function authorNotString() {
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ for (let author of [{}, [], 42]) {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ author,
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ },
+ });
+
+ checkAddon(ID, addon, {
+ creator: null,
+ });
+
+ await addon.uninstall();
+ }
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+});
+
+add_task(async function testThemeExtension() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ author: "Some author",
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ },
+ });
+
+ checkAddon(ID, addon, {
+ creator: "Some author",
+ version: "1.0",
+ name: "Web Extension Name",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: false,
+ userDisabled: true,
+ isSystem: false,
+ type: "theme",
+ isWebExtension: true,
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ });
+
+ await addon.uninstall();
+
+ // Also test one without a proper 'theme' section.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ addon = await promiseInstallWebExtension({
+ manifest: {
+ author: "Some author",
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ theme: null,
+ },
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ checkAddon(ID, addon, {
+ type: "extension",
+ isWebExtension: true,
+ });
+
+ await addon.uninstall();
+});
+
+// Test that we can update from a webextension to a webextension-theme
+add_task(async function test_theme_upgrade() {
+ // First install a regular webextension
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ },
+ });
+
+ checkAddon(ID, addon, {
+ version: "1.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ type: "extension",
+ signedState: AddonManager.SIGNEDSTATE_PRIVILEGED,
+ });
+
+ // Create a webextension theme with the same ID
+ addon = await promiseInstallWebExtension({
+ manifest: {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ },
+ });
+
+ checkAddon(ID, addon, {
+ version: "2.0",
+ name: "Test WebExtension 1 (temporary)",
+ isCompatible: true,
+ appDisabled: false,
+ isActive: true,
+ // This is what we're really interested in:
+ type: "theme",
+ isWebExtension: true,
+ });
+
+ await addon.uninstall();
+
+ addon = await promiseAddonByID(ID);
+ Assert.equal(addon, null);
+});
+
+add_task(async function test_developer_properties() {
+ const name = "developer-name";
+ const url = "https://example.org";
+
+ for (const { type, manifestProps, files } of [
+ {
+ type: "dictionary",
+ manifestProps: {
+ dictionaries: {
+ "en-US": "en-US.dic",
+ },
+ },
+ files: {
+ "en-US.dic": "",
+ "en-US.aff": "",
+ },
+ },
+ {
+ type: "statictheme",
+ manifestProps: {
+ theme: {
+ colors: {
+ frame: "#FFF",
+ tab_background_text: "#000",
+ },
+ },
+ },
+ },
+ {
+ type: "langpack",
+ manifestProps: {
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global",
+ },
+ version: "20190326174300",
+ },
+ },
+ },
+ },
+ ]) {
+ const id = `${type}@mozilla.com`;
+
+ info(`== loading add-on with id=${id} ==`);
+
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ developer: {
+ name,
+ url,
+ },
+ author: "Will be overridden by developer",
+ homepage_url: "https://will.be.overridden",
+ browser_specific_settings: { gecko: { id } },
+ ...manifestProps,
+ },
+ files,
+ });
+
+ checkAddon(id, addon, { creator: name, homepageURL: url });
+
+ await addon.uninstall();
+ }
+});
+
+add_task(async function test_invalid_homepage_and_developer_urls() {
+ const INVALID_URLS = [
+ "chrome://browser/content/",
+ "data:text/json,...",
+ "javascript:;",
+ "/",
+ "not-an-url",
+ ];
+ const EXPECTED_ERROR_RE =
+ /Access denied for URL|may not load or link to|is not a valid URL/;
+
+ for (let url of INVALID_URLS) {
+ // First, we verify `homepage_url`, which has a `url` "format" defined
+ // since it exists.
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ homepage_url: url,
+ });
+ ok(
+ EXPECTED_ERROR_RE.test(normalized.error),
+ `got expected error for ${url}`
+ );
+
+ // The `developer.url` now has a "format" but it was a late addition so we
+ // are only raising a warning instead of an error.
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ developer: { url },
+ });
+ ok(!normalized.error, "expected no error");
+ ok(
+ // Despites this prop being named `errors`, we are checking the warnings
+ // here.
+ EXPECTED_ERROR_RE.test(normalized.errors[0]),
+ `got expected warning for ${url}`
+ );
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+ }
+});
+
+add_task(async function test_valid_homepage_and_developer_urls() {
+ let normalized = await ExtensionTestUtils.normalizeManifest({
+ developer: { url: "https://example.com" },
+ });
+ ok(!normalized.error, "expected no error");
+
+ normalized = await ExtensionTestUtils.normalizeManifest({
+ homepage_url: "https://example.com",
+ });
+ ok(!normalized.error, "expected no error");
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js
new file mode 100644
index 0000000000..dd4222ec88
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_events.js
@@ -0,0 +1,94 @@
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+add_task(async function () {
+ let triggered = {};
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ for (let event of ["install", "uninstall", "update"]) {
+ triggered[event] = false;
+ Management.on(event, () => (triggered[event] = true));
+ }
+
+ async function expectEvents(expected, fn) {
+ let events = Object.keys(expected);
+ for (let event of events) {
+ triggered[event] = false;
+ }
+
+ await fn();
+ await new Promise(executeSoon);
+
+ for (let event of events) {
+ equal(
+ triggered[event],
+ expected[event],
+ `Event ${event} was${expected[event] ? "" : " not"} triggered`
+ );
+ }
+ }
+
+ await promiseStartupManager();
+
+ const id = "webextension@tests.mozilla.org";
+
+ // Install version 1.0, shouldn't see any events
+ await expectEvents({ update: false, uninstall: false }, async () => {
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ });
+
+ // Install version 2.0, we should get an update event but not an uninstall
+ await expectEvents({ update: true, uninstall: false }, async () => {
+ await promiseInstallWebExtension({
+ manifest: {
+ version: "2.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+ });
+
+ // Install version 3.0 as a temporary addon, we should again get
+ // update but not uninstall
+ let v3 = createTempWebExtensionFile({
+ manifest: {
+ version: "3.0",
+ browser_specific_settings: { gecko: { id } },
+ },
+ });
+
+ await expectEvents({ update: true, uninstall: false }, () =>
+ AddonManager.installTemporaryAddon(v3)
+ );
+
+ // Uninstall the temporary addon, this causes version 2.0 still installed
+ // in the profile to be revealed. Again, this results in an update event.
+ let addon = await promiseAddonByID(id);
+ await expectEvents({ update: true, uninstall: false }, () =>
+ addon.uninstall()
+ );
+
+ // Re-install version 3.0 as a temporary addon
+ await AddonManager.installTemporaryAddon(v3);
+
+ // Now shut down the addons manager, this should cause the temporary
+ // addon to be uninstalled which reveals 2.0 from the profile.
+ await expectEvents({ update: true, uninstall: false }, () =>
+ promiseShutdownManager()
+ );
+
+ // When we start up again we should not see any events
+ await expectEvents({ install: false }, () => promiseStartupManager());
+
+ addon = await promiseAddonByID(id);
+
+ // When we uninstall from the profile, the addon is now gone, we should
+ // get an uninstall events.
+ await expectEvents({ update: false, uninstall: true }, () =>
+ addon.uninstall()
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
new file mode 100644
index 0000000000..112a2c8410
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_icons.js
@@ -0,0 +1,212 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const ID = "webextension1@tests.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+profileDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+async function testSimpleIconsetParsing(manifest) {
+ await promiseWriteWebManifestForExtension(manifest, profileDir);
+
+ await promiseRestartManager();
+
+ let uri = do_get_addon_root_uri(profileDir, ID);
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ function check_icons(addon_copy) {
+ deepEqual(addon_copy.icons, {
+ 16: uri + "icon16.png",
+ 32: uri + "icon32.png",
+ 48: uri + "icon48.png",
+ 64: uri + "icon64.png",
+ });
+
+ // iconURL should map to icons[48]
+ equal(addon.iconURL, uri + "icon48.png");
+
+ // AddonManager gets the correct icon sizes from addon.icons
+ equal(AddonManager.getPreferredIconURL(addon, 1), uri + "icon16.png");
+ equal(AddonManager.getPreferredIconURL(addon, 16), uri + "icon16.png");
+ equal(AddonManager.getPreferredIconURL(addon, 30), uri + "icon32.png");
+ equal(AddonManager.getPreferredIconURL(addon, 48), uri + "icon48.png");
+ equal(AddonManager.getPreferredIconURL(addon, 64), uri + "icon64.png");
+ equal(AddonManager.getPreferredIconURL(addon, 128), uri + "icon64.png");
+ }
+
+ check_icons(addon);
+
+ // check if icons are persisted through a restart
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ check_icons(addon);
+
+ await addon.uninstall();
+}
+
+async function testRetinaIconsetParsing(manifest) {
+ await promiseWriteWebManifestForExtension(manifest, profileDir);
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ let uri = do_get_addon_root_uri(profileDir, ID);
+
+ // AddonManager displays larger icons for higher pixel density
+ equal(
+ AddonManager.getPreferredIconURL(addon, 32, {
+ devicePixelRatio: 2,
+ }),
+ uri + "icon64.png"
+ );
+
+ equal(
+ AddonManager.getPreferredIconURL(addon, 48, {
+ devicePixelRatio: 2,
+ }),
+ uri + "icon128.png"
+ );
+
+ equal(
+ AddonManager.getPreferredIconURL(addon, 64, {
+ devicePixelRatio: 2,
+ }),
+ uri + "icon128.png"
+ );
+
+ await addon.uninstall();
+}
+
+async function testNoIconsParsing(manifest) {
+ await promiseWriteWebManifestForExtension(manifest, profileDir);
+
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(ID);
+ Assert.notEqual(addon, null);
+
+ deepEqual(addon.icons, {});
+
+ equal(addon.iconURL, null);
+
+ equal(AddonManager.getPreferredIconURL(addon, 128), null);
+
+ await addon.uninstall();
+}
+
+// Test simple icon set parsing
+add_task(async function () {
+ await promiseStartupManager();
+ await testSimpleIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 16: "icon16.png",
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ });
+
+ // Now for theme-type extensions too.
+ await testSimpleIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 16: "icon16.png",
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ });
+});
+
+// Test AddonManager.getPreferredIconURL for retina screen sizes
+add_task(async function () {
+ await testRetinaIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ 128: "icon128.png",
+ 256: "icon256.png",
+ },
+ });
+
+ await testRetinaIconsetParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ icons: {
+ 32: "icon32.png",
+ 48: "icon48.png",
+ 64: "icon64.png",
+ 128: "icon128.png",
+ 256: "icon256.png",
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ });
+});
+
+// Handles no icons gracefully
+add_task(async function () {
+ await testNoIconsParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ });
+
+ await testNoIconsParsing({
+ name: "Web Extension Name",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ theme: { images: { theme_frame: "example.png" } },
+ });
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
new file mode 100644
index 0000000000..1b2080e8db
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install.js
@@ -0,0 +1,696 @@
+let profileDir;
+add_task(async function setup() {
+ profileDir = gProfD.clone();
+ profileDir.append("extensions");
+
+ if (!profileDir.exists()) {
+ profileDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ }
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+const IMPLICIT_ID_XPI = "data/webext-implicit-id.xpi";
+const IMPLICIT_ID_ID = "webext_implicit_id@tests.mozilla.org";
+
+// webext-implicit-id.xpi has a minimal manifest with no
+// applications or browser_specific_settings, so its id comes
+// from its signature, which should be the ID constant defined below.
+add_task(async function test_implicit_id() {
+ let addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ equal(addon, null, "Add-on is not installed");
+
+ await promiseInstallFile(do_get_file(IMPLICIT_ID_XPI));
+
+ addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await addon.uninstall();
+});
+
+// We should also be able to install webext-implicit-id.xpi temporarily
+// and it should look just like the regular install (ie, the ID should
+// come from the signature)
+add_task(async function test_implicit_id_temp() {
+ let addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ equal(addon, null, "Add-on is not installed");
+
+ let xpifile = do_get_file(IMPLICIT_ID_XPI);
+ await AddonManager.installTemporaryAddon(xpifile);
+
+ addon = await promiseAddonByID(IMPLICIT_ID_ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ // The sourceURI of a temporary installed addon should be equal to the
+ // file url of the installed xpi file.
+ equal(
+ addon.sourceURI && addon.sourceURI.spec,
+ Services.io.newFileURI(xpifile).spec,
+ "SourceURI of the add-on has the expected value"
+ );
+
+ await addon.uninstall();
+});
+
+// Test that extension install error attach the detailed error messages to the
+// Error object.
+add_task(async function test_invalid_extension_install_errors() {
+ const manifest = {
+ name: "invalid",
+ browser_specific_settings: {
+ gecko: {
+ id: "invalid@tests.mozilla.org",
+ },
+ },
+ description: "extension with an invalid 'matches' value",
+ manifest_version: 2,
+ content_scripts: [
+ {
+ matches: "*://*.foo.com/*",
+ js: ["content.js"],
+ },
+ ],
+ version: "1.0",
+ };
+
+ const addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ gTmpD,
+ "the-addon-sub-dir"
+ );
+
+ await Assert.rejects(
+ AddonManager.installTemporaryAddon(addonDir),
+ err => {
+ return (
+ err.additionalErrors.length == 1 &&
+ err.additionalErrors[0] ==
+ `Reading manifest: Error processing content_scripts.0.matches: ` +
+ `Expected array instead of "*://*.foo.com/*"`
+ );
+ },
+ "Exception has the proper additionalErrors details"
+ );
+
+ Services.obs.notifyObservers(addonDir, "flush-cache-entry");
+ addonDir.remove(true);
+});
+
+// We should be able to temporarily install an unsigned web extension
+// that does not have an ID in its manifest.
+add_task(async function test_unsigned_no_id_temp_install() {
+ AddonTestUtils.useRealCertChecks = true;
+ const manifest = {
+ name: "no ID",
+ description: "extension without an ID",
+ manifest_version: 2,
+ version: "1.0",
+ };
+
+ const addonDir = await promiseWriteWebManifestForExtension(
+ manifest,
+ gTmpD,
+ "the-addon-sub-dir"
+ );
+ const testDate = new Date();
+ const addon = await AddonManager.installTemporaryAddon(addonDir);
+
+ ok(addon.id, "ID should have been auto-generated");
+ Assert.less(
+ Math.abs(addon.installDate - testDate),
+ 10000,
+ "addon has an expected installDate"
+ );
+ Assert.less(
+ Math.abs(addon.updateDate - testDate),
+ 10000,
+ "addon has an expected updateDate"
+ );
+
+ // The sourceURI of a temporary installed addon should be equal to the
+ // file url of the installed source dir.
+ equal(
+ addon.sourceURI && addon.sourceURI.spec,
+ Services.io.newFileURI(addonDir).spec,
+ "SourceURI of the add-on has the expected value"
+ );
+
+ // Install the same directory again, as if re-installing or reloading.
+ const secondAddon = await AddonManager.installTemporaryAddon(addonDir);
+ // The IDs should be the same.
+ equal(secondAddon.id, addon.id, "Reinstalled add-on has the expected ID");
+ equal(
+ secondAddon.installDate.valueOf(),
+ addon.installDate.valueOf(),
+ "Reloaded add-on has the expected installDate."
+ );
+
+ await secondAddon.uninstall();
+ Services.obs.notifyObservers(addonDir, "flush-cache-entry");
+ addonDir.remove(true);
+ AddonTestUtils.useRealCertChecks = false;
+});
+
+// We should be able to install two extensions from manifests without IDs
+// at different locations and get two unique extensions.
+add_task(async function test_multiple_no_id_extensions() {
+ AddonTestUtils.useRealCertChecks = true;
+ const manifest = {
+ name: "no ID",
+ description: "extension without an ID",
+ manifest_version: 2,
+ version: "1.0",
+ };
+
+ let extension1 = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+
+ let extension2 = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+
+ await Promise.all([extension1.startup(), extension2.startup()]);
+
+ const allAddons = await AddonManager.getAllAddons();
+
+ info(`Found these add-ons: ${allAddons.map(a => a.name).join(", ")}`);
+ const filtered = allAddons.filter(addon => addon.name === manifest.name);
+ // Make sure we have two add-ons by the same name.
+ equal(filtered.length, 2, "Two add-ons are installed with the same name");
+
+ await extension1.unload();
+ await extension2.unload();
+ AddonTestUtils.useRealCertChecks = false;
+});
+
+// Test that we can get the ID from browser_specific_settings
+add_task(async function test_bss_id() {
+ const ID = "webext_bss_id@tests.mozilla.org";
+
+ let manifest = {
+ name: "bss test",
+ description: "test that ID may be in browser_specific_settings",
+ manifest_version: 2,
+ version: "1.0",
+
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ },
+ },
+ };
+
+ let addon = await promiseAddonByID(ID);
+ equal(addon, null, "Add-on is not installed");
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await extension.unload();
+});
+
+// Test that if we have IDs in both browser_specific_settings and applications,
+// that we prefer the ID in browser_specific_settings.
+add_task(async function test_two_ids() {
+ const GOOD_ID = "two_ids@tests.mozilla.org";
+ const BAD_ID = "i_am_obsolete@tests.mozilla.org";
+
+ let manifest = {
+ name: "two id test",
+ description:
+ "test a web extension with ids in both applications and browser_specific_settings",
+ manifest_version: 2,
+ version: "1.0",
+
+ applications: {
+ gecko: {
+ id: BAD_ID,
+ },
+ },
+
+ browser_specific_settings: {
+ gecko: {
+ id: GOOD_ID,
+ },
+ },
+ };
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ await extension.startup();
+
+ let addon = await promiseAddonByID(BAD_ID);
+ equal(addon, null, "Add-on is not found using bad ID");
+ addon = await promiseAddonByID(GOOD_ID);
+ notEqual(addon, null, "Add-on is found using good ID");
+
+ await extension.unload();
+});
+
+// Test that strict_min_version and strict_max_version are enforced for
+// loading temporary extension.
+add_task(async function test_strict_min_max() {
+ // the app version being compared to is 1.9.2
+ const addonId = "strict_min_max@tests.mozilla.org";
+ const MANIFEST = {
+ name: "strict min max test",
+ description: "test strict min and max with temporary loading",
+ manifest_version: 2,
+ version: "1.0",
+ };
+
+ // bad max good min
+ let apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "1",
+ strict_max_version: "1",
+ },
+ },
+ };
+ let testManifest = Object.assign(apps, MANIFEST);
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ let expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 1. add-on maxVersion: 1."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified maxVersion is not valid"
+ );
+
+ let addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad min good max
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 2. add-on maxVersion: 2."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion is not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad both
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ strict_max_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 2. add-on maxVersion: 1."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion and maxVersion are not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad only min
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on minVersion: 2."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion and maxVersion are not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // bad only max
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_max_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ expectedMsg = new RegExp(
+ "Add-on strict_min_max@tests.mozilla.org is not compatible with application version. " +
+ "add-on maxVersion: 1."
+ );
+ await Assert.rejects(
+ extension.startup(),
+ expectedMsg,
+ "Install rejects when specified minVersion and maxVersion are not valid"
+ );
+
+ addon = await promiseAddonByID(addonId);
+ equal(addon, null, "Add-on is not installed");
+
+ // good both
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: addonId,
+ strict_min_version: "1",
+ strict_max_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ addon = await promiseAddonByID(addonId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, addonId, "Installed add-on has the expected ID");
+ await extension.unload();
+
+ // good only min
+ let newId = "strict_min_only@tests.mozilla.org";
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_min_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ addon = await promiseAddonByID(newId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, newId, "Installed add-on has the expected ID");
+
+ await extension.unload();
+
+ // good only max
+ newId = "strict_max_only@tests.mozilla.org";
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_max_version: "2",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ addon = await promiseAddonByID(newId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, newId, "Installed add-on has the expected ID");
+
+ await extension.unload();
+
+ // * in min will throw an error
+ for (let version of ["0.*", "0.*.0"]) {
+ newId = "strict_min_star@tests.mozilla.org";
+ let minStarApps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_min_version: version,
+ },
+ },
+ };
+
+ let minStarTestManifest = Object.assign(minStarApps, MANIFEST);
+
+ let minStarExtension = ExtensionTestUtils.loadExtension({
+ manifest: minStarTestManifest,
+ useAddonManager: "temporary",
+ });
+
+ await Assert.rejects(
+ minStarExtension.startup(),
+ /The use of '\*' in strict_min_version is invalid/,
+ "loading an extension with a * in strict_min_version throws an exception"
+ );
+
+ let minStarAddon = await promiseAddonByID(newId);
+ equal(minStarAddon, null, "Add-on is not installed");
+ }
+
+ // incompatible extension but with compatibility checking off
+ newId = "checkCompatibility@tests.mozilla.org";
+ apps = {
+ browser_specific_settings: {
+ gecko: {
+ id: newId,
+ strict_max_version: "1",
+ },
+ },
+ };
+ testManifest = Object.assign(apps, MANIFEST);
+
+ extension = ExtensionTestUtils.loadExtension({
+ manifest: testManifest,
+ useAddonManager: "temporary",
+ });
+
+ let savedCheckCompatibilityValue = AddonManager.checkCompatibility;
+ AddonManager.checkCompatibility = false;
+ await extension.startup();
+ addon = await promiseAddonByID(newId);
+
+ notEqual(addon, null, "Add-on is installed");
+ equal(addon.id, newId, "Installed add-on has the expected ID");
+
+ await extension.unload();
+ AddonManager.checkCompatibility = savedCheckCompatibilityValue;
+});
+
+// Check permissions prompt
+add_task(async function test_permissions_prompt() {
+ const manifest = {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["tabs", "storage", "https://*.example.com/*", "<all_urls>"],
+ };
+
+ let xpi = ExtensionTestCommon.generateXPI({ manifest });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ let perminfo;
+ install.promptHandler = info => {
+ perminfo = info;
+ return Promise.resolve();
+ };
+
+ await install.install();
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+ equal(
+ perminfo.existingAddon,
+ null,
+ "Permission info does not include an existing addon"
+ );
+ notEqual(perminfo.addon, null, "Permission info includes the new addon");
+ let perms = perminfo.addon.userPermissions;
+ deepEqual(
+ perms.permissions,
+ ["tabs", "storage"],
+ "API permissions are correct"
+ );
+ deepEqual(
+ perms.origins,
+ ["https://*.example.com/*", "<all_urls>"],
+ "Host permissions are correct"
+ );
+
+ let addon = await promiseAddonByID(perminfo.addon.id);
+ notEqual(addon, null, "Extension was installed");
+
+ await addon.uninstall();
+ await IOUtils.remove(xpi.path);
+});
+
+// Check permissions prompt cancellation
+add_task(async function test_permissions_prompt_cancel() {
+ const manifest = {
+ name: "permissions test",
+ description: "permissions test",
+ manifest_version: 2,
+ version: "1.0",
+
+ permissions: ["webRequestBlocking"],
+ };
+
+ let xpi = ExtensionTestCommon.generateXPI({ manifest });
+
+ let install = await AddonManager.getInstallForFile(xpi);
+
+ let perminfo;
+ install.promptHandler = info => {
+ perminfo = info;
+ return Promise.reject();
+ };
+
+ await promiseCompleteInstall(install);
+
+ notEqual(perminfo, undefined, "Permission handler was invoked");
+
+ let addon = await promiseAddonByID(perminfo.addon.id);
+ equal(addon, null, "Extension was not installed");
+
+ await IOUtils.remove(xpi.path);
+});
+
+// Test that presence of 'edge' property in 'browser_specific_settings' doesn't prevent installation from completing successfully
+add_task(async function test_non_gecko_bss_install() {
+ const ID = "ms_edge@tests.mozilla.org";
+
+ const manifest = {
+ name: "MS Edge and unknown browser test",
+ description:
+ "extension with bss properties for 'edge', and 'unknown_browser'",
+ manifest_version: 2,
+ version: "1.0",
+ applications: { gecko: { id: ID } },
+ browser_specific_settings: {
+ edge: {
+ browser_action_next_to_addressbar: true,
+ },
+ unknown_browser: {
+ unknown_setting: true,
+ },
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ const addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await extension.unload();
+});
+
+// Test that bss overrides applications if both are present.
+add_task(async function test_duplicate_bss() {
+ const ID = "expected@tests.mozilla.org";
+
+ const manifest = {
+ manifest_version: 2,
+ version: "1.0",
+ applications: {
+ gecko: { id: "unexpected@tests.mozilla.org" },
+ },
+ browser_specific_settings: {
+ gecko: { id: ID },
+ },
+ };
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest,
+ useAddonManager: "temporary",
+ });
+ ExtensionTestUtils.failOnSchemaWarnings(false);
+ await extension.startup();
+ ExtensionTestUtils.failOnSchemaWarnings(true);
+
+ const addon = await promiseAddonByID(ID);
+ notEqual(addon, null, "Add-on is installed");
+
+ await extension.unload();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js
new file mode 100644
index 0000000000..0edc6ec5a4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_install_syntax_error.js
@@ -0,0 +1,42 @@
+const ADDON_ID = "webext-test@tests.mozilla.org";
+
+add_task(async function setup() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
+ await promiseStartupManager();
+});
+
+add_task(async function install_xpi() {
+ // WebExtension with a JSON syntax error in manifest.json
+ let xpi1 = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ "manifest_version: 2,
+ "browser_specific_settings": {"gecko": {"id": "${ADDON_ID}"}},
+ "name": "Temp WebExt with Error",
+ "version": "0.1"
+ }`,
+ },
+ });
+
+ // Valid WebExtension
+ let xpi2 = AddonTestUtils.createTempWebExtensionFile({
+ files: {
+ "manifest.json": String.raw`{
+ "manifest_version": 2,
+ "browser_specific_settings": {"gecko": {"id": "${ADDON_ID}"}},
+ "name": "Temp WebExt without Error",
+ "version": "0.1"
+ }`,
+ },
+ });
+
+ let install1 = await AddonManager.getInstallForFile(xpi1);
+ Assert.equal(install1.state, AddonManager.STATE_DOWNLOAD_FAILED);
+ Assert.equal(install1.error, AddonManager.ERROR_CORRUPT_FILE);
+
+ // Replace xpi1 with xpi2 to have the same filename to reproduce install error
+ xpi2.moveTo(xpi1.parent, xpi1.leafName);
+
+ let install2 = await AddonManager.getInstallForFile(xpi2);
+ Assert.equal(install2.error, 0);
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js
new file mode 100644
index 0000000000..cb70a3910f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_langpack.js
@@ -0,0 +1,669 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+ChromeUtils.defineLazyGetter(this, "resourceProtocol", () =>
+ Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler)
+);
+
+Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false);
+
+const ID = "langpack-und@test.mozilla.org";
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+// Langpacks versions follow the following convention:
+// <firefox major>.<firefox minor>.YYYYMMDD.HHmmss
+// with no leading zeros allowed (as enforced per version format, see MDN doc page at https://mzl.la/3M6L15y).
+//
+// See https://searchfox.org/mozilla-central/rev/26790fe/python/mozbuild/mozbuild/action/langpack_manifest.py#388-398
+var server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
+AddonTestUtils.registerJSON(server, "/test_update_langpack.json", {
+ addons: {
+ "langpack-und@test.mozilla.org": {
+ updates: [
+ {
+ version: "58.0.20230105.121014",
+ applications: {
+ gecko: {
+ strict_min_version: "58.0",
+ strict_max_version: "58.*",
+ },
+ },
+ },
+ {
+ version: "60.0.20230207.112555",
+ update_link:
+ "http://example.com/addons/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ },
+ },
+ },
+ {
+ version: "60.1.20230309.91233",
+ update_link:
+ "http://example.com/addons/dotrelease/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ },
+ },
+ },
+ ],
+ },
+ },
+});
+
+// A second update url, which is included in the last of the langpack
+// version from the previous one (and used to cover the staging of a
+// langpack from one dotrelease to another).
+AddonTestUtils.registerJSON(server, "/test_update_langpack2.json", {
+ addons: {
+ "langpack-und@test.mozilla.org": {
+ updates: [
+ {
+ version: "60.2.20230319.94511",
+ update_link:
+ "http://example.com/addons/dotrelease2/langpack-und@test.mozilla.org.xpi",
+ applications: {
+ gecko: {
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ },
+ },
+ },
+ ],
+ },
+ },
+});
+
+function promisePostponeInstall(install) {
+ return new Promise((resolve, reject) => {
+ let listener = {
+ onDownloadEnded: () => {
+ install.postpone();
+ },
+ 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);
+ });
+}
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "58");
+
+const ADDONS = {
+ langpack_1: {
+ "browser/localization/und/browser.ftl":
+ "message-browser = Value from Browser\n",
+ "localization/und/toolkit_test.ftl": "message-id1 = Value 1\n",
+ "chrome/und/locale/und/global/test.properties":
+ "message = Value from .properties\n",
+ "manifest.json": {
+ name: "und Language Pack",
+ version: "58.0.20230105.121014",
+ manifest_version: 2,
+ browser_specific_settings: {
+ gecko: {
+ id: ID,
+ strict_min_version: "58.0",
+ strict_max_version: "58.*",
+ update_url: "http://example.com/test_update_langpack.json",
+ },
+ },
+ sources: {
+ browser: {
+ base_path: "browser/",
+ },
+ },
+ langpack_id: "und",
+ languages: {
+ und: {
+ chrome_resources: {
+ global: "chrome/und/locale/und/global/",
+ },
+ version: "20171001190118",
+ },
+ },
+ author: "Mozilla Localization Task Force",
+ description: "Language pack for Testy for und",
+ },
+ },
+};
+
+// clone the extension so we can create an update.
+const langpack_update = JSON.parse(JSON.stringify(ADDONS.langpack_1));
+langpack_update["manifest.json"].version = "60.0.20230207.112555";
+langpack_update["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ update_url: "http://example.com/test_update_langpack.json",
+};
+
+const langpack_update_dotrelease = JSON.parse(
+ JSON.stringify(ADDONS.langpack_1)
+);
+langpack_update_dotrelease["manifest.json"].version = "60.1.20230309.91233";
+langpack_update_dotrelease["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ update_url: "http://example.com/test_update_langpack2.json",
+};
+
+// Another langpack for another dot release part of the same major version as the previous one.
+const langpack_update_dotrelease2 = JSON.parse(
+ JSON.stringify(ADDONS.langpack_1)
+);
+langpack_update_dotrelease2["manifest.json"].version = "60.2.20230319.94511";
+langpack_update_dotrelease2["manifest.json"].browser_specific_settings.gecko = {
+ id: ID,
+ strict_min_version: "60.0",
+ strict_max_version: "60.*",
+ update_url: "http://example.com/test_update_langpack2.json",
+};
+
+let xpi = AddonTestUtils.createTempXPIFile(langpack_update);
+server.registerFile(`/addons/${ID}.xpi`, xpi);
+
+let xpiDotRelease = AddonTestUtils.createTempXPIFile(
+ langpack_update_dotrelease
+);
+server.registerFile(`/addons/dotrelease/${ID}.xpi`, xpiDotRelease);
+
+let xpiDotRelease2 = AddonTestUtils.createTempXPIFile(
+ langpack_update_dotrelease2
+);
+server.registerFile(`/addons/dotrelease2/${ID}.xpi`, xpiDotRelease2);
+
+function promiseLangpackStartup() {
+ return new Promise(resolve => {
+ const EVENT = "webextension-langpack-startup";
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, EVENT);
+ resolve();
+ }, EVENT);
+ });
+}
+
+add_task(async function setup() {
+ Services.prefs.clearUserPref("extensions.startupScanScopes");
+});
+
+/**
+ * This is a basic life-cycle test which verifies that
+ * the language pack registers and unregisters correct
+ * languages at various stages.
+ */
+add_task(async function test_basic_lifecycle() {
+ await promiseStartupManager();
+
+ // Make sure that `und` locale is not installed.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ false,
+ "und not installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ false,
+ "und not available"
+ );
+
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ // Now make sure that `und` locale is available.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ true,
+ "und is installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ true,
+ "und is available"
+ );
+
+ await addon.disable();
+
+ // It is not available after the langpack has been disabled.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ false,
+ "und not installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ false,
+ "und not available"
+ );
+
+ // This quirky code here allows us to handle a scenario where enabling the
+ // addon is synchronous or asynchronous.
+ await Promise.all([promiseLangpackStartup(), addon.enable()]);
+
+ // After re-enabling it, the `und` locale is available again.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ true,
+ "und is installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ true,
+ "und is available"
+ );
+
+ await addon.uninstall();
+
+ // After the langpack has been uninstalled, no more `und` in locales.
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ false,
+ "und not installed"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ false,
+ "und not available"
+ );
+});
+
+/**
+ * This test verifies that registries are able to load and synchronously return
+ * correct strings available in the language pack.
+ */
+add_task(async function test_locale_registries() {
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ {
+ // Toolkit string
+ let bundles = L10nRegistry.getInstance().generateBundlesSync(
+ ["und"],
+ ["toolkit_test.ftl"]
+ );
+ let bundle0 = bundles.next().value;
+ ok(bundle0);
+ equal(bundle0.hasMessage("message-id1"), true);
+ }
+
+ {
+ // Browser string
+ let bundles = L10nRegistry.getInstance().generateBundlesSync(
+ ["und"],
+ ["browser.ftl"]
+ );
+ let bundle0 = bundles.next().value;
+ ok(bundle0);
+ equal(bundle0.hasMessage("message-browser"), true);
+ }
+
+ {
+ // Test chrome package
+ let reqLocs = Services.locale.requestedLocales;
+ Services.locale.requestedLocales = ["und"];
+
+ let bundle = Services.strings.createBundle(
+ "chrome://global/locale/test.properties"
+ );
+ let entry = bundle.GetStringFromName("message");
+ equal(entry, "Value from .properties");
+
+ Services.locale.requestedLocales = reqLocs;
+ }
+
+ await addon.uninstall();
+});
+
+/**
+ * This test verifies that registries are able to load and asynchronously return
+ * correct strings available in the language pack.
+ */
+add_task(async function test_locale_registries_async() {
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ {
+ // Toolkit string
+ let bundles = L10nRegistry.getInstance().generateBundles(
+ ["und"],
+ ["toolkit_test.ftl"]
+ );
+ let bundle0 = (await bundles.next()).value;
+ equal(bundle0.hasMessage("message-id1"), true);
+ }
+
+ {
+ // Browser string
+ let bundles = L10nRegistry.getInstance().generateBundles(
+ ["und"],
+ ["browser.ftl"]
+ );
+ let bundle0 = (await bundles.next()).value;
+ equal(bundle0.hasMessage("message-browser"), true);
+ }
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+add_task(async function test_langpack_app_shutdown() {
+ let langpackId = `langpack-und-${AppConstants.MOZ_BUILD_APP.replace(
+ "/",
+ "-"
+ )}`;
+ let check = (yes, msg) => {
+ equal(resourceProtocol.hasSubstitution(langpackId), yes, msg);
+ };
+
+ await promiseStartupManager();
+
+ check(false, "no initial resource substitution");
+
+ await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ check(true, "langpack resource available after startup");
+
+ await promiseShutdownManager();
+
+ check(true, "langpack resource available after app shutdown");
+
+ await promiseStartupManager();
+
+ let addon = await AddonManager.getAddonByID(ID);
+ await addon.uninstall();
+
+ check(false, "langpack resource removed during shutdown for uninstall");
+
+ await promiseShutdownManager();
+});
+
+add_task(async function test_amazing_disappearing_langpacks() {
+ let check = yes => {
+ equal(
+ L10nRegistry.getInstance().getAvailableLocales().includes("und"),
+ yes,
+ "check L10nRegistry"
+ );
+ equal(
+ Services.locale.availableLocales.includes("und"),
+ yes,
+ "check availableLocales"
+ );
+ };
+
+ await promiseStartupManager();
+
+ check(false);
+
+ await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+
+ check(true);
+
+ await promiseShutdownManager();
+
+ check(false);
+
+ await AddonTestUtils.manuallyUninstall(AddonTestUtils.profileExtensions, ID);
+
+ await promiseStartupManager();
+
+ check(false);
+});
+
+/**
+ * This test verifies that language pack will get disabled after app
+ * gets upgraded.
+ */
+add_task(async function test_disable_after_app_update() {
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+
+ await promiseRestartManager("59");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive);
+ Assert.ok(addon.appDisabled);
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+/**
+ * This test verifies that a postponed language pack update will be
+ * applied after a restart.
+ */
+add_task(async function test_after_app_update() {
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+
+ await promiseRestartManager("60");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive);
+ Assert.ok(addon.appDisabled);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+
+ let update = await promiseFindAddonUpdates(addon);
+ Assert.ok(update.updateAvailable, "update is available");
+ let install = update.updateAvailable;
+ let postponed = promisePostponeInstall(install);
+ install.install();
+ await postponed;
+ Assert.equal(
+ install.state,
+ AddonManager.STATE_POSTPONED,
+ "install postponed"
+ );
+
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.1.20230309.91233");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+});
+
+// Support setting the request locale.
+function promiseLocaleChanged(requestedLocales) {
+ let changed = ExtensionUtils.promiseObserved(
+ "intl:requested-locales-changed"
+ );
+ Services.locale.requestedLocales = requestedLocales;
+ return changed;
+}
+
+/**
+ * This test verifies that an addon update for the next version can be
+ * retrieved and staged for restart.
+ */
+add_task(async function test_staged_langpack_for_app_update() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ // Mimick a major release update happening while a
+ // langpack from a dotrelease what already available
+ // (and then assert that the dotrelease langpack
+ // is the one staged and then installed on browser
+ // restart)
+ await AddonManager.stageLangpacksForAppUpdate("60");
+ await promiseRestartManager("60");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.1.20230309.91233");
+
+ // Mimick a second dotrelease update, along with
+ // staging of the langpacks released along with it
+ // (then assert that the langpack for the second
+ // dotrelease is staged and then installed on
+ // browser restart).
+ await promiseRestartManager("60.1");
+ await AddonManager.stageLangpacksForAppUpdate("60.2");
+ await promiseRestartManager("60.2");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "60.2.20230319.94511");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that an addon update for the next version can be
+ * retrieved and staged for restart, but a restart failure falls back.
+ */
+add_task(async function test_staged_langpack_for_app_update_fail() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ await AddonManager.stageLangpacksForAppUpdate("60");
+ await promiseRestartManager();
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that an update restart works when the langpack
+ * cannot be updated.
+ */
+add_task(async function test_staged_langpack_for_app_update_not_found() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ await AddonManager.stageLangpacksForAppUpdate("59");
+ await promiseRestartManager("59");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive);
+ Assert.equal(addon.version, "58.0.20230105.121014");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
+
+/**
+ * This test verifies that a compat update with an invalid max_version
+ * will be disabled, at least allowing Firefox to startup without failures.
+ */
+add_task(async function test_staged_langpack_compat_startup() {
+ let originalLocales = Services.locale.requestedLocales;
+
+ await promiseStartupManager("58");
+ let [, { addon }] = await Promise.all([
+ promiseLangpackStartup(),
+ AddonTestUtils.promiseInstallXPI(ADDONS.langpack_1),
+ ]);
+ Assert.ok(addon.isActive);
+ await promiseLocaleChanged(["und"]);
+
+ // Mimick a compatibility update
+ let compatUpdate = {
+ targetApplications: [
+ {
+ id: "toolkit@mozilla.org",
+ minVersion: "58",
+ maxVersion: "*",
+ },
+ ],
+ };
+ addon.__AddonInternal__.applyCompatibilityUpdate(compatUpdate);
+
+ await promiseRestartManager("59");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(!addon.isActive, "addon is not active after upgrade");
+ ok(!addon.isCompatible, "compatibility update fixed");
+
+ await promiseRestartManager("58");
+
+ addon = await promiseAddonByID(ID);
+ Assert.ok(addon.isActive, "addon is active after downgrade");
+ ok(addon.isCompatible, "compatibility update fixed");
+
+ await addon.uninstall();
+ await promiseShutdownManager();
+ Services.locale.requestedLocales = originalLocales;
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js
new file mode 100644
index 0000000000..564e9086ac
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_paths.js
@@ -0,0 +1,47 @@
+let profileDir;
+add_task(async function setup() {
+ profileDir = gProfD.clone();
+ profileDir.append("extensions");
+
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+ await promiseStartupManager();
+});
+
+// When installing an unpacked addon we derive the ID from the
+// directory name. Make sure that if the directory name is not a valid
+// addon ID that we reject it.
+add_task(async function test_bad_unpacked_path() {
+ let MANIFEST_ID = "webext_bad_path@tests.mozilla.org";
+
+ let manifest = {
+ name: "path test",
+ description: "test of a bad directory name",
+ manifest_version: 2,
+ version: "1.0",
+
+ browser_specific_settings: {
+ gecko: {
+ id: MANIFEST_ID,
+ },
+ },
+ };
+
+ const directories = ["not a valid ID", '"quotes"@tests.mozilla.org'];
+
+ for (let dir of directories) {
+ try {
+ await promiseWriteWebManifestForExtension(manifest, profileDir, dir);
+ } catch (ex) {
+ // This can fail if the underlying filesystem (looking at you windows)
+ // doesn't handle some of the characters in the ID. In that case,
+ // just ignore this test on this platform.
+ continue;
+ }
+ await promiseRestartManager();
+
+ let addon = await promiseAddonByID(dir);
+ Assert.equal(addon, null);
+ addon = await promiseAddonByID(MANIFEST_ID);
+ Assert.equal(addon, null);
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js
new file mode 100644
index 0000000000..8e8e79c2c7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension_theme.js
@@ -0,0 +1,365 @@
+"use strict";
+
+/**
+ * This file contains test for 'theme' type WebExtension addons. Tests focus mostly
+ * on interoperability between the different theme formats (XUL and LWT) and
+ * Addon Manager integration.
+ *
+ * Coverage may overlap with other tests in this folder.
+ */
+
+const THEME_IDS = [
+ "theme3@tests.mozilla.org",
+ "theme2@personas.mozilla.org", // Unused. Legacy. Evil.
+ "default-theme@mozilla.org",
+];
+const REAL_THEME_IDS = [THEME_IDS[0], THEME_IDS[2]];
+const DEFAULT_THEME = THEME_IDS[2];
+
+const profileDir = gProfD.clone();
+profileDir.append("extensions");
+
+Services.prefs.setIntPref(
+ "extensions.enabledScopes",
+ AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION
+);
+
+// We remember the last/ currently active theme for tracking events.
+var gActiveTheme = null;
+
+add_task(async function setup_to_default_browserish_state() {
+ createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+
+ await promiseWriteWebManifestForExtension(
+ {
+ author: "Some author",
+ manifest_version: 2,
+ name: "Web Extension Name",
+ version: "1.0",
+ theme: { images: { theme_frame: "example.png" } },
+ browser_specific_settings: {
+ gecko: {
+ id: THEME_IDS[0],
+ },
+ },
+ },
+ profileDir
+ );
+
+ await promiseStartupManager();
+
+ if (AppConstants.MOZ_DEV_EDITION) {
+ // Developer Edition selects the wrong theme by default.
+ let defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME);
+ await defaultTheme.enable();
+ }
+
+ let [t1, t2, d] = await promiseAddonsByIDs(THEME_IDS);
+ Assert.ok(t1, "Theme addon should exist");
+ Assert.equal(t2, null, "Theme addon is not a thing anymore");
+ Assert.ok(d, "Theme addon should exist");
+
+ await t1.disable();
+ await new Promise(executeSoon);
+ Assert.ok(!t1.isActive, "Theme should be disabled");
+ Assert.ok(d.isActive, "Default theme should be active");
+
+ await promiseRestartManager();
+
+ [t1, t2, d] = await promiseAddonsByIDs(THEME_IDS);
+ Assert.ok(!t1.isActive, "Theme should still be disabled");
+ Assert.ok(d.isActive, "Default theme should still be active");
+
+ gActiveTheme = d.id;
+});
+
+/**
+ * Set the `userDisabled` property of one specific theme and check if the theme
+ * switching works as expected by checking the state of all installed themes.
+ *
+ * @param {String} which ID of the addon to set the `userDisabled` property on
+ * @param {Boolean} disabled Flag value to switch to
+ */
+async function setDisabledStateAndCheck(which, disabled = false) {
+ if (disabled) {
+ Assert.equal(which, gActiveTheme, "Only the active theme can be disabled");
+ }
+
+ let themeToDisable = disabled ? which : gActiveTheme;
+ let themeToEnable = disabled ? DEFAULT_THEME : which;
+
+ let expectedStates = {
+ [themeToDisable]: true,
+ [themeToEnable]: false,
+ };
+ let addonEvents = {
+ [themeToDisable]: [{ event: "onDisabling" }, { event: "onDisabled" }],
+ [themeToEnable]: [{ event: "onEnabling" }, { event: "onEnabled" }],
+ };
+
+ // Set the state of the theme to change.
+ let theme = await promiseAddonByID(which);
+ await expectEvents({ addonEvents }, () => {
+ if (disabled) {
+ theme.disable();
+ } else {
+ theme.enable();
+ }
+ });
+
+ let isDisabled;
+ for (theme of await promiseAddonsByIDs(REAL_THEME_IDS)) {
+ isDisabled = theme.id in expectedStates ? expectedStates[theme.id] : true;
+ Assert.equal(
+ theme.userDisabled,
+ isDisabled,
+ `Theme '${theme.id}' should be ${isDisabled ? "dis" : "en"}abled`
+ );
+ Assert.equal(
+ theme.pendingOperations,
+ AddonManager.PENDING_NONE,
+ "There should be no pending operations when no restart is expected"
+ );
+ Assert.equal(
+ theme.isActive,
+ !isDisabled,
+ `Theme '${theme.id} should be ${isDisabled ? "in" : ""}active`
+ );
+ }
+
+ await promiseRestartManager();
+
+ // All should still be good after a restart of the Addon Manager.
+ for (theme of await promiseAddonsByIDs(REAL_THEME_IDS)) {
+ isDisabled = theme.id in expectedStates ? expectedStates[theme.id] : true;
+ Assert.equal(
+ theme.userDisabled,
+ isDisabled,
+ `Theme '${theme.id}' should be ${isDisabled ? "dis" : "en"}abled`
+ );
+ Assert.equal(
+ theme.isActive,
+ !isDisabled,
+ `Theme '${theme.id}' should be ${isDisabled ? "in" : ""}active`
+ );
+ Assert.equal(
+ theme.pendingOperations,
+ AddonManager.PENDING_NONE,
+ "There should be no pending operations left"
+ );
+ if (!isDisabled) {
+ gActiveTheme = theme.id;
+ }
+ }
+}
+
+add_task(async function test_WebExtension_themes() {
+ // Enable the WebExtension theme.
+ await setDisabledStateAndCheck(THEME_IDS[0]);
+
+ // Disabling WebExtension should revert to the default theme.
+ await setDisabledStateAndCheck(THEME_IDS[0], true);
+
+ // Enable it again.
+ await setDisabledStateAndCheck(THEME_IDS[0]);
+});
+
+add_task(async function test_default_theme() {
+ // Explicitly enable the default theme.
+ await setDisabledStateAndCheck(DEFAULT_THEME);
+
+ // Swith to the WebExtension theme.
+ await setDisabledStateAndCheck(THEME_IDS[0]);
+
+ // Enable it again.
+ await setDisabledStateAndCheck(DEFAULT_THEME);
+});
+
+add_task(async function uninstall_offers_undo() {
+ let defaultTheme = await AddonManager.getAddonByID(DEFAULT_THEME);
+ const ID = THEME_IDS[0];
+ let theme = await promiseAddonByID(ID);
+
+ Assert.ok(theme, "Webextension theme is present");
+
+ async function promiseAddonEvent(event, id) {
+ let [addon] = await AddonTestUtils.promiseAddonEvent(event);
+ if (id) {
+ Assert.equal(addon.id, id, `Got event for expected addon (${event})`);
+ }
+ }
+
+ async function uninstallTheme() {
+ let uninstallingPromise = promiseAddonEvent("onUninstalling", ID);
+ await theme.uninstall(true);
+ await uninstallingPromise;
+
+ Assert.ok(
+ hasFlag(theme.pendingOperations, AddonManager.PENDING_UNINSTALL),
+ "Theme being uninstalled has PENDING_UNINSTALL flag"
+ );
+ }
+
+ async function cancelUninstallTheme() {
+ let cancelPromise = promiseAddonEvent("onOperationCancelled", ID);
+ theme.cancelUninstall();
+ await cancelPromise;
+
+ Assert.equal(
+ theme.pendingOperations,
+ AddonManager.PENDING_NONE,
+ "PENDING_UNINSTALL flag is cleared when uninstall is canceled"
+ );
+ }
+
+ // A theme should still be disabled if the uninstallation of a disabled theme
+ // is undone.
+ Assert.ok(!theme.isActive, "Webextension theme is not active");
+ Assert.ok(defaultTheme.isActive, "Default theme is active");
+ await uninstallTheme();
+ await cancelUninstallTheme();
+ Assert.ok(!theme.isActive, "Webextension theme is still not active");
+ Assert.ok(defaultTheme.isActive, "Default theme is still active");
+
+ // Enable theme, the previously active theme should be disabled.
+ await Promise.all([
+ promiseAddonEvent("onDisabled", DEFAULT_THEME),
+ promiseAddonEvent("onEnabled", ID),
+ theme.enable(),
+ ]);
+ Assert.ok(theme.isActive, "Webextension theme is active after enabling");
+ Assert.ok(!defaultTheme.isActive, "Default theme is not active any more");
+
+ // Uninstall active theme, default theme should become active.
+ await Promise.all([
+ // Note: no listener for onDisabled & ID because the uninstall is pending.
+ promiseAddonEvent("onEnabled", DEFAULT_THEME),
+ uninstallTheme(),
+ ]);
+ Assert.ok(!theme.isActive, "Webextension theme is not active upon uninstall");
+ Assert.ok(defaultTheme.isActive, "Default theme is active again");
+
+ // Undo uninstall, default theme should be deactivated.
+ await Promise.all([
+ // Note: no listener for onEnabled & ID because the uninstall was pending.
+ promiseAddonEvent("onDisabled", DEFAULT_THEME),
+ cancelUninstallTheme(),
+ ]);
+ Assert.ok(theme.isActive, "Webextension theme is active upon undo uninstall");
+ Assert.ok(!defaultTheme.isActive, "Default theme is not active again");
+
+ // Immediately remove the theme. Default theme should be activated.
+ await Promise.all([
+ promiseAddonEvent("onEnabled", DEFAULT_THEME),
+ theme.uninstall(),
+ ]);
+
+ await promiseRestartManager();
+});
+
+// Test that default_locale works with WE themes
+add_task(async function default_locale_themes() {
+ let addon = await promiseInstallWebExtension({
+ manifest: {
+ default_locale: "en",
+ name: "__MSG_name__",
+ description: "__MSG_description__",
+ theme: {
+ colors: {
+ frame: "black",
+ tab_background_text: "white",
+ },
+ },
+ },
+ files: {
+ "_locales/en/messages.json": `{
+ "name": {
+ "message": "the name"
+ },
+ "description": {
+ "message": "the description"
+ }
+ }`,
+ },
+ });
+
+ addon = await promiseAddonByID(addon.id);
+ equal(addon.name, "the name");
+ equal(addon.description, "the description");
+ equal(addon.type, "theme");
+ await addon.uninstall();
+});
+
+add_task(async function test_theme_update() {
+ let addon = await AddonManager.getAddonByID(DEFAULT_THEME);
+ ok(!addon.userDisabled, "default theme is enabled");
+
+ await AddonTestUtils.promiseRestartManager("2");
+
+ addon = await AddonManager.getAddonByID(DEFAULT_THEME);
+ ok(!addon.userDisabled, "default theme is enabled after upgrade");
+});
+
+add_task(async function test_builtin_theme_permissions() {
+ const ADDON_ID = "mytheme@mozilla.org";
+
+ let themeDef = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ version: "1.0",
+ theme: {},
+ },
+ };
+
+ function checkPerms(addon) {
+ // builtin themes enable or disable based on disabled state
+ Assert.equal(
+ addon.userDisabled,
+ hasFlag(addon.permissions, AddonManager.PERM_CAN_ENABLE),
+ "enable permission is correct"
+ );
+ Assert.equal(
+ !addon.userDisabled,
+ hasFlag(addon.permissions, AddonManager.PERM_CAN_DISABLE),
+ "disable permission is correct"
+ );
+ // builtin themes do not get any other permission
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_CAN_INSTALL),
+ "cannot install by user"
+ );
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_CAN_UPGRADE),
+ "cannot upgrade"
+ );
+ Assert.ok(
+ !hasFlag(addon.permissions, AddonManager.PERM_CAN_UNINSTALL),
+ "cannot uninstall"
+ );
+ Assert.ok(
+ !hasFlag(
+ addon.permissions,
+ AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS
+ ),
+ "can change private browsing access"
+ );
+ Assert.ok(
+ hasFlag(addon.permissions, AddonManager.PERM_API_CAN_UNINSTALL),
+ "can uninstall via API"
+ );
+ }
+
+ await setupBuiltinExtension(themeDef, "first-loc", false);
+ await AddonManager.maybeInstallBuiltinAddon(
+ ADDON_ID,
+ "1.0",
+ "resource://first-loc/"
+ );
+
+ let addon = await AddonManager.getAddonByID(ADDON_ID);
+ checkPerms(addon);
+ await addon.enable();
+ checkPerms(addon);
+
+ await addon.uninstall();
+});
diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.toml b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.toml
new file mode 100644
index 0000000000..34eb268cc0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-unpack.toml
@@ -0,0 +1,15 @@
+[DEFAULT]
+head = "head_addons.js head_unpack.js"
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"]
+dupe-manifest = true
+tags = "addons"
+
+["test_filepointer.js"]
+skip-if = [
+ "!allow_legacy_extensions",
+ "require_signing",
+]
+
+["test_webextension_paths.js"]
+tags = "webextensions"
diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.toml b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..6b1cb010d4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.toml
@@ -0,0 +1,362 @@
+[DEFAULT]
+skip-if = ["os == 'android'"]
+tags = "addons"
+head = "head_addons.js"
+firefox-appdir = "browser"
+dupe-manifest = true
+support-files = ["data/**"]
+
+["test_AMBrowserExtensionsImport.js"]
+
+["test_AbuseReporter.js"]
+
+["test_AddonRepository.js"]
+
+["test_AddonRepository_appIsShuttingDown.js"]
+
+["test_AddonRepository_cache.js"]
+
+["test_AddonRepository_cache_locale.js"]
+
+["test_AddonRepository_langpacks.js"]
+
+["test_AddonRepository_paging.js"]
+
+["test_ProductAddonChecker.js"]
+
+["test_ProductAddonChecker_signatures.js"]
+head = "head_addons.js head_cert_handling.js"
+
+["test_QuarantinedDomains_AMRemoteSettings.js"]
+head = "head_addons.js head_amremotesettings.js ../../../../components/extensions/test/xpcshell/head_telemetry.js"
+
+["test_QuarantinedDomains_AddonWrapper.js"]
+
+["test_XPIStates.js"]
+skip-if = ["condprof"] # Bug 1769184 - by design for now
+
+["test_XPIcancel.js"]
+
+["test_addonStartup.js"]
+
+["test_addon_manager_telemetry_events.js"]
+
+["test_amo_stats_telemetry.js"]
+
+["test_aom_startup.js"]
+
+["test_bad_json.js"]
+
+["test_badschema.js"]
+
+["test_builtin_location.js"]
+
+["test_cacheflush.js"]
+
+["test_childprocess.js"]
+head = ""
+
+["test_colorways_builtin_theme_upgrades.js"]
+skip-if = ["appname == 'thunderbird'"] # Bug 1809438 - No colorways in Thunderbird
+
+["test_cookies.js"]
+
+["test_corrupt.js"]
+
+["test_crash_annotation_quoting.js"]
+
+["test_db_path.js"]
+head = ""
+
+["test_delay_update_webextension.js"]
+tags = "webextensions"
+
+["test_dependencies.js"]
+
+["test_dictionary_webextension.js"]
+
+["test_distribution.js"]
+
+["test_distribution_langpack.js"]
+
+["test_embedderDisabled.js"]
+
+["test_error.js"]
+skip-if = ["os == 'win'"] # Bug 1508482
+
+["test_ext_management.js"]
+tags = "webextensions"
+
+["test_general.js"]
+
+["test_getInstallSourceFromHost.js"]
+
+["test_gmpProvider.js"]
+skip-if = ["appname != 'firefox'"]
+
+["test_harness.js"]
+
+["test_hidden.js"]
+
+["test_install.js"]
+
+["test_installOrigins.js"]
+
+["test_install_cancel.js"]
+
+["test_install_file_change.js"]
+
+["test_install_icons.js"]
+
+["test_installtrigger_deprecation.js"]
+head = "head_addons.js head_amremotesettings.js"
+
+["test_installtrigger_schemes.js"]
+
+["test_isDebuggable.js"]
+
+["test_isReady.js"]
+
+["test_loadManifest_isPrivileged.js"]
+
+["test_locale.js"]
+
+["test_moved_extension_metadata.js"]
+skip-if = ["true"] # bug 1777900
+
+["test_no_addons.js"]
+
+["test_nodisable_hidden.js"]
+
+["test_onPropertyChanged_appDisabled.js"]
+head = "head_addons.js head_compat.js"
+skip-if = ["tsan"] # Times out, bug 1674773
+
+["test_permissions.js"]
+
+["test_permissions_prefs.js"]
+
+["test_pref_properties.js"]
+
+["test_provider_markSafe.js"]
+
+["test_provider_shutdown.js"]
+
+["test_provider_unsafe_access_shutdown.js"]
+
+["test_provider_unsafe_access_startup.js"]
+
+["test_proxies.js"]
+skip-if = ["require_signing"]
+
+["test_recommendations.js"]
+skip-if = ["require_signing"]
+
+["test_registerchrome.js"]
+
+["test_registry.js"]
+run-if = ["os == 'win'"]
+
+["test_reinstall_disabled_addon.js"]
+
+["test_reload.js"]
+skip-if = ["os == 'win'"] # There's a problem removing a temp file without manually clearing the cache on Windows
+tags = "webextensions"
+
+["test_remote_pref_telemetry.js"]
+
+["test_safemode.js"]
+
+["test_schema_change.js"]
+
+["test_seen.js"]
+
+["test_shutdown.js"]
+
+["test_shutdown_barriers.js"]
+
+["test_shutdown_early.js"]
+skip-if = ["condprof"] # Bug 1769184 - by design for now
+
+["test_sideload_scopes.js"]
+head = "head_addons.js head_sideload.js"
+skip-if = [
+ "os == 'linux'", # Bug 1613268
+ "condprof", # Bug 1769184 - by design for now
+]
+
+["test_sideloads.js"]
+
+["test_sideloads_after_rebuild.js"]
+skip-if = ["condprof"] # Bug 1769184 - by design for now
+head = "head_addons.js head_sideload.js"
+
+["test_signed_inject.js"]
+skip-if = ["true"] # Bug 1394122
+
+["test_signed_install.js"]
+
+["test_signed_langpack.js"]
+
+["test_signed_long.js"]
+
+["test_signed_updatepref.js"]
+skip-if = [
+ "require_signing",
+ "!allow_legacy_extensions",
+]
+
+["test_signed_verify.js"]
+
+["test_sitePermsAddonProvider.js"]
+skip-if = ["appname == 'thunderbird'"] # Disabled in extensions.manifest
+
+["test_startup.js"]
+head = "head_addons.js head_sideload.js"
+skip-if = [
+ "os == 'linux'", # Bug 1613268
+ "condprof", # Bug 1769184 - by design for now
+]
+
+["test_startup_enable.js"]
+
+["test_startup_isPrivileged.js"]
+
+["test_startup_scan.js"]
+head = "head_addons.js head_sideload.js"
+
+["test_strictcompatibility.js"]
+head = "head_addons.js head_compat.js"
+
+["test_syncGUID.js"]
+
+["test_system_allowed.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_delay_update.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_profile_location.js"]
+
+["test_system_repository.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_reset.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_blank.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_checkSizeHash.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_custom.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_empty.js"]
+head = "head_addons.js head_system_addons.js"
+skip-if = ["true"] # Failing intermittently due to a race condition in the test, see bug 1348981
+
+["test_system_update_enterprisepolicy.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_fail.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_installTelemetryInfo.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_newset.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_overlapping.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_uninstall_check.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_update_upgrades.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_system_upgrades.js"]
+skip-if = ["condprof"] # Bug 1769184 - by design for now
+head = "head_addons.js head_system_addons.js"
+
+["test_systemaddomstartupprefs.js"]
+skip-if = ["condprof"] # Bug 1769184 - by design for now
+head = "head_addons.js head_system_addons.js"
+
+["test_temporary.js"]
+skip-if = ["os == 'win'"] # Bug 1469904
+tags = "webextensions"
+
+["test_trash_directory.js"]
+run-if = ["os == 'win'"]
+
+["test_types.js"]
+
+["test_undouninstall.js"]
+skip-if = ["os == 'win'"] # Bug 1358846
+
+["test_update.js"]
+
+["test_updateCancel.js"]
+
+["test_update_addontype.js"]
+
+["test_update_compatmode.js"]
+head = "head_addons.js head_compat.js"
+
+["test_update_ignorecompat.js"]
+skip-if = ["true"] # Bug 676922 Bug 1437697
+
+["test_update_isPrivileged.js"]
+skip-if = ["condprof"] # Bug 1769184 - by design for now
+
+["test_update_noSystemAddonUpdate.js"]
+head = "head_addons.js head_system_addons.js"
+
+["test_update_strictcompat.js"]
+head = "head_addons.js head_compat.js"
+
+["test_update_theme.js"]
+
+["test_update_webextensions.js"]
+tags = "webextensions"
+
+["test_updatecheck.js"]
+
+["test_updatecheck_errors.js"]
+
+["test_updatecheck_json.js"]
+
+["test_updateid.js"]
+
+["test_updateversion.js"]
+
+["test_upgrade.js"]
+head = "head_addons.js head_compat.js"
+run-sequentially = "Uses global XCurProcD dir."
+
+["test_upgrade_incompatible.js"]
+
+["test_webextension.js"]
+tags = "webextensions"
+
+["test_webextension_events.js"]
+tags = "webextensions"
+
+["test_webextension_icons.js"]
+tags = "webextensions"
+
+["test_webextension_install.js"]
+tags = "webextensions"
+
+["test_webextension_install_syntax_error.js"]
+tags = "webextensions"
+
+["test_webextension_langpack.js"]
+tags = "webextensions"
+
+["test_webextension_theme.js"]
+tags = "webextensions"
diff --git a/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi b/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi
new file mode 100644
index 0000000000..f2948e6994
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs
new file mode 100644
index 0000000000..fffcb9f255
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/authRedirect.sjs
@@ -0,0 +1,21 @@
+// Simple script redirects to the query part of the uri if the browser
+// authenticates with username "testuser" password "testpass"
+
+function handleRequest(request, response) {
+ if (request.hasHeader("Authorization")) {
+ if (
+ request.getHeader("Authorization") == "Basic dGVzdHVzZXI6dGVzdHBhc3M="
+ ) {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", request.queryString);
+ response.write("See " + request.queryString);
+ } else {
+ response.setStatusLine(request.httpVersion, 403, "Forbidden");
+ response.write("Invalid credentials");
+ }
+ } else {
+ response.setStatusLine(request.httpVersion, 401, "Authentication required");
+ response.setHeader("WWW-Authenticate", 'basic realm="XPInstall"', false);
+ response.write("Unauthenticated request");
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser.toml b/toolkit/mozapps/extensions/test/xpinstall/browser.toml
new file mode 100644
index 0000000000..f6ca43982e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser.toml
@@ -0,0 +1,175 @@
+[DEFAULT]
+support-files = [
+ "amosigned.xpi",
+ "authRedirect.sjs",
+ "bug540558.html",
+ "bug638292.html",
+ "bug645699.html",
+ "cookieRedirect.sjs",
+ "corrupt.xpi",
+ "empty.xpi",
+ "enabled.html",
+ "hashRedirect.sjs",
+ "head.js",
+ "incompatible.xpi",
+ "installchrome.html",
+ "installtrigger.html",
+ "installtrigger_frame.html",
+ "navigate.html",
+ "recommended.xpi",
+ "redirect.sjs",
+ "slowinstall.sjs",
+ "startsoftwareupdate.html",
+ "triggerredirect.html",
+ "unsigned.xpi",
+ "unsigned_mv3.xpi",
+ "webmidi_permission.xpi",
+ "../xpcshell/data/signing_checks/privileged.xpi",
+]
+
+["browser_amosigned_trigger.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_amosigned_trigger_iframe.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_amosigned_url.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_auth.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_auth2.js"]
+
+["browser_auth3.js"]
+
+["browser_auth4.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_badargs.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_badargs2.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_badhash.js"]
+
+["browser_badhashtype.js"]
+
+["browser_block_fullscreen_prompt.js"]
+https_first_disabled = true # Bug 1737265
+skip-if = ["os == 'mac' && debug"] #Bug 1590136
+
+["browser_bug540558.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_bug611242.js"]
+
+["browser_bug638292.js"]
+
+["browser_bug645699.js"]
+
+["browser_bug645699_postDownload.js"]
+
+["browser_bug672485.js"]
+skip-if = ["true"] # disabled due to a leak. See bug 682410.
+
+["browser_containers.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_cookies.js"]
+
+["browser_cookies2.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_cookies3.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_cookies4.js"]
+skip-if = ["true"] # Bug 1084646
+
+["browser_corrupt.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_datauri.js"]
+
+["browser_doorhanger_installs.js"]
+https_first_disabled = true # Bug 1737265
+skip-if = [
+ "os == 'win' && os_version == '10.0' && bits == 64", #Bug 1615449
+]
+
+["browser_empty.js"]
+
+["browser_enabled.js"]
+
+["browser_hash.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_hash2.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_httphash.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_httphash2.js"]
+
+["browser_httphash3.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_httphash4.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_httphash5.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_httphash6.js"]
+skip-if = ["true"] # Bug 1449788
+
+["browser_installchrome.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_localfile.js"]
+
+["browser_localfile2.js"]
+
+["browser_localfile3.js"]
+
+["browser_localfile4.js"]
+
+["browser_localfile4_postDownload.js"]
+
+["browser_newwindow.js"]
+skip-if = ["!debug"] # This is a test for leaks, see comment in the test.
+
+["browser_offline.js"]
+
+["browser_privatebrowsing.js"]
+https_first_disabled = true # Bug 1737265
+skip-if = ["debug"] # Bug 1541577 - leaks on debug
+
+["browser_relative.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_required_useractivation.js"]
+
+["browser_softwareupdate.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_trigger_redirect.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_unsigned_trigger.js"]
+https_first_disabled = true # Bug 1737265
+skip-if = ["require_signing"]
+
+["browser_unsigned_trigger_iframe.js"]
+https_first_disabled = true # Bug 1737265
+skip-if = ["require_signing"]
+
+["browser_unsigned_trigger_xorigin.js"]
+https_first_disabled = true # Bug 1737265
+
+["browser_unsigned_url.js"]
+https_first_disabled = true # Bug 1737265
+skip-if = ["require_signing"]
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js
new file mode 100644
index 0000000000..97aa9e1a94
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: /http:\/\/example.com\/.*\/installtrigger.html/,
+ });
+
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js
new file mode 100644
index 0000000000..f9d91e1cbd
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_trigger_iframe.js
@@ -0,0 +1,77 @@
+// ----------------------------------------------------------------------------
+// Test for bug 589598 - Ensure that installing through InstallTrigger
+// works in an iframe in web content.
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var inner_url = encodeURIComponent(
+ TESTROOT +
+ "installtrigger.html?" +
+ encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ )
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger_frame.html?" + inner_url
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.frames[0].document.getElementById("return").textContent,
+ status: content.frames[0].document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(
+ results.return,
+ "true",
+ "installTrigger in iframe should have claimed success"
+ );
+ is(results.status, "0", "Callback in iframe should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js
new file mode 100644
index 0000000000..a6b50c2b27
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_amosigned_url.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on by navigating directly to the url
+function test() {
+ waitForExplicitFinish();
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ runTest
+ );
+}
+
+function runTest() {
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "amosigned.xpi"
+ );
+ });
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "link",
+ source: "unknown",
+ sourceURL: undefined,
+ });
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth.js
new file mode 100644
index 0000000000..2248af4270
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds when authentication is required
+// This verifies bug 312473
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.prefs.setIntPref("network.auth.subresource-http-auth-allow", 2);
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ return ["testuser", "testpass"];
+}
+
+function download_failed(install) {
+ ok(false, "Install should not have failed");
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js
new file mode 100644
index 0000000000..b5a5c09749
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth2.js
@@ -0,0 +1,73 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when authentication is required and bad
+// credentials are given
+// This verifies bug 312473
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ requestLongerTimeout(2);
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ return ["baduser", "badpass"];
+}
+
+function download_failed(install) {
+ is(
+ install.error,
+ AddonManager.ERROR_NETWORK_FAILURE,
+ "Install should have failed"
+ );
+}
+
+function install_ended(install, addon) {
+ ok(false, "Add-on should not have installed");
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js
new file mode 100644
index 0000000000..d348be6d30
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth3.js
@@ -0,0 +1,72 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when authentication is required and it is
+// canceled
+// This verifies bug 312473
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ return null;
+}
+
+function download_failed(install) {
+ is(
+ install.error,
+ AddonManager.ERROR_NETWORK_FAILURE,
+ "Install should have failed"
+ );
+}
+
+function install_ended(install, addon) {
+ ok(false, "Add-on should not have installed");
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js
new file mode 100644
index 0000000000..46ee2b5cb6
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_auth4.js
@@ -0,0 +1,71 @@
+// Test whether a request for auth for an XPI switches to the appropriate tab
+var gNewTab;
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // Turn off the authentication dialog blocking for this test.
+ Services.prefs.setBoolPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow",
+ true
+ );
+
+ Harness.authenticationCallback = get_auth_info;
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI":
+ TESTROOT + "authRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gNewTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.getBrowserForTab(gNewTab),
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function get_auth_info() {
+ is(
+ gBrowser.selectedTab,
+ gNewTab,
+ "Should have focused the tab loading the XPI"
+ );
+ return ["testuser", "testpass"];
+}
+
+function download_failed(install) {
+ ok(false, "Install should not have failed");
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ var authMgr = Cc["@mozilla.org/network/http-auth-manager;1"].getService(
+ Ci.nsIHttpAuthManager
+ );
+ authMgr.clearAll();
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Services.prefs.clearUserPref(
+ "network.auth.non-web-content-triggered-resources-http-auth-allow"
+ );
+
+ gBrowser.removeTab(gNewTab);
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js
new file mode 100644
index 0000000000..8f75a7a653
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs.js
@@ -0,0 +1,49 @@
+// ----------------------------------------------------------------------------
+// Test whether passing a simple string to InstallTrigger.install throws an
+// exception
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ waitForExplicitFinish();
+
+ var triggers = encodeURIComponent(JSON.stringify(TESTROOT + "amosigned.xpi"));
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TESTROOT);
+
+ ContentTask.spawn(gBrowser.selectedBrowser, null, function () {
+ return new Promise(resolve => {
+ addEventListener(
+ "load",
+ () => {
+ content.addEventListener("InstallTriggered", () => {
+ resolve(content.document.getElementById("return").textContent);
+ });
+ },
+ true
+ );
+ });
+ }).then(page_loaded);
+
+ // In non-e10s the exception in the content page would trigger a test failure
+ if (!gMultiProcessBrowser) {
+ expectUncaughtException();
+ }
+
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function page_loaded(result) {
+ is(result, "exception", "installTrigger should have failed");
+
+ // In non-e10s the exception from the page is thrown after the event so we
+ // have to spin the event loop to make sure it arrives so expectUncaughtException
+ // sees it.
+ executeSoon(() => {
+ gBrowser.removeCurrentTab();
+ finish();
+ });
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js
new file mode 100644
index 0000000000..8376cc83a4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badargs2.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Test whether passing an undefined url InstallTrigger.install throws an
+// exception
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ waitForExplicitFinish();
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: undefined,
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TESTROOT);
+
+ ContentTask.spawn(gBrowser.selectedBrowser, null, function () {
+ return new Promise(resolve => {
+ addEventListener(
+ "load",
+ () => {
+ content.addEventListener("InstallTriggered", () => {
+ resolve(content.document.getElementById("return").textContent);
+ });
+ },
+ true
+ );
+ });
+ }).then(page_loaded);
+
+ // In non-e10s the exception in the content page would trigger a test failure
+ if (!gMultiProcessBrowser) {
+ expectUncaughtException();
+ }
+
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function page_loaded(result) {
+ is(result, "exception", "installTrigger should have failed");
+
+ // In non-e10s the exception from the page is thrown after the event so we
+ // have to spin the event loop to make sure it arrives so expectUncaughtException
+ // sees it.
+ executeSoon(() => {
+ gBrowser.removeCurrentTab();
+ finish();
+ });
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js
new file mode 100644
index 0000000000..985eae9cfc
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badhash.js
@@ -0,0 +1,46 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when an invalid hash is included
+// This verifies bug 302284
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "sha1:643b08418599ddbd1ea8a511c90696578fb844b9",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_INCORRECT_HASH, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js b/toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js
new file mode 100644
index 0000000000..f6c1b17d1f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_badhashtype.js
@@ -0,0 +1,46 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when an unknown hash type is included
+// This verifies bug 302284
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "foo:3d0dc22e1f394e159b08aaf5f0f97de4d5c65f4f",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_INCORRECT_HASH, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js b/toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js
new file mode 100644
index 0000000000..97f09d9a9c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_block_fullscreen_prompt.js
@@ -0,0 +1,129 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+
+/**
+ * Spawns content task in browser to enter / leave fullscreen
+ * @param browser - Browser to use for JS fullscreen requests
+ * @param {Boolean} fullscreenState - true to enter fullscreen, false to leave
+ */
+function changeFullscreen(browser, fullscreenState) {
+ return SpecialPowers.spawn(
+ browser,
+ [fullscreenState],
+ async function (state) {
+ if (state) {
+ await content.document.body.requestFullscreen();
+ } else {
+ await content.document.exitFullscreen();
+ }
+ }
+ );
+}
+
+function triggerInstall(browser, xpi_url) {
+ return SpecialPowers.spawn(browser, [xpi_url], async function (xpi_url) {
+ content.location = xpi_url;
+ });
+}
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ // Relax the user input requirements while running this test.
+ set: [["xpinstall.userActivation.required", false]],
+ });
+});
+
+// This tests if addon installation is blocked when requested in fullscreen
+add_task(async function testFullscreenBlockAddonInstallPrompt() {
+ // Open example.com
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, TESTROOT);
+
+ // Enter and wait for fullscreen
+ await changeFullscreen(gBrowser.selectedBrowser, true);
+ await TestUtils.waitForCondition(
+ () => window.fullScreen,
+ "Waiting for window to enter fullscreen"
+ );
+
+ // Trigger addon installation and expect it to be blocked
+ let addonEventPromise = TestUtils.topicObserved(
+ "addon-install-fullscreen-blocked"
+ );
+ await triggerInstall(gBrowser.selectedBrowser, "amosigned.xpi");
+ await addonEventPromise;
+
+ // Test if addon installation prompt has been blocked
+ let panelOpened;
+ try {
+ panelOpened = await TestUtils.waitForCondition(
+ () => PopupNotifications.isPanelOpen,
+ 100,
+ 10
+ );
+ } catch (ex) {
+ panelOpened = false;
+ }
+ is(panelOpened, false, "Addon installation prompt not opened");
+
+ window.fullScreen = false;
+ await BrowserTestUtils.waitForEvent(window, "fullscreenchange");
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// This tests if the addon install prompt is closed when entering fullscreen
+add_task(async function testFullscreenCloseAddonInstallPrompt() {
+ // Open example.com
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com");
+
+ // Trigger addon installation
+ let addonEventPromise = TestUtils.topicObserved(
+ "webextension-permission-prompt"
+ );
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [TESTROOT + "amosigned.xpi"],
+ xpi_url => {
+ this.content.location = xpi_url;
+ }
+ );
+ // Wait for addon install event
+ info("Wait for webextension-permission-prompt");
+ await addonEventPromise;
+
+ // Test if addon installation prompt is visible
+ await TestUtils.waitForCondition(
+ () => PopupNotifications.isPanelOpen,
+ "Waiting for addon installation prompt to open"
+ );
+ Assert.ok(
+ PopupNotifications.getNotification(
+ "addon-webext-permissions",
+ gBrowser.selectedBrowser
+ ) != null,
+ "Opened notification is webextension permissions prompt"
+ );
+
+ // Switch to fullscreen and test for addon installation prompt close
+ await changeFullscreen(gBrowser.selectedBrowser, true);
+ await TestUtils.waitForCondition(
+ () => window.fullScreen,
+ "Waiting for window to enter fullscreen"
+ );
+ await TestUtils.waitForCondition(
+ () => !PopupNotifications.isPanelOpen,
+ "Waiting for addon installation prompt to close"
+ );
+
+ window.fullScreen = false;
+ await BrowserTestUtils.waitForEvent(window, "fullscreenchange");
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js
new file mode 100644
index 0000000000..ece34583a1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug540558.js
@@ -0,0 +1,31 @@
+// ----------------------------------------------------------------------------
+// Tests that calling InstallTrigger.installChrome works
+function test() {
+ // This test depends on InstallTrigger.installChrome availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = check_xpi_install;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TESTROOT + "bug540558.html");
+}
+
+function check_xpi_install(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js
new file mode 100644
index 0000000000..98cf433f6f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug611242.js
@@ -0,0 +1,34 @@
+// ----------------------------------------------------------------------------
+// Test whether setting a new property in InstallTrigger then persists to other
+// page loads
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TESTROOT + "enabled.html" },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.wrappedJSObject.InstallTrigger.enabled.k = function () {};
+ });
+
+ BrowserTestUtils.startLoadingURIString(
+ browser,
+ TESTROOT2 + "enabled.html"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+ await SpecialPowers.spawn(browser, [], () => {
+ is(
+ content.wrappedJSObject.InstallTrigger.enabled.k,
+ undefined,
+ "Property should not be defined"
+ );
+ });
+ }
+ );
+});
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js
new file mode 100644
index 0000000000..078d94cb50
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug638292.js
@@ -0,0 +1,51 @@
+// ----------------------------------------------------------------------------
+// Test whether an InstallTrigger.enabled is working
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ });
+
+ let testtab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "bug638292.html"
+ );
+
+ async function verify(link, button) {
+ info("Clicking " + link);
+
+ let loadedPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + link,
+ { button },
+ gBrowser.selectedBrowser
+ );
+
+ let newtab = await loadedPromise;
+
+ let result = await SpecialPowers.spawn(
+ newtab.linkedBrowser,
+ [],
+ async function () {
+ return content.document.getElementById("enabled").textContent == "true";
+ }
+ );
+
+ ok(result, "installTrigger for " + link + " should have been enabled");
+
+ // Focus the old tab (link3 is opened in the background)
+ if (link != "link3") {
+ await BrowserTestUtils.switchTab(gBrowser, testtab);
+ }
+ gBrowser.removeTab(newtab);
+ }
+
+ await verify("link1", 0);
+ await verify("link2", 0);
+ await verify("link3", 1);
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js
new file mode 100644
index 0000000000..690ac2b3eb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699.js
@@ -0,0 +1,69 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content. This should be blocked by the whitelist check.
+// This verifies bug 645699
+function test() {
+ if (
+ !SpecialPowers.Services.prefs.getBoolPref(
+ "extensions.InstallTrigger.enabled"
+ ) ||
+ !SpecialPowers.Services.prefs.getBoolPref(
+ "extensions.InstallTriggerImpl.enabled"
+ )
+ ) {
+ ok(true, "InstallTrigger is not enabled");
+ return;
+ }
+
+ // prompt prior to download
+ SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.postDownloadThirdPartyPrompt", false],
+ ["extensions.InstallTrigger.requireUserInput", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.org/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TESTROOT + "bug645699.html");
+}
+
+function allow_blocked(installInfo) {
+ is(
+ installInfo.browser,
+ gBrowser.selectedBrowser,
+ "Install should have been triggered by the right browser"
+ );
+ is(
+ installInfo.originatingURI.spec,
+ gBrowser.currentURI.spec,
+ "Install should have been triggered by the right uri"
+ );
+ return false;
+}
+
+function confirm_install(panel) {
+ ok(false, "Should not see the install dialog");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+ PermissionTestUtils.remove("http://example.org/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js
new file mode 100644
index 0000000000..aa8b948c14
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug645699_postDownload.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content. This should be blocked by the origin allow check.
+// This verifies bug 645699
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ // Prevent the Harness from ending the test on download cancel.
+ Harness.downloadCancelledCallback = () => {
+ return false;
+ };
+
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.org/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, TESTROOT + "bug645699.html");
+}
+
+function allow_blocked(installInfo) {
+ is(
+ installInfo.browser,
+ gBrowser.selectedBrowser,
+ "Install should have been triggered by the right browser"
+ );
+ is(
+ installInfo.originatingURI.spec,
+ gBrowser.currentURI.spec,
+ "Install should have been triggered by the right uri"
+ );
+ return false;
+}
+
+function confirm_install(panel) {
+ ok(false, "Should not see the install dialog");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+ PermissionTestUtils.remove("http://example.org/", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js b/toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js
new file mode 100644
index 0000000000..216d543458
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_bug672485.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var gWindowWatcher = null;
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installCancelledCallback = cancelled_install;
+ Harness.installEndedCallback = complete_install;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gWindowWatcher = Services.ww;
+ delete Services.ww;
+ is(Services.ww, undefined, "Services.ww should now be undefined");
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ ok(false, "Should not see the install dialog");
+ return false;
+}
+
+function cancelled_install() {
+ ok(true, "Install should b cancelled");
+}
+
+function complete_install() {
+ ok(false, "Install should not have completed");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+
+ Services.ww = gWindowWatcher;
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_containers.js b/toolkit/mozapps/extensions/test/xpinstall/browser_containers.js
new file mode 100644
index 0000000000..486f391c9e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_containers.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const MY_CONTEXT = 2;
+let gDidSeeChannel = false;
+
+function check_channel(subject) {
+ if (!(subject instanceof Ci.nsIHttpChannel)) {
+ return;
+ }
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ let uri = channel.URI;
+ if (!uri || !uri.spec.endsWith("amosigned.xpi")) {
+ return;
+ }
+ gDidSeeChannel = true;
+ ok(true, "Got request for " + uri.spec);
+
+ let loadInfo = channel.loadInfo;
+ is(
+ loadInfo.originAttributes.userContextId,
+ MY_CONTEXT,
+ "Got expected usercontextid"
+ );
+}
+// ----------------------------------------------------------------------------
+// Tests we send the right cookies when installing through an InstallTrigger call
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI("http://example.com/"),
+ { userContextId: MY_CONTEXT }
+ );
+
+ PermissionTestUtils.add(principal, "install", Services.perms.ALLOW_ACTION);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "", {
+ userContextId: MY_CONTEXT,
+ });
+ Services.obs.addObserver(check_channel, "http-on-before-connect");
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: /http:\/\/example.com\/.*\/installtrigger.html/,
+ });
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ ok(
+ gDidSeeChannel,
+ "Should have seen the request for the XPI and verified it was sent the right way."
+ );
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.obs.removeObserver(check_channel, "http-on-before-connect");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js
new file mode 100644
index 0000000000..fc08de89b1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies.js
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent fails when no cookies
+// are set
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_NETWORK_FAILURE, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js
new file mode 100644
index 0000000000..1465f0f93d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies2.js
@@ -0,0 +1,64 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent succeeds when cookies
+// are set
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.cookies.add(
+ "example.com",
+ "/browser/" + RELATIVE_DIR,
+ "xpinstall",
+ "true",
+ false,
+ false,
+ true,
+ Date.now() / 1000 + 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.cookies.remove(
+ "example.com",
+ "xpinstall",
+ "/browser/" + RELATIVE_DIR,
+ {}
+ );
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js
new file mode 100644
index 0000000000..03ceead636
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies3.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent succeeds when cookies
+// are set and third party cookies are disabled.
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.cookies.add(
+ "example.com",
+ "/browser/" + RELATIVE_DIR,
+ "xpinstall",
+ "true",
+ false,
+ false,
+ true,
+ Date.now() / 1000 + 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 1);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.cookies.remove(
+ "example.com",
+ "xpinstall",
+ "/browser/" + RELATIVE_DIR,
+ {}
+ );
+
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js
new file mode 100644
index 0000000000..931a9a5ff5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_cookies4.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Test that an install that requires cookies to be sent fails when cookies
+// are set and third party cookies are disabled and the request is to a third
+// party.
+// This verifies bug 462739
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ Services.cookies.add(
+ "example.org",
+ "/browser/" + RELATIVE_DIR,
+ "xpinstall",
+ "true",
+ false,
+ false,
+ true,
+ Date.now() / 1000 + 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ Services.prefs.setIntPref("network.cookie.cookieBehavior", 1);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Cookie check":
+ TESTROOT2 + "cookieRedirect.sjs?" + TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_NETWORK_FAILURE, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.cookies.remove(
+ "example.org",
+ "xpinstall",
+ "/browser/" + RELATIVE_DIR,
+ {}
+ );
+
+ Services.prefs.clearUserPref("network.cookie.cookieBehavior");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js b/toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js
new file mode 100644
index 0000000000..11ecc6446d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_corrupt.js
@@ -0,0 +1,53 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when the xpi is corrupt.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Corrupt XPI": TESTROOT + "corrupt.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_CORRUPT_FILE, "Install should fail");
+}
+
+const finish_test = async function (count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.status, "-207", "Callback should have seen the failure");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js b/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js
new file mode 100644
index 0000000000..08ec7e41cb
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_datauri.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// ----------------------------------------------------------------------------
+// Checks that a chained redirect through a data URI and javascript is blocked
+
+function setup_redirect(aSettings) {
+ var url = TESTROOT + "redirect.sjs?mode=setup";
+ for (var name in aSettings) {
+ url += "&" + name + "=" + encodeURIComponent(aSettings[name]);
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ req.send(null);
+}
+
+function test() {
+ waitForExplicitFinish();
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["network.allow_redirect_to_data", true],
+ ["security.data_uri.block_toplevel_data_uri_navigations", false],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ runTest
+ );
+}
+
+function runTest() {
+ Harness.installOriginBlockedCallback = install_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ setup_redirect({
+ Location:
+ "data:text/html,<script>window.location.href='" +
+ TESTROOT +
+ "amosigned.xpi'</script>",
+ });
+
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "redirect.sjs?mode=redirect"
+ );
+}
+
+function install_blocked(installInfo) {
+ is(
+ installInfo.installs.length,
+ 1,
+ "Got one AddonInstall instance as expected"
+ );
+ AddonTestUtils.checkInstallInfo(installInfo.installs[0], {
+ method: "link",
+ source: "unknown",
+ sourceURL: /moz-nullprincipal:\{.*\}/,
+ });
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+ finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js b/toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js
new file mode 100644
index 0000000000..01c8089180
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_doorhanger_installs.js
@@ -0,0 +1,1545 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// TODO(Bug 1789718): adapt to synthetic addon type implemented by the SitePermAddonProvider
+// or remove if redundant, after the deprecated XPIProvider-based implementation is also removed.
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionPermissions } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionPermissions.sys.mjs"
+);
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+const SECUREROOT =
+ "https://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/";
+const PROGRESS_NOTIFICATION = "addon-progress";
+
+const CHROMEROOT = extractChromeRoot(gTestPath);
+
+AddonTestUtils.initMochitest(this);
+
+function waitForTick() {
+ return new Promise(resolve => executeSoon(resolve));
+}
+
+function getObserverTopic(aNotificationId) {
+ let topic = aNotificationId;
+ if (topic == "xpinstall-disabled") {
+ topic = "addon-install-disabled";
+ } else if (topic == "addon-progress") {
+ topic = "addon-install-started";
+ } else if (topic == "addon-installed") {
+ topic = "webextension-install-notify";
+ }
+ return topic;
+}
+
+async function waitForProgressNotification(
+ aPanelOpen = false,
+ aExpectedCount = 1,
+ wantDisabled = true,
+ expectedAnchorID = "unified-extensions-button",
+ win = window
+) {
+ let notificationId = PROGRESS_NOTIFICATION;
+ info("Waiting for " + notificationId + " notification");
+
+ let topic = getObserverTopic(notificationId);
+
+ let observerPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ // Ignore the progress notification unless that is the notification we want
+ if (
+ notificationId != PROGRESS_NOTIFICATION &&
+ aTopic == getObserverTopic(PROGRESS_NOTIFICATION)
+ ) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve();
+ }, topic);
+ });
+
+ let panelEventPromise;
+ if (aPanelOpen) {
+ panelEventPromise = Promise.resolve();
+ } else {
+ panelEventPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "popupshowing",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ }
+
+ await observerPromise;
+ await panelEventPromise;
+ await waitForTick();
+
+ info("Saw a notification");
+ ok(win.PopupNotifications.isPanelOpen, "Panel should be open");
+ is(
+ win.PopupNotifications.panel.childNodes.length,
+ aExpectedCount,
+ "Should be the right number of notifications"
+ );
+ if (win.PopupNotifications.panel.childNodes.length) {
+ let nodes = Array.from(win.PopupNotifications.panel.childNodes);
+ let notification = nodes.find(
+ n => n.id == notificationId + "-notification"
+ );
+ ok(notification, `Should have seen the right notification`);
+ is(
+ notification.button.hasAttribute("disabled"),
+ wantDisabled,
+ "The install button should be disabled?"
+ );
+
+ let n = win.PopupNotifications.getNotification(PROGRESS_NOTIFICATION);
+ is(
+ n?.anchorElement?.id || n?.anchorElement?.parentElement?.id,
+ expectedAnchorID,
+ "expected the right anchor ID"
+ );
+ }
+
+ return win.PopupNotifications.panel;
+}
+
+function acceptAppMenuNotificationWhenShown(
+ id,
+ extensionId,
+ {
+ dismiss = false,
+ checkIncognito = false,
+ incognitoChecked = false,
+ incognitoHidden = false,
+ global = window,
+ } = {}
+) {
+ const { AppMenuNotifications, PanelUI, document } = global;
+ return new Promise(resolve => {
+ let permissionChangePromise = null;
+ function appMenuPopupHidden() {
+ PanelUI.panel.removeEventListener("popuphidden", appMenuPopupHidden);
+ ok(
+ !PanelUI.menuButton.hasAttribute("badge-status"),
+ "badge is not set after addon-installed"
+ );
+ resolve(permissionChangePromise);
+ }
+ function appMenuPopupShown() {
+ PanelUI.panel.removeEventListener("popupshown", appMenuPopupShown);
+ PanelUI.menuButton.click();
+ }
+ function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification) {
+ return;
+ }
+
+ is(notification.id, id, `${id} notification shown`);
+ ok(PanelUI.isNotificationPanelOpen, "notification panel open");
+
+ PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);
+
+ let checkbox = document.getElementById("addon-incognito-checkbox");
+ is(checkbox.hidden, incognitoHidden, "checkbox visibility is correct");
+ is(checkbox.checked, incognitoChecked, "checkbox is marked as expected");
+
+ // If we're unchecking or checking the incognito property, this will
+ // trigger an update in ExtensionPermission, let's wait for it before
+ // returning from this promise.
+ if (incognitoChecked != checkIncognito) {
+ permissionChangePromise = new Promise(resolve => {
+ const listener = (type, change) => {
+ if (extensionId == change.extensionId) {
+ // Let's make sure we received the right message
+ let { permissions } = checkIncognito
+ ? change.added
+ : change.removed;
+ ok(permissions.includes("internal:privateBrowsingAllowed"));
+ resolve();
+ }
+ };
+ Management.once("change-permissions", listener);
+ });
+ }
+
+ checkbox.checked = checkIncognito;
+
+ if (dismiss) {
+ // Dismiss the panel by clicking on the appMenu button.
+ PanelUI.panel.addEventListener("popupshown", appMenuPopupShown);
+ PanelUI.panel.addEventListener("popuphidden", appMenuPopupHidden);
+ PanelUI.menuButton.click();
+ return;
+ }
+
+ // Dismiss the panel by clicking the primary button.
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ let popupnotification = document.getElementById(popupnotificationID);
+
+ popupnotification.button.click();
+ resolve(permissionChangePromise);
+ }
+ PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
+ });
+}
+
+async function waitForNotification(
+ aId,
+ aExpectedCount = 1,
+ expectedAnchorID = "unified-extensions-button",
+ win = window
+) {
+ info("Waiting for " + aId + " notification");
+
+ let topic = getObserverTopic(aId);
+
+ let observerPromise;
+ if (aId !== "addon-webext-permissions") {
+ observerPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ // Ignore the progress notification unless that is the notification we want
+ if (
+ aId != PROGRESS_NOTIFICATION &&
+ aTopic == getObserverTopic(PROGRESS_NOTIFICATION)
+ ) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve();
+ }, topic);
+ });
+ }
+
+ let panelEventPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "PanelUpdated",
+ function eventListener(e) {
+ // Skip notifications that are not the one that we are supposed to be looking for
+ if (!e.detail.includes(aId)) {
+ return;
+ }
+ win.PopupNotifications.panel.removeEventListener(
+ "PanelUpdated",
+ eventListener
+ );
+ resolve();
+ }
+ );
+ });
+
+ await observerPromise;
+ await panelEventPromise;
+ await waitForTick();
+
+ info("Saw a " + aId + " notification");
+ ok(win.PopupNotifications.isPanelOpen, "Panel should be open");
+ is(
+ win.PopupNotifications.panel.childNodes.length,
+ aExpectedCount,
+ "Should be the right number of notifications"
+ );
+ if (win.PopupNotifications.panel.childNodes.length) {
+ let nodes = Array.from(win.PopupNotifications.panel.childNodes);
+ let notification = nodes.find(n => n.id == aId + "-notification");
+ ok(notification, "Should have seen the " + aId + " notification");
+
+ let n = win.PopupNotifications.getNotification(aId);
+ is(
+ n?.anchorElement?.id || n?.anchorElement?.parentElement?.id,
+ expectedAnchorID,
+ "expected the right anchor ID"
+ );
+ }
+ await SimpleTest.promiseFocus(win.PopupNotifications.window);
+
+ return win.PopupNotifications.panel;
+}
+
+function waitForNotificationClose(win = window) {
+ if (!win.PopupNotifications.isPanelOpen) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => {
+ info("Waiting for notification to close");
+ win.PopupNotifications.panel.addEventListener(
+ "popuphidden",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ });
+}
+
+async function waitForInstallDialog(id = "addon-webext-permissions") {
+ let panel = await waitForNotification(id);
+ return panel.childNodes[0];
+}
+
+function removeTabAndWaitForNotificationClose() {
+ let closePromise = waitForNotificationClose();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ return closePromise;
+}
+
+function acceptInstallDialog(installDialog) {
+ installDialog.button.click();
+}
+
+async function waitForSingleNotification(aCallback) {
+ while (PopupNotifications.panel.childNodes.length != 1) {
+ await new Promise(resolve => executeSoon(resolve));
+
+ info("Waiting for single notification");
+ // Notification should never close while we wait
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ }
+}
+
+function setupRedirect(aSettings) {
+ var url =
+ "https://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs?mode=setup";
+ for (var name in aSettings) {
+ url += "&" + name + "=" + aSettings[name];
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ req.send(null);
+}
+
+var TESTS = [
+ async function test_disabledInstall() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["xpinstall.enabled", false]],
+ });
+ let notificationPromise = waitForNotification("xpinstall-disabled");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Enable",
+ "Should have seen the right button"
+ );
+ is(
+ notification.getAttribute("label"),
+ "Software installation is currently disabled. Click Enable and try again.",
+ "notification label is correct"
+ );
+
+ let closePromise = waitForNotificationClose();
+ // Click on Enable
+ EventUtils.synthesizeMouseAtCenter(notification.button, {});
+ await closePromise;
+
+ try {
+ ok(
+ Services.prefs.getBoolPref("xpinstall.enabled"),
+ "Installation should be enabled"
+ );
+ } catch (e) {
+ ok(false, "xpinstall.enabled should be set");
+ }
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Shouldn't be any pending installs");
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_blockedInstall() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", false]],
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Continue to Installation",
+ "Should have seen the right button"
+ );
+ is(
+ notification
+ .querySelector("#addon-install-blocked-info")
+ .getAttribute("href"),
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "unlisted-extensions-risks",
+ "Got the expected SUMO page as a learn more link in the addon-install-blocked panel"
+ );
+ let message = panel.ownerDocument.getElementById(
+ "addon-install-blocked-message"
+ );
+ is(
+ message.textContent,
+ "You are attempting to install an add-on from example.com. Make sure you trust this site before continuing.",
+ "Should have seen the right message"
+ );
+
+ let dialogPromise = waitForInstallDialog();
+ // Click on Allow
+ EventUtils.synthesizeMouse(notification.button, 20, 10, {});
+
+ // Notification should have changed to progress notification
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ notification = panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-progress-notification",
+ "Should have seen the progress notification"
+ );
+
+ let installDialog = await dialogPromise;
+
+ notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_blockedInstallDomain() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.postDownloadThirdPartyPrompt", true],
+ ["extensions.install_origins.enabled", true],
+ ],
+ });
+
+ let progressPromise = waitForProgressNotification();
+ let notificationPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: TESTROOT2 + "webmidi_permission.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on WebMIDI test addon can not be installed from this location.",
+ "Should have seen the right message"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_allowedInstallDomain() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.postDownloadThirdPartyPrompt", true],
+ ["extensions.install_origins.enabled", true],
+ ],
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: TESTROOT + "webmidi_permission.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Continue to Installation",
+ "Should have seen the right button"
+ );
+ let message = panel.ownerDocument.getElementById(
+ "addon-install-blocked-message"
+ );
+ is(
+ message.textContent,
+ "You are attempting to install an add-on from example.com. Make sure you trust this site before continuing.",
+ "Should have seen the right message"
+ );
+
+ // Next we get the permissions prompt, which also warns of the unsigned state of the addon
+ notificationPromise = waitForNotification("addon-webext-permissions");
+ // Click on Allow on the 3rd party panel
+ notification.button.click();
+ panel = await notificationPromise;
+ notification = panel.childNodes[0];
+
+ is(notification.button.label, "Add", "Should have seen the right button");
+
+ is(
+ notification.id,
+ "addon-webext-permissions-notification",
+ "Should have seen the permissions panel"
+ );
+ let singlePerm = panel.ownerDocument.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ is(
+ singlePerm.textContent,
+ "Access MIDI devices",
+ "Should have seen the right permission text"
+ );
+
+ notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "webmidi@test.mozilla.org",
+ { incognitoHidden: false, checkIncognito: true }
+ );
+
+ // Click on Allow on the permissions panel
+ notification.button.click();
+
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID("webmidi@test.mozilla.org");
+ await TestUtils.topicObserved("webextension-sitepermissions-startup");
+
+ // This addon should have a site permission with private browsing.
+ let uri = Services.io.newURI(addon.siteOrigin);
+ let pbPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {
+ privateBrowsingId: 1,
+ }
+ );
+ let permission = Services.perms.testExactPermissionFromPrincipal(
+ pbPrincipal,
+ "midi"
+ );
+ is(
+ permission,
+ Services.perms.ALLOW_ACTION,
+ "api access in private browsing granted"
+ );
+
+ await addon.uninstall();
+
+ // Verify the permission has not been retained.
+ let { permissions } = await ExtensionPermissions.get(
+ "webmidi@test.mozilla.org"
+ );
+ ok(
+ !permissions.includes("internal:privateBrowsingAllowed"),
+ "permission is not set after uninstall"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_blockedPostDownload() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", true]],
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.button.label,
+ "Continue to Installation",
+ "Should have seen the right button"
+ );
+ let message = panel.ownerDocument.getElementById(
+ "addon-install-blocked-message"
+ );
+ is(
+ message.textContent,
+ "You are attempting to install an add-on from example.com. Make sure you trust this site before continuing.",
+ "Should have seen the right message"
+ );
+
+ let dialogPromise = waitForInstallDialog();
+ // Click on Allow
+ EventUtils.synthesizeMouse(notification.button, 20, 10, {});
+
+ let installDialog = await dialogPromise;
+
+ notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_recommendedPostDownload() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", true]],
+ });
+
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "recommended.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+
+ let installDialog = await waitForInstallDialog();
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "{811d77f1-f306-4187-9251-b4ff99bad60b}"
+ );
+
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "{811d77f1-f306-4187-9251-b4ff99bad60b}"
+ );
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_priviledgedNo3rdPartyPrompt() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", true]],
+ });
+ AddonManager.checkUpdateSecurity = false;
+ registerCleanupFunction(() => {
+ AddonManager.checkUpdateSecurity = true;
+ });
+
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "privileged.xpi",
+ })
+ );
+
+ let installDialogPromise = waitForInstallDialog();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "test@tests.mozilla.org",
+ { incognitoHidden: true }
+ );
+
+ (await installDialogPromise).button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID("test@tests.mozilla.org");
+ await addon.uninstall();
+
+ await BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+ AddonManager.checkUpdateSecurity = true;
+ },
+
+ async function test_permaBlockInstall() {
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ let target = TESTROOT + "installtrigger.html?" + triggers;
+
+ BrowserTestUtils.openNewForegroundTab(gBrowser, target);
+ let notification = (await notificationPromise).firstElementChild;
+ let neverAllowBtn = notification.menupopup.firstElementChild;
+
+ neverAllowBtn.click();
+
+ await TestUtils.waitForCondition(
+ () => !PopupNotifications.isPanelOpen,
+ "Waiting for notification to close"
+ );
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let installPerm = PermissionTestUtils.testPermission(
+ gBrowser.currentURI,
+ "install"
+ );
+ is(
+ installPerm,
+ Ci.nsIPermissionManager.DENY_ACTION,
+ "Addon installation should be blocked for site"
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ PermissionTestUtils.remove(target, "install");
+ },
+
+ async function test_permaBlockedInstallNoPrompt() {
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ let target = TESTROOT + "installtrigger.html?" + triggers;
+
+ PermissionTestUtils.add(target, "install", Services.perms.DENY_ACTION);
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, target);
+
+ let panelOpened;
+ try {
+ panelOpened = await TestUtils.waitForCondition(
+ () => PopupNotifications.isPanelOpen,
+ 100,
+ 10
+ );
+ } catch (ex) {
+ panelOpened = false;
+ }
+ is(panelOpened, false, "Addon prompt should not open");
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ PermissionTestUtils.remove(target, "install");
+ },
+
+ async function test_whitelistedInstall() {
+ let originalTab = gBrowser.selectedTab;
+ let tab;
+ gBrowser.selectedTab = originalTab;
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let dialogPromise = waitForInstallDialog();
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ ).then(newTab => (tab = newTab));
+ await progressPromise;
+ let installDialog = await dialogPromise;
+ await BrowserTestUtils.waitForCondition(
+ () => !!tab,
+ "tab should be present"
+ );
+
+ is(
+ gBrowser.selectedTab,
+ tab,
+ "tab selected in response to the addon-install-confirmation notification"
+ );
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { dismiss: true }
+ );
+ acceptInstallDialog(installDialog);
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ // Test that the addon does not have permission. Reload it to ensure it would
+ // have been set if possible.
+ await addon.reload();
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(
+ !policy.privateBrowsingAllowed,
+ "private browsing permission was not granted"
+ );
+
+ await addon.uninstall();
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_failedDownload() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let failPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "missing.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await failPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on could not be downloaded because of a connection failure.",
+ "Should have seen the right message"
+ );
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_corruptFile() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let failPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "corrupt.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await failPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on downloaded from this site could not be installed " +
+ "because it appears to be corrupt.",
+ "Should have seen the right message"
+ );
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_incompatible() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let failPromise = waitForNotification("addon-install-failed");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "incompatible.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ let panel = await failPromise;
+
+ let notification = panel.childNodes[0];
+ let brandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ let brandShortName = brandBundle.GetStringFromName("brandShortName");
+ let message = `XPI Test could not be installed because it is not compatible with ${brandShortName} ${Services.appinfo.version}.`;
+ is(
+ notification.getAttribute("label"),
+ message,
+ "Should have seen the right message"
+ );
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_localFile() {
+ let cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let path;
+ try {
+ path = cr.convertChromeURL(makeURI(CHROMEROOT + "corrupt.xpi")).spec;
+ } catch (ex) {
+ path = CHROMEROOT + "corrupt.xpi";
+ }
+
+ let failPromise = new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, "addon-install-failed");
+ resolve();
+ }, "addon-install-failed");
+ });
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.startLoadingURIString(gBrowser, path);
+ await failPromise;
+
+ // Wait for the browser code to add the failure notification
+ await waitForSingleNotification();
+
+ let notification = PopupNotifications.panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-install-failed-notification",
+ "Should have seen the install fail"
+ );
+ is(
+ notification.getAttribute("label"),
+ "This add-on could not be installed because it appears to be corrupt.",
+ "Should have seen the right message"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_urlBar() {
+ let progressPromise = waitForProgressNotification();
+ let dialogPromise = waitForInstallDialog();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gURLBar.value = TESTROOT + "amosigned.xpi";
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ await progressPromise;
+ let installDialog = await dialogPromise;
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { checkIncognito: true }
+ );
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ // The panel is reloading the addon due to the permission change, we need some way
+ // to wait for the reload to finish. addon.startupPromise doesn't do it for
+ // us, so we'll just restart again.
+ await addon.reload();
+
+ // This addon should have private browsing permission.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(policy.privateBrowsingAllowed, "private browsing permission granted");
+
+ await addon.uninstall();
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_wrongHost() {
+ let requestedUrl = TESTROOT2 + "enabled.html";
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ requestedUrl
+ );
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT2 + "enabled.html"
+ );
+ await loadedPromise;
+
+ let progressPromise = waitForProgressNotification();
+ let notificationPromise = waitForNotification("addon-install-failed");
+ BrowserTestUtils.startLoadingURIString(gBrowser, TESTROOT + "corrupt.xpi");
+ await progressPromise;
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ is(
+ notification.getAttribute("label"),
+ "The add-on downloaded from this site could not be installed " +
+ "because it appears to be corrupt.",
+ "Should have seen the right message"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_renotifyBlocked() {
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let closePromise = waitForNotificationClose();
+ // hide the panel (this simulates the user dismissing it)
+ panel.hidePopup();
+ await closePromise;
+
+ info("Timeouts after this probably mean bug 589954 regressed");
+
+ await new Promise(resolve => executeSoon(resolve));
+
+ notificationPromise = waitForNotification("addon-install-blocked");
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 2, "Should be two pending installs");
+
+ await removeTabAndWaitForNotificationClose(gBrowser.selectedTab);
+
+ installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should have cancelled the installs");
+ },
+
+ async function test_cancel() {
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let notificationPromise = waitForNotification(PROGRESS_NOTIFICATION);
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "slowinstall.sjs?file=amosigned.xpi",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+ let notification = panel.childNodes[0];
+
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ is(
+ PopupNotifications.panel.childNodes.length,
+ 1,
+ "Should be only one notification"
+ );
+ is(
+ notification.id,
+ "addon-progress-notification",
+ "Should have seen the progress notification"
+ );
+
+ // Cancel the download
+ let install = notification.notification.options.installs[0];
+ let cancelledPromise = new Promise(resolve => {
+ install.addListener({
+ onDownloadCancelled() {
+ install.removeListener(this);
+ resolve();
+ },
+ });
+ });
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+ await cancelledPromise;
+
+ await waitForTick();
+
+ ok(!PopupNotifications.isPanelOpen, "Notification should be closed");
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending install");
+
+ PermissionTestUtils.remove("http://example.com/", "install");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ },
+
+ async function test_failedSecurity() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_INSTALL_REQUIREBUILTINCERTS, false],
+ ["extensions.postDownloadThirdPartyPrompt", false],
+ ],
+ });
+
+ setupRedirect({
+ Location: TESTROOT + "amosigned.xpi",
+ });
+
+ let notificationPromise = waitForNotification("addon-install-blocked");
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: "redirect.sjs?mode=redirect",
+ })
+ );
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ SECUREROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let notification = panel.childNodes[0];
+ // Click on Allow
+ EventUtils.synthesizeMouse(notification.button, 20, 10, {});
+
+ // Notification should have changed to progress notification
+ ok(PopupNotifications.isPanelOpen, "Notification should still be open");
+ is(
+ PopupNotifications.panel.childNodes.length,
+ 1,
+ "Should be only one notification"
+ );
+ notification = panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-progress-notification",
+ "Should have seen the progress notification"
+ );
+
+ // Wait for it to fail
+ await new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, "addon-install-failed");
+ resolve();
+ }, "addon-install-failed");
+ });
+
+ // Allow the browser code to add the failure notification and then wait
+ // for the progress notification to dismiss itself
+ await waitForSingleNotification();
+ is(
+ PopupNotifications.panel.childNodes.length,
+ 1,
+ "Should be only one notification"
+ );
+ notification = panel.childNodes[0];
+ is(
+ notification.id,
+ "addon-install-failed-notification",
+ "Should have seen the install fail"
+ );
+
+ await removeTabAndWaitForNotificationClose();
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_incognito_checkbox() {
+ // Grant permission up front.
+ const permissionName = "internal:privateBrowsingAllowed";
+ let incognitoPermission = {
+ permissions: [permissionName],
+ origins: [],
+ };
+ await ExtensionPermissions.add(
+ "amosigned-xpi@tests.mozilla.org",
+ incognitoPermission
+ );
+
+ let progressPromise = waitForProgressNotification();
+ let dialogPromise = waitForInstallDialog();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gURLBar.value = TESTROOT + "amosigned.xpi";
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ await progressPromise;
+ let installDialog = await dialogPromise;
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { incognitoChecked: true }
+ );
+ installDialog.button.click();
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ // The panel is reloading the addon due to the permission change, we need some way
+ // to wait for the reload to finish. addon.startupPromise doesn't do it for
+ // us, so we'll just restart again.
+ await AddonTestUtils.promiseWebExtensionStartup(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ // This addon should no longer have private browsing permission.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(!policy.privateBrowsingAllowed, "private browsing permission removed");
+
+ await addon.uninstall();
+
+ await removeTabAndWaitForNotificationClose();
+ },
+
+ async function test_incognito_checkbox_new_window() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+ // Grant permission up front.
+ const permissionName = "internal:privateBrowsingAllowed";
+ let incognitoPermission = {
+ permissions: [permissionName],
+ origins: [],
+ };
+ await ExtensionPermissions.add(
+ "amosigned-xpi@tests.mozilla.org",
+ incognitoPermission
+ );
+
+ let panelEventPromise = new Promise(resolve => {
+ win.PopupNotifications.panel.addEventListener(
+ "PanelUpdated",
+ function eventListener(e) {
+ if (e.detail.includes("addon-webext-permissions")) {
+ win.PopupNotifications.panel.removeEventListener(
+ "PanelUpdated",
+ eventListener
+ );
+ resolve();
+ }
+ }
+ );
+ });
+
+ win.gBrowser.selectedTab = BrowserTestUtils.addTab(
+ win.gBrowser,
+ "about:blank"
+ );
+ await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+ win.gURLBar.value = TESTROOT + "amosigned.xpi";
+ win.gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+
+ await panelEventPromise;
+ await waitForTick();
+
+ let panel = win.PopupNotifications.panel;
+ let installDialog = panel.childNodes[0];
+
+ let notificationPromise = acceptAppMenuNotificationWhenShown(
+ "addon-installed",
+ "amosigned-xpi@tests.mozilla.org",
+ { incognitoChecked: true, global: win }
+ );
+ acceptInstallDialog(installDialog);
+ await notificationPromise;
+
+ let installs = await AddonManager.getAllInstalls();
+ is(installs.length, 0, "Should be no pending installs");
+
+ let addon = await AddonManager.getAddonByID(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+ // The panel is reloading the addon due to the permission change, we need some way
+ // to wait for the reload to finish. addon.startupPromise doesn't do it for
+ // us, so we'll just restart again.
+ await AddonTestUtils.promiseWebExtensionStartup(
+ "amosigned-xpi@tests.mozilla.org"
+ );
+
+ // This addon should no longer have private browsing permission.
+ let policy = WebExtensionPolicy.getByID(addon.id);
+ ok(!policy.privateBrowsingAllowed, "private browsing permission removed");
+
+ await addon.uninstall();
+
+ await BrowserTestUtils.closeWindow(win);
+ },
+
+ async function test_blockedInstallDomain_with_unified_extensions() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.install_origins.enabled", true]],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ let progressPromise = waitForProgressNotification(
+ false,
+ 1,
+ true,
+ "unified-extensions-button",
+ win
+ );
+ let notificationPromise = waitForNotification(
+ "addon-install-failed",
+ 1,
+ "unified-extensions-button",
+ win
+ );
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ XPI: TESTROOT2 + "webmidi_permission.xpi",
+ })
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await progressPromise;
+ await notificationPromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_mv3_installOrigins_disallowed_with_unified_extensions() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable signature check because we load an unsigned MV3 extension.
+ ["xpinstall.signatures.required", false],
+ ["extensions.install_origins.enabled", true],
+ ],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ let notificationPromise = waitForNotification(
+ "addon-install-failed",
+ 1,
+ "unified-extensions-button",
+ win
+ );
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ // This XPI does not have any `install_origins` in its manifest.
+ XPI: "unsigned_mv3.xpi",
+ })
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ await notificationPromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ },
+
+ async function test_mv3_installOrigins_allowed_with_unified_extensions() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable signature check because we load an unsigned MV3 extension.
+ ["xpinstall.signatures.required", false],
+ // When this pref is disabled, install should be possible.
+ ["extensions.install_origins.enabled", false],
+ ],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ let notificationPromise = waitForNotification(
+ "addon-install-blocked",
+ 1,
+ "unified-extensions-button",
+ win
+ );
+ let triggers = encodeURIComponent(
+ JSON.stringify({
+ // This XPI does not have any `install_origins` in its manifest.
+ XPI: "unsigned_mv3.xpi",
+ })
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+ let panel = await notificationPromise;
+
+ let closePromise = waitForNotificationClose(win);
+ // hide the panel (this simulates the user dismissing it)
+ panel.hidePopup();
+ await closePromise;
+
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ },
+];
+
+var gTestStart = null;
+
+var XPInstallObserver = {
+ observe(aSubject, aTopic, aData) {
+ var installInfo = aSubject.wrappedJSObject;
+ info(
+ "Observed " + aTopic + " for " + installInfo.installs.length + " installs"
+ );
+ installInfo.installs.forEach(function (aInstall) {
+ info(
+ "Install of " +
+ aInstall.sourceURI.spec +
+ " was in state " +
+ aInstall.state
+ );
+ });
+ },
+};
+
+add_task(async function () {
+ requestLongerTimeout(4);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.logging.enabled", true],
+ ["extensions.strictCompatibility", true],
+ ["extensions.install.requireSecureOrigin", false],
+ ["security.dialog_enable_delay", 0],
+ // These tests currently depends on InstallTrigger.install.
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ Services.obs.addObserver(XPInstallObserver, "addon-install-started");
+ Services.obs.addObserver(XPInstallObserver, "addon-install-blocked");
+ Services.obs.addObserver(XPInstallObserver, "addon-install-failed");
+
+ registerCleanupFunction(async function () {
+ // Make sure no more test parts run in case we were timed out
+ TESTS = [];
+
+ let aInstalls = await AddonManager.getAllInstalls();
+ aInstalls.forEach(function (aInstall) {
+ aInstall.cancel();
+ });
+
+ Services.obs.removeObserver(XPInstallObserver, "addon-install-started");
+ Services.obs.removeObserver(XPInstallObserver, "addon-install-blocked");
+ Services.obs.removeObserver(XPInstallObserver, "addon-install-failed");
+ });
+
+ for (let i = 0; i < TESTS.length; ++i) {
+ if (gTestStart) {
+ info("Test part took " + (Date.now() - gTestStart) + "ms");
+ }
+
+ ok(!PopupNotifications.isPanelOpen, "Notification should be closed");
+
+ let installs = await AddonManager.getAllInstalls();
+
+ is(installs.length, 0, "Should be no active installs");
+ info("Running " + TESTS[i].name);
+ gTestStart = Date.now();
+ await TESTS[i]();
+ }
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_empty.js b/toolkit/mozapps/extensions/test/xpinstall/browser_empty.js
new file mode 100644
index 0000000000..d9739a0dcf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_empty.js
@@ -0,0 +1,39 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when there is no install script present.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Empty XPI": TESTROOT + "empty.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_CORRUPT_FILE, "Install should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js b/toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js
new file mode 100644
index 0000000000..b8ee1b254f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_enabled.js
@@ -0,0 +1,103 @@
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+});
+
+// Test whether an InstallTrigger.enabled is working
+add_task(async function test_enabled() {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "enabled.html"
+ );
+
+ let text = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ undefined,
+ () => content.document.getElementById("enabled").textContent
+ );
+
+ is(text, "true", "installTrigger should have been enabled");
+ gBrowser.removeCurrentTab();
+});
+
+// Test whether an InstallTrigger.enabled is working
+add_task(async function test_disabled() {
+ Services.prefs.setBoolPref("xpinstall.enabled", false);
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "enabled.html"
+ );
+
+ let text = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ undefined,
+ () => content.document.getElementById("enabled").textContent
+ );
+
+ is(text, "false", "installTrigger should have not been enabled");
+ Services.prefs.clearUserPref("xpinstall.enabled");
+ gBrowser.removeCurrentTab();
+});
+
+// Test whether an InstallTrigger.install call fails when xpinstall is disabled
+add_task(async function test_disabled2() {
+ let installDisabledCalled = false;
+
+ Harness.installDisabledCallback = installInfo => {
+ installDisabledCalled = true;
+ ok(true, "Saw installation disabled");
+ };
+
+ Harness.installBlockedCallback = installInfo => {
+ ok(false, "Should never see the blocked install notification");
+ return false;
+ };
+
+ Harness.installConfirmCallback = panel => {
+ ok(false, "Should never see an install confirmation dialog");
+ return false;
+ };
+
+ Harness.setup();
+ Services.prefs.setBoolPref("xpinstall.enabled", false);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+
+ await BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "InstallTriggered",
+ true,
+ undefined,
+ true
+ );
+
+ let text = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ undefined,
+ () => content.document.getElementById("return").textContent
+ );
+
+ is(text, "false", "installTrigger should have not been enabled");
+ ok(installDisabledCalled, "installDisabled callback was called");
+
+ Services.prefs.clearUserPref("xpinstall.enabled");
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_hash.js b/toolkit/mozapps/extensions/test/xpinstall/browser_hash.js
new file mode 100644
index 0000000000..ab7d21b64e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_hash.js
@@ -0,0 +1,47 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds when a valid hash is included
+// This verifies bug 302284
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "sha1:ee95834ad862245a9ef99ccecc2a857cadc16404",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js
new file mode 100644
index 0000000000..9fd0c66292
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_hash2.js
@@ -0,0 +1,47 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds using case-insensitive hashes
+// This verifies bug 603021
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ Hash: "sha1:EE95834AD862245A9EF99CCECC2A857CADC16404",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js
new file mode 100644
index 0000000000..1ce8eb55af
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Test whether an install succeeds when a valid hash is included in the HTTPS
+// request
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url +=
+ "?sha1:ee95834ad862245a9ef99ccecc2a857cadc16404|" +
+ TESTROOT +
+ "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js
new file mode 100644
index 0000000000..56014b0a3e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash2.js
@@ -0,0 +1,52 @@
+// ----------------------------------------------------------------------------
+// Test whether an install fails when a invalid hash is included in the HTTPS
+// request
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(install.error, AddonManager.ERROR_INCORRECT_HASH, "Download should fail");
+}
+
+function finish_test(count) {
+ is(count, 0, "0 Add-ons should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js
new file mode 100644
index 0000000000..ffb5a3ddb4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash3.js
@@ -0,0 +1,52 @@
+// ----------------------------------------------------------------------------
+// Tests that the HTTPS hash is ignored when InstallTrigger is passed a hash.
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ Hash: "sha1:ee95834ad862245a9ef99ccecc2a857cadc16404",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js
new file mode 100644
index 0000000000..4964d56443
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash4.js
@@ -0,0 +1,49 @@
+// ----------------------------------------------------------------------------
+// Test that hashes are ignored in the headers of HTTP requests
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var url = "http://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js
new file mode 100644
index 0000000000..727f13180b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash5.js
@@ -0,0 +1,53 @@
+// ----------------------------------------------------------------------------
+// Test that only the first HTTPS hash is used
+// This verifies bug 591070
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ var url = "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:ee95834ad862245a9ef99ccecc2a857cadc16404|";
+ url += "https://example.com/browser/" + RELATIVE_DIR + "hashRedirect.sjs";
+ url += "?sha1:foobar|" + TESTROOT + "amosigned.xpi";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js
new file mode 100644
index 0000000000..81f35f2140
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_httphash6.js
@@ -0,0 +1,107 @@
+// ----------------------------------------------------------------------------
+// Tests that a new hash is accepted when restarting a failed download
+// This verifies bug 593535
+function setup_redirect(aSettings) {
+ var url =
+ "https://example.com/browser/" + RELATIVE_DIR + "redirect.sjs?mode=setup";
+ for (var name in aSettings) {
+ url += "&" + name + "=" + aSettings[name];
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", url, false);
+ req.send(null);
+}
+
+var gInstall = null;
+
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadFailedCallback = download_failed;
+ Harness.installsCompletedCallback = finish_failed_download;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIREBUILTINCERTS, false);
+
+ // Set up the redirect to give a bad hash
+ setup_redirect({
+ "X-Target-Digest": "sha1:foo",
+ Location: "http://example.com/browser/" + RELATIVE_DIR + "amosigned.xpi",
+ });
+
+ var url =
+ "https://example.com/browser/" +
+ RELATIVE_DIR +
+ "redirect.sjs?mode=redirect";
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: url,
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_failed(install) {
+ is(
+ install.error,
+ AddonManager.ERROR_INCORRECT_HASH,
+ "Should have seen a hash failure"
+ );
+ // Stash the failed download while the harness cleans itself up
+ gInstall = install;
+}
+
+function finish_failed_download() {
+ // Setup to track the successful re-download
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ // Give it the right hash this time
+ setup_redirect({
+ "X-Target-Digest": "sha1:ee95834ad862245a9ef99ccecc2a857cadc16404",
+ Location: "http://example.com/browser/" + RELATIVE_DIR + "amosigned.xpi",
+ });
+
+ // The harness expects onNewInstall events for all installs that are about to start
+ Harness.onNewInstall(gInstall);
+
+ // Restart the install as a regular webpage install so the harness tracks it
+ AddonManager.installAddonFromWebpage(
+ "application/x-xpinstall",
+ gBrowser.selectedBrowser,
+ gBrowser.contentPrincipal,
+ gInstall
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIREBUILTINCERTS);
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js b/toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js
new file mode 100644
index 0000000000..319214b66a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_installchrome.js
@@ -0,0 +1,36 @@
+// ----------------------------------------------------------------------------
+// Tests that calling InstallTrigger.installChrome works
+function test() {
+ // This test depends on InstallTrigger.installChrome availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT +
+ "installchrome.html? " +
+ encodeURIComponent(TESTROOT + "amosigned.xpi")
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js
new file mode 100644
index 0000000000..65ae80bd92
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile.js
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+// Tests installing an local file works when loading the url
+function test() {
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot + "unsigned.xpi";
+ try {
+ xpipath = cr.convertChromeURL(makeURI(chromeroot + "amosigned.xpi")).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.startLoadingURIString(gBrowser, xpipath);
+ });
+}
+
+function install_ended(install, addon) {
+ Assert.deepEqual(
+ install.installTelemetryInfo,
+ { source: "file-url" },
+ "Got the expected install.installTelemetryInfo"
+ );
+
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js
new file mode 100644
index 0000000000..bfd52b18b9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile2.js
@@ -0,0 +1,61 @@
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ ],
+ });
+});
+
+// ----------------------------------------------------------------------------
+// Test whether an install fails if the url is a local file when requested from
+// web content
+add_task(async function test() {
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = getChromeRoot(gTestPath);
+ var xpipath = chromeroot + "amosigned.xpi";
+ try {
+ xpipath = cr.convertChromeURL(makeURI(xpipath)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": xpipath,
+ })
+ );
+
+ // In non-e10s the exception in the content page would trigger a test failure
+ if (!gMultiProcessBrowser) {
+ expectUncaughtException();
+ }
+
+ let URI = TESTROOT + "installtrigger.html?manualStartInstall" + triggers;
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URI },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ let installTriggered = ContentTaskUtils.waitForEvent(
+ docShell.chromeEventHandler,
+ "InstallTriggered",
+ true,
+ null,
+ true
+ );
+ content.wrappedJSObject.startInstall();
+ await installTriggered;
+ let doc = content.document;
+ is(
+ doc.getElementById("return").textContent,
+ "exception",
+ "installTrigger should have failed"
+ );
+ });
+ }
+ );
+});
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js
new file mode 100644
index 0000000000..c8d8532ed0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile3.js
@@ -0,0 +1,42 @@
+// ----------------------------------------------------------------------------
+// Tests installing an add-on from a local file with whitelisting disabled.
+// This should be blocked by the whitelist check.
+function test() {
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ // Disable direct request whitelisting, installing from file should be blocked.
+ Services.prefs.setBoolPref("xpinstall.whitelist.directRequest", false);
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot + "amosigned.xpi";
+ try {
+ xpipath = cr.convertChromeURL(makeURI(xpipath)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.startLoadingURIString(gBrowser, xpipath);
+ });
+}
+
+function allow_blocked(installInfo) {
+ ok(true, "Seen blocked");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.prefs.clearUserPref("xpinstall.whitelist.directRequest");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js
new file mode 100644
index 0000000000..771832a72b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4.js
@@ -0,0 +1,55 @@
+// ----------------------------------------------------------------------------
+// Tests installing an add-on from a local file with file origins disabled.
+// This should be blocked by the origin allowed check.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ // prompt prior to download
+ SpecialPowers.pushPrefEnv({
+ set: [["extensions.postDownloadThirdPartyPrompt", false]],
+ });
+
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ // Disable local file install, installing by file referrer should be blocked.
+ Services.prefs.setBoolPref("xpinstall.whitelist.fileRequest", false);
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot;
+ try {
+ xpipath = cr.convertChromeURL(makeURI(chromeroot)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ xpipath + "installtrigger.html?" + triggers
+ );
+}
+
+function allow_blocked(installInfo) {
+ ok(true, "Seen blocked");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.prefs.clearUserPref("xpinstall.whitelist.fileRequest");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js
new file mode 100644
index 0000000000..8f8484a1c9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_localfile4_postDownload.js
@@ -0,0 +1,54 @@
+// ----------------------------------------------------------------------------
+// Tests installing an add-on from a local file with file origins disabled.
+// This should be blocked by the origin allowed check.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installBlockedCallback = allow_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ // Prevent the Harness from ending the test on download cancel.
+ Harness.downloadCancelledCallback = () => {
+ return false;
+ };
+ Harness.setup();
+
+ // Disable local file install, installing by file referrer should be blocked.
+ Services.prefs.setBoolPref("xpinstall.whitelist.fileRequest", false);
+
+ var cr = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+
+ var chromeroot = extractChromeRoot(gTestPath);
+ var xpipath = chromeroot;
+ try {
+ xpipath = cr.convertChromeURL(makeURI(chromeroot)).spec;
+ } catch (ex) {
+ // scenario where we are running from a .jar and already extracted
+ }
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ xpipath + "installtrigger.html?" + triggers
+ );
+}
+
+function allow_blocked(installInfo) {
+ ok(true, "Seen blocked");
+ return false;
+}
+
+function finish_test(count) {
+ is(count, 0, "No add-ons should have been installed");
+
+ Services.prefs.clearUserPref("xpinstall.whitelist.fileRequest");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js b/toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js
new file mode 100644
index 0000000000..c4bc5c5d56
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_newwindow.js
@@ -0,0 +1,89 @@
+// This functionality covered in this test is also covered in other tests.
+// The purpose of this test is to catch window leaks. It should fail in
+// debug builds if a window reference is held onto after an install finishes.
+// See bug 1541577 for further details.
+
+let win;
+let popupPromise;
+let newtabPromise;
+const exampleURI = Services.io.newURI("http://example.com");
+async function test() {
+ waitForExplicitFinish(); // have to call this ourselves because we're async.
+
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first", false]],
+ });
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install => {
+ return install.addon.uninstall();
+ };
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ win = await BrowserTestUtils.openNewBrowserWindow();
+ Harness.setup(win);
+
+ PermissionTestUtils.add(exampleURI, "install", Services.perms.ALLOW_ACTION);
+
+ const triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ },
+ })
+ );
+
+ const url = `${TESTROOT}installtrigger.html?${triggers}`;
+ newtabPromise = BrowserTestUtils.openNewForegroundTab(win.gBrowser, url);
+ popupPromise = BrowserTestUtils.waitForEvent(
+ win.PanelUI.notificationPanel,
+ "popupshown"
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+async function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove(exampleURI, "install");
+
+ const results = await SpecialPowers.spawn(
+ win.gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ // Explicitly click the "OK" button to avoid the panel reopening in the other window once this
+ // window closes (see also bug 1535069):
+ await popupPromise;
+ win.PanelUI.notificationPanel
+ .querySelector("popupnotification[popupid=addon-installed]")
+ .button.click();
+
+ // Wait for the promise returned by BrowserTestUtils.openNewForegroundTab
+ // to be resolved before removing the window to prevent an uncaught exception
+ // triggered from inside openNewForegroundTab to trigger a test failure due
+ // to a race between openNewForegroundTab and closeWindow calls, e.g. as for
+ // Bug 1728482).
+ await newtabPromise;
+
+ // Now finish the test:
+ await BrowserTestUtils.closeWindow(win);
+ Harness.finish(win);
+ win = null;
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_offline.js b/toolkit/mozapps/extensions/test/xpinstall/browser_offline.js
new file mode 100644
index 0000000000..946ad1dff7
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_offline.js
@@ -0,0 +1,82 @@
+var proxyPrefValue;
+
+// ----------------------------------------------------------------------------
+// Tests that going offline cancels an in progress download.
+function test() {
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.downloadProgressCallback = download_progress;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": TESTROOT + "amosigned.xpi",
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function download_progress(addon, value, maxValue) {
+ try {
+ // Tests always connect to localhost, and per bug 87717, localhost is now
+ // reachable in offline mode. To avoid this, disable any proxy.
+ proxyPrefValue = Services.prefs.getIntPref("network.proxy.type");
+ Services.prefs.setIntPref("network.proxy.type", 0);
+ Services.io.manageOfflineStatus = false;
+ Services.io.offline = true;
+ } catch (ex) {}
+}
+
+function finish_test(count) {
+ function wait_for_online() {
+ info("Checking if the browser is still offline...");
+
+ let tab = gBrowser.selectedTab;
+ BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "DOMContentLoaded",
+ true
+ ).then(async function () {
+ let url = await ContentTask.spawn(
+ tab.linkedBrowser,
+ null,
+ async function () {
+ return content.document.documentURI;
+ }
+ );
+ info("loaded: " + url);
+ if (/^about:neterror\?e=netOffline/.test(url)) {
+ wait_for_online();
+ } else {
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+ }
+ });
+ BrowserTestUtils.startLoadingURIString(
+ tab.linkedBrowser,
+ "http://example.com/"
+ );
+ }
+
+ is(count, 0, "No add-ons should have been installed");
+ try {
+ Services.prefs.setIntPref("network.proxy.type", proxyPrefValue);
+ Services.io.offline = false;
+ } catch (ex) {}
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ wait_for_online();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js b/toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js
new file mode 100644
index 0000000000..9d55a3d8fa
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_privatebrowsing.js
@@ -0,0 +1,133 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+let gDidSeeChannel = false;
+
+AddonTestUtils.initMochitest(this);
+
+function check_channel(subject) {
+ if (!(subject instanceof Ci.nsIHttpChannel)) {
+ return;
+ }
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ let uri = channel.URI;
+ if (!uri || !uri.spec.endsWith("amosigned.xpi")) {
+ return;
+ }
+ gDidSeeChannel = true;
+ ok(true, "Got request for " + uri.spec);
+
+ let loadInfo = channel.loadInfo;
+ is(
+ loadInfo.originAttributes.privateBrowsingId,
+ 1,
+ "Request should have happened using private browsing"
+ );
+}
+// ----------------------------------------------------------------------------
+// Tests we send the right cookies when installing through an InstallTrigger call
+let gPrivateWin;
+async function test() {
+ waitForExplicitFinish(); // have to call this ourselves because we're async.
+
+ // This test currently depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first_pbm", false]],
+ });
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ gPrivateWin = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+ Harness.setup(gPrivateWin);
+
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI("http://example.com/"),
+ { privateBrowsingId: 1 }
+ );
+
+ PermissionTestUtils.add(principal, "install", Services.perms.ALLOW_ACTION);
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gPrivateWin.gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gPrivateWin.gBrowser
+ );
+ Services.obs.addObserver(check_channel, "http-on-before-connect");
+ BrowserTestUtils.startLoadingURIString(
+ gPrivateWin.gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ AddonTestUtils.checkInstallInfo(install, {
+ method: "installTrigger",
+ source: "test-host",
+ sourceURL: /http:\/\/example.com\/.*\/installtrigger.html/,
+ });
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ ok(
+ gDidSeeChannel,
+ "Should have seen the request for the XPI and verified it was sent the right way."
+ );
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ Services.obs.removeObserver(check_channel, "http-on-before-connect");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gPrivateWin.gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ await TestUtils.waitForCondition(() =>
+ gPrivateWin.AppMenuNotifications._notifications.some(
+ n => n.id == "addon-installed"
+ )
+ );
+ // Explicitly remove the notification to avoid the panel reopening in the
+ // other window once this window closes (see also bug 1535069):
+ gPrivateWin.AppMenuNotifications.removeNotification("addon-installed");
+
+ // Now finish the test:
+ await BrowserTestUtils.closeWindow(gPrivateWin);
+ Harness.finish(gPrivateWin);
+ gPrivateWin = null;
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_relative.js b/toolkit/mozapps/extensions/test/xpinstall/browser_relative.js
new file mode 100644
index 0000000000..10d5df8b73
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_relative.js
@@ -0,0 +1,67 @@
+// ----------------------------------------------------------------------------
+// Tests that InstallTrigger deals with relative urls correctly.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: "amosigned.xpi",
+ IconURL: "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js b/toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js
new file mode 100644
index 0000000000..6c8894699d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_required_useractivation.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const XPI_URL = `${TESTROOT}amosigned.xpi`;
+
+async function runTestCase(spawnArgs, spawnFn, { expectInstall, clickLink }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Make use the user activation requirements is enabled while running this test.
+ ["xpinstall.userActivation.required", true],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(TESTROOT, async browser => {
+ const expectedError = `${XPI_URL} install cancelled because of missing user gesture activation`;
+
+ let promiseDone;
+
+ if (expectInstall) {
+ promiseDone = TestUtils.topicObserved("addon-install-blocked").then(
+ ([subject]) => {
+ // Cancel the pending installation flow.
+ subject.wrappedJSObject.cancel();
+ }
+ );
+ } else {
+ promiseDone = new Promise(resolve => {
+ function messageHandler(msgObj) {
+ if (
+ msgObj instanceof Ci.nsIScriptError &&
+ msgObj.message.includes(expectedError)
+ ) {
+ ok(
+ true,
+ "Expect error on triggering navigation to xpi without user gesture activation"
+ );
+ cleanupListener();
+ resolve();
+ }
+ }
+ let listenerCleared = false;
+ function cleanupListener() {
+ if (!listenerCleared) {
+ Services.console.unregisterListener(messageHandler);
+ }
+ listenerCleared = true;
+ }
+ Services.console.registerListener(messageHandler);
+ registerCleanupFunction(cleanupListener);
+ });
+ }
+
+ await SpecialPowers.spawn(browser, spawnArgs, spawnFn);
+
+ if (clickLink) {
+ info("Click link element");
+ // Wait for the install to trigger the third party website doorhanger.
+ // Trigger the link by simulating a mouse click, and expect it to trigger the
+ // install flow instead (the window is still navigated to the xpi url from the
+ // webpage JS code, but doing it while handling a DOM event does make it pass
+ // the user activation check).
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link-to-xpi-file",
+ {},
+ browser
+ );
+ }
+
+ info("Wait test case to be completed");
+ await promiseDone;
+ ok(true, "Test case run completed");
+ });
+}
+
+add_task(async function testSuccessOnUserActivatedLink() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ const { document } = this.content;
+ const link = document.createElement("a");
+ link.id = "link-to-xpi-file";
+ link.setAttribute("href", xpiURL);
+ link.textContent = "Link to XPI File";
+
+ // Empty the test case and add the link, if the link is not visible
+ // without scrolling, BrowserTestUtils.synthesizeMouseAtCenter may
+ // fail to trigger the mouse event.
+ document.body.innerHTML = "";
+ document.body.appendChild(link);
+ },
+ { expectInstall: true, clickLink: true }
+ );
+});
+
+add_task(async function testSuccessOnJSWithUserActivation() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ const { document } = this.content;
+ const link = document.createElement("a");
+ link.id = "link-to-xpi-file";
+ link.setAttribute("href", "#");
+ link.textContent = "Link to XPI File";
+
+ // Empty the test case and add the link, if the link is not visible
+ // without scrolling, BrowserTestUtils.synthesizeMouseAtCenter may
+ // fail to trigger the mouse event.
+ document.body.innerHTML = "";
+ document.body.appendChild(link);
+
+ this.content.eval(`
+ const linkEl = document.querySelector("#link-to-xpi-file");
+ linkEl.onclick = () => {
+ // This is expected to trigger the install flow successfully if handling
+ // a user gesture DOM event, but to fail when triggered outside of it (as
+ // done a few line below).
+ window.location = "${xpiURL}";
+ };
+ `);
+ },
+ { expectInstall: true, clickLink: true }
+ );
+});
+
+add_task(async function testFailureOnJSWithoutUserActivation() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ this.content.eval(`window.location = "${xpiURL}";`);
+ },
+ { expectInstall: false }
+ );
+});
+
+add_task(async function testFailureOnJSWithoutUserActivation() {
+ await runTestCase(
+ [XPI_URL],
+ xpiURL => {
+ this.content.eval(`
+ const frame = document.createElement("iframe");
+ frame.src = "${xpiURL}";
+ document.body.innerHTML = "";
+ document.body.appendChild(frame);
+ `);
+ },
+ { expectInstall: false }
+ );
+});
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js b/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js
new file mode 100644
index 0000000000..b01137927b
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_signed_url.js
@@ -0,0 +1,32 @@
+// ----------------------------------------------------------------------------
+// Tests installing an signed add-on by navigating directly to the url
+function test() {
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "amosigned.xpi"
+ );
+ });
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js b/toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js
new file mode 100644
index 0000000000..76ddf0b7d3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_softwareupdate.js
@@ -0,0 +1,36 @@
+// ----------------------------------------------------------------------------
+// Tests that calling InstallTrigger.startSoftwareUpdate works
+function test() {
+ // This test depends on InstallTrigger.startSoftwareUpdate availability.
+ setInstallTriggerPrefs();
+
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT +
+ "startsoftwareupdate.html? " +
+ encodeURIComponent(TESTROOT + "amosigned.xpi")
+ );
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js b/toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js
new file mode 100644
index 0000000000..51e08e5001
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_trigger_redirect.js
@@ -0,0 +1,48 @@
+// ----------------------------------------------------------------------------
+// Tests that the InstallTrigger callback can redirect to a relative url.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "triggerredirect.html"
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ is(
+ gBrowser.currentURI.spec,
+ TESTROOT + "triggerredirect.html#foo",
+ "Should have redirected"
+ );
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js
new file mode 100644
index 0000000000..cb81cf3d36
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger.js
@@ -0,0 +1,68 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on through an InstallTrigger call in web
+// content.
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var triggers = encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "unsigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger.html?" + triggers
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.document.getElementById("return").textContent,
+ status: content.document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(results.return, "true", "installTrigger should have claimed success");
+ is(results.status, "0", "Callback should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js
new file mode 100644
index 0000000000..c98477d6e9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_iframe.js
@@ -0,0 +1,77 @@
+// ----------------------------------------------------------------------------
+// Test for bug 589598 - Ensure that installing through InstallTrigger
+// works in an iframe in web content.
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var inner_url = encodeURIComponent(
+ TESTROOT +
+ "installtrigger.html?" +
+ encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "unsigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ )
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT + "installtrigger_frame.html?" + inner_url
+ );
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+const finish_test = async function (count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ const results = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return {
+ return: content.frames[0].document.getElementById("return").textContent,
+ status: content.frames[0].document.getElementById("status").textContent,
+ };
+ }
+ );
+
+ is(
+ results.return,
+ "true",
+ "installTrigger in iframe should have claimed success"
+ );
+ is(results.status, "0", "Callback in iframe should have seen a success");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js
new file mode 100644
index 0000000000..7edbf318a0
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_trigger_xorigin.js
@@ -0,0 +1,58 @@
+// ----------------------------------------------------------------------------
+// Ensure that an inner frame from a different origin can't initiate an install
+
+var wasOriginBlocked = false;
+
+function test() {
+ // This test depends on InstallTrigger.install availability.
+ setInstallTriggerPrefs();
+
+ Harness.installOriginBlockedCallback = install_blocked;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.finalContentEvent = "InstallComplete";
+ Harness.setup();
+
+ PermissionTestUtils.add(
+ "http://example.com/",
+ "install",
+ Services.perms.ALLOW_ACTION
+ );
+
+ var inner_url = encodeURIComponent(
+ TESTROOT +
+ "installtrigger.html?" +
+ encodeURIComponent(
+ JSON.stringify({
+ "Unsigned XPI": {
+ URL: TESTROOT + "amosigned.xpi",
+ IconURL: TESTROOT + "icon.png",
+ toString() {
+ return this.URL;
+ },
+ },
+ })
+ )
+ );
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser,
+ TESTROOT2 + "installtrigger_frame.html?" + inner_url
+ );
+}
+
+function install_blocked(installInfo) {
+ wasOriginBlocked = true;
+}
+
+function finish_test(count) {
+ ok(
+ wasOriginBlocked,
+ "Should have been blocked due to the cross origin request."
+ );
+
+ is(count, 0, "No add-ons should have been installed");
+ PermissionTestUtils.remove("http://example.com", "install");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js
new file mode 100644
index 0000000000..ef5723640e
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/browser_unsigned_url.js
@@ -0,0 +1,43 @@
+// ----------------------------------------------------------------------------
+// Tests installing an unsigned add-on by navigating directly to the url
+function test() {
+ waitForExplicitFinish();
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ },
+ runTest
+ );
+}
+
+function runTest() {
+ Harness.installConfirmCallback = confirm_install;
+ Harness.installEndedCallback = install_ended;
+ Harness.installsCompletedCallback = finish_test;
+ Harness.setup();
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => {
+ BrowserTestUtils.startLoadingURIString(gBrowser, TESTROOT + "unsigned.xpi");
+ });
+}
+
+function confirm_install(panel) {
+ is(panel.getAttribute("name"), "XPI Test", "Should have seen the name");
+ return true;
+}
+
+function install_ended(install, addon) {
+ return addon.uninstall();
+}
+
+function finish_test(count) {
+ is(count, 1, "1 Add-on should have been successfully installed");
+
+ gBrowser.removeCurrentTab();
+ Harness.finish();
+}
+// ----------------------------------------------------------------------------
diff --git a/toolkit/mozapps/extensions/test/xpinstall/bug540558.html b/toolkit/mozapps/extensions/test/xpinstall/bug540558.html
new file mode 100644
index 0000000000..045e3e2d7c
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/bug540558.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page tests that window.InstallTrigger.install works -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* exported startInstall */
+function startInstall() {
+ window.InstallTrigger.install({
+ "Unsigned XPI": "amosigned.xpi",
+ });
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/bug638292.html b/toolkit/mozapps/extensions/test/xpinstall/bug638292.html
new file mode 100644
index 0000000000..198207d4bf
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/bug638292.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page tests InstallTrigger is defined in a new window -->
+
+<head>
+<title>InstallTrigger tests</title>
+</head>
+<body>
+<p>InstallTrigger tests</p>
+<p><a id="link1" target="_blank" href="enabled.html">Open window with target</a></p>
+<p><a id="link2" onclick="window.open(this.href); return false" href="enabled.html">Open window with JS</a></p>
+<p><a id="link3" href="enabled.html">Open window with middle-click</a></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/bug645699.html b/toolkit/mozapps/extensions/test/xpinstall/bug645699.html
new file mode 100644
index 0000000000..d993ad070d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/bug645699.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function startInstall() {
+ var whiteUrl = "https://example.org/";
+
+ try {
+ Object.defineProperty(window, "location", { value: { href: whiteUrl } });
+ throw new Error("Object.defineProperty(window, 'location', ...) should have thrown");
+ } catch (exc) {
+ if (!(exc instanceof TypeError))
+ throw exc;
+ }
+ Object.defineProperty(document, "documentURIObject", { spec: { href: whiteUrl } });
+
+ InstallTrigger.install({
+ "Unsigned XPI": "http://example.com/browser/toolkit/mozapps/extensions/test/xpinstall/amosigned.xpi",
+ });
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs
new file mode 100644
index 0000000000..5ddc6f9bd4
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/cookieRedirect.sjs
@@ -0,0 +1,23 @@
+// Simple script redirects to the query part of the uri if the cookie "xpinstall"
+// has the value "true", otherwise gives a 500 error.
+
+function handleRequest(request, response) {
+ let cookie = null;
+ if (request.hasHeader("Cookie")) {
+ let cookies = request.getHeader("Cookie").split(";");
+ for (let i = 0; i < cookies.length; i++) {
+ if (cookies[i].substring(0, 10) == "xpinstall=") {
+ cookie = cookies[i].substring(10);
+ }
+ }
+ }
+
+ if (cookie == "true") {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", request.queryString);
+ response.write("See " + request.queryString);
+ } else {
+ response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
+ response.write("Invalid request");
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi b/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi
new file mode 100644
index 0000000000..35d7bd5e5d
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/corrupt.xpi
@@ -0,0 +1 @@
+This is a corrupt zip file
diff --git a/toolkit/mozapps/extensions/test/xpinstall/empty.xpi b/toolkit/mozapps/extensions/test/xpinstall/empty.xpi
new file mode 100644
index 0000000000..74ed2b8174
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/empty.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/enabled.html b/toolkit/mozapps/extensions/test/xpinstall/enabled.html
new file mode 100644
index 0000000000..dea8a59036
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/enabled.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will test if InstallTrigger seems to be enabled -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported init */
+function init() {
+ document.getElementById("enabled").textContent = InstallTrigger.enabled() ? "true" : "false";
+ dump("Sending PageLoaded\n");
+ var event = new CustomEvent("PageLoaded");
+ window.dispatchEvent(event);
+}
+</script>
+</head>
+<body onload="init()">
+<p>InstallTrigger tests</p>
+<p id="enabled"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs
new file mode 100644
index 0000000000..8137f9e58a
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/hashRedirect.sjs
@@ -0,0 +1,14 @@
+// Simple script redirects takes the query part of te request and splits it on
+// the | character. Anything before is included as the X-Target-Digest header
+// the latter part is used as the url to redirect to
+
+function handleRequest(request, response) {
+ let pos = request.queryString.indexOf("|");
+ let header = request.queryString.substring(0, pos);
+ let url = request.queryString.substring(pos + 1);
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("X-Target-Digest", header);
+ response.setHeader("Location", url);
+ response.write("See " + url);
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/head.js b/toolkit/mozapps/extensions/test/xpinstall/head.js
new file mode 100644
index 0000000000..33ac33c830
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/head.js
@@ -0,0 +1,568 @@
+/* eslint no-unused-vars: ["error", {vars: "local", args: "none"}] */
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const RELATIVE_DIR = "toolkit/mozapps/extensions/test/xpinstall/";
+
+const TESTROOT = "http://example.com/browser/" + RELATIVE_DIR;
+const TESTROOT2 = "http://example.org/browser/" + RELATIVE_DIR;
+const PROMPT_URL = "chrome://global/content/commonDialog.xhtml";
+const ADDONS_URL = "chrome://mozapps/content/extensions/aboutaddons.html";
+const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
+const PREF_INSTALL_REQUIREBUILTINCERTS =
+ "extensions.install.requireBuiltInCerts";
+const PREF_INSTALL_REQUIRESECUREORIGIN =
+ "extensions.install.requireSecureOrigin";
+const CHROME_NAME = "mochikit";
+
+function getChromeRoot(path) {
+ if (path === undefined) {
+ return "chrome://" + CHROME_NAME + "/content/browser/" + RELATIVE_DIR;
+ }
+ return getRootDirectory(path);
+}
+
+function extractChromeRoot(path) {
+ var chromeRootPath = getChromeRoot(path);
+ var jar = getJar(chromeRootPath);
+ if (jar) {
+ var tmpdir = extractJarToTmp(jar);
+ return "file://" + tmpdir.path + "/";
+ }
+ return chromeRootPath;
+}
+
+function setInstallTriggerPrefs() {
+ Services.prefs.setBoolPref("extensions.InstallTrigger.enabled", true);
+ Services.prefs.setBoolPref("extensions.InstallTriggerImpl.enabled", true);
+ // Relax the user input requirements while running tests that call this test helper.
+ Services.prefs.setBoolPref("xpinstall.userActivation.required", false);
+ registerCleanupFunction(clearInstallTriggerPrefs);
+}
+
+function clearInstallTriggerPrefs() {
+ Services.prefs.clearUserPref("extensions.InstallTrigger.enabled");
+ Services.prefs.clearUserPref("extensions.InstallTriggerImpl.enabled");
+ Services.prefs.clearUserPref("xpinstall.userActivation.required");
+}
+
+/**
+ * This is a test harness designed to handle responding to UI during the process
+ * of installing an XPI. A test can set callbacks to hear about specific parts
+ * of the sequence.
+ * Before use setup must be called and finish must be called afterwards.
+ */
+var Harness = {
+ // If set then the callback is called when an install is attempted and
+ // software installation is disabled.
+ installDisabledCallback: null,
+ // If set then the callback is called when an install is attempted and
+ // then canceled.
+ installCancelledCallback: null,
+ // If set then the callback will be called when an install's origin is blocked.
+ installOriginBlockedCallback: null,
+ // If set then the callback will be called when an install is blocked by the
+ // whitelist. The callback should return true to continue with the install
+ // anyway.
+ installBlockedCallback: null,
+ // If set will be called in the event of authentication being needed to get
+ // the xpi. Should return a 2 element array of username and password, or
+ // null to not authenticate.
+ authenticationCallback: null,
+ // If set this will be called to allow checking the contents of the xpinstall
+ // confirmation dialog. The callback should return true to continue the install.
+ installConfirmCallback: null,
+ // If set will be called when downloading of an item has begun.
+ downloadStartedCallback: null,
+ // If set will be called during the download of an item.
+ downloadProgressCallback: null,
+ // If set will be called when an xpi fails to download.
+ downloadFailedCallback: null,
+ // If set will be called when an xpi download is cancelled.
+ downloadCancelledCallback: null,
+ // If set will be called when downloading of an item has ended.
+ downloadEndedCallback: null,
+ // If set will be called when installation by the extension manager of an xpi
+ // item starts
+ installStartedCallback: null,
+ // If set will be called when an xpi fails to install.
+ installFailedCallback: null,
+ // If set will be called when each xpi item to be installed completes
+ // installation.
+ installEndedCallback: null,
+ // If set will be called when all triggered items are installed or the install
+ // is canceled.
+ installsCompletedCallback: null,
+ // If set the harness will wait for this DOM event before calling
+ // installsCompletedCallback
+ finalContentEvent: null,
+
+ waitingForEvent: false,
+ pendingCount: null,
+ installCount: null,
+ runningInstalls: null,
+
+ waitingForFinish: false,
+
+ // A unique value to return from the installConfirmCallback to indicate that
+ // the install UI shouldn't be closed automatically
+ leaveOpen: {},
+
+ // Setup and tear down functions
+ setup(win = window) {
+ if (!this.waitingForFinish) {
+ waitForExplicitFinish();
+ this.waitingForFinish = true;
+
+ Services.prefs.setBoolPref(PREF_INSTALL_REQUIRESECUREORIGIN, false);
+ Services.prefs.setBoolPref(PREF_LOGGING_ENABLED, true);
+ Services.prefs.setBoolPref(
+ "network.cookieJarSettings.unblocked_for_testing",
+ true
+ );
+
+ Services.obs.addObserver(this, "addon-install-started");
+ Services.obs.addObserver(this, "addon-install-disabled");
+ Services.obs.addObserver(this, "addon-install-origin-blocked");
+ Services.obs.addObserver(this, "addon-install-blocked");
+ Services.obs.addObserver(this, "addon-install-failed");
+
+ // For browser_auth tests which trigger auth dialogs.
+ Services.obs.addObserver(this, "tabmodal-dialog-loaded");
+ Services.obs.addObserver(this, "common-dialog-loaded");
+
+ this._boundWin = Cu.getWeakReference(win); // need this so our addon manager listener knows which window to use.
+ AddonManager.addInstallListener(this);
+ AddonManager.addAddonListener(this);
+
+ win.addEventListener("popupshown", this);
+ win.PanelUI.notificationPanel.addEventListener("popupshown", this);
+
+ var self = this;
+ registerCleanupFunction(async function () {
+ Services.prefs.clearUserPref(PREF_LOGGING_ENABLED);
+ Services.prefs.clearUserPref(PREF_INSTALL_REQUIRESECUREORIGIN);
+ Services.prefs.clearUserPref(
+ "network.cookieJarSettings.unblocked_for_testing"
+ );
+
+ Services.obs.removeObserver(self, "addon-install-started");
+ Services.obs.removeObserver(self, "addon-install-disabled");
+ Services.obs.removeObserver(self, "addon-install-origin-blocked");
+ Services.obs.removeObserver(self, "addon-install-blocked");
+ Services.obs.removeObserver(self, "addon-install-failed");
+
+ Services.obs.removeObserver(self, "tabmodal-dialog-loaded");
+ Services.obs.removeObserver(self, "common-dialog-loaded");
+
+ AddonManager.removeInstallListener(self);
+ AddonManager.removeAddonListener(self);
+
+ win.removeEventListener("popupshown", self);
+ win.PanelUI.notificationPanel.removeEventListener("popupshown", self);
+ win = null;
+
+ let aInstalls = await AddonManager.getAllInstalls();
+ is(
+ aInstalls.length,
+ 0,
+ "Should be no active installs at the end of the test"
+ );
+ await Promise.all(
+ aInstalls.map(async function (aInstall) {
+ info(
+ "Install for " +
+ aInstall.sourceURI +
+ " is in state " +
+ aInstall.state
+ );
+ if (aInstall.state == AddonManager.STATE_INSTALLED) {
+ await aInstall.addon.uninstall();
+ } else {
+ aInstall.cancel();
+ }
+ })
+ );
+ });
+ }
+
+ this.installCount = 0;
+ this.pendingCount = 0;
+ this.runningInstalls = [];
+ },
+
+ finish(win = window) {
+ // Some tests using this harness somehow finish leaving
+ // the addon-installed panel open. hiding here addresses
+ // that which fixes the rest of the tests. Since no test
+ // here cares about this panel, we just need it to close.
+ win.PanelUI.notificationPanel.hidePopup();
+ win.AppMenuNotifications.removeNotification("addon-installed");
+ delete this._boundWin;
+ finish();
+ },
+
+ endTest() {
+ let callback = this.installsCompletedCallback;
+ let count = this.installCount;
+
+ is(this.runningInstalls.length, 0, "Should be no running installs left");
+ this.runningInstalls.forEach(function (aInstall) {
+ info(
+ "Install for " + aInstall.sourceURI + " is in state " + aInstall.state
+ );
+ });
+
+ this.installOriginBlockedCallback = null;
+ this.installBlockedCallback = null;
+ this.authenticationCallback = null;
+ this.installConfirmCallback = null;
+ this.downloadStartedCallback = null;
+ this.downloadProgressCallback = null;
+ this.downloadCancelledCallback = null;
+ this.downloadFailedCallback = null;
+ this.downloadEndedCallback = null;
+ this.installStartedCallback = null;
+ this.installFailedCallback = null;
+ this.installEndedCallback = null;
+ this.installsCompletedCallback = null;
+ this.runningInstalls = null;
+
+ if (callback) {
+ executeSoon(() => callback(count));
+ }
+ },
+
+ promptReady(dialog) {
+ let promptType = dialog.args.promptType;
+
+ switch (promptType) {
+ case "alert":
+ case "alertCheck":
+ case "confirmCheck":
+ case "confirm":
+ case "confirmEx":
+ PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 0 });
+ break;
+ case "promptUserAndPass":
+ // This is a login dialog, hopefully an authentication prompt
+ // for the xpi.
+ if (this.authenticationCallback) {
+ var auth = this.authenticationCallback();
+ if (auth && auth.length == 2) {
+ PromptTestUtils.handlePrompt(dialog, {
+ loginInput: auth[0],
+ passwordInput: auth[1],
+ buttonNumClick: 0,
+ });
+ } else {
+ PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 });
+ }
+ } else {
+ PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 });
+ }
+ break;
+ default:
+ ok(false, "prompt type " + promptType + " not handled in test.");
+ break;
+ }
+ },
+
+ popupReady(panel) {
+ if (this.installBlockedCallback) {
+ ok(false, "Should have been blocked by the whitelist");
+ }
+ this.pendingCount++;
+
+ // If there is a confirm callback then its return status determines whether
+ // to install the items or not. If not the test is over.
+ let result = true;
+ if (this.installConfirmCallback) {
+ result = this.installConfirmCallback(panel);
+ if (result === this.leaveOpen) {
+ return;
+ }
+ }
+
+ const panelEl = panel.closest("panel");
+ const panelState = panelEl.state;
+
+ const clickButton = () => {
+ info(`Clicking ${result ? "primary" : "secondary"} panel button`);
+ Assert.equal(
+ panelEl.state,
+ "open",
+ "Expect panel state to be open when clicking panel buttons"
+ );
+ if (!result) {
+ panel.secondaryButton.click();
+ } else {
+ panel.button.click();
+ }
+ };
+
+ if (panelState === "showing") {
+ info(
+ "panel is still showing, wait for 'popup-shown' topic to be notified"
+ );
+ BrowserUtils.promiseObserved(
+ "popup-shown",
+ shownPanel => shownPanel === panelEl
+ ).then(clickButton);
+ } else {
+ clickButton();
+ }
+ },
+
+ handleEvent(event) {
+ if (event.type === "popupshown") {
+ if (event.target == event.view.PanelUI.notificationPanel) {
+ event.view.PanelUI.notificationPanel.hidePopup();
+ } else if (event.target.firstElementChild) {
+ let popupId = event.target.firstElementChild.getAttribute("popupid");
+ if (popupId === "addon-webext-permissions") {
+ this.popupReady(event.target.firstElementChild);
+ } else if (popupId === "addon-install-failed") {
+ event.target.firstElementChild.button.click();
+ }
+ }
+ }
+ },
+
+ // Install blocked handling
+
+ installDisabled(installInfo) {
+ ok(
+ !!this.installDisabledCallback,
+ "Installation shouldn't have been disabled"
+ );
+ if (this.installDisabledCallback) {
+ this.installDisabledCallback(installInfo);
+ }
+ this.expectingCancelled = true;
+ this.expectingCancelled = false;
+ this.endTest();
+ },
+
+ installCancelled(installInfo) {
+ if (this.expectingCancelled) {
+ return;
+ }
+
+ ok(
+ !!this.installCancelledCallback,
+ "Installation shouldn't have been cancelled"
+ );
+ if (this.installCancelledCallback) {
+ this.installCancelledCallback(installInfo);
+ }
+ this.endTest();
+ },
+
+ installOriginBlocked(installInfo) {
+ ok(!!this.installOriginBlockedCallback, "Shouldn't have been blocked");
+ if (this.installOriginBlockedCallback) {
+ this.installOriginBlockedCallback(installInfo);
+ }
+ this.endTest();
+ },
+
+ installBlocked(installInfo) {
+ ok(
+ !!this.installBlockedCallback,
+ "Shouldn't have been blocked by the whitelist"
+ );
+ if (
+ this.installBlockedCallback &&
+ this.installBlockedCallback(installInfo)
+ ) {
+ this.installBlockedCallback = null;
+ installInfo.install();
+ } else {
+ this.expectingCancelled = true;
+ installInfo.installs.forEach(function (install) {
+ install.cancel();
+ });
+ this.expectingCancelled = false;
+ this.endTest();
+ }
+ },
+
+ // Addon Install Listener
+
+ onNewInstall(install) {
+ this.runningInstalls.push(install);
+
+ if (this.finalContentEvent && !this.waitingForEvent) {
+ this.waitingForEvent = true;
+ info("Waiting for " + this.finalContentEvent);
+ BrowserTestUtils.waitForContentEvent(
+ this._boundWin.get().gBrowser.selectedBrowser,
+ this.finalContentEvent,
+ true,
+ null,
+ true
+ ).then(() => {
+ info("Saw " + this.finalContentEvent + "," + this.waitingForEvent);
+ this.waitingForEvent = false;
+ if (this.pendingCount == 0) {
+ this.endTest();
+ }
+ });
+ }
+ },
+
+ onDownloadStarted(install) {
+ this.pendingCount++;
+ if (this.downloadStartedCallback) {
+ this.downloadStartedCallback(install);
+ }
+ },
+
+ onDownloadProgress(install) {
+ if (this.downloadProgressCallback) {
+ this.downloadProgressCallback(install);
+ }
+ },
+
+ onDownloadEnded(install) {
+ if (this.downloadEndedCallback) {
+ this.downloadEndedCallback(install);
+ }
+ },
+
+ onDownloadCancelled(install) {
+ isnot(
+ this.runningInstalls.indexOf(install),
+ -1,
+ "Should only see cancelations for started installs"
+ );
+ this.runningInstalls.splice(this.runningInstalls.indexOf(install), 1);
+
+ if (
+ this.downloadCancelledCallback &&
+ this.downloadCancelledCallback(install) === false
+ ) {
+ return;
+ }
+ this.checkTestEnded();
+ },
+
+ onDownloadFailed(install) {
+ if (this.downloadFailedCallback) {
+ this.downloadFailedCallback(install);
+ }
+ this.checkTestEnded();
+ },
+
+ onInstallStarted(install) {
+ if (this.installStartedCallback) {
+ this.installStartedCallback(install);
+ }
+ },
+
+ async onInstallEnded(install, addon) {
+ this.installCount++;
+ if (this.installEndedCallback) {
+ await this.installEndedCallback(install, addon);
+ }
+ this.checkTestEnded();
+ },
+
+ onInstallFailed(install) {
+ if (this.installFailedCallback) {
+ this.installFailedCallback(install);
+ }
+ this.checkTestEnded();
+ },
+
+ onUninstalled(addon) {
+ let idx = this.runningInstalls.findIndex(install => install.addon == addon);
+ if (idx != -1) {
+ this.runningInstalls.splice(idx, 1);
+ this.checkTestEnded();
+ }
+ },
+
+ onInstallCancelled(install) {
+ // This is ugly. We have a bunch of tests that cancel installs
+ // but don't expect this event to be raised.
+ // For at least one test (browser_whitelist3.js), we used to generate
+ // onDownloadCancelled when the user cancelled the installation at the
+ // confirmation prompt. We're now generating onInstallCancelled instead
+ // of onDownloadCancelled but making this code unconditional breaks a
+ // bunch of other tests. Ugh.
+ let idx = this.runningInstalls.indexOf(install);
+ if (idx != -1) {
+ this.runningInstalls.splice(this.runningInstalls.indexOf(install), 1);
+ this.checkTestEnded();
+ }
+ },
+
+ checkTestEnded() {
+ if (--this.pendingCount == 0 && !this.waitingForEvent) {
+ this.endTest();
+ }
+ },
+
+ // nsIObserver
+
+ observe(subject, topic, data) {
+ var installInfo = subject.wrappedJSObject;
+ switch (topic) {
+ case "addon-install-started":
+ is(
+ this.runningInstalls.length,
+ installInfo.installs.length,
+ "Should have seen the expected number of installs started"
+ );
+ break;
+ case "addon-install-disabled":
+ this.installDisabled(installInfo);
+ break;
+ case "addon-install-cancelled":
+ this.installCancelled(installInfo);
+ break;
+ case "addon-install-origin-blocked":
+ this.installOriginBlocked(installInfo);
+ break;
+ case "addon-install-blocked":
+ this.installBlocked(installInfo);
+ break;
+ case "addon-install-failed":
+ installInfo.installs.forEach(function (aInstall) {
+ isnot(
+ this.runningInstalls.indexOf(aInstall),
+ -1,
+ "Should only see failures for started installs"
+ );
+
+ ok(
+ aInstall.error != 0 || aInstall.addon.appDisabled,
+ "Failed installs should have an error or be appDisabled"
+ );
+
+ this.runningInstalls.splice(
+ this.runningInstalls.indexOf(aInstall),
+ 1
+ );
+ }, this);
+ break;
+ case "tabmodal-dialog-loaded":
+ let browser = subject.ownerGlobal.gBrowser.selectedBrowser;
+ let prompt = browser.tabModalPromptBox.getPrompt(subject);
+ this.promptReady(prompt.Dialog);
+ break;
+ case "common-dialog-loaded":
+ this.promptReady(subject.Dialog);
+ break;
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
diff --git a/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi b/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi
new file mode 100644
index 0000000000..de895fd1d9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/incompatible.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/installchrome.html b/toolkit/mozapps/extensions/test/xpinstall/installchrome.html
new file mode 100644
index 0000000000..d9ff573ab5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/installchrome.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept a url as the uri query and pass it to InstallTrigger.installChrome -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function startInstall() {
+ InstallTrigger.installChrome(InstallTrigger.SKIN,
+ decodeURIComponent(document.location.search.substring(1)),
+ "test");
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html b/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html
new file mode 100644
index 0000000000..d68e4acbe3
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/installtrigger.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some json as the uri query and pass it to InstallTrigger.install -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function installCallback(url, status) {
+ document.getElementById("status").textContent = status;
+
+ dump("Sending InstallComplete\n");
+ var event = new CustomEvent("InstallComplete");
+ var target = window.parent ? window.parent : window;
+ target.dispatchEvent(event);
+}
+
+function startInstall(viaWindowLoaded = false) {
+ var event = new CustomEvent("InstallTriggered");
+ var text;
+ if (viaWindowLoaded) {
+ text = decodeURIComponent(document.location.search.substring(1));
+ } else {
+ text = decodeURIComponent(document.location.search.substring("?manualStartInstall".length));
+ }
+ var triggers = JSON.parse(text);
+ try {
+ document.getElementById("return").textContent = InstallTrigger.install(triggers, installCallback);
+ dump("Sending InstallTriggered\n");
+ window.dispatchEvent(event);
+ } catch (e) {
+ document.getElementById("return").textContent = "exception";
+ dump("Sending InstallTriggered\n");
+ window.dispatchEvent(event);
+ if (viaWindowLoaded) {
+ throw e;
+ }
+ }
+}
+
+window.onload = function () {
+ if (!document.location.search.startsWith("?manualStartInstall")) {
+ startInstall(true);
+ }
+}
+</script>
+</head>
+<body>
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html b/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html
new file mode 100644
index 0000000000..7e4bccab18
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/installtrigger_frame.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some url as the uri query and load it in
+ an inner iframe, which will run InstallTrigger.install -->
+
+<head>
+<title>InstallTrigger frame tests</title>
+<script type="text/javascript">
+/* exported prepChild */
+function prepChild() {
+ // Pass our parameters over to the child
+ var child = window.frames[0];
+ var url = decodeURIComponent(document.location.search.substr(1));
+ child.location = url;
+}
+</script>
+</head>
+<body onload="prepChild()">
+
+<iframe src="about:blank">
+</iframe>
+
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/navigate.html b/toolkit/mozapps/extensions/test/xpinstall/navigate.html
new file mode 100644
index 0000000000..96052009b9
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/navigate.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept some url as the uri query and navigate to it by
+ clicking a link -->
+
+<head>
+<title>Navigation tests</title>
+<script type="text/javascript">
+/* exported navigate */
+function navigate() {
+ var url = decodeURIComponent(document.location.search.substr(1));
+ var link = document.getElementById("link");
+ link.href = url;
+ link.click();
+}
+</script>
+</head>
+<body onload="navigate()">
+
+<p><a id="link">Test Link</a></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/recommended.xpi b/toolkit/mozapps/extensions/test/xpinstall/recommended.xpi
new file mode 100644
index 0000000000..e180decfc5
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/recommended.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs b/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs
new file mode 100644
index 0000000000..14236ee821
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/redirect.sjs
@@ -0,0 +1,39 @@
+// Script has two modes based on the query string. If the mode is "setup" then
+// parameters from the query string configure the redirection. If the mode is
+// "redirect" then a redirect is returned
+
+function handleRequest(request, response) {
+ let parts = request.queryString.split("&");
+ let settings = {};
+
+ parts.forEach(function (aString) {
+ let [k, v] = aString.split("=");
+ settings[k] = decodeURIComponent(v);
+ });
+
+ if (settings.mode == "setup") {
+ delete settings.mode;
+
+ // Object states must be an nsISupports
+ var state = {
+ settings,
+ QueryInterface: ChromeUtils.generateQI([]),
+ };
+ state.wrappedJSObject = state;
+
+ setObjectState("xpinstall-redirect-settings", state);
+ response.setStatusLine(request.httpVersion, 200, "Ok");
+ response.setHeader("Content-Type", "text/plain");
+ response.write("Setup complete");
+ } else if (settings.mode == "redirect") {
+ getObjectState("xpinstall-redirect-settings", function (aObject) {
+ settings = aObject.wrappedJSObject.settings;
+ });
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ for (var name in settings) {
+ response.setHeader(name, settings[name]);
+ }
+ response.write("Done");
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/restartless.xpi b/toolkit/mozapps/extensions/test/xpinstall/restartless.xpi
new file mode 100644
index 0000000000..9fee8f60b1
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/restartless.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs b/toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs
new file mode 100644
index 0000000000..e2a889c329
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/slowinstall.sjs
@@ -0,0 +1,103 @@
+// In an SJS file we need to get NetUtil ourselves, despite
+// what eslint might think applies for browser tests.
+// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix
+let { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+const RELATIVE_PATH = "browser/toolkit/mozapps/extensions/test/xpinstall";
+const NOTIFICATION_TOPIC = "slowinstall-complete";
+
+/**
+ * Helper function to create a JS object representing the url parameters from
+ * the request's queryString.
+ *
+ * @param aQueryString
+ * The request's query string.
+ * @return A JS object representing the url parameters from the request's
+ * queryString.
+ */
+function parseQueryString(aQueryString) {
+ var paramArray = aQueryString.split("&");
+ var regex = /^([^=]+)=(.*)$/;
+ var params = {};
+ for (var i = 0, sz = paramArray.length; i < sz; i++) {
+ var match = regex.exec(paramArray[i]);
+ if (!match) {
+ throw new Error("Bad parameter in queryString! '" + paramArray[i] + "'");
+ }
+ params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
+ }
+
+ return params;
+}
+
+function handleRequest(aRequest, aResponse) {
+ let id = +getState("ID");
+ setState("ID", "" + (id + 1));
+
+ function LOG(str) {
+ dump("slowinstall.sjs[" + id + "]: " + str + "\n");
+ }
+
+ aResponse.setStatusLine(aRequest.httpVersion, 200, "OK");
+
+ var params = {};
+ if (aRequest.queryString) {
+ params = parseQueryString(aRequest.queryString);
+ }
+
+ if (params.file) {
+ let xpiFile = "";
+
+ function complete_download() {
+ LOG("Completing download");
+
+ try {
+ // Doesn't seem to be a sane way to read using IOUtils and write to an
+ // nsIOutputStream so here we are.
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(xpiFile);
+ let stream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ stream.init(file, -1, -1, stream.DEFER_OPEN + stream.CLOSE_ON_EOF);
+
+ NetUtil.asyncCopy(stream, aResponse.bodyOutputStream, () => {
+ LOG("Download complete");
+ aResponse.finish();
+ });
+ } catch (e) {
+ LOG("Exception " + e);
+ }
+ }
+
+ let waitForComplete = new Promise(resolve => {
+ function complete() {
+ Services.obs.removeObserver(complete, NOTIFICATION_TOPIC);
+ resolve();
+ }
+
+ Services.obs.addObserver(complete, NOTIFICATION_TOPIC);
+ });
+
+ aResponse.processAsync();
+
+ const dir = Services.dirsvc.get("CurWorkD", Ci.nsIFile).path;
+ xpiFile = PathUtils.join(dir, ...RELATIVE_PATH.split("/"), params.file);
+ LOG("Starting slow download of " + xpiFile);
+
+ IOUtils.stat(xpiFile).then(info => {
+ aResponse.setHeader("Content-Type", "binary/octet-stream");
+ aResponse.setHeader("Content-Length", info.size.toString());
+
+ LOG("Download paused");
+ waitForComplete.then(complete_download);
+ });
+ } else if (params.continue) {
+ dump(
+ "slowinstall.sjs: Received signal to complete all current downloads.\n"
+ );
+ Services.obs.notifyObservers(null, NOTIFICATION_TOPIC);
+ }
+}
diff --git a/toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html b/toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html
new file mode 100644
index 0000000000..83792ebdb2
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/startsoftwareupdate.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will accept a url as the uri query and pass it to InstallTrigger.startSoftwareUpdate -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function startInstall() {
+ InstallTrigger.startSoftwareUpdate(decodeURIComponent(document.location.search.substring(1)));
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html b/toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html
new file mode 100644
index 0000000000..1b098d6948
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/triggerredirect.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
+<html>
+
+<!-- This page will attempt an install and then try to load a new page in the tab -->
+
+<head>
+<title>InstallTrigger tests</title>
+<script type="text/javascript">
+/* globals InstallTrigger */
+/* exported startInstall */
+function installCallback(url, status) {
+ document.location = "#foo";
+
+ dump("Sending InstallComplete\n");
+ var event = new CustomEvent("InstallComplete");
+ window.dispatchEvent(event);
+}
+
+function startInstall() {
+ InstallTrigger.install({
+ "Unsigned XPI": {
+ URL: "amosigned.xpi",
+ IconURL: "icon.png",
+ toString() { return this.URL; },
+ },
+ }, installCallback);
+}
+</script>
+</head>
+<body onload="startInstall()">
+<p>InstallTrigger tests</p>
+<p id="return"></p>
+<p id="status"></p>
+</body>
+</html>
diff --git a/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi b/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi
new file mode 100644
index 0000000000..95f99a748f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/unsigned.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpi b/toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpi
new file mode 100644
index 0000000000..7ef5534f45
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/unsigned_mv3.xpi
Binary files differ
diff --git a/toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpi b/toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpi
new file mode 100644
index 0000000000..9a2effdd0f
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpinstall/webmidi_permission.xpi
Binary files differ
diff --git a/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs b/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs
new file mode 100644
index 0000000000..e916fdad4c
--- /dev/null
+++ b/toolkit/mozapps/handling/ContentDispatchChooser.sys.mjs
@@ -0,0 +1,465 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Constants
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs";
+
+const DIALOG_URL_APP_CHOOSER =
+ "chrome://mozapps/content/handling/appChooser.xhtml";
+const DIALOG_URL_PERMISSION =
+ "chrome://mozapps/content/handling/permissionDialog.xhtml";
+
+const gPrefs = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ gPrefs,
+ "promptForExternal",
+ "network.protocol-handler.prompt-from-external",
+ true
+);
+
+const PROTOCOL_HANDLER_OPEN_PERM_KEY = "open-protocol-handler";
+const PERMISSION_KEY_DELIMITER = "^";
+
+export class nsContentDispatchChooser {
+ /**
+ * Prompt the user to open an external application.
+ * If the triggering principal doesn't have permission to open apps for the
+ * protocol of aURI, we show a permission prompt first.
+ * If the caller has permission and a preferred handler is set, we skip the
+ * dialogs and directly open the handler.
+ * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers.
+ * @param {nsIURI} aURI - URI to be handled.
+ * @param {nsIPrincipal} [aPrincipal] - Principal which triggered the load.
+ * @param {BrowsingContext} [aBrowsingContext] - Context of the load.
+ * @param {bool} [aTriggeredExternally] - Whether the load came from outside
+ * this application.
+ */
+ async handleURI(
+ aHandler,
+ aURI,
+ aPrincipal,
+ aBrowsingContext,
+ aTriggeredExternally = false
+ ) {
+ let callerHasPermission = this._hasProtocolHandlerPermission(
+ aHandler.type,
+ aPrincipal,
+ aTriggeredExternally
+ );
+
+ // Force showing the dialog for links passed from outside the application.
+ // This avoids infinite loops, see bug 1678255, bug 1667468, etc.
+ if (
+ aTriggeredExternally &&
+ gPrefs.promptForExternal &&
+ // ... unless we intend to open the link with a website or extension:
+ !(
+ aHandler.preferredAction == Ci.nsIHandlerInfo.useHelperApp &&
+ aHandler.preferredApplicationHandler instanceof Ci.nsIWebHandlerApp
+ )
+ ) {
+ aHandler.alwaysAskBeforeHandling = true;
+ }
+
+ if ("mailto" === aURI.scheme) {
+ Glean.protocolhandlerMailto.visit.record({
+ triggered_externally: aTriggeredExternally,
+ });
+ }
+
+ // Skip the dialog if a preferred application is set and the caller has
+ // permission.
+ if (
+ callerHasPermission &&
+ !aHandler.alwaysAskBeforeHandling &&
+ (aHandler.preferredAction == Ci.nsIHandlerInfo.useHelperApp ||
+ aHandler.preferredAction == Ci.nsIHandlerInfo.useSystemDefault)
+ ) {
+ try {
+ aHandler.launchWithURI(aURI, aBrowsingContext);
+ return;
+ } catch (error) {
+ // We are not supposed to ask, but when file not found the user most likely
+ // uninstalled the application which handles the uri so we will continue
+ // by application chooser dialog.
+ if (error.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
+ aHandler.alwaysAskBeforeHandling = true;
+ } else {
+ throw error;
+ }
+ }
+ }
+
+ let shouldOpenHandler = false;
+
+ try {
+ shouldOpenHandler = await this._prompt(
+ aHandler,
+ aPrincipal,
+ callerHasPermission,
+ aBrowsingContext,
+ aURI
+ );
+ } catch (error) {
+ console.error(error.message);
+ }
+
+ if (!shouldOpenHandler) {
+ return;
+ }
+
+ // Site was granted permission and user chose to open application.
+ // Launch the external handler.
+ aHandler.launchWithURI(aURI, aBrowsingContext);
+ }
+
+ /**
+ * Get the name of the application set to handle the the protocol.
+ * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers.
+ * @returns {string|null} - Human readable handler name or null if the user
+ * is expected to set a handler.
+ */
+ _getHandlerName(aHandler) {
+ if (aHandler.alwaysAskBeforeHandling) {
+ return null;
+ }
+ if (
+ aHandler.preferredAction == Ci.nsIHandlerInfo.useSystemDefault &&
+ aHandler.hasDefaultHandler
+ ) {
+ return aHandler.defaultDescription;
+ }
+ return aHandler.preferredApplicationHandler?.name;
+ }
+
+ /**
+ * Show permission or/and app chooser prompt.
+ * @param {nsIHandlerInfo} aHandler - Info about protocol and handlers.
+ * @param {nsIPrincipal} aPrincipal - Principal which triggered the load.
+ * @param {boolean} aHasPermission - Whether the caller has permission to
+ * open the protocol.
+ * @param {BrowsingContext} [aBrowsingContext] - Context associated with the
+ * protocol navigation.
+ */
+ async _prompt(aHandler, aPrincipal, aHasPermission, aBrowsingContext, aURI) {
+ let shouldOpenHandler = false;
+ let resetHandlerChoice = false;
+ let updateHandlerData = false;
+
+ const isStandardProtocol = E10SUtils.STANDARD_SAFE_PROTOCOLS.includes(
+ aURI.scheme
+ );
+ const {
+ hasDefaultHandler,
+ preferredApplicationHandler,
+ alwaysAskBeforeHandling,
+ } = aHandler;
+
+ // This will skip the app chooser dialog flow unless the user explicitly opts to choose
+ // another app in the permission dialog.
+ if (
+ !isStandardProtocol &&
+ hasDefaultHandler &&
+ preferredApplicationHandler == null &&
+ alwaysAskBeforeHandling
+ ) {
+ aHandler.alwaysAskBeforeHandling = false;
+ updateHandlerData = true;
+ }
+
+ // If caller does not have permission, prompt the user.
+ if (!aHasPermission) {
+ let canPersistPermission = this._isSupportedPrincipal(aPrincipal);
+
+ let outArgs = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ // Whether the permission request was granted
+ outArgs.setProperty("granted", false);
+ // If the user wants to select a new application for the protocol.
+ // This will cause us to show the chooser dialog, even if an app is set.
+ outArgs.setProperty("resetHandlerChoice", null);
+ // If the we should store the permission and not prompt again for it.
+ outArgs.setProperty("remember", null);
+
+ await this._openDialog(
+ DIALOG_URL_PERMISSION,
+ {
+ handler: aHandler,
+ principal: aPrincipal,
+ browsingContext: aBrowsingContext,
+ outArgs,
+ canPersistPermission,
+ preferredHandlerName: this._getHandlerName(aHandler),
+ },
+ aBrowsingContext
+ );
+ if (!outArgs.getProperty("granted")) {
+ // User denied request
+ return false;
+ }
+
+ // Check if user wants to set a new application to handle the protocol.
+ resetHandlerChoice = outArgs.getProperty("resetHandlerChoice");
+
+ // If the user wants to select a new app we don't persist the permission.
+ if (!resetHandlerChoice && aPrincipal) {
+ let remember = outArgs.getProperty("remember");
+ this._updatePermission(aPrincipal, aHandler.type, remember);
+ }
+
+ shouldOpenHandler = true;
+ }
+
+ // Prompt if the user needs to make a handler choice for the protocol.
+ if (aHandler.alwaysAskBeforeHandling || resetHandlerChoice) {
+ // User has not set a preferred application to handle this protocol scheme.
+ // Open the application chooser dialog
+ let outArgs = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag
+ );
+ outArgs.setProperty("openHandler", false);
+ outArgs.setProperty("preferredAction", aHandler.preferredAction);
+ outArgs.setProperty(
+ "preferredApplicationHandler",
+ aHandler.preferredApplicationHandler
+ );
+ outArgs.setProperty(
+ "alwaysAskBeforeHandling",
+ aHandler.alwaysAskBeforeHandling
+ );
+ let usePrivateBrowsing = aBrowsingContext?.usePrivateBrowsing;
+ await this._openDialog(
+ DIALOG_URL_APP_CHOOSER,
+ {
+ handler: aHandler,
+ outArgs,
+ usePrivateBrowsing,
+ enableButtonDelay: aHasPermission,
+ },
+ aBrowsingContext
+ );
+
+ shouldOpenHandler = outArgs.getProperty("openHandler");
+
+ // If the user accepted the dialog, apply their selection.
+ if (shouldOpenHandler) {
+ for (let prop of [
+ "preferredAction",
+ "preferredApplicationHandler",
+ "alwaysAskBeforeHandling",
+ ]) {
+ aHandler[prop] = outArgs.getProperty(prop);
+ }
+ updateHandlerData = true;
+ }
+ }
+
+ if (updateHandlerData) {
+ // Store handler data
+ Cc["@mozilla.org/uriloader/handler-service;1"]
+ .getService(Ci.nsIHandlerService)
+ .store(aHandler);
+ }
+
+ return shouldOpenHandler;
+ }
+
+ /**
+ * Test if a given principal has the open-protocol-handler permission for a
+ * specific protocol.
+ * @param {string} scheme - Scheme of the protocol.
+ * @param {nsIPrincipal} aPrincipal - Principal to test for permission.
+ * @returns {boolean} - true if permission is set, false otherwise.
+ */
+ _hasProtocolHandlerPermission(scheme, aPrincipal, aTriggeredExternally) {
+ // Permission disabled by pref
+ if (!nsContentDispatchChooser.isPermissionEnabled) {
+ return true;
+ }
+
+ // If a handler is set to open externally by default we skip the dialog.
+ if (
+ Services.prefs.getBoolPref(
+ "network.protocol-handler.external." + scheme,
+ false
+ )
+ ) {
+ return true;
+ }
+
+ if (
+ !aPrincipal ||
+ (aPrincipal.isSystemPrincipal && !aTriggeredExternally)
+ ) {
+ return false;
+ }
+
+ let key = this._getSkipProtoDialogPermissionKey(scheme);
+ return (
+ Services.perms.testPermissionFromPrincipal(aPrincipal, key) ===
+ Services.perms.ALLOW_ACTION
+ );
+ }
+
+ /**
+ * Get open-protocol-handler permission key for a protocol.
+ * @param {string} aProtocolScheme - Scheme of the protocol.
+ * @returns {string} - Permission key.
+ */
+ _getSkipProtoDialogPermissionKey(aProtocolScheme) {
+ return (
+ PROTOCOL_HANDLER_OPEN_PERM_KEY +
+ PERMISSION_KEY_DELIMITER +
+ aProtocolScheme
+ );
+ }
+
+ /**
+ * Opens a dialog as a SubDialog on tab level.
+ * If we don't have a BrowsingContext or tab level dialogs are not supported,
+ * we will fallback to a standalone window.
+ * @param {string} aDialogURL - URL of the dialog to open.
+ * @param {Object} aDialogArgs - Arguments passed to the dialog.
+ * @param {BrowsingContext} [aBrowsingContext] - BrowsingContext associated
+ * with the tab the dialog is associated with.
+ */
+ async _openDialog(aDialogURL, aDialogArgs, aBrowsingContext) {
+ // Make the app chooser dialog resizable
+ let resizable = `resizable=${
+ aDialogURL == DIALOG_URL_APP_CHOOSER ? "yes" : "no"
+ }`;
+
+ if (aBrowsingContext) {
+ let window = aBrowsingContext.topChromeWindow;
+ if (!window) {
+ throw new Error(
+ "Can't show external protocol dialog. BrowsingContext has no chrome window associated."
+ );
+ }
+
+ let { topFrameElement } = aBrowsingContext;
+ if (topFrameElement?.tagName != "browser") {
+ throw new Error(
+ "Can't show external protocol dialog. BrowsingContext has no browser associated."
+ );
+ }
+
+ // If the app does not support window.gBrowser or getTabDialogBox(),
+ // fallback to the standalone application chooser window.
+ let getTabDialogBox = window.gBrowser?.getTabDialogBox;
+ if (getTabDialogBox) {
+ return getTabDialogBox(topFrameElement).open(
+ aDialogURL,
+ {
+ features: resizable,
+ allowDuplicateDialogs: false,
+ keepOpenSameOriginNav: true,
+ },
+ aDialogArgs
+ ).closedPromise;
+ }
+ }
+
+ // If we don't have a BrowsingContext, we need to show a standalone window.
+ let win = Services.ww.openWindow(
+ null,
+ aDialogURL,
+ null,
+ `chrome,dialog=yes,centerscreen,${resizable}`,
+ aDialogArgs
+ );
+
+ // Wait until window is closed.
+ return new Promise(resolve => {
+ win.addEventListener("unload", function onUnload(event) {
+ if (event.target.location != aDialogURL) {
+ return;
+ }
+ win.removeEventListener("unload", onUnload);
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Update the open-protocol-handler permission for the site which triggered
+ * the dialog. Sites with this permission may skip this dialog.
+ * @param {nsIPrincipal} aPrincipal - subject to update the permission for.
+ * @param {string} aScheme - Scheme of protocol to allow.
+ * @param {boolean} aAllow - Whether to set / unset the permission.
+ */
+ _updatePermission(aPrincipal, aScheme, aAllow) {
+ // If enabled, store open-protocol-handler permission for content principals.
+ if (
+ !nsContentDispatchChooser.isPermissionEnabled ||
+ aPrincipal.isSystemPrincipal ||
+ !this._isSupportedPrincipal(aPrincipal)
+ ) {
+ return;
+ }
+
+ let principal = aPrincipal;
+
+ // If this action was triggered by an extension content script then set the
+ // permission on the extension's principal.
+ let addonPolicy = aPrincipal.contentScriptAddonPolicy;
+ if (addonPolicy) {
+ principal = Services.scriptSecurityManager.principalWithOA(
+ addonPolicy.extension.principal,
+ principal.originAttributes
+ );
+ }
+
+ let permKey = this._getSkipProtoDialogPermissionKey(aScheme);
+ if (aAllow) {
+ Services.perms.addFromPrincipal(
+ principal,
+ permKey,
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ } else {
+ Services.perms.removeFromPrincipal(principal, permKey);
+ }
+ }
+
+ /**
+ * Determine if we can use a principal to store permissions.
+ * @param {nsIPrincipal} aPrincipal - Principal to test.
+ * @returns {boolean} - true if we can store permissions, false otherwise.
+ */
+ _isSupportedPrincipal(aPrincipal) {
+ if (!aPrincipal) {
+ return false;
+ }
+
+ // If this is an add-on content script then we will be able to store
+ // permissions against the add-on's principal.
+ if (aPrincipal.contentScriptAddonPolicy) {
+ return true;
+ }
+
+ return ["http", "https", "moz-extension", "file"].some(scheme =>
+ aPrincipal.schemeIs(scheme)
+ );
+ }
+}
+
+nsContentDispatchChooser.prototype.classID = Components.ID(
+ "e35d5067-95bc-4029-8432-e8f1e431148d"
+);
+nsContentDispatchChooser.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIContentDispatchChooser",
+]);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ nsContentDispatchChooser,
+ "isPermissionEnabled",
+ "security.external_protocol_requires_permission",
+ true
+);
diff --git a/toolkit/mozapps/handling/components.conf b/toolkit/mozapps/handling/components.conf
new file mode 100644
index 0000000000..95afe50a87
--- /dev/null
+++ b/toolkit/mozapps/handling/components.conf
@@ -0,0 +1,14 @@
+# -*- 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': '{e35d5067-95bc-4029-8432-e8f1e431148d}',
+ 'contract_ids': ['@mozilla.org/content-dispatch-chooser;1'],
+ 'esModule': 'resource://gre/modules/ContentDispatchChooser.sys.mjs',
+ 'constructor': 'nsContentDispatchChooser',
+ },
+]
diff --git a/toolkit/mozapps/handling/content/appChooser.js b/toolkit/mozapps/handling/content/appChooser.js
new file mode 100644
index 0000000000..2958ad68b4
--- /dev/null
+++ b/toolkit/mozapps/handling/content/appChooser.js
@@ -0,0 +1,369 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { PrivateBrowsingUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"
+);
+const { EnableDelayHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromptUtils.sys.mjs"
+);
+
+class MozHandler extends window.MozElements.MozRichlistitem {
+ static get markup() {
+ return `
+ <vbox pack="center">
+ <html:img alt="" height="32" width="32" loading="lazy" />
+ </vbox>
+ <vbox flex="1">
+ <label class="name"/>
+ <label class="description"/>
+ </vbox>
+ `;
+ }
+
+ connectedCallback() {
+ this.textContent = "";
+ this.appendChild(this.constructor.fragment);
+ this.initializeAttributeInheritance();
+ }
+
+ static get inheritedAttributes() {
+ return {
+ img: "src=image,disabled",
+ ".name": "value=name,disabled",
+ ".description": "value=description,disabled",
+ };
+ }
+
+ get label() {
+ return `${this.getAttribute("name")} ${this.getAttribute("description")}`;
+ }
+}
+
+customElements.define("mozapps-handler", MozHandler, {
+ extends: "richlistitem",
+});
+
+window.addEventListener("DOMContentLoaded", () => dialog.initialize(), {
+ once: true,
+});
+
+let dialog = {
+ /**
+ * This function initializes the content of the dialog.
+ */
+ initialize() {
+ let args = window.arguments[0].wrappedJSObject || window.arguments[0];
+ let { handler, outArgs, usePrivateBrowsing, enableButtonDelay } = args;
+
+ this._handlerInfo = handler.QueryInterface(Ci.nsIHandlerInfo);
+ this._outArgs = outArgs;
+
+ this.isPrivate =
+ usePrivateBrowsing ||
+ (window.opener && PrivateBrowsingUtils.isWindowPrivate(window.opener));
+
+ this._dialog = document.querySelector("dialog");
+ this._itemChoose = document.getElementById("item-choose");
+ this._rememberCheck = document.getElementById("remember");
+
+ // Register event listener for the checkbox hint.
+ this._rememberCheck.addEventListener("change", () => this.onCheck());
+
+ document.addEventListener("dialogaccept", () => {
+ this.onAccept();
+ });
+
+ // UI is ready, lets populate our list
+ this.populateList();
+
+ this.initL10n();
+
+ if (enableButtonDelay) {
+ this._delayHelper = new EnableDelayHelper({
+ disableDialog: () => {
+ this._acceptBtnDisabled = true;
+ this.updateAcceptButton();
+ },
+ enableDialog: () => {
+ this._acceptBtnDisabled = false;
+ this.updateAcceptButton();
+ },
+ focusTarget: window,
+ });
+ }
+ },
+
+ initL10n() {
+ let rememberLabel = document.getElementById("remember-label");
+ document.l10n.setAttributes(rememberLabel, "chooser-dialog-remember", {
+ scheme: this._handlerInfo.type,
+ });
+
+ let description = document.getElementById("description");
+ document.l10n.setAttributes(description, "chooser-dialog-description", {
+ scheme: this._handlerInfo.type,
+ });
+ },
+
+ /**
+ * Populates the list that a user can choose from.
+ */
+ populateList: function populateList() {
+ var items = document.getElementById("items");
+ var possibleHandlers = this._handlerInfo.possibleApplicationHandlers;
+ var preferredHandler = this._handlerInfo.preferredApplicationHandler;
+ for (let i = possibleHandlers.length - 1; i >= 0; --i) {
+ let app = possibleHandlers.queryElementAt(i, Ci.nsIHandlerApp);
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.setAttribute("name", app.name);
+ elm.obj = app;
+
+ // We defer loading the favicon so it doesn't delay load. The dialog is
+ // opened in a SubDialog which will only show on window load.
+ if (app instanceof Ci.nsILocalHandlerApp) {
+ // See if we have an nsILocalHandlerApp and set the icon
+ let uri = Services.io.newFileURI(app.executable);
+ elm.setAttribute("image", "moz-icon://" + uri.spec + "?size=32");
+ } else if (app instanceof Ci.nsIWebHandlerApp) {
+ let uri = Services.io.newURI(app.uriTemplate);
+ if (/^https?$/.test(uri.scheme)) {
+ // Unfortunately we can't use the favicon service to get the favicon,
+ // because the service looks for a record with the exact URL we give
+ // it, and users won't have such records for URLs they don't visit,
+ // and users won't visit the handler's URL template, they'll only
+ // visit URLs derived from that template (i.e. with %s in the template
+ // replaced by the URL of the content being handled).
+ elm.setAttribute("image", uri.prePath + "/favicon.ico");
+ }
+ elm.setAttribute("description", uri.prePath);
+
+ // Check for extensions needing private browsing access before
+ // creating UI elements.
+ if (this.isPrivate) {
+ let policy = WebExtensionPolicy.getByURI(uri);
+ if (policy && !policy.privateBrowsingAllowed) {
+ elm.setAttribute("disabled", true);
+ this.getPrivateBrowsingDisabledLabel().then(label => {
+ elm.setAttribute("description", label);
+ });
+ if (app == preferredHandler) {
+ preferredHandler = null;
+ }
+ }
+ }
+ } else if (app instanceof Ci.nsIDBusHandlerApp) {
+ elm.setAttribute("description", app.method);
+ } else if (!(app instanceof Ci.nsIGIOMimeApp)) {
+ // We support GIO application handler, but no action required there
+ throw new Error("unknown handler type");
+ }
+
+ items.insertBefore(elm, this._itemChoose);
+ if (preferredHandler && app == preferredHandler) {
+ this.selectedItem = elm;
+ }
+ }
+
+ if (this._handlerInfo.hasDefaultHandler) {
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.id = "os-default-handler";
+ elm.setAttribute("name", this._handlerInfo.defaultDescription);
+
+ items.insertBefore(elm, items.firstChild);
+ if (
+ this._handlerInfo.preferredAction == Ci.nsIHandlerInfo.useSystemDefault
+ ) {
+ this.selectedItem = elm;
+ }
+ }
+
+ // Add gio handlers
+ if (Cc["@mozilla.org/gio-service;1"]) {
+ let gIOSvc = Cc["@mozilla.org/gio-service;1"].getService(
+ Ci.nsIGIOService
+ );
+ var gioApps = gIOSvc.getAppsForURIScheme(this._handlerInfo.type);
+ for (let handler of gioApps.enumerate(Ci.nsIHandlerApp)) {
+ // OS handler share the same name, it's most likely the same app, skipping...
+ if (handler.name == this._handlerInfo.defaultDescription) {
+ continue;
+ }
+ // Check if the handler is already in possibleHandlers
+ let appAlreadyInHandlers = false;
+ for (let i = possibleHandlers.length - 1; i >= 0; --i) {
+ let app = possibleHandlers.queryElementAt(i, Ci.nsIHandlerApp);
+ // nsGIOMimeApp::Equals is able to compare with nsILocalHandlerApp
+ if (handler.equals(app)) {
+ appAlreadyInHandlers = true;
+ break;
+ }
+ }
+ if (!appAlreadyInHandlers) {
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.setAttribute("name", handler.name);
+ elm.obj = handler;
+ items.insertBefore(elm, this._itemChoose);
+ }
+ }
+ }
+
+ items.ensureSelectedElementIsVisible();
+ },
+
+ /**
+ * Brings up a filepicker and allows a user to choose an application.
+ */
+ async chooseApplication() {
+ let title = await this.getChooseAppWindowTitle();
+
+ var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(window, title, Ci.nsIFilePicker.modeOpen);
+ fp.appendFilters(Ci.nsIFilePicker.filterApps);
+
+ fp.open(rv => {
+ if (rv == Ci.nsIFilePicker.returnOK && fp.file) {
+ let uri = Services.io.newFileURI(fp.file);
+
+ let handlerApp = Cc[
+ "@mozilla.org/uriloader/local-handler-app;1"
+ ].createInstance(Ci.nsILocalHandlerApp);
+ handlerApp.executable = fp.file;
+
+ // if this application is already in the list, select it and don't add it again
+ let parent = document.getElementById("items");
+ for (let i = 0; i < parent.childNodes.length; ++i) {
+ let elm = parent.childNodes[i];
+ if (
+ elm.obj instanceof Ci.nsILocalHandlerApp &&
+ elm.obj.equals(handlerApp)
+ ) {
+ parent.selectedItem = elm;
+ parent.ensureSelectedElementIsVisible();
+ return;
+ }
+ }
+
+ let elm = document.createXULElement("richlistitem", {
+ is: "mozapps-handler",
+ });
+ elm.setAttribute("name", fp.file.leafName);
+ elm.setAttribute("image", "moz-icon://" + uri.spec + "?size=32");
+ elm.obj = handlerApp;
+
+ parent.selectedItem = parent.insertBefore(elm, parent.firstChild);
+ parent.ensureSelectedElementIsVisible();
+ }
+ });
+ },
+
+ /**
+ * Function called when the OK button is pressed.
+ */
+ onAccept() {
+ this.updateHandlerData(this._rememberCheck.checked);
+ this._outArgs.setProperty("openHandler", true);
+ },
+
+ /**
+ * Determines if the accept button should be disabled or not
+ */
+ updateAcceptButton() {
+ this._dialog.setAttribute(
+ "buttondisabledaccept",
+ this._acceptBtnDisabled || this._itemChoose.selected
+ );
+ },
+
+ /**
+ * Update the handler info to reflect the user choice.
+ * @param {boolean} skipAsk - Whether we should persist the application
+ * choice and skip asking next time.
+ */
+ updateHandlerData(skipAsk) {
+ // We need to make sure that the default is properly set now
+ if (this.selectedItem.obj) {
+ // default OS handler doesn't have this property
+ this._outArgs.setProperty(
+ "preferredAction",
+ Ci.nsIHandlerInfo.useHelperApp
+ );
+ this._outArgs.setProperty(
+ "preferredApplicationHandler",
+ this.selectedItem.obj
+ );
+ } else {
+ this._outArgs.setProperty(
+ "preferredAction",
+ Ci.nsIHandlerInfo.useSystemDefault
+ );
+ }
+ this._outArgs.setProperty("alwaysAskBeforeHandling", !skipAsk);
+ },
+
+ /**
+ * Updates the UI based on the checkbox being checked or not.
+ */
+ onCheck() {
+ if (document.getElementById("remember").checked) {
+ document.getElementById("remember-text").setAttribute("visible", "true");
+ } else {
+ document.getElementById("remember-text").removeAttribute("visible");
+ }
+ },
+
+ /**
+ * Function called when the user double clicks on an item of the list
+ */
+ onDblClick: function onDblClick() {
+ if (this.selectedItem == this._itemChoose) {
+ this.chooseApplication();
+ } else {
+ this._dialog.acceptDialog();
+ }
+ },
+
+ // Getters / Setters
+
+ /**
+ * Returns/sets the selected element in the richlistbox
+ */
+ get selectedItem() {
+ return document.getElementById("items").selectedItem;
+ },
+ set selectedItem(aItem) {
+ document.getElementById("items").selectedItem = aItem;
+ },
+
+ /**
+ * Lazy l10n getter for the title of the app chooser window
+ */
+ async getChooseAppWindowTitle() {
+ if (!this._chooseAppWindowTitle) {
+ this._chooseAppWindowTitle = await document.l10n.formatValues([
+ "choose-other-app-window-title",
+ ]);
+ }
+ return this._chooseAppWindowTitle;
+ },
+
+ /**
+ * Lazy l10n getter for handler menu items which are disabled due to private
+ * browsing.
+ */
+ async getPrivateBrowsingDisabledLabel() {
+ if (!this._privateBrowsingDisabledLabel) {
+ this._privateBrowsingDisabledLabel = await document.l10n.formatValues([
+ "choose-dialog-privatebrowsing-disabled",
+ ]);
+ }
+ return this._privateBrowsingDisabledLabel;
+ },
+};
diff --git a/toolkit/mozapps/handling/content/appChooser.xhtml b/toolkit/mozapps/handling/content/appChooser.xhtml
new file mode 100644
index 0000000000..8ac984e387
--- /dev/null
+++ b/toolkit/mozapps/handling/content/appChooser.xhtml
@@ -0,0 +1,73 @@
+<?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/. -->
+
+<!DOCTYPE window>
+
+<window
+ persist="width height screenX screenY"
+ aria-describedby="description-text"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="chooser-window"
+ data-l10n-attrs="style"
+>
+ <dialog
+ id="handling"
+ buttons="accept,cancel"
+ defaultButton="none"
+ data-l10n-id="chooser-dialog"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept"
+ >
+ <linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <html:link
+ rel="stylesheet"
+ href="chrome://mozapps/content/handling/handler.css"
+ />
+ <html:link
+ rel="stylesheet"
+ href="chrome://mozapps/skin/handling/handling.css"
+ />
+
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="toolkit/global/handlerDialog.ftl" />
+ </linkset>
+
+ <script
+ src="chrome://mozapps/content/handling/appChooser.js"
+ type="application/javascript"
+ />
+
+ <description id="description" />
+
+ <vbox id="chooser" flex="1">
+ <richlistbox
+ id="items"
+ flex="1"
+ ondblclick="dialog.onDblClick();"
+ onselect="dialog.updateAcceptButton();"
+ >
+ <richlistitem id="item-choose" orient="horizontal" selected="true">
+ <label data-l10n-id="choose-other-app-description" flex="1" />
+ <button
+ oncommand="dialog.chooseApplication();"
+ data-l10n-id="choose-app-btn"
+ />
+ </richlistitem>
+ </richlistbox>
+ </vbox>
+
+ <vbox id="rememberContainer">
+ <html:label class="toggle-container-with-text">
+ <html:input type="checkbox" id="remember" />
+ <html:span id="remember-label" />
+ </html:label>
+ <description
+ id="remember-text"
+ data-l10n-id="chooser-dialog-remember-extra"
+ />
+ </vbox>
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/handling/content/handler.css b/toolkit/mozapps/handling/content/handler.css
new file mode 100644
index 0000000000..2e60b1b41f
--- /dev/null
+++ b/toolkit/mozapps/handling/content/handler.css
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace html "http://www.w3.org/1999/xhtml";
+
+#description {
+ font-weight: bold;
+}
+
+#remember-text:not([visible]) {
+ visibility: hidden;
+}
+
+dialog {
+ padding: 16px calc(16px - 4px);
+}
+
+#items,
+label, description {
+ margin: 0;
+}
+
+#items label {
+ margin-inline: 4px;
+}
+
+#description,
+#description-box,
+#rememberContainer,
+#chooser {
+ margin: 0 4px 16px;
+}
+
+#chooser img:is(:-moz-broken, :not([src])) {
+ visibility: hidden;
+}
+
+/* avoid double inline margins when #description is nested: */
+#description-box > #description {
+ margin-inline: 0;
+}
+
+/* Parent selector to win on specificity against common.css */
+#rememberContainer > .toggle-container-with-text {
+ align-items: baseline;
+ color: var(--text-color-deemphasized);
+}
+
+.toggle-container-with-text > html|input[type="checkbox"] {
+ margin-inline-end: 8px;
+ /* Ensure the checkbox is properly aligned with the text: */
+ translate: 0 calc(1px + max(60% - .6em, 0px));
+}
+
+#rememberContainer:not([hidden]) {
+ /* Ensure we don't get sized to the smallest child when the checkbox text wraps. */
+ display: block;
+}
diff --git a/toolkit/mozapps/handling/content/permissionDialog.js b/toolkit/mozapps/handling/content/permissionDialog.js
new file mode 100644
index 0000000000..de9df6c3ac
--- /dev/null
+++ b/toolkit/mozapps/handling/content/permissionDialog.js
@@ -0,0 +1,223 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { EnableDelayHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromptUtils.sys.mjs"
+);
+
+let dialog = {
+ /**
+ * This function initializes the content of the dialog.
+ */
+ initialize() {
+ let args = window.arguments[0].wrappedJSObject || window.arguments[0];
+ let {
+ handler,
+ principal,
+ outArgs,
+ canPersistPermission,
+ preferredHandlerName,
+ browsingContext,
+ } = args;
+
+ this._handlerInfo = handler.QueryInterface(Ci.nsIHandlerInfo);
+ this._principal = principal?.QueryInterface(Ci.nsIPrincipal);
+ this._addonPolicy =
+ this._principal?.addonPolicy ?? this._principal?.contentScriptAddonPolicy;
+ this._browsingContext = browsingContext;
+ this._outArgs = outArgs.QueryInterface(Ci.nsIWritablePropertyBag);
+ this._preferredHandlerName = preferredHandlerName;
+
+ this._dialog = document.querySelector("dialog");
+ this._checkRemember = document.getElementById("remember");
+ this._checkRememberContainer = document.getElementById("rememberContainer");
+
+ if (!canPersistPermission) {
+ this._checkRememberContainer.hidden = true;
+ }
+
+ let changeAppLink = document.getElementById("change-app");
+
+ // allow the user to choose another application if they wish,
+ // but don't offer this if the protocol was opened via
+ // system principal (URLbar) and there's a preferred handler
+ if (this._preferredHandlerName && !this._principal?.isSystemPrincipal) {
+ changeAppLink.hidden = false;
+
+ changeAppLink.addEventListener("click", () => this.onChangeApp());
+ }
+ document.addEventListener("dialogaccept", () => this.onAccept());
+ this.initL10n();
+
+ this._delayHelper = new EnableDelayHelper({
+ disableDialog: () => {
+ this._dialog.setAttribute("buttondisabledaccept", true);
+ },
+ enableDialog: () => {
+ this._dialog.setAttribute("buttondisabledaccept", false);
+ },
+ focusTarget: window,
+ });
+ },
+
+ /**
+ * Checks whether the principal that triggered this dialog is top level
+ * (not embedded in a frame).
+ * @returns {boolean} - true if principal is top level, false otherwise.
+ * If the triggering principal is null this method always returns false.
+ */
+ triggeringPrincipalIsTop() {
+ if (!this._principal) {
+ return false;
+ }
+
+ let topContentPrincipal =
+ this._browsingContext?.top.embedderElement?.contentPrincipal;
+ if (!topContentPrincipal) {
+ return false;
+ }
+ return this._principal.equals(topContentPrincipal);
+ },
+
+ /**
+ * Determines the l10n ID to use for the dialog description, depending on
+ * the triggering principal and the preferred application handler.
+ */
+ get l10nDescriptionId() {
+ if (this._addonPolicy) {
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-extension-app";
+ }
+ return "permission-dialog-description-extension";
+ }
+
+ if (this._principal?.schemeIs("file")) {
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-file-app";
+ }
+ return "permission-dialog-description-file";
+ }
+
+ if (this._principal?.isSystemPrincipal && this._preferredHandlerName) {
+ return "permission-dialog-description-system-app";
+ }
+
+ if (this._principal?.isSystemPrincipal && !this._preferredHandlerName) {
+ return "permission-dialog-description-system-noapp";
+ }
+
+ // We only show the website address if the request didn't come from the top
+ // level frame. If we can't get a host to display, fall back to the copy
+ // without host.
+ if (!this.triggeringPrincipalIsTop() && this.displayPrePath) {
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-host-app";
+ }
+ return "permission-dialog-description-host";
+ }
+
+ if (this._preferredHandlerName) {
+ return "permission-dialog-description-app";
+ }
+
+ return "permission-dialog-description";
+ },
+
+ /**
+ * Determines the l10n ID to use for the "remember permission" checkbox,
+ * depending on the triggering principal and the preferred application
+ * handler.
+ */
+ get l10nCheckboxId() {
+ if (!this._principal) {
+ return null;
+ }
+
+ if (this._addonPolicy) {
+ return "permission-dialog-remember-extension";
+ }
+ if (this._principal.schemeIs("file")) {
+ return "permission-dialog-remember-file";
+ }
+ return "permission-dialog-remember";
+ },
+
+ /**
+ * Computes the prePath to show in the prompt. It's the prePath of the site
+ * that wants to navigate to the external protocol.
+ * @returns {string|null} - prePath to show, or null if we can't derive an
+ * exposable prePath from the triggering principal.
+ */
+ get displayPrePath() {
+ if (!this._principal) {
+ return null;
+ }
+
+ // NullPrincipals don't expose a meaningful prePath. Instead use the
+ // precursorPrincipal, which the NullPrincipal was derived from.
+ if (this._principal.isNullPrincipal) {
+ return this._principal.precursorPrincipal?.exposablePrePath;
+ }
+
+ return this._principal?.exposablePrePath;
+ },
+
+ initL10n() {
+ // The UI labels depend on whether we will show the application chooser next
+ // or directly open the assigned protocol handler.
+
+ // Fluent id for dialog accept button
+ let idAcceptButton;
+ let acceptButton = this._dialog.getButton("accept");
+
+ if (this._preferredHandlerName) {
+ idAcceptButton = "permission-dialog-btn-open-link";
+ } else {
+ idAcceptButton = "permission-dialog-btn-choose-app";
+
+ let descriptionExtra = document.getElementById("description-extra");
+ descriptionExtra.hidden = false;
+ acceptButton.addEventListener("click", () => this.onChangeApp());
+ }
+ document.l10n.setAttributes(acceptButton, idAcceptButton);
+
+ let description = document.getElementById("description");
+
+ let host = this.displayPrePath;
+ let scheme = this._handlerInfo.type;
+
+ document.l10n.setAttributes(description, this.l10nDescriptionId, {
+ host,
+ scheme,
+ extension: this._addonPolicy?.name,
+ appName: this._preferredHandlerName,
+ });
+
+ if (!this._checkRememberContainer.hidden) {
+ let checkboxLabel = document.getElementById("remember-label");
+ document.l10n.setAttributes(checkboxLabel, this.l10nCheckboxId, {
+ host,
+ scheme,
+ });
+ }
+ },
+
+ onAccept() {
+ this._outArgs.setProperty("remember", this._checkRemember.checked);
+ this._outArgs.setProperty("granted", true);
+ },
+
+ onChangeApp() {
+ this._outArgs.setProperty("resetHandlerChoice", true);
+
+ // We can't call the dialogs accept handler here. If the accept button is
+ // still disabled, it will prevent closing.
+ this.onAccept();
+ window.close();
+ },
+};
+
+window.addEventListener("DOMContentLoaded", () => dialog.initialize(), {
+ once: true,
+});
diff --git a/toolkit/mozapps/handling/content/permissionDialog.xhtml b/toolkit/mozapps/handling/content/permissionDialog.xhtml
new file mode 100644
index 0000000000..bdc67d9e68
--- /dev/null
+++ b/toolkit/mozapps/handling/content/permissionDialog.xhtml
@@ -0,0 +1,56 @@
+<?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/. -->
+
+<!DOCTYPE window>
+
+<window
+ aria-describedby="description"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+>
+ <dialog
+ buttons="accept,cancel"
+ defaultButton="none"
+ data-l10n-attrs="buttonlabelaccept, buttonaccesskeyaccept"
+ >
+ <linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <html:link
+ rel="stylesheet"
+ href="chrome://mozapps/content/handling/handler.css"
+ />
+
+ <html:link rel="localization" href="toolkit/global/handlerDialog.ftl" />
+ </linkset>
+
+ <script
+ src="chrome://mozapps/content/handling/permissionDialog.js"
+ type="application/javascript"
+ />
+
+ <vbox id="description-box">
+ <description id="description"></description>
+ <label
+ id="change-app"
+ hidden="true"
+ is="text-link"
+ data-l10n-id="permission-dialog-set-change-app-link"
+ ></label>
+ <description
+ id="description-extra"
+ hidden="true"
+ data-l10n-id="permission-dialog-unset-description"
+ >
+ </description>
+ </vbox>
+
+ <vbox id="rememberContainer">
+ <html:label class="toggle-container-with-text">
+ <html:input type="checkbox" id="remember" />
+ <html:span id="remember-label" />
+ </html:label>
+ </vbox>
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/handling/jar.mn b/toolkit/mozapps/handling/jar.mn
new file mode 100644
index 0000000000..769c757ed7
--- /dev/null
+++ b/toolkit/mozapps/handling/jar.mn
@@ -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/.
+
+toolkit.jar:
+% content mozapps %content/mozapps/
+ content/mozapps/handling/handler.css (content/handler.css)
+ content/mozapps/handling/appChooser.xhtml (content/appChooser.xhtml)
+ content/mozapps/handling/appChooser.js (content/appChooser.js)
+ content/mozapps/handling/permissionDialog.xhtml (content/permissionDialog.xhtml)
+ content/mozapps/handling/permissionDialog.js (content/permissionDialog.js)
diff --git a/toolkit/mozapps/handling/metrics.yaml b/toolkit/mozapps/handling/metrics.yaml
new file mode 100644
index 0000000000..2466f0993c
--- /dev/null
+++ b/toolkit/mozapps/handling/metrics.yaml
@@ -0,0 +1,37 @@
+
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Adding a new metric? We have docs for that!
+
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Firefox :: General'
+
+protocolhandler.mailto:
+ visit:
+ type: event
+ description: >
+ a URI of type mailto was visited. Furthermore we want to know if from
+ within the browser.
+ bugs:
+ - https://bugzilla.mozilla.org/1864216
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1864216#c8
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ extra_keys:
+ triggered_externally:
+ description: >
+ If Firefox was invoked to handle the link this value is true and if
+ the callee comes from within Firefox, this value is false
+ type: boolean
+ send_in_pings:
+ - active
+ - events
+ - metrics
diff --git a/toolkit/mozapps/handling/moz.build b/toolkit/mozapps/handling/moz.build
new file mode 100644
index 0000000000..0fd8fe7d35
--- /dev/null
+++ b/toolkit/mozapps/handling/moz.build
@@ -0,0 +1,18 @@
+# -*- 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 = ("Firefox", "File Handling")
+
+EXTRA_JS_MODULES += [
+ "ContentDispatchChooser.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/mozapps/installer/find-dupes.py b/toolkit/mozapps/installer/find-dupes.py
new file mode 100644
index 0000000000..1931481b7c
--- /dev/null
+++ b/toolkit/mozapps/installer/find-dupes.py
@@ -0,0 +1,148 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import sys
+import hashlib
+import functools
+from mozbuild.preprocessor import Preprocessor
+from mozbuild.util import DefinesAction
+from mozpack.packager.unpack import UnpackFinder
+from mozpack.files import DeflatedFile
+from collections import OrderedDict
+from io import StringIO
+import argparse
+import buildconfig
+
+"""
+Find files duplicated in a given packaged directory, independently of its
+package format.
+"""
+
+
+def normalize_osx_path(p):
+ """
+ Strips the first 3 elements of an OSX app path
+
+ >>> normalize_osx_path('Nightly.app/foo/bar/baz')
+ 'baz'
+ """
+ bits = p.split("/")
+ if len(bits) > 3 and bits[0].endswith(".app"):
+ return "/".join(bits[3:])
+ return p
+
+
+def is_l10n_file(path):
+ return (
+ "/locale/" in path
+ or "/localization/" in path
+ or path.startswith("localization/")
+ )
+
+
+def normalize_path(p):
+ return normalize_osx_path(p)
+
+
+def find_dupes(source, allowed_dupes, bail=True):
+ chunk_size = 1024 * 10
+ allowed_dupes = set(allowed_dupes)
+ checksums = OrderedDict()
+ for p, f in UnpackFinder(source):
+ checksum = hashlib.sha1()
+ content_size = 0
+ for buf in iter(functools.partial(f.open().read, chunk_size), b""):
+ checksum.update(buf)
+ content_size += len(buf)
+ m = checksum.digest()
+ if m not in checksums:
+ if isinstance(f, DeflatedFile):
+ compressed = f.file.compressed_size
+ else:
+ compressed = content_size
+ checksums[m] = (content_size, compressed, [])
+ checksums[m][2].append(p)
+ total = 0
+ total_compressed = 0
+ num_dupes = 0
+ unexpected_dupes = []
+ for m, (size, compressed, paths) in sorted(
+ checksums.items(), key=lambda x: x[1][1]
+ ):
+ if len(paths) > 1:
+ _compressed = " (%d compressed)" % compressed if compressed != size else ""
+ _times = " (%d times)" % (len(paths) - 1) if len(paths) > 2 else ""
+ print("Duplicates {} bytes{}{}:".format(size, _compressed, _times))
+ print("".join(" %s\n" % p for p in paths))
+ total += (len(paths) - 1) * size
+ total_compressed += (len(paths) - 1) * compressed
+ num_dupes += 1
+
+ for p in paths:
+ if not is_l10n_file(p) and normalize_path(p) not in allowed_dupes:
+ unexpected_dupes.append(p)
+
+ if num_dupes:
+ total_compressed = (
+ "%d compressed" % total_compressed
+ if total_compressed != total
+ else "uncompressed"
+ )
+ print(
+ "WARNING: Found {} duplicated files taking {} bytes ({})".format(
+ num_dupes, total, total_compressed
+ )
+ )
+
+ if unexpected_dupes:
+ errortype = "ERROR" if bail else "WARNING"
+ print("{}: The following duplicated files are not allowed:".format(errortype))
+ print("\n".join(unexpected_dupes))
+ if bail:
+ sys.exit(1)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Find duplicate files in directory.")
+ parser.add_argument(
+ "--warning",
+ "-w",
+ action="store_true",
+ help="Only warn about duplicates, do not exit with an error",
+ )
+ parser.add_argument(
+ "--file",
+ "-f",
+ action="append",
+ dest="dupes_files",
+ default=[],
+ help="Add exceptions to the duplicate list from this file",
+ )
+ parser.add_argument("-D", action=DefinesAction)
+ parser.add_argument("-U", action="append", default=[])
+ parser.add_argument("directory", help="The directory to check for duplicates in")
+
+ args = parser.parse_args()
+
+ allowed_dupes = []
+ for filename in args.dupes_files:
+ pp = Preprocessor()
+ pp.context.update(buildconfig.defines["ALLDEFINES"])
+ if args.D:
+ pp.context.update(args.D)
+ for undefine in args.U:
+ if undefine in pp.context:
+ del pp.context[undefine]
+ pp.out = StringIO()
+ pp.do_filter("substitution")
+ pp.do_include(filename)
+ allowed_dupes.extend(
+ [line.partition("#")[0].rstrip() for line in pp.out.getvalue().splitlines()]
+ )
+
+ find_dupes(args.directory, bail=not args.warning, allowed_dupes=allowed_dupes)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/toolkit/mozapps/installer/informulate.py b/toolkit/mozapps/installer/informulate.py
new file mode 100644
index 0000000000..0f646450c5
--- /dev/null
+++ b/toolkit/mozapps/installer/informulate.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env 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/.
+
+# Generate build info files for use by other tools.
+# This script assumes it is being run in a Mozilla CI build.
+
+from argparse import ArgumentParser
+import datetime
+import buildconfig
+import json
+import mozinfo
+import os
+
+
+def main():
+ parser = ArgumentParser()
+ parser.add_argument("output_json", help="Output JSON file")
+ parser.add_argument("buildhub_json", help="Output buildhub JSON file")
+ parser.add_argument("output_txt", help="Output text file")
+ # TODO: Move package-name.mk variables into moz.configure.
+ parser.add_argument("pkg_platform", help="Package platform identifier")
+ parser.add_argument(
+ "--no-download", action="store_true", help="Do not include download information"
+ )
+ parser.add_argument("--package", help="Path to application package file")
+ parser.add_argument("--installer", help="Path to application installer file")
+ args = parser.parse_args()
+ mozinfo.find_and_update_from_json()
+
+ important_substitutions = [
+ "target_alias",
+ "host_alias",
+ "MOZ_UPDATE_CHANNEL",
+ "MOZ_APP_VENDOR",
+ "MOZ_APP_NAME",
+ "MOZ_APP_VERSION",
+ "MOZ_APP_MAXVERSION",
+ "MOZ_APP_ID",
+ "MOZ_SOURCE_REPO",
+ ]
+ other_substitutions = [
+ "CC",
+ "CXX",
+ "AS",
+ ]
+
+ all_key_value_pairs = {
+ x.lower(): buildconfig.substs[x] for x in important_substitutions
+ }
+
+ def stringify(x):
+ if isinstance(x, (tuple, list)):
+ return " ".join(x)
+ return x or ""
+
+ all_key_value_pairs.update(
+ {x.lower(): stringify(buildconfig.substs.get(x)) for x in other_substitutions}
+ )
+ build_id = os.environ["MOZ_BUILD_DATE"]
+ all_key_value_pairs.update(
+ {
+ "buildid": build_id,
+ "moz_source_stamp": buildconfig.substs["MOZ_SOURCE_CHANGESET"],
+ "moz_pkg_platform": args.pkg_platform,
+ }
+ )
+
+ with open(args.output_json, "wt") as f:
+ json.dump(all_key_value_pairs, f, indent=2, sort_keys=True)
+ f.write("\n")
+
+ with open(args.buildhub_json, "wt") as f:
+ build_time = datetime.datetime.strptime(build_id, "%Y%m%d%H%M%S")
+ s = buildconfig.substs
+ record = {
+ "build": {
+ "id": build_id,
+ "date": build_time.isoformat() + "Z",
+ "as": all_key_value_pairs["as"],
+ "cc": all_key_value_pairs["cc"],
+ "cxx": all_key_value_pairs["cxx"],
+ "host": s["host_alias"],
+ "target": s["target_alias"],
+ },
+ "source": {
+ "product": s["MOZ_APP_NAME"],
+ "repository": s["MOZ_SOURCE_REPO"],
+ "tree": os.environ["MH_BRANCH"],
+ "revision": s["MOZ_SOURCE_CHANGESET"],
+ },
+ "target": {
+ "platform": args.pkg_platform,
+ "os": mozinfo.info["os"],
+ # This would be easier if the locale was specified at configure time.
+ "locale": os.environ.get("AB_CD", "en-US"),
+ "version": s["MOZ_APP_VERSION_DISPLAY"] or s["MOZ_APP_VERSION"],
+ "channel": s["MOZ_UPDATE_CHANNEL"],
+ },
+ }
+
+ if args.no_download:
+ package = None
+ elif args.installer and os.path.exists(args.installer):
+ package = args.installer
+ else:
+ package = args.package
+ if package:
+ st = os.stat(package)
+ mtime = datetime.datetime.fromtimestamp(st.st_mtime)
+ record["download"] = {
+ # The release pipeline will update these keys.
+ "url": os.path.basename(package),
+ "mimetype": "application/octet-stream",
+ "date": mtime.isoformat() + "Z",
+ "size": st.st_size,
+ }
+
+ json.dump(record, f, indent=2, sort_keys=True)
+ f.write("\n")
+
+ with open(args.output_txt, "wt") as f:
+ f.write("buildID={}\n".format(build_id))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/toolkit/mozapps/installer/js-compare-ast.js b/toolkit/mozapps/installer/js-compare-ast.js
new file mode 100644
index 0000000000..ed660d3f8a
--- /dev/null
+++ b/toolkit/mozapps/installer/js-compare-ast.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/. */
+
+/**
+ * This script compares the AST of two JavaScript files passed as arguments.
+ * The script exits with a 0 status code if both files parse properly and the
+ * ASTs of both files are identical modulo location differences. The script
+ * exits with status code 1 if any of these conditions don't hold.
+ *
+ * This script is used as part of packaging to verify minified JavaScript files
+ * are identical to their original files.
+ */
+
+// Available to the js shell.
+/* global snarf, scriptArgs, quit */
+
+"use strict";
+
+function ast(filename) {
+ return JSON.stringify(Reflect.parse(snarf(filename), { loc: 0 }));
+}
+
+if (scriptArgs.length !== 2) {
+ throw new Error("usage: js js-compare-ast.js FILE1.js FILE2.js");
+}
+
+var ast0 = ast(scriptArgs[0]);
+var ast1 = ast(scriptArgs[1]);
+
+quit(ast0 == ast1 ? 0 : 1);
diff --git a/toolkit/mozapps/installer/l10n-repack.py b/toolkit/mozapps/installer/l10n-repack.py
new file mode 100644
index 0000000000..a6f7ebb4dd
--- /dev/null
+++ b/toolkit/mozapps/installer/l10n-repack.py
@@ -0,0 +1,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/.
+
+"""
+Replace localized parts of a packaged directory with data from a langpack
+directory.
+"""
+
+from mozpack.packager import l10n
+from argparse import ArgumentParser
+import buildconfig
+
+# Set of files or directories not listed in a chrome.manifest but that are
+# localized.
+NON_CHROME = set(
+ [
+ "**/crashreporter*.ini",
+ "dictionaries",
+ "defaultagent_localized.ini",
+ "defaults/profile",
+ "defaults/pref*/*-l10n.js",
+ "locale.ini",
+ "update.locale",
+ "updater.ini",
+ "extensions/langpack-*@*",
+ "distribution/extensions/langpack-*@*",
+ "**/multilocale.txt",
+ ]
+)
+
+
+def valid_extra_l10n(arg):
+ if "=" not in arg:
+ raise ValueError("Invalid value")
+ return tuple(arg.split("=", 1))
+
+
+def main():
+ parser = ArgumentParser()
+ parser.add_argument("build", help="Directory containing the build to repack")
+ parser.add_argument("l10n", help="Directory containing the staged langpack")
+ parser.add_argument(
+ "extra_l10n",
+ nargs="*",
+ metavar="BASE=PATH",
+ type=valid_extra_l10n,
+ help="Extra directories with staged localized files "
+ "to be considered under the given base in the "
+ "repacked build",
+ )
+ parser.add_argument(
+ "--non-resource",
+ nargs="+",
+ metavar="PATTERN",
+ default=[],
+ help="Extra files not to be considered as resources",
+ )
+ parser.add_argument(
+ "--minify",
+ action="store_true",
+ default=False,
+ help="Make some files more compact while packaging",
+ )
+ args = parser.parse_args()
+
+ buildconfig.substs["USE_ELF_HACK"] = False
+ buildconfig.substs["PKG_STRIP"] = False
+ l10n.repack(
+ args.build,
+ args.l10n,
+ extra_l10n=dict(args.extra_l10n),
+ non_resources=args.non_resource,
+ non_chrome=NON_CHROME,
+ minify=args.minify,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/toolkit/mozapps/installer/linux/rpm/mozilla.desktop b/toolkit/mozapps/installer/linux/rpm/mozilla.desktop
new file mode 100644
index 0000000000..79048fcf7c
--- /dev/null
+++ b/toolkit/mozapps/installer/linux/rpm/mozilla.desktop
@@ -0,0 +1,21 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[Desktop Entry]
+Version=1.0
+Name=@MOZ_APP_DISPLAYNAME@
+GenericName=Web Browser
+Comment=Your web, the way you like it
+Exec=@MOZ_APP_NAME@
+Icon=@MOZ_APP_NAME@
+Terminal=false
+Type=Application
+StartupWMClass=@MOZ_APP_REMOTINGNAME@
+MimeType=text/html;text/xml;application/xhtml+xml;text/mml;x-scheme-handler/http;x-scheme-handler/https;
+StartupNotify=true
+X-MultipleArgs=false
+X-Desktop-File-Install-Version=0.16
+Categories=Network;WebBrowser;
+Encoding=UTF-8
diff --git a/toolkit/mozapps/installer/linux/rpm/mozilla.spec b/toolkit/mozapps/installer/linux/rpm/mozilla.spec
new file mode 100644
index 0000000000..21f7c3a60c
--- /dev/null
+++ b/toolkit/mozapps/installer/linux/rpm/mozilla.spec
@@ -0,0 +1,116 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+%global __jar_repack %{nil}
+
+#Use a consistent string to refer to the package by
+%define pr_name "%{moz_app_displayname} %{moz_app_version}"
+
+Name: %{moz_app_name}
+Version: %{moz_numeric_app_version}
+Release: %{?moz_rpm_release:%{moz_rpm_release}}%{?buildid:.%{buildid}}
+Summary: %{pr_name}
+Group: Applications/Internet
+License: MPL 2
+Vendor: Mozilla
+URL: http://www.mozilla.org/projects/firefox/
+Source0: %{name}.desktop
+BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
+
+#AutoProv: no
+
+BuildRequires: desktop-file-utils
+
+
+%description
+%{pr_name}. This package was built from
+%{moz_source_repo}/rev/%{moz_source_stamp}
+
+#We only want a subpackage for the SDK if the required
+#files were generated. Like the tests subpackage, we
+#probably only need to conditionaly define the %files
+#section.
+%if %{?createtests:1}
+%package tests
+Summary: %{pr_name} tests
+Group: Developement/Libraries
+requires: %{name} = %{version}-%{release}
+%description tests
+%{pr_name} test harness files and test cases
+%endif
+
+%prep
+echo No-op prep
+
+
+%build
+echo No-op build
+
+
+%install
+rm -rf $RPM_BUILD_ROOT
+make install DESTDIR=$RPM_BUILD_ROOT
+desktop-file-validate %{SOURCE0}
+desktop-file-install --vendor mozilla \
+ --dir $RPM_BUILD_ROOT%{_datadir}/applications \
+ %{SOURCE0}
+#In order to make branding work in a generic way, We find
+#all the icons that are likely to be used for desktop files
+#and install them appropriately
+find %{moz_branding_directory} -name "default*.png" | tee icons.list
+for i in $(cat icons.list) ; do
+ size=$(echo $i | sed "s/.*default\([0-9]*\).png$/\1/")
+ icondir=$RPM_BUILD_ROOT/%{_datadir}/icons/hicolor/${size}x${size}/apps/
+ mkdir -p $icondir
+ cp -a $i ${icondir}%{name}.png
+done
+rm icons.list #cleanup
+
+%if %{?createtests:1}
+#wastefully creates a zip file, but ensures that we stage all test suites
+make package-tests
+testdir=$RPM_BUILD_ROOT/%{_datadir}/%{_testsinstalldir}/tests
+mkdir -p $testdir
+cp -a dist/test-stage/* $testdir/
+%endif
+
+%clean
+rm -rf $RPM_BUILD_ROOT
+
+
+%post
+#this is needed to get gnome-panel to update the icons
+update-desktop-database &> /dev/null || :
+touch --no-create %{_datadir}/icons/hicolor || :
+if [ -x %{_bindir}/gtk-update-icon-cache ] ; then
+ %{_bindir}/gtk-update-icon-cache --quiet ${_datadir}/icons/hicolor &> /dev/null || :
+fi
+
+
+%postun
+#this is needed to get gnome-panel to update the icons
+update-desktop-database &> /dev/null || :
+touch --no-create %{_datadir}/icons/hicolor || :
+if [ -x %{_bindir}/gtk-update-icon-cache ] ; then
+ %{_bindir}/gtk-update-icon-cache --quiet ${_datadir}/icons/hicolor &> /dev/null || :
+fi
+
+
+%files
+%defattr(-,root,root,-)
+%{_installdir}
+%{_bindir}
+%{_datadir}/applications/
+%{_datadir}/icons/
+%doc
+
+
+%if %{?createtests:1}
+%files tests
+%{_datadir}/%{_testsinstalldir}/tests/
+%endif
+
+#%changelog
+#* %{name} %{version} %{moz_rpm_release}
+#- Please see %{moz_source_repo}/shortlog/%{moz_source_stamp}
diff --git a/toolkit/mozapps/installer/moz.build b/toolkit/mozapps/installer/moz.build
new file mode 100644
index 0000000000..b92363a166
--- /dev/null
+++ b/toolkit/mozapps/installer/moz.build
@@ -0,0 +1,8 @@
+# -*- 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 = ("Firefox", "Installer")
diff --git a/toolkit/mozapps/installer/package-name.mk b/toolkit/mozapps/installer/package-name.mk
new file mode 100644
index 0000000000..36239fe79f
--- /dev/null
+++ b/toolkit/mozapps/installer/package-name.mk
@@ -0,0 +1,135 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# assemble package names, see convention at
+# http://developer.mozilla.org/index.php?title=En/Package_Filename_Convention
+# Note that release packages are named during the post-build release
+# automation, so they aren't part of this file.
+
+ifndef PACKAGE_NAME_MK_INCLUDED
+PACKAGE_NAME_MK_INCLUDED := 1
+
+ifndef MOZ_PKG_VERSION
+MOZ_PKG_VERSION = $(MOZ_APP_VERSION)
+endif
+
+ifndef MOZ_PKG_PLATFORM
+MOZ_PKG_PLATFORM := $(TARGET_RAW_OS)-$(TARGET_RAW_CPU)
+
+ifeq ($(MOZ_BUILD_APP),mobile/android)
+MOZ_PKG_PLATFORM := android-$(TARGET_RAW_CPU)
+endif
+
+# TARGET_RAW_OS/TARGET_RAW_CPU may be unintuitive, so we hardcode some special formats
+ifeq ($(OS_ARCH),WINNT)
+ifeq ($(TARGET_CPU),x86)
+MOZ_PKG_PLATFORM := win32
+else
+ifeq ($(TARGET_CPU),aarch64)
+MOZ_PKG_PLATFORM := win64-aarch64
+else
+MOZ_PKG_PLATFORM := win64
+endif
+endif
+endif
+ifeq ($(OS_ARCH),Darwin)
+MOZ_PKG_PLATFORM := mac
+endif
+ifeq ($(TARGET_RAW_OS),linux-gnu)
+MOZ_PKG_PLATFORM := linux-$(TARGET_RAW_CPU)
+endif
+endif #MOZ_PKG_PLATFORM
+
+ifdef MOZ_PKG_SPECIAL
+MOZ_PKG_PLATFORM := $(MOZ_PKG_PLATFORM)-$(MOZ_PKG_SPECIAL)
+endif
+
+MOZ_PKG_DIR ?= $(MOZ_APP_NAME)
+
+ifndef MOZ_PKG_APPNAME
+MOZ_PKG_APPNAME = $(MOZ_APP_NAME)
+endif
+
+ifdef MOZ_SIMPLE_PACKAGE_NAME
+PKG_BASENAME := $(MOZ_SIMPLE_PACKAGE_NAME)
+else
+PKG_BASENAME = $(MOZ_PKG_APPNAME)-$(MOZ_PKG_VERSION).$(AB_CD).$(MOZ_PKG_PLATFORM)
+endif
+PKG_PATH =
+SDK_PATH =
+PKG_INST_BASENAME = $(PKG_BASENAME).installer
+PKG_STUB_BASENAME = $(PKG_BASENAME).installer-stub
+PKG_INST_PATH = install/sea/
+PKG_UPDATE_BASENAME = $(PKG_BASENAME)
+CHECKSUMS_FILE_BASENAME = $(PKG_BASENAME)
+MOZ_INFO_BASENAME = $(PKG_BASENAME)
+PKG_UPDATE_PATH = update/
+COMPLETE_MAR = $(PKG_UPDATE_PATH)$(PKG_UPDATE_BASENAME).complete.mar
+ifdef MOZ_SIMPLE_PACKAGE_NAME
+PKG_LANGPACK_BASENAME = $(MOZ_SIMPLE_PACKAGE_NAME).langpack
+PKG_LANGPACK_PATH =
+else
+PKG_LANGPACK_BASENAME = $(MOZ_PKG_APPNAME)-$(MOZ_PKG_VERSION).$(AB_CD).langpack
+PKG_LANGPACK_PATH = $(MOZ_PKG_PLATFORM)/xpi/
+endif
+LANGPACK = $(PKG_LANGPACK_PATH)$(PKG_LANGPACK_BASENAME).xpi
+PKG_SRCPACK_BASENAME = source
+PKG_BUNDLE_BASENAME = $(MOZ_PKG_APPNAME)-$(MOZ_PKG_VERSION)
+PKG_SRCPACK_PATH =
+
+# Symbol package naming
+SYMBOL_FULL_ARCHIVE_BASENAME = $(PKG_BASENAME).crashreporter-symbols-full
+SYMBOL_ARCHIVE_BASENAME = $(PKG_BASENAME).crashreporter-symbols
+
+# Generated file package naming
+GENERATED_SOURCE_FILE_PACKAGE = $(PKG_BASENAME).generated-files.tar.gz
+
+# Code coverage package naming
+CODE_COVERAGE_ARCHIVE_BASENAME = $(PKG_BASENAME).code-coverage-gcno
+
+# Mozsearch package naming
+MOZSEARCH_ARCHIVE_BASENAME = $(PKG_BASENAME).mozsearch-index
+MOZSEARCH_INCLUDEMAP_BASENAME = $(PKG_BASENAME).mozsearch-distinclude
+MOZSEARCH_SCIP_INDEX_BASENAME = $(PKG_BASENAME).mozsearch-scip-index
+MOZSEARCH_JAVA_INDEX_BASENAME = $(PKG_BASENAME).mozsearch-java-index
+
+# Mozharness naming
+MOZHARNESS_PACKAGE = mozharness.zip
+
+# Test package naming
+TEST_PACKAGE = $(PKG_BASENAME).common.tests.tar.gz
+CPP_TEST_PACKAGE = $(PKG_BASENAME).cppunittest.tests.tar.gz
+XPC_TEST_PACKAGE = $(PKG_BASENAME).xpcshell.tests.tar.gz
+MOCHITEST_PACKAGE = $(PKG_BASENAME).mochitest.tests.tar.gz
+REFTEST_PACKAGE = $(PKG_BASENAME).reftest.tests.tar.gz
+WP_TEST_PACKAGE = $(PKG_BASENAME).web-platform.tests.tar.gz
+TALOS_PACKAGE = $(PKG_BASENAME).talos.tests.tar.gz
+AWSY_PACKAGE = $(PKG_BASENAME).awsy.tests.tar.gz
+GTEST_PACKAGE = $(PKG_BASENAME).gtest.tests.tar.gz
+
+# `.xpt` artifacts: for use in artifact builds.
+XPT_ARTIFACTS_ARCHIVE_BASENAME = $(PKG_BASENAME).xpt_artifacts
+
+ifneq (,$(wildcard $(DIST)/bin/application.ini))
+BUILDID = $(shell $(PYTHON3) $(MOZILLA_DIR)/config/printconfigsetting.py $(DIST)/bin/application.ini App BuildID)
+else
+BUILDID = $(shell $(PYTHON3) $(MOZILLA_DIR)/config/printconfigsetting.py $(DIST)/bin/platform.ini Build BuildID)
+endif
+
+MOZ_SOURCESTAMP_FILE = $(DIST)/$(PKG_PATH)/$(MOZ_INFO_BASENAME).txt
+MOZ_BUILDINFO_FILE = $(DIST)/$(PKG_PATH)/$(MOZ_INFO_BASENAME).json
+MOZ_BUILDHUB_JSON = $(DIST)/$(PKG_PATH)/buildhub.json
+MOZ_BUILDID_INFO_TXT_FILE = $(DIST)/$(PKG_PATH)/$(MOZ_INFO_BASENAME)_info.txt
+MOZ_MOZINFO_FILE = $(DIST)/$(PKG_PATH)/$(MOZ_INFO_BASENAME).mozinfo.json
+MOZ_TEST_PACKAGES_FILE = $(DIST)/$(PKG_PATH)/$(PKG_BASENAME).test_packages.json
+
+# JavaScript Shell
+ifdef MOZ_SIMPLE_PACKAGE_NAME
+JSSHELL_NAME := $(MOZ_SIMPLE_PACKAGE_NAME).jsshell.zip
+else
+JSSHELL_NAME = jsshell-$(MOZ_PKG_PLATFORM).zip
+endif
+PKG_JSSHELL = $(DIST)/$(JSSHELL_NAME)
+
+endif # PACKAGE_NAME_MK_INCLUDED
diff --git a/toolkit/mozapps/installer/packager.mk b/toolkit/mozapps/installer/packager.mk
new file mode 100644
index 0000000000..aeba614e40
--- /dev/null
+++ b/toolkit/mozapps/installer/packager.mk
@@ -0,0 +1,236 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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_DIR)/toolkit/mozapps/installer/package-name.mk
+include $(MOZILLA_DIR)/toolkit/mozapps/installer/upload-files.mk
+
+# This is how we create the binary packages we release to the public.
+
+# browser/locales/Makefile uses this makefile for its variable defs, but
+# doesn't want the libs:: rule.
+ifndef PACKAGER_NO_LIBS
+libs:: make-package
+endif
+
+ifdef MOZ_AUTOMATION
+# This allows `RUN_{FIND_DUPES,MOZHARNESS_ZIP}=1 ./mach package` to test locally.
+RUN_FIND_DUPES ?= $(MOZ_AUTOMATION)
+RUN_MOZHARNESS_ZIP ?= $(MOZ_AUTOMATION)
+endif
+
+export USE_ELF_HACK
+
+stage-package: multilocale.txt locale-manifest.in $(MOZ_PKG_MANIFEST) $(MOZ_PKG_MANIFEST_DEPS)
+ NO_PKG_FILES="$(NO_PKG_FILES)" \
+ $(PYTHON3) $(MOZILLA_DIR)/toolkit/mozapps/installer/packager.py $(DEFINES) $(ACDEFINES) \
+ --format $(MOZ_PACKAGER_FORMAT) \
+ $(addprefix --removals ,$(MOZ_PKG_REMOVALS)) \
+ $(if $(filter-out 0,$(MOZ_PKG_FATAL_WARNINGS)),,--ignore-errors) \
+ $(if $(MOZ_AUTOMATION),,--ignore-broken-symlinks) \
+ $(if $(MOZ_PACKAGER_MINIFY),--minify) \
+ $(if $(MOZ_PACKAGER_MINIFY_JS),--minify-js \
+ $(addprefix --js-binary ,$(JS_BINARY)) \
+ ) \
+ $(addprefix --jarlog ,$(wildcard $(JARLOG_FILE_AB_CD))) \
+ $(addprefix --compress ,$(JAR_COMPRESSION)) \
+ $(MOZ_PKG_MANIFEST) '$(DIST)' '$(DIST)'/$(MOZ_PKG_DIR)$(if $(MOZ_PKG_MANIFEST),,$(_BINPATH:%=/%)) \
+ $(if $(filter omni,$(MOZ_PACKAGER_FORMAT)),$(if $(NON_OMNIJAR_FILES),--non-resource $(NON_OMNIJAR_FILES)))
+ifdef RUN_FIND_DUPES
+ $(PYTHON3) $(MOZILLA_DIR)/toolkit/mozapps/installer/find-dupes.py $(DEFINES) $(ACDEFINES) $(MOZ_PKG_DUPEFLAGS) $(DIST)/$(MOZ_PKG_DIR)
+endif # RUN_FIND_DUPES
+ifndef MOZ_IS_COMM_TOPDIR
+ifdef RUN_MOZHARNESS_ZIP
+ # Package mozharness
+ $(call py_action,test_archive $(MOZHARNESS_PACKAGE), \
+ mozharness \
+ $(ABS_DIST)/$(PKG_PATH)$(MOZHARNESS_PACKAGE))
+endif # RUN_MOZHARNESS_ZIP
+endif # MOZ_IS_COMM_TOPDIR
+ifdef MOZ_PACKAGE_JSSHELL
+ # Package JavaScript Shell
+ @echo 'Packaging JavaScript Shell...'
+ $(RM) $(PKG_JSSHELL)
+ $(MAKE_JSSHELL)
+endif # MOZ_PACKAGE_JSSHELL
+ifdef MOZ_AUTOMATION
+ifdef MOZ_ARTIFACT_BUILD_SYMBOLS
+ @echo 'Packaging existing crashreporter symbols from artifact build...'
+ $(NSINSTALL) -D $(DIST)/$(PKG_PATH)
+ cd $(DIST)/crashreporter-symbols && \
+ zip -r5D '../$(PKG_PATH)$(SYMBOL_ARCHIVE_BASENAME).zip' . -i '*.sym' -i '*.txt'
+ifeq ($(MOZ_ARTIFACT_BUILD_SYMBOLS),full)
+ $(call py_action,symbols_archive $(SYMBOL_FULL_ARCHIVE_BASENAME).tar.zst,'$(DIST)/$(PKG_PATH)$(SYMBOL_FULL_ARCHIVE_BASENAME).tar.zst' \
+ $(abspath $(DIST)/crashreporter-symbols) \
+ --full-archive)
+endif
+endif # MOZ_ARTIFACT_BUILD_SYMBOLS
+endif # MOZ_AUTOMATION
+ifdef MOZ_CODE_COVERAGE
+ @echo 'Generating chrome-map for coverage data...'
+ $(PYTHON3) $(topsrcdir)/mach build-backend -b ChromeMap
+ @echo 'Packaging code coverage data...'
+ $(RM) $(CODE_COVERAGE_ARCHIVE_BASENAME).zip
+ $(PYTHON3) -mmozbuild.codecoverage.packager \
+ --output-file='$(DIST)/$(PKG_PATH)$(CODE_COVERAGE_ARCHIVE_BASENAME).zip'
+endif
+ifdef ENABLE_MOZSEARCH_PLUGIN
+ @echo 'Generating mozsearch index tarball...'
+ $(RM) $(MOZSEARCH_ARCHIVE_BASENAME).zip
+ cd $(topobjdir)/mozsearch_index && \
+ zip -r5D '$(ABS_DIST)/$(PKG_PATH)$(MOZSEARCH_ARCHIVE_BASENAME).zip' .
+ @echo 'Generating mozsearch distinclude map...'
+ cd $(topobjdir)/ && cp _build_manifests/install/dist_include '$(ABS_DIST)/$(PKG_PATH)$(MOZSEARCH_INCLUDEMAP_BASENAME).map'
+ @echo 'Generating mozsearch scip index...'
+ $(RM) $(MOZSEARCH_SCIP_INDEX_BASENAME).zip
+ cp $(topsrcdir)/.cargo/config.in $(topsrcdir)/.cargo/config
+ cd $(topsrcdir)/ && \
+ CARGO=$(MOZ_FETCHES_DIR)/rustc/bin/cargo \
+ RUSTC=$(MOZ_FETCHES_DIR)/rustc/bin/rustc \
+ $(MOZ_FETCHES_DIR)/rustc/bin/rust-analyzer scip . && \
+ zip -r5D '$(ABS_DIST)/$(PKG_PATH)$(MOZSEARCH_SCIP_INDEX_BASENAME).zip' \
+ index.scip
+ rm $(topsrcdir)/.cargo/config
+ifeq ($(MOZ_BUILD_APP),mobile/android)
+ @echo 'Generating mozsearch java/kotlin semanticdb tarball...'
+ $(RM) $(MOZSEARCH_JAVA_INDEX_BASENAME).zip
+ cd $(topsrcdir)/ && \
+ $(PYTHON3) $(topsrcdir)/mach android compile-all && \
+ cd $(topobjdir)/mozsearch_java_index && \
+ zip -r5D '$(ABS_DIST)/$(PKG_PATH)$(MOZSEARCH_JAVA_INDEX_BASENAME).zip' .
+endif # MOZ_BUILD_APP == mobile/android
+endif
+ifeq (Darwin, $(OS_ARCH))
+ifneq (,$(MOZ_ASAN)$(LIBFUZZER)$(MOZ_UBSAN))
+ @echo "Rewriting sanitizer runtime dylib paths for all binaries in $(DIST)/$(MOZ_PKG_DIR)/$(_BINPATH) ..."
+ $(PYTHON3) $(MOZILLA_DIR)/build/unix/rewrite_sanitizer_dylib.py '$(DIST)/$(MOZ_PKG_DIR)/$(_BINPATH)'
+endif # MOZ_ASAN || LIBFUZZER || MOZ_UBSAN
+endif # Darwin
+ifndef MOZ_ARTIFACT_BUILDS
+ @echo 'Generating XPT artifacts archive ($(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip)'
+ $(call py_action,zip $(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip,-C $(topobjdir)/config/makefiles/xpidl '$(ABS_DIST)/$(PKG_PATH)$(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip' '*.xpt')
+else
+ @echo 'Packaging existing XPT artifacts from artifact build into archive ($(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip)'
+ $(call py_action,zip $(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip,-C $(ABS_DIST)/xpt_artifacts '$(ABS_DIST)/$(PKG_PATH)$(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip' '*.xpt')
+endif # MOZ_ARTIFACT_BUILDS
+
+prepare-package: stage-package
+
+make-package-internal: prepare-package make-sourcestamp-file
+ @echo 'Compressing...'
+ $(call MAKE_PACKAGE,$(DIST))
+
+make-package: FORCE
+ $(MAKE) make-package-internal
+ifeq (WINNT,$(OS_ARCH))
+ifeq ($(MOZ_PKG_FORMAT),ZIP)
+ $(MAKE) -C windows ZIP_IN='$(ABS_DIST)/$(PACKAGE)' installer
+endif
+endif
+ifdef MOZ_AUTOMATION
+ cp $(DEPTH)/mozinfo.json $(MOZ_MOZINFO_FILE)
+ $(PYTHON3) $(MOZILLA_DIR)/toolkit/mozapps/installer/informulate.py \
+ $(MOZ_BUILDINFO_FILE) $(MOZ_BUILDHUB_JSON) $(MOZ_BUILDID_INFO_TXT_FILE) \
+ $(MOZ_PKG_PLATFORM) \
+ $(if $(or $(filter-out mobile/android,$(MOZ_BUILD_APP)),$(MOZ_ANDROID_WITH_FENNEC)), \
+ --package=$(DIST)/$(PACKAGE) --installer=$(INSTALLER_PACKAGE), \
+ --no-download \
+ )
+endif
+ $(TOUCH) $@
+
+GARBAGE += make-package
+
+make-sourcestamp-file::
+ $(NSINSTALL) -D $(DIST)/$(PKG_PATH)
+ @awk '$$2 == "MOZ_BUILDID" {print $$3}' $(DEPTH)/buildid.h > $(MOZ_SOURCESTAMP_FILE)
+ifdef MOZ_INCLUDE_SOURCE_INFO
+ @awk '$$2 == "MOZ_SOURCE_URL" {print $$3}' $(DEPTH)/source-repo.h >> $(MOZ_SOURCESTAMP_FILE)
+endif
+
+# The install target will install the application to prefix/lib/appname-version
+install:: prepare-package
+ifneq (,$(filter WINNT Darwin,$(OS_TARGET)))
+ $(error "make install" is not supported on this platform. Use "make package" instead.)
+endif
+ $(NSINSTALL) -D $(DESTDIR)$(installdir)
+ (cd $(DIST)/$(MOZ_PKG_DIR) && $(TAR) --exclude=precomplete $(TAR_CREATE_FLAGS) - .) | \
+ (cd $(DESTDIR)$(installdir) && tar -xf -)
+ $(NSINSTALL) -D $(DESTDIR)$(bindir)
+ $(RM) -f $(DESTDIR)$(bindir)/$(MOZ_APP_NAME)
+ ln -s $(installdir)/$(MOZ_APP_NAME) $(DESTDIR)$(bindir)
+
+upload:
+ $(PYTHON3) -u $(MOZILLA_DIR)/build/upload.py --base-path $(DIST) $(UPLOAD_FILES)
+ mkdir -p `dirname $(CHECKSUM_FILE)`
+ @$(PYTHON3) $(MOZILLA_DIR)/build/checksums.py \
+ -o $(CHECKSUM_FILE) \
+ $(CHECKSUM_ALGORITHM_PARAM) \
+ $(UPLOAD_PATH)
+ @echo 'CHECKSUM FILE START'
+ @cat $(CHECKSUM_FILE)
+ @echo 'CHECKSUM FILE END'
+ $(PYTHON3) -u $(MOZILLA_DIR)/build/upload.py --base-path $(DIST) $(CHECKSUM_FILES)
+
+# source-package creates a source tarball from the files in MOZ_PKG_SRCDIR,
+# which is either set to a clean checkout or defaults to $topsrcdir
+source-package:
+ @echo 'Generate the sourcestamp file'
+ # Make sure to have repository information available and then generate the
+ # sourcestamp file.
+ $(MAKE) -C $(DEPTH) 'source-repo.h' 'buildid.h'
+ $(MAKE) make-sourcestamp-file
+ @echo 'Packaging source tarball...'
+ # We want to include the sourcestamp file in the source tarball, so copy it
+ # in the root source directory. This is useful to enable telemetry submissions
+ # from builds made from the source package with the correct revision information.
+ # Don't bother removing it as this is only used by automation.
+ @cp $(MOZ_SOURCESTAMP_FILE) '$(MOZ_PKG_SRCDIR)/sourcestamp.txt'
+ $(MKDIR) -p $(DIST)/$(PKG_SRCPACK_PATH)
+ (cd $(MOZ_PKG_SRCDIR) && $(CREATE_SOURCE_TAR) - ./ ) | xz -9e > $(SOURCE_TAR)
+
+hg-bundle:
+ $(MKDIR) -p $(DIST)/$(PKG_SRCPACK_PATH)
+ $(CREATE_HG_BUNDLE_CMD)
+
+source-upload:
+ $(MAKE) upload UPLOAD_FILES='$(SOURCE_UPLOAD_FILES)' CHECKSUM_FILE='$(SOURCE_CHECKSUM_FILE)'
+
+
+ALL_LOCALES = $(if $(filter en-US,$(LOCALES)),$(LOCALES),$(LOCALES) en-US)
+
+# Firefox uses @RESPATH@.
+# Fennec uses @BINPATH@ and doesn't have the @RESPATH@ variable defined.
+ifeq ($(MOZ_BUILD_APP),mobile/android)
+BASE_PATH:=@BINPATH@
+MULTILOCALE_DIR = $(DIST)/$(BINPATH)/res
+else
+BASE_PATH:=@RESPATH@
+MULTILOCALE_DIR = $(DIST)/$(RESPATH)/res
+endif
+
+# This version of the target uses MOZ_CHROME_MULTILOCALE to build multilocale.txt
+# and places it in dist/bin/res - it should be used when packaging a build.
+multilocale.txt: LOCALES?=$(MOZ_CHROME_MULTILOCALE)
+multilocale.txt:
+ $(call py_action,file_generate $@,$(MOZILLA_DIR)/toolkit/locales/gen_multilocale.py main '$(MULTILOCALE_DIR)/multilocale.txt' $(MDDEPDIR)/multilocale.txt.pp '$(MULTILOCALE_DIR)/multilocale.txt' $(ALL_LOCALES))
+
+# This version of the target uses AB_CD to build multilocale.txt and places it
+# in the $(XPI_NAME)/res dir - it should be used when repackaging a build.
+multilocale.txt-%: LOCALES?=$(AB_CD)
+multilocale.txt-%: MULTILOCALE_DIR=$(DIST)/xpi-stage/$(XPI_NAME)/res
+multilocale.txt-%:
+ $(call py_action,file_generate multilocale.txt,$(MOZILLA_DIR)/toolkit/locales/gen_multilocale.py main '$(MULTILOCALE_DIR)/multilocale.txt' $(MDDEPDIR)/multilocale.txt.pp '$(MULTILOCALE_DIR)/multilocale.txt' $(ALL_LOCALES))
+
+locale-manifest.in: LOCALES?=$(MOZ_CHROME_MULTILOCALE)
+locale-manifest.in: $(GLOBAL_DEPS) FORCE
+ printf '\n[multilocale]\n' > $@
+ printf '$(BASE_PATH)/res/multilocale.txt\n' >> $@
+ for LOCALE in $(ALL_LOCALES) ;\
+ do \
+ for ENTRY in $(MOZ_CHROME_LOCALE_ENTRIES) ;\
+ do \
+ printf "$$ENTRY""$$LOCALE"'@JAREXT@\n' >> $@; \
+ printf "$$ENTRY""$$LOCALE"'.manifest\n' >> $@; \
+ done \
+ done
diff --git a/toolkit/mozapps/installer/packager.py b/toolkit/mozapps/installer/packager.py
new file mode 100644
index 0000000000..29364045dd
--- /dev/null
+++ b/toolkit/mozapps/installer/packager.py
@@ -0,0 +1,295 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 mozpack.packager.formats import (
+ FlatFormatter,
+ JarFormatter,
+ OmniJarFormatter,
+)
+from mozpack.packager import (
+ preprocess_manifest,
+ preprocess,
+ Component,
+ SimpleManifestSink,
+)
+from mozpack.files import (
+ GeneratedFile,
+ FileFinder,
+ File,
+)
+from mozpack.copier import (
+ FileCopier,
+ Jarrer,
+)
+from mozpack.errors import errors
+from mozpack.files import ExecutableFile
+import mozpack.path as mozpath
+import buildconfig
+from argparse import ArgumentParser
+from collections import OrderedDict
+from createprecomplete import generate_precomplete
+import os
+import plistlib
+from io import StringIO
+import subprocess
+
+
+class PackagerFileFinder(FileFinder):
+ def get(self, path):
+ f = super(PackagerFileFinder, self).get(path)
+ # Normalize Info.plist files, and remove the MozillaDeveloper*Path
+ # entries which are only needed on unpackaged builds.
+ if mozpath.basename(path) == "Info.plist":
+ info = plistlib.load(f.open(), dict_type=OrderedDict)
+ info.pop("MozillaDeveloperObjPath", None)
+ info.pop("MozillaDeveloperRepoPath", None)
+ return GeneratedFile(plistlib.dumps(info, sort_keys=False))
+ return f
+
+
+class RemovedFiles(GeneratedFile):
+ """
+ File class for removed-files. Is used as a preprocessor parser.
+ """
+
+ def __init__(self, copier):
+ self.copier = copier
+ GeneratedFile.__init__(self, "")
+
+ def handle_line(self, f):
+ f = f.strip()
+ if not f:
+ return
+ if self.copier.contains(f):
+ errors.error("Removal of packaged file(s): %s" % f)
+ self.content = f + "\n"
+
+
+def split_define(define):
+ """
+ Give a VAR[=VAL] string, returns a (VAR, VAL) tuple, where VAL defaults to
+ 1. Numeric VALs are returned as ints.
+ """
+ if "=" in define:
+ name, value = define.split("=", 1)
+ try:
+ value = int(value)
+ except ValueError:
+ pass
+ return (name, value)
+ return (define, 1)
+
+
+class NoPkgFilesRemover(object):
+ """
+ Formatter wrapper to handle NO_PKG_FILES.
+ """
+
+ def __init__(self, formatter, has_manifest):
+ assert "NO_PKG_FILES" in os.environ
+ self._formatter = formatter
+ self._files = os.environ["NO_PKG_FILES"].split()
+ if has_manifest:
+ self._error = errors.error
+ self._msg = "NO_PKG_FILES contains file listed in manifest: %s"
+ else:
+ self._error = errors.warn
+ self._msg = "Skipping %s"
+
+ def add_base(self, base, *args):
+ self._formatter.add_base(base, *args)
+
+ def add(self, path, content):
+ if not any(mozpath.match(path, spec) for spec in self._files):
+ self._formatter.add(path, content)
+ else:
+ self._error(self._msg % path)
+
+ def add_manifest(self, entry):
+ self._formatter.add_manifest(entry)
+
+ def contains(self, path):
+ return self._formatter.contains(path)
+
+
+def main():
+ parser = ArgumentParser()
+ parser.add_argument(
+ "-D",
+ dest="defines",
+ action="append",
+ metavar="VAR[=VAL]",
+ help="Define a variable",
+ )
+ parser.add_argument(
+ "--format",
+ default="omni",
+ help="Choose the chrome format for packaging "
+ + "(omni, jar or flat ; default: %(default)s)",
+ )
+ parser.add_argument("--removals", default=None, help="removed-files source file")
+ parser.add_argument(
+ "--ignore-errors",
+ action="store_true",
+ default=False,
+ help="Transform errors into warnings.",
+ )
+ parser.add_argument(
+ "--ignore-broken-symlinks",
+ action="store_true",
+ default=False,
+ help="Do not fail when processing broken symlinks.",
+ )
+ parser.add_argument(
+ "--minify",
+ action="store_true",
+ default=False,
+ help="Make some files more compact while packaging",
+ )
+ parser.add_argument(
+ "--minify-js",
+ action="store_true",
+ help="Minify JavaScript files while packaging.",
+ )
+ parser.add_argument(
+ "--js-binary",
+ help="Path to js binary. This is used to verify "
+ "minified JavaScript. If this is not defined, "
+ "minification verification will not be performed.",
+ )
+ parser.add_argument(
+ "--jarlog", default="", help="File containing jar " + "access logs"
+ )
+ parser.add_argument(
+ "--compress",
+ choices=("none", "deflate"),
+ default="deflate",
+ help="Use given jar compression (default: deflate)",
+ )
+ parser.add_argument("manifest", default=None, nargs="?", help="Manifest file name")
+ parser.add_argument("source", help="Source directory")
+ parser.add_argument("destination", help="Destination directory")
+ parser.add_argument(
+ "--non-resource",
+ nargs="+",
+ metavar="PATTERN",
+ default=[],
+ help="Extra files not to be considered as resources",
+ )
+ args = parser.parse_args()
+
+ defines = dict(buildconfig.defines["ALLDEFINES"])
+ if args.ignore_errors:
+ errors.ignore_errors()
+
+ if args.defines:
+ for name, value in [split_define(d) for d in args.defines]:
+ defines[name] = value
+
+ compress = {
+ "none": False,
+ "deflate": True,
+ }[args.compress]
+
+ copier = FileCopier()
+ if args.format == "flat":
+ formatter = FlatFormatter(copier)
+ elif args.format == "jar":
+ formatter = JarFormatter(copier, compress=compress)
+ elif args.format == "omni":
+ formatter = OmniJarFormatter(
+ copier,
+ buildconfig.substs["OMNIJAR_NAME"],
+ compress=compress,
+ non_resources=args.non_resource,
+ )
+ else:
+ errors.fatal("Unknown format: %s" % args.format)
+
+ # Adjust defines according to the requested format.
+ if isinstance(formatter, OmniJarFormatter):
+ defines["MOZ_OMNIJAR"] = 1
+ elif "MOZ_OMNIJAR" in defines:
+ del defines["MOZ_OMNIJAR"]
+
+ respath = ""
+ if "RESPATH" in defines:
+ respath = SimpleManifestSink.normalize_path(defines["RESPATH"])
+ while respath.startswith("/"):
+ respath = respath[1:]
+
+ with errors.accumulate():
+ finder_args = dict(
+ minify=args.minify,
+ minify_js=args.minify_js,
+ ignore_broken_symlinks=args.ignore_broken_symlinks,
+ )
+ if args.js_binary:
+ finder_args["minify_js_verify_command"] = [
+ args.js_binary,
+ os.path.join(
+ os.path.abspath(os.path.dirname(__file__)), "js-compare-ast.js"
+ ),
+ ]
+ finder = PackagerFileFinder(args.source, find_executables=True, **finder_args)
+ if "NO_PKG_FILES" in os.environ:
+ sinkformatter = NoPkgFilesRemover(formatter, args.manifest is not None)
+ else:
+ sinkformatter = formatter
+ sink = SimpleManifestSink(finder, sinkformatter)
+ if args.manifest:
+ preprocess_manifest(sink, args.manifest, defines)
+ else:
+ sink.add(Component(""), "bin/*")
+ sink.close(args.manifest is not None)
+
+ if args.removals:
+ removals_in = StringIO(open(args.removals).read())
+ removals_in.name = args.removals
+ removals = RemovedFiles(copier)
+ preprocess(removals_in, removals, defines)
+ copier.add(mozpath.join(respath, "removed-files"), removals)
+
+ # If a pdb file is present and we were instructed to copy it, include it.
+ # Run on all OSes to capture MinGW builds
+ if buildconfig.substs.get("MOZ_COPY_PDBS"):
+ # We want to mutate the copier while we're iterating through it, so copy
+ # the items to a list first.
+ copier_items = [(p, f) for p, f in copier]
+ for p, f in copier_items:
+ if isinstance(f, ExecutableFile):
+ pdbname = os.path.splitext(f.inputs()[0])[0] + ".pdb"
+ if os.path.exists(pdbname):
+ copier.add(os.path.basename(pdbname), File(pdbname))
+
+ # Setup preloading
+ if args.jarlog:
+ if not os.path.exists(args.jarlog):
+ raise Exception("Cannot find jar log: %s" % args.jarlog)
+ omnijars = []
+ if isinstance(formatter, OmniJarFormatter):
+ omnijars = [
+ mozpath.join(base, buildconfig.substs["OMNIJAR_NAME"])
+ for base in sink.packager.get_bases(addons=False)
+ ]
+
+ from mozpack.mozjar import JarLog
+
+ log = JarLog(args.jarlog)
+ for p, f in copier:
+ if not isinstance(f, Jarrer):
+ continue
+ if respath:
+ p = mozpath.relpath(p, respath)
+ if p in log:
+ f.preload(log[p])
+ elif p in omnijars:
+ raise Exception("No jar log data for %s" % p)
+
+ copier.copy(args.destination)
+ generate_precomplete(os.path.normpath(os.path.join(args.destination, respath)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/toolkit/mozapps/installer/strip.py b/toolkit/mozapps/installer/strip.py
new file mode 100644
index 0000000000..ef81b1a2b6
--- /dev/null
+++ b/toolkit/mozapps/installer/strip.py
@@ -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/.
+
+"""
+Strip all files that can be stripped in the given directory.
+"""
+
+import sys
+from mozpack.files import FileFinder
+from mozpack.copier import FileCopier
+
+
+def strip(dir):
+ copier = FileCopier()
+ # The FileFinder will give use ExecutableFile instances for files
+ # that can be stripped, and copying ExecutableFiles defaults to
+ # stripping them when buildconfig.substs['PKG_STRIP'] is set.
+ for p, f in FileFinder(dir, find_executables=True):
+ copier.add(p, f)
+ copier.copy(dir)
+
+
+if __name__ == "__main__":
+ strip(sys.argv[1])
diff --git a/toolkit/mozapps/installer/unify.py b/toolkit/mozapps/installer/unify.py
new file mode 100644
index 0000000000..8b93a8dcea
--- /dev/null
+++ b/toolkit/mozapps/installer/unify.py
@@ -0,0 +1,77 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import argparse
+import os
+import buildconfig
+from mozpack.packager.formats import (
+ FlatFormatter,
+ JarFormatter,
+ OmniJarFormatter,
+)
+from mozpack.packager import SimplePackager
+from mozpack.copier import (
+ FileCopier,
+ Jarrer,
+)
+from mozpack.errors import errors
+from mozpack.files import FileFinder
+from mozpack.mozjar import JAR_DEFLATED
+from mozpack.packager.unpack import UnpackFinder
+from mozpack.unify import UnifiedBuildFinder
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Merge two builds of a Gecko-based application into a Universal build"
+ )
+ parser.add_argument("app1", help="Directory containing the application")
+ parser.add_argument("app2", help="Directory containing the application to merge")
+ parser.add_argument(
+ "--non-resource",
+ nargs="+",
+ metavar="PATTERN",
+ default=[],
+ help="Extra files not to be considered as resources",
+ )
+
+ options = parser.parse_args()
+
+ buildconfig.substs["OS_ARCH"] = "Darwin"
+ buildconfig.substs["LIPO"] = os.environ.get("LIPO")
+
+ app1_finder = UnpackFinder(FileFinder(options.app1, find_executables=True))
+ app2_finder = UnpackFinder(FileFinder(options.app2, find_executables=True))
+ app_finder = UnifiedBuildFinder(app1_finder, app2_finder)
+
+ copier = FileCopier()
+ compress = min(app1_finder.compressed, JAR_DEFLATED)
+ if app1_finder.kind == "flat":
+ formatter = FlatFormatter(copier)
+ elif app1_finder.kind == "jar":
+ formatter = JarFormatter(copier, compress=compress)
+ elif app1_finder.kind == "omni":
+ formatter = OmniJarFormatter(
+ copier,
+ app1_finder.omnijar,
+ compress=compress,
+ non_resources=options.non_resource,
+ )
+
+ with errors.accumulate():
+ packager = SimplePackager(formatter)
+ for p, f in app_finder:
+ packager.add(p, f)
+ packager.close()
+
+ # Transplant jar preloading information.
+ for path, log in app1_finder.jarlogs.items():
+ assert isinstance(copier[path], Jarrer)
+ copier[path].preload(log)
+
+ copier.copy(options.app1, skip_if_older=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/toolkit/mozapps/installer/unpack.py b/toolkit/mozapps/installer/unpack.py
new file mode 100644
index 0000000000..34edb59e38
--- /dev/null
+++ b/toolkit/mozapps/installer/unpack.py
@@ -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/.
+
+import argparse
+import sys
+import os
+from mozpack.packager.unpack import unpack
+import buildconfig
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Unpack a Gecko-based application")
+ parser.add_argument("directory", help="Location of the application")
+ parser.add_argument("--omnijar", help="Name of the omnijar")
+
+ options = parser.parse_args(sys.argv[1:])
+
+ buildconfig.substs["USE_ELF_HACK"] = False
+ buildconfig.substs["PKG_STRIP"] = False
+ unpack(options.directory, options.omnijar)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/toolkit/mozapps/installer/upload-files.mk b/toolkit/mozapps/installer/upload-files.mk
new file mode 100644
index 0000000000..6e1283e238
--- /dev/null
+++ b/toolkit/mozapps/installer/upload-files.mk
@@ -0,0 +1,434 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 MOZ_PKG_FORMAT
+ ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+ MOZ_PKG_FORMAT = DMG
+ else
+ ifeq (WINNT,$(OS_ARCH))
+ MOZ_PKG_FORMAT = ZIP
+ else
+ ifeq (SunOS,$(OS_ARCH))
+ MOZ_PKG_FORMAT = BZ2
+ else
+ ifeq (gtk,$(MOZ_WIDGET_TOOLKIT))
+ MOZ_PKG_FORMAT = BZ2
+ else
+ ifeq (android,$(MOZ_WIDGET_TOOLKIT))
+ MOZ_PKG_FORMAT = APK
+ else
+ MOZ_PKG_FORMAT = TGZ
+ endif
+ endif
+ endif
+ endif
+ endif
+endif # MOZ_PKG_FORMAT
+
+ifeq ($(OS_ARCH),WINNT)
+INSTALLER_DIR = windows
+endif
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+ifndef _APPNAME
+_APPNAME = $(MOZ_MACBUNDLE_NAME)
+endif
+ifndef _BINPATH
+_BINPATH = $(_APPNAME)/Contents/MacOS
+endif # _BINPATH
+ifndef _RESPATH
+# Resource path for the precomplete file
+_RESPATH = $(_APPNAME)/Contents/Resources
+endif
+endif
+
+PACKAGE_BASE_DIR = $(ABS_DIST)
+PACKAGE = $(PKG_PATH)$(PKG_BASENAME)$(PKG_SUFFIX)
+
+# JavaScript Shell packaging
+JSSHELL_BINS = \
+ js$(BIN_SUFFIX) \
+ $(DLL_PREFIX)mozglue$(DLL_SUFFIX) \
+ $(NULL)
+
+ifndef MOZ_SYSTEM_NSPR
+ ifdef MOZ_FOLD_LIBS
+ JSSHELL_BINS += $(DLL_PREFIX)nss3$(DLL_SUFFIX)
+ else
+ JSSHELL_BINS += \
+ $(DLL_PREFIX)nspr4$(DLL_SUFFIX) \
+ $(DLL_PREFIX)plds4$(DLL_SUFFIX) \
+ $(DLL_PREFIX)plc4$(DLL_SUFFIX) \
+ $(NULL)
+ endif # MOZ_FOLD_LIBS
+endif # MOZ_SYSTEM_NSPR
+
+ifdef MSVC_C_RUNTIME_DLL
+ JSSHELL_BINS += $(MSVC_C_RUNTIME_DLL)
+endif
+ifdef MSVC_C_RUNTIME_1_DLL
+ JSSHELL_BINS += $(MSVC_C_RUNTIME_1_DLL)
+endif
+ifdef MSVC_CXX_RUNTIME_DLL
+ JSSHELL_BINS += $(MSVC_CXX_RUNTIME_DLL)
+endif
+
+ifdef LLVM_SYMBOLIZER
+ JSSHELL_BINS += $(notdir $(LLVM_SYMBOLIZER))
+endif
+ifdef MOZ_CLANG_RT_ASAN_LIB_PATH
+ JSSHELL_BINS += $(notdir $(MOZ_CLANG_RT_ASAN_LIB_PATH))
+endif
+
+ifdef FUZZING_INTERFACES
+ JSSHELL_BINS += fuzz-tests$(BIN_SUFFIX)
+endif
+
+MAKE_JSSHELL = $(call py_action,zip $(JSSHELL_NAME),-C $(DIST)/bin --strip $(abspath $(PKG_JSSHELL)) $(JSSHELL_BINS))
+
+ifneq (,$(PGO_JARLOG_PATH))
+ # The backslash subst is to work around an issue with our version of mozmake,
+ # where backslashes get slurped in command-line arguments if a command is run
+ # with a double-quote character. The command to packager.py happens to be one
+ # of these commands, where double-quotes appear in certain ACDEFINES values.
+ # This turns a jarlog path like "Z:\task..." into "Z:task", which fails.
+ # Switching the backslashes for forward slashes works around the issue.
+ JARLOG_FILE_AB_CD = $(subst \,/,$(PGO_JARLOG_PATH))
+else
+ JARLOG_FILE_AB_CD = $(topobjdir)/jarlog/$(AB_CD).log
+endif
+
+TAR_CREATE_FLAGS := --exclude=.mkdir.done $(TAR_CREATE_FLAGS)
+CREATE_FINAL_TAR = $(TAR) -c --owner=0 --group=0 --numeric-owner \
+ --mode=go-w --exclude=.mkdir.done -f
+UNPACK_TAR = tar -xf-
+
+ifeq ($(MOZ_PKG_FORMAT),TAR)
+ PKG_SUFFIX = .tar
+ INNER_MAKE_PACKAGE = cd $(1) && $(CREATE_FINAL_TAR) - $(MOZ_PKG_DIR) > $(PACKAGE)
+ INNER_UNMAKE_PACKAGE = cd $(1) && $(UNPACK_TAR) < $(UNPACKAGE)
+endif
+
+ifeq ($(MOZ_PKG_FORMAT),TGZ)
+ PKG_SUFFIX = .tar.gz
+ INNER_MAKE_PACKAGE = cd $(1) && $(CREATE_FINAL_TAR) - $(MOZ_PKG_DIR) | gzip -vf9 > $(PACKAGE)
+ INNER_UNMAKE_PACKAGE = cd $(1) && gunzip -c $(UNPACKAGE) | $(UNPACK_TAR)
+endif
+
+ifeq ($(MOZ_PKG_FORMAT),BZ2)
+ PKG_SUFFIX = .tar.bz2
+ ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+ INNER_MAKE_PACKAGE = cd $(1) && $(CREATE_FINAL_TAR) - -C $(MOZ_PKG_DIR) $(_APPNAME) | bzip2 -vf > $(PACKAGE)
+ else
+ INNER_MAKE_PACKAGE = cd $(1) && $(CREATE_FINAL_TAR) - $(MOZ_PKG_DIR) | bzip2 -vf > $(PACKAGE)
+ endif
+ INNER_UNMAKE_PACKAGE = cd $(1) && bunzip2 -c $(UNPACKAGE) | $(UNPACK_TAR)
+endif
+
+ifeq ($(MOZ_PKG_FORMAT),ZIP)
+ PKG_SUFFIX = .zip
+ INNER_MAKE_PACKAGE = $(call py_action,zip,'$(PACKAGE)' '$(MOZ_PKG_DIR)' -x '**/.mkdir.done',$(1))
+ INNER_UNMAKE_PACKAGE = $(call py_action,make_unzip,$(UNPACKAGE),$(1))
+endif
+
+#Create an RPM file
+ifeq ($(MOZ_PKG_FORMAT),RPM)
+ PKG_SUFFIX = .rpm
+ MOZ_NUMERIC_APP_VERSION = $(shell echo $(MOZ_PKG_VERSION) | sed 's/[^0-9.].*//' )
+ MOZ_RPM_RELEASE = $(shell echo $(MOZ_PKG_VERSION) | sed 's/[0-9.]*//' )
+
+ RPMBUILD_TOPDIR=$(ABS_DIST)/rpmbuild
+ RPMBUILD_RPMDIR=$(ABS_DIST)
+ RPMBUILD_SRPMDIR=$(ABS_DIST)
+ RPMBUILD_SOURCEDIR=$(RPMBUILD_TOPDIR)/SOURCES
+ RPMBUILD_SPECDIR=$(topsrcdir)/toolkit/mozapps/installer/linux/rpm
+ RPMBUILD_BUILDDIR=$(ABS_DIST)/..
+
+ SPEC_FILE = $(RPMBUILD_SPECDIR)/mozilla.spec
+ RPM_INCIDENTALS=$(topsrcdir)/toolkit/mozapps/installer/linux/rpm
+
+ RPM_CMD = \
+ echo Creating RPM && \
+ $(PYTHON3) -m mozbuild.action.preprocessor \
+ -DMOZ_APP_NAME=$(MOZ_APP_NAME) \
+ -DMOZ_APP_DISPLAYNAME='$(MOZ_APP_DISPLAYNAME)' \
+ -DMOZ_APP_REMOTINGNAME='$(MOZ_APP_REMOTINGNAME)' \
+ $(RPM_INCIDENTALS)/mozilla.desktop \
+ -o $(RPMBUILD_SOURCEDIR)/$(MOZ_APP_NAME).desktop && \
+ rm -rf $(ABS_DIST)/$(TARGET_RAW_CPU) && \
+ $(RPMBUILD) -bb \
+ $(SPEC_FILE) \
+ --target $(TARGET_RAW_CPU) \
+ --buildroot $(RPMBUILD_TOPDIR)/BUILDROOT \
+ --define 'moz_app_name $(MOZ_APP_NAME)' \
+ --define 'moz_app_displayname $(MOZ_APP_DISPLAYNAME)' \
+ --define 'moz_app_version $(MOZ_APP_VERSION)' \
+ --define 'moz_numeric_app_version $(MOZ_NUMERIC_APP_VERSION)' \
+ --define 'moz_rpm_release $(MOZ_RPM_RELEASE)' \
+ --define 'buildid $(BUILDID)' \
+ --define 'moz_source_repo $(shell awk '$$2 == "MOZ_SOURCE_REPO" {print $$3}' $(DEPTH)/source-repo.h)' \
+ --define 'moz_source_stamp $(shell awk '$$2 == "MOZ_SOURCE_STAMP" {print $$3}' $(DEPTH)/source-repo.h)' \
+ --define 'moz_branding_directory $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)' \
+ --define '_topdir $(RPMBUILD_TOPDIR)' \
+ --define '_rpmdir $(RPMBUILD_RPMDIR)' \
+ --define '_sourcedir $(RPMBUILD_SOURCEDIR)' \
+ --define '_specdir $(RPMBUILD_SPECDIR)' \
+ --define '_srcrpmdir $(RPMBUILD_SRPMDIR)' \
+ --define '_builddir $(RPMBUILD_BUILDDIR)' \
+ --define '_prefix $(prefix)' \
+ --define '_libdir $(libdir)' \
+ --define '_bindir $(bindir)' \
+ --define '_datadir $(datadir)' \
+ --define '_installdir $(installdir)'
+
+ ifdef ENABLE_TESTS
+ RPM_CMD += \
+ --define 'createtests yes' \
+ --define '_testsinstalldir $(shell basename $(installdir))'
+ endif
+
+ #For each of the main/tests rpms we want to make sure that
+ #if they exist that they are in objdir/dist/ and that they get
+ #uploaded and that they are beside the other build artifacts
+ MAIN_RPM= $(MOZ_APP_NAME)-$(MOZ_NUMERIC_APP_VERSION)-$(MOZ_RPM_RELEASE).$(BUILDID).$(TARGET_RAW_CPU)$(PKG_SUFFIX)
+ UPLOAD_EXTRA_FILES += $(MAIN_RPM)
+ RPM_CMD += && mv $(TARGET_RAW_CPU)/$(MAIN_RPM) $(ABS_DIST)/
+
+ ifdef ENABLE_TESTS
+ TESTS_RPM=$(MOZ_APP_NAME)-tests-$(MOZ_NUMERIC_APP_VERSION)-$(MOZ_RPM_RELEASE).$(BUILDID).$(TARGET_RAW_CPU)$(PKG_SUFFIX)
+ UPLOAD_EXTRA_FILES += $(TESTS_RPM)
+ RPM_CMD += && mv $(TARGET_RAW_CPU)/$(TESTS_RPM) $(ABS_DIST)/
+ endif
+
+ INNER_MAKE_PACKAGE = cd $(1) && $(RPM_CMD)
+ #Avoiding rpm repacks, going to try creating/uploading xpi in rpm files instead
+ INNER_UNMAKE_PACKAGE = $(error Try using rpm2cpio and cpio)
+
+endif #Create an RPM file
+
+
+ifeq ($(MOZ_PKG_FORMAT),APK)
+INNER_MAKE_PACKAGE = true
+INNER_UNMAKE_PACKAGE = true
+endif
+
+ifeq ($(MOZ_PKG_FORMAT),DMG)
+ PKG_SUFFIX = .dmg
+
+ _ABS_MOZSRCDIR = $(shell cd $(MOZILLA_DIR) && pwd)
+ PKG_DMG_SOURCE = $(MOZ_PKG_DIR)
+ INNER_MAKE_PACKAGE = \
+ $(call py_action,make_dmg, \
+ $(if $(MOZ_PKG_MAC_DSSTORE),--dsstore '$(MOZ_PKG_MAC_DSSTORE)') \
+ $(if $(MOZ_PKG_MAC_BACKGROUND),--background '$(MOZ_PKG_MAC_BACKGROUND)') \
+ $(if $(MOZ_PKG_MAC_ICON),--icon '$(MOZ_PKG_MAC_ICON)') \
+ --volume-name '$(MOZ_APP_DISPLAYNAME)' \
+ '$(PKG_DMG_SOURCE)' '$(PACKAGE)', \
+ $(1))
+ INNER_UNMAKE_PACKAGE = \
+ $(call py_action,unpack_dmg, \
+ $(if $(MOZ_PKG_MAC_DSSTORE),--dsstore '$(MOZ_PKG_MAC_DSSTORE)') \
+ $(if $(MOZ_PKG_MAC_BACKGROUND),--background '$(MOZ_PKG_MAC_BACKGROUND)') \
+ $(if $(MOZ_PKG_MAC_ICON),--icon '$(MOZ_PKG_MAC_ICON)') \
+ $(UNPACKAGE) $(MOZ_PKG_DIR), \
+ $(1))
+endif
+
+MAKE_PACKAGE = $(INNER_MAKE_PACKAGE)
+
+NO_PKG_FILES += \
+ core \
+ bsdecho \
+ js \
+ js-config \
+ jscpucfg \
+ nsinstall \
+ viewer \
+ TestGtkEmbed \
+ elf-dynstr-gc \
+ mangle* \
+ maptsv* \
+ mfc* \
+ msdump* \
+ msmap* \
+ nm2tsv* \
+ nsinstall* \
+ res/samples \
+ res/throbber \
+ shlibsign* \
+ certutil* \
+ pk12util* \
+ BadCertAndPinningServer* \
+ DelegatedCredentialsServer* \
+ EncryptedClientHelloServer* \
+ FaultyServer* \
+ OCSPStaplingServer* \
+ SanctionsTestServer* \
+ GenerateOCSPResponse* \
+ chrome/chrome.rdf \
+ chrome/app-chrome.manifest \
+ chrome/overlayinfo \
+ components/compreg.dat \
+ components/xpti.dat \
+ content_unit_tests \
+ necko_unit_tests \
+ *.dSYM \
+ $(NULL)
+
+# If a manifest has not been supplied, the following
+# files should be excluded from the package too
+ifndef MOZ_PKG_MANIFEST
+ NO_PKG_FILES += ssltunnel*
+endif
+
+ifdef MOZ_DMD
+ NO_PKG_FILES += SmokeDMD
+endif
+
+DEFINES += -DDLL_PREFIX=$(DLL_PREFIX) -DDLL_SUFFIX=$(DLL_SUFFIX) -DBIN_SUFFIX=$(BIN_SUFFIX)
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+ DEFINES += -DDIR_MACOS=Contents/MacOS/ -DDIR_RESOURCES=Contents/Resources/
+else
+ DEFINES += -DDIR_MACOS= -DDIR_RESOURCES=
+endif
+
+ifdef MOZ_FOLD_LIBS
+ DEFINES += -DMOZ_FOLD_LIBS=1
+endif
+
+# The following target stages files into two directories: one directory for
+# core files, and one for optional extensions based on the information in
+# the MOZ_PKG_MANIFEST file.
+
+PKG_ARG = , '$(pkg)'
+
+ifndef MOZ_PACKAGER_FORMAT
+ MOZ_PACKAGER_FORMAT = $(error MOZ_PACKAGER_FORMAT is not set)
+endif
+
+ifneq (android,$(MOZ_WIDGET_TOOLKIT))
+ JAR_COMPRESSION ?= none
+endif
+
+ifeq ($(OS_TARGET), WINNT)
+ INSTALLER_PACKAGE = $(DIST)/$(PKG_INST_PATH)$(PKG_INST_BASENAME).exe
+endif
+
+# These are necessary because some of our packages/installers contain spaces
+# in their filenames and GNU Make's $(wildcard) function doesn't properly
+# deal with them.
+empty :=
+space = $(empty) $(empty)
+QUOTED_WILDCARD = $(if $(wildcard $(subst $(space),?,$(1))),'$(1)')
+ESCAPE_SPACE = $(subst $(space),\$(space),$(1))
+ESCAPE_WILDCARD = $(subst $(space),?,$(1))
+
+# This variable defines which OpenSSL algorithm to use to
+# generate checksums for files that we upload
+CHECKSUM_ALGORITHM_PARAM = -d sha512 -d md5 -d sha1
+
+# This variable defines where the checksum file will be located
+CHECKSUM_FILE = '$(DIST)/$(PKG_PATH)/$(CHECKSUMS_FILE_BASENAME).checksums'
+CHECKSUM_FILES = $(CHECKSUM_FILE)
+
+# Upload MAR tools only if AB_CD is unset or en_US
+ifeq (,$(AB_CD:en-US=))
+ ifeq (WINNT,$(OS_TARGET))
+ UPLOAD_EXTRA_FILES += host/bin/mar.exe
+ UPLOAD_EXTRA_FILES += host/bin/mbsdiff.exe
+ else
+ UPLOAD_EXTRA_FILES += host/bin/mar
+ UPLOAD_EXTRA_FILES += host/bin/mbsdiff
+ endif
+endif
+
+UPLOAD_FILES= \
+ $(call QUOTED_WILDCARD,$(DIST)/$(PACKAGE)) \
+ $(call QUOTED_WILDCARD,$(INSTALLER_PACKAGE)) \
+ $(call QUOTED_WILDCARD,$(DIST)/$(LANGPACK)) \
+ $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(MOZHARNESS_PACKAGE)) \
+ $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(SYMBOL_ARCHIVE_BASENAME).zip) \
+ $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(GENERATED_SOURCE_FILE_PACKAGE)) \
+ $(call QUOTED_WILDCARD,$(MOZ_SOURCESTAMP_FILE)) \
+ $(call QUOTED_WILDCARD,$(MOZ_BUILDINFO_FILE)) \
+ $(call QUOTED_WILDCARD,$(MOZ_BUILDHUB_JSON)) \
+ $(call QUOTED_WILDCARD,$(MOZ_BUILDID_INFO_TXT_FILE)) \
+ $(call QUOTED_WILDCARD,$(MOZ_MOZINFO_FILE)) \
+ $(call QUOTED_WILDCARD,$(MOZ_TEST_PACKAGES_FILE)) \
+ $(call QUOTED_WILDCARD,$(PKG_JSSHELL)) \
+ $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(SYMBOL_FULL_ARCHIVE_BASENAME).tar.zst) \
+ $(call QUOTED_WILDCARD,$(topobjdir)/$(MOZ_BUILD_APP)/installer/windows/instgen/setup.exe) \
+ $(call QUOTED_WILDCARD,$(topobjdir)/$(MOZ_BUILD_APP)/installer/windows/instgen/setup-stub.exe) \
+ $(call QUOTED_WILDCARD,$(topsrcdir)/toolchains.json) \
+ $(call QUOTED_WILDCARD,$(topobjdir)/config.status) \
+ $(if $(UPLOAD_EXTRA_FILES), $(foreach f, $(UPLOAD_EXTRA_FILES), $(wildcard $(DIST)/$(f))))
+
+ifneq ($(filter-out en-US,$(AB_CD)),)
+ UPLOAD_FILES += \
+ $(call QUOTED_WILDCARD,$(topobjdir)/$(MOZ_BUILD_APP)/installer/windows/l10ngen/setup.exe) \
+ $(call QUOTED_WILDCARD,$(topobjdir)/$(MOZ_BUILD_APP)/installer/windows/l10ngen/setup-stub.exe)
+endif
+
+ifdef MOZ_CODE_COVERAGE
+ UPLOAD_FILES += \
+ $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(CODE_COVERAGE_ARCHIVE_BASENAME).zip) \
+ $(call QUOTED_WILDCARD,$(topobjdir)/chrome-map.json) \
+ $(NULL)
+endif
+
+
+ifdef ENABLE_MOZSEARCH_PLUGIN
+ UPLOAD_FILES += $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(MOZSEARCH_ARCHIVE_BASENAME).zip)
+ UPLOAD_FILES += $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(MOZSEARCH_SCIP_INDEX_BASENAME).zip)
+ UPLOAD_FILES += $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(MOZSEARCH_INCLUDEMAP_BASENAME).map)
+ifeq ($(MOZ_BUILD_APP),mobile/android)
+ UPLOAD_FILES += $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(MOZSEARCH_JAVA_INDEX_BASENAME).zip)
+endif
+endif
+
+ifdef MOZ_STUB_INSTALLER
+ UPLOAD_FILES += $(call QUOTED_WILDCARD,$(DIST)/$(PKG_INST_PATH)$(PKG_STUB_BASENAME).exe)
+endif
+
+# Upload `.xpt` artifacts for use in artifact builds.
+UPLOAD_FILES += $(call QUOTED_WILDCARD,$(DIST)/$(PKG_PATH)$(XPT_ARTIFACTS_ARCHIVE_BASENAME).zip)
+
+ifndef MOZ_PKG_SRCDIR
+ MOZ_PKG_SRCDIR = $(topsrcdir)
+endif
+
+SRC_TAR_PREFIX = $(MOZ_APP_NAME)-$(MOZ_PKG_VERSION)
+SRC_TAR_EXCLUDE_PATHS += \
+ --exclude='.hg*' \
+ --exclude='.git' \
+ --exclude='.gitattributes' \
+ --exclude='.gitkeep' \
+ --exclude='.gitmodules' \
+ --exclude='CVS' \
+ --exclude='.cvs*' \
+ --exclude='.mozconfig*' \
+ --exclude='*.pyc' \
+ --exclude='$(MOZILLA_DIR)/Makefile' \
+ --exclude='$(MOZILLA_DIR)/dist'
+ifdef MOZ_OBJDIR
+ SRC_TAR_EXCLUDE_PATHS += --exclude='$(MOZ_OBJDIR)'
+endif
+CREATE_SOURCE_TAR = $(TAR) -c --owner=0 --group=0 --numeric-owner \
+ --mode=go-w $(SRC_TAR_EXCLUDE_PATHS) --transform='s,^\./,$(SRC_TAR_PREFIX)/,' -f
+
+SOURCE_TAR = $(DIST)/$(PKG_SRCPACK_PATH)$(PKG_SRCPACK_BASENAME).tar.xz
+HG_BUNDLE_FILE = $(DIST)/$(PKG_SRCPACK_PATH)$(PKG_BUNDLE_BASENAME).bundle
+SOURCE_CHECKSUM_FILE = $(DIST)/$(PKG_SRCPACK_PATH)$(PKG_SRCPACK_BASENAME).checksums
+SOURCE_UPLOAD_FILES = $(SOURCE_TAR)
+
+HG ?= hg
+CREATE_HG_BUNDLE_CMD = $(HG) -v -R $(topsrcdir) bundle --base null
+ifdef HG_BUNDLE_REVISION
+ CREATE_HG_BUNDLE_CMD += -r $(HG_BUNDLE_REVISION)
+endif
+CREATE_HG_BUNDLE_CMD += $(HG_BUNDLE_FILE)
+ifdef UPLOAD_HG_BUNDLE
+ SOURCE_UPLOAD_FILES += $(HG_BUNDLE_FILE)
+endif
diff --git a/toolkit/mozapps/installer/windows/nsis/common.nsh b/toolkit/mozapps/installer/windows/nsis/common.nsh
new file mode 100755
index 0000000000..a5e3977fb0
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/common.nsh
@@ -0,0 +1,8842 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+
+################################################################################
+# Helper defines and macros for toolkit applications
+
+/**
+ * Avoid creating macros / functions that overwrite registers (see the
+ * GetLongPath macro for one way to avoid this)!
+ *
+ * Before using the registers exchange the passed in params and save existing
+ * register values to the stack.
+ *
+ * Exch $R9 ; exhange the original $R9 with the top of the stack
+ * Exch 1 ; exchange the top of the stack with 1 below the top of the stack
+ * Exch $R8 ; exchange the original $R8 with the top of the stack
+ * Exch 2 ; exchange the top of the stack with 2 below the top of the stack
+ * Exch $R7 ; exchange the original $R7 with the top of the stack
+ * Push $R6 ; push the original $R6 onto the top of the stack
+ * Push $R5 ; push the original $R5 onto the top of the stack
+ * Push $R4 ; push the original $R4 onto the top of the stack
+ *
+ * <do stuff>
+ *
+ * ; Restore the values.
+ * Pop $R4 ; restore the value for $R4 from the top of the stack
+ * Pop $R5 ; restore the value for $R5 from the top of the stack
+ * Pop $R6 ; restore the value for $R6 from the top of the stack
+ * Exch $R7 ; exchange the new $R7 value with the top of the stack
+ * Exch 2 ; exchange the top of the stack with 2 below the top of the stack
+ * Exch $R8 ; exchange the new $R8 value with the top of the stack
+ * Exch 1 ; exchange the top of the stack with 2 below the top of the stack
+ * Exch $R9 ; exchange the new $R9 value with the top of the stack
+ *
+ *
+ * When inserting macros in common.nsh from another macro in common.nsh that
+ * can be used from the uninstaller _MOZFUNC_UN will be undefined when it is
+ * inserted. Use the following to redefine _MOZFUNC_UN with its original value
+ * (see the RegCleanMain macro for an example).
+ *
+ * !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ * !insertmacro ${_MOZFUNC_UN_TMP}FileJoin
+ * !insertmacro ${_MOZFUNC_UN_TMP}LineFind
+ * !insertmacro ${_MOZFUNC_UN_TMP}TextCompareNoDetails
+ * !insertmacro ${_MOZFUNC_UN_TMP}TrimNewLines
+ * !undef _MOZFUNC_UN
+ * !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ * !undef _MOZFUNC_UN_TMP
+ */
+
+; When including a file provided by NSIS check if its verbose macro is defined
+; to prevent loading the file a second time.
+!ifmacrondef TEXTFUNC_VERBOSE
+ !include TextFunc.nsh
+!endif
+
+!ifmacrondef FILEFUNC_VERBOSE
+ !include FileFunc.nsh
+!endif
+
+!ifmacrondef LOGICLIB_VERBOSITY
+ !include LogicLib.nsh
+!endif
+
+!ifndef WINMESSAGES_INCLUDED
+ !include WinMessages.nsh
+!endif
+
+; When including WinVer.nsh check if ___WINVER__NSH___ is defined to prevent
+; loading the file a second time.
+!ifndef ___WINVER__NSH___
+ !include WinVer.nsh
+!endif
+
+; When including x64.nsh check if ___X64__NSH___ is defined to prevent
+; loading the file a second time.
+!ifndef ___X64__NSH___
+ !include x64.nsh
+!endif
+
+; NSIS provided macros that we have overridden.
+!include overrides.nsh
+
+!define SHORTCUTS_LOG "shortcuts_log.ini"
+!define TO_BE_DELETED "tobedeleted"
+
+; !define SHCNF_DWORD 0x0003
+; !define SHCNF_FLUSH 0x1000
+!ifndef SHCNF_DWORDFLUSH
+ !define SHCNF_DWORDFLUSH 0x1003
+!endif
+!ifndef SHCNE_ASSOCCHANGED
+ !define SHCNE_ASSOCCHANGED 0x08000000
+!endif
+
+################################################################################
+# Macros for debugging
+
+/**
+ * The following two macros assist with verifying that a macro doesn't
+ * overwrite any registers.
+ *
+ * Usage:
+ * ${debugSetRegisters}
+ * <do stuff>
+ * ${debugDisplayRegisters}
+ */
+
+/**
+ * Sets all register values to their name to assist with verifying that a macro
+ * doesn't overwrite any registers.
+ */
+!macro debugSetRegisters
+ StrCpy $0 "$$0"
+ StrCpy $1 "$$1"
+ StrCpy $2 "$$2"
+ StrCpy $3 "$$3"
+ StrCpy $4 "$$4"
+ StrCpy $5 "$$5"
+ StrCpy $6 "$$6"
+ StrCpy $7 "$$7"
+ StrCpy $8 "$$8"
+ StrCpy $9 "$$9"
+ StrCpy $R0 "$$R0"
+ StrCpy $R1 "$$R1"
+ StrCpy $R2 "$$R2"
+ StrCpy $R3 "$$R3"
+ StrCpy $R4 "$$R4"
+ StrCpy $R5 "$$R5"
+ StrCpy $R6 "$$R6"
+ StrCpy $R7 "$$R7"
+ StrCpy $R8 "$$R8"
+ StrCpy $R9 "$$R9"
+!macroend
+!define debugSetRegisters "!insertmacro debugSetRegisters"
+
+/**
+ * Displays all register values to assist with verifying that a macro doesn't
+ * overwrite any registers.
+ */
+!macro debugDisplayRegisters
+ MessageBox MB_OK \
+ "Register Values:$\n\
+ $$0 = $0$\n$$1 = $1$\n$$2 = $2$\n$$3 = $3$\n$$4 = $4$\n\
+ $$5 = $5$\n$$6 = $6$\n$$7 = $7$\n$$8 = $8$\n$$9 = $9$\n\
+ $$R0 = $R0$\n$$R1 = $R1$\n$$R2 = $R2$\n$$R3 = $R3$\n$$R4 = $R4$\n\
+ $$R5 = $R5$\n$$R6 = $R6$\n$$R7 = $R7$\n$$R8 = $R8$\n$$R9 = $R9"
+!macroend
+!define debugDisplayRegisters "!insertmacro debugDisplayRegisters"
+
+
+################################################################################
+# Modern User Interface (MUI) override macros
+
+; Removed macros in nsis 2.33u (ported from nsis 2.22)
+; MUI_LANGUAGEFILE_DEFINE
+; MUI_LANGUAGEFILE_LANGSTRING_PAGE
+; MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE
+; MUI_LANGUAGEFILE_LANGSTRING_DEFINE
+; MUI_LANGUAGEFILE_UNLANGSTRING_PAGE
+
+!macro MOZ_MUI_LANGUAGEFILE_DEFINE DEFINE NAME
+
+ !ifndef "${DEFINE}"
+ !define "${DEFINE}" "${${NAME}}"
+ !endif
+ !undef "${NAME}"
+
+!macroend
+
+!macro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE PAGE NAME
+
+ !ifdef MUI_${PAGE}PAGE
+ LangString "${NAME}" 0 "${${NAME}}"
+ !undef "${NAME}"
+ !else
+ !undef "${NAME}"
+ !endif
+
+!macroend
+
+!macro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE PAGE NAME
+
+ !ifdef MUI_${PAGE}PAGE | MUI_UN${PAGE}PAGE
+ LangString "${NAME}" 0 "${${NAME}}"
+ !undef "${NAME}"
+ !else
+ !undef "${NAME}"
+ !endif
+
+!macroend
+
+!macro MOZ_MUI_LANGUAGEFILE_LANGSTRING_DEFINE DEFINE NAME
+
+ !ifdef "${DEFINE}"
+ LangString "${NAME}" 0 "${${NAME}}"
+ !endif
+ !undef "${NAME}"
+
+!macroend
+
+!macro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE PAGE NAME
+
+ !ifdef MUI_UNINSTALLER
+ !ifdef MUI_UN${PAGE}PAGE
+ LangString "${NAME}" 0 "${${NAME}}"
+ !undef "${NAME}"
+ !else
+ !undef "${NAME}"
+ !endif
+ !else
+ !undef "${NAME}"
+ !endif
+
+!macroend
+
+; Modified version of the following MUI macros to support Mozilla localization.
+; MUI_LANGUAGE
+; MUI_LANGUAGEFILE_BEGIN
+; MOZ_MUI_LANGUAGEFILE_END
+; See <NSIS App Dir>/Contrib/Modern UI/System.nsh for more information
+!define MUI_INSTALLOPTIONS_READ "!insertmacro MUI_INSTALLOPTIONS_READ"
+
+!macro MOZ_MUI_LANGUAGE LANGUAGE
+ !verbose push
+ !verbose ${MUI_VERBOSE}
+ !include "${LANGUAGE}.nsh"
+ !verbose pop
+!macroend
+
+!macro MOZ_MUI_LANGUAGEFILE_BEGIN LANGUAGE
+ !insertmacro MUI_INSERT
+ !ifndef "MUI_LANGUAGEFILE_${LANGUAGE}_USED"
+ !define "MUI_LANGUAGEFILE_${LANGUAGE}_USED"
+ LoadLanguageFile "${LANGUAGE}.nlf"
+ !else
+ !error "Modern UI language file ${LANGUAGE} included twice!"
+ !endif
+!macroend
+
+; Custom version of MUI_LANGUAGEFILE_END. The macro to add the default MUI
+; strings and the macros for several strings that are part of the NSIS MUI and
+; not in our locale files have been commented out.
+!macro MOZ_MUI_LANGUAGEFILE_END
+
+# !include "${NSISDIR}\Contrib\Modern UI\Language files\Default.nsh"
+ !ifdef MUI_LANGUAGEFILE_DEFAULT_USED
+ !undef MUI_LANGUAGEFILE_DEFAULT_USED
+ !warning "${LANGUAGE} Modern UI language file version doesn't match. Using default English texts for missing strings."
+ !endif
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_DEFINE "MUI_${LANGUAGE}_LANGNAME" "MUI_LANGNAME"
+
+ !ifndef MUI_LANGDLL_PUSHLIST
+ !define MUI_LANGDLL_PUSHLIST "'${MUI_${LANGUAGE}_LANGNAME}' ${LANG_${LANGUAGE}} "
+ !else
+ !ifdef MUI_LANGDLL_PUSHLIST_TEMP
+ !undef MUI_LANGDLL_PUSHLIST_TEMP
+ !endif
+ !define MUI_LANGDLL_PUSHLIST_TEMP "${MUI_LANGDLL_PUSHLIST}"
+ !undef MUI_LANGDLL_PUSHLIST
+ !define MUI_LANGDLL_PUSHLIST "'${MUI_${LANGUAGE}_LANGNAME}' ${LANG_${LANGUAGE}} ${MUI_LANGDLL_PUSHLIST_TEMP}"
+ !endif
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE WELCOME "MUI_TEXT_WELCOME_INFO_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE WELCOME "MUI_TEXT_WELCOME_INFO_TEXT"
+
+!ifdef MUI_TEXT_LICENSE_TITLE
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE LICENSE "MUI_TEXT_LICENSE_TITLE"
+!endif
+!ifdef MUI_TEXT_LICENSE_SUBTITLE
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE LICENSE "MUI_TEXT_LICENSE_SUBTITLE"
+!endif
+!ifdef MUI_INNERTEXT_LICENSE_TOP
+ !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE LICENSE "MUI_INNERTEXT_LICENSE_TOP"
+!endif
+
+# !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE LICENSE "MUI_INNERTEXT_LICENSE_BOTTOM"
+
+!ifdef MUI_INNERTEXT_LICENSE_BOTTOM_CHECKBOX
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE LICENSE "MUI_INNERTEXT_LICENSE_BOTTOM_CHECKBOX"
+!endif
+
+!ifdef MUI_INNERTEXT_LICENSE_BOTTOM_RADIOBUTTONS
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE LICENSE "MUI_INNERTEXT_LICENSE_BOTTOM_RADIOBUTTONS"
+!endif
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE COMPONENTS "MUI_TEXT_COMPONENTS_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE COMPONENTS "MUI_TEXT_COMPONENTS_SUBTITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE COMPONENTS "MUI_INNERTEXT_COMPONENTS_DESCRIPTION_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE COMPONENTS "MUI_INNERTEXT_COMPONENTS_DESCRIPTION_INFO"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE DIRECTORY "MUI_TEXT_DIRECTORY_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE DIRECTORY "MUI_TEXT_DIRECTORY_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE STARTMENU "MUI_TEXT_STARTMENU_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE STARTMENU "MUI_TEXT_STARTMENU_SUBTITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE STARTMENU "MUI_INNERTEXT_STARTMENU_TOP"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE STARTMENU "MUI_INNERTEXT_STARTMENU_CHECKBOX"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE INSTFILES "MUI_TEXT_INSTALLING_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE INSTFILES "MUI_TEXT_INSTALLING_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE INSTFILES "MUI_TEXT_FINISH_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE INSTFILES "MUI_TEXT_FINISH_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE INSTFILES "MUI_TEXT_ABORT_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE INSTFILES "MUI_TEXT_ABORT_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE FINISH "MUI_BUTTONTEXT_FINISH"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_INFO_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_INFO_TEXT"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_INFO_REBOOT"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_REBOOTNOW"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_REBOOTLATER"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_RUN"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_MULTILANGSTRING_PAGE FINISH "MUI_TEXT_FINISH_SHOWREADME"
+
+; Support for using the existing MUI_TEXT_ABORTWARNING string
+!ifdef MOZ_MUI_CUSTOM_ABORT
+ LangString MOZ_MUI_TEXT_ABORTWARNING 0 "${MUI_TEXT_ABORTWARNING}"
+!endif
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_DEFINE MUI_ABORTWARNING "MUI_TEXT_ABORTWARNING"
+
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE WELCOME "MUI_UNTEXT_WELCOME_INFO_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE WELCOME "MUI_UNTEXT_WELCOME_INFO_TEXT"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE CONFIRM "MUI_UNTEXT_CONFIRM_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE CONFIRM "MUI_UNTEXT_CONFIRM_SUBTITLE"
+
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE LICENSE "MUI_UNTEXT_LICENSE_TITLE"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE LICENSE "MUI_UNTEXT_LICENSE_SUBTITLE"
+
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE LICENSE "MUI_UNINNERTEXT_LICENSE_BOTTOM"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE LICENSE "MUI_UNINNERTEXT_LICENSE_BOTTOM_CHECKBOX"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE LICENSE "MUI_UNINNERTEXT_LICENSE_BOTTOM_RADIOBUTTONS"
+
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE COMPONENTS "MUI_UNTEXT_COMPONENTS_TITLE"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE COMPONENTS "MUI_UNTEXT_COMPONENTS_SUBTITLE"
+
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE DIRECTORY "MUI_UNTEXT_DIRECTORY_TITLE"
+# !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE DIRECTORY "MUI_UNTEXT_DIRECTORY_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE INSTFILES "MUI_UNTEXT_UNINSTALLING_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE INSTFILES "MUI_UNTEXT_UNINSTALLING_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE INSTFILES "MUI_UNTEXT_FINISH_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE INSTFILES "MUI_UNTEXT_FINISH_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE INSTFILES "MUI_UNTEXT_ABORT_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE INSTFILES "MUI_UNTEXT_ABORT_SUBTITLE"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE FINISH "MUI_UNTEXT_FINISH_INFO_TITLE"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE FINISH "MUI_UNTEXT_FINISH_INFO_TEXT"
+ !insertmacro MOZ_MUI_LANGUAGEFILE_UNLANGSTRING_PAGE FINISH "MUI_UNTEXT_FINISH_INFO_REBOOT"
+
+ !insertmacro MOZ_MUI_LANGUAGEFILE_LANGSTRING_DEFINE MUI_UNABORTWARNING "MUI_UNTEXT_ABORTWARNING"
+
+ !ifndef MUI_LANGDLL_LANGUAGES
+ !define MUI_LANGDLL_LANGUAGES "'${LANGFILE_${LANGUAGE}_NAME}' '${LANG_${LANGUAGE}}' "
+ !define MUI_LANGDLL_LANGUAGES_CP "'${LANGFILE_${LANGUAGE}_NAME}' '${LANG_${LANGUAGE}}' '${LANG_${LANGUAGE}_CP}' "
+ !else
+ !ifdef MUI_LANGDLL_LANGUAGES_TEMP
+ !undef MUI_LANGDLL_LANGUAGES_TEMP
+ !endif
+ !define MUI_LANGDLL_LANGUAGES_TEMP "${MUI_LANGDLL_LANGUAGES}"
+ !undef MUI_LANGDLL_LANGUAGES
+
+ !ifdef MUI_LANGDLL_LANGUAGES_CP_TEMP
+ !undef MUI_LANGDLL_LANGUAGES_CP_TEMP
+ !endif
+ !define MUI_LANGDLL_LANGUAGES_CP_TEMP "${MUI_LANGDLL_LANGUAGES_CP}"
+ !undef MUI_LANGDLL_LANGUAGES_CP
+
+ !define MUI_LANGDLL_LANGUAGES "'${LANGFILE_${LANGUAGE}_NAME}' '${LANG_${LANGUAGE}}' ${MUI_LANGDLL_LANGUAGES_TEMP}"
+ !define MUI_LANGDLL_LANGUAGES_CP "'${LANGFILE_${LANGUAGE}_NAME}' '${LANG_${LANGUAGE}}' '${LANG_${LANGUAGE}_CP}' ${MUI_LANGDLL_LANGUAGES_CP_TEMP}"
+ !endif
+
+!macroend
+
+/**
+ * Creates an InstallOptions file with a UTF-16LE BOM and adds the RTL value
+ * to the Settings section.
+ *
+ * @param _FILE
+ * The name of the file to be created in $PLUGINSDIR.
+ */
+!macro InitInstallOptionsFile _FILE
+ Push $R9
+
+ FileOpen $R9 "$PLUGINSDIR\${_FILE}" w
+ FileWriteWord $R9 "65279"
+ FileClose $R9
+ WriteIniStr "$PLUGINSDIR\${_FILE}" "Settings" "RTL" "$(^RTL)"
+
+ Pop $R9
+!macroend
+
+
+################################################################################
+# Macros for handling files in use
+
+/**
+ * Checks for files in use in the $INSTDIR directory. To check files in
+ * sub-directories this macro would need to be rewritten to create
+ * sub-directories in the temporary directory used to backup the files that are
+ * checked.
+ *
+ * Example usage:
+ *
+ * ; The first string to be pushed onto the stack MUST be "end" to indicate
+ * ; that there are no more files in the $INSTDIR directory to check.
+ * Push "end"
+ * Push "freebl3.dll"
+ * ; The last file pushed should be the app's main exe so if it is in use this
+ * ; macro will return after the first check.
+ * Push "${FileMainEXE}"
+ * ${CheckForFilesInUse} $R9
+ *
+ * !IMPORTANT - this macro uses the $R7, $R8, and $R9 registers and makes no
+ * attempt to restore their original values.
+ *
+ * @return _RESULT
+ * false if all of the files popped from the stack are not in use.
+ * True if any of the files popped from the stack are in use.
+ * $R7 = Temporary backup directory where the files will be copied to.
+ * $R8 = value popped from the stack. This will either be a file name for a file
+ * in the $INSTDIR directory or "end" to indicate that there are no
+ * additional files to check.
+ * $R9 = _RESULT
+ */
+!macro CheckForFilesInUse
+
+ !ifndef ${_MOZFUNC_UN}CheckForFilesInUse
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}CheckForFilesInUse "!insertmacro ${_MOZFUNC_UN}CheckForFilesInUseCall"
+
+ Function ${_MOZFUNC_UN}CheckForFilesInUse
+ ; Create a temporary backup directory.
+ GetTempFileName $R7 "$INSTDIR"
+ Delete "$R7"
+ SetOutPath "$R7"
+ StrCpy $R9 "false"
+
+ Pop $R8
+ ${While} $R8 != "end"
+ ${Unless} ${FileExists} "$INSTDIR\$R8"
+ Pop $R8 ; get next file to check before continuing
+ ${Continue}
+ ${EndUnless}
+
+ ClearErrors
+ CopyFiles /SILENT "$INSTDIR\$R8" "$R7\$R8" ; try to copy
+ ${If} ${Errors}
+ ; File is in use
+ StrCpy $R9 "true"
+ ${Break}
+ ${EndIf}
+
+ Delete "$INSTDIR\$R8" ; delete original
+ ${If} ${Errors}
+ ; File is in use
+ StrCpy $R9 "true"
+ Delete "$R7\$R8" ; delete temp copy
+ ${Break}
+ ${EndIf}
+
+ Pop $R8 ; get next file to check
+ ${EndWhile}
+
+ ; clear stack
+ ${While} $R8 != "end"
+ Pop $R8
+ ${EndWhile}
+
+ ; restore everything
+ SetOutPath "$INSTDIR"
+ CopyFiles /SILENT "$R7\*" "$INSTDIR\"
+ RmDir /r "$R7"
+ SetOutPath "$EXEDIR"
+ ClearErrors
+
+ Push $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CheckForFilesInUseCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call CheckForFilesInUse
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.CheckForFilesInUseCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.CheckForFilesInUse
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.CheckForFilesInUse
+ !ifndef un.CheckForFilesInUse
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro CheckForFilesInUse
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetCommonDirectory
+
+ !ifndef ${_MOZFUNC_UN}GetCommonDirectory
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetCommonDirectory "!insertmacro ${_MOZFUNC_UN}GetCommonDirectoryCall"
+
+ Function ${_MOZFUNC_UN}GetCommonDirectory
+ Push $0 ; Save $0
+
+ ; This gets C:\ProgramData or the equivalent.
+ ${GetCommonAppDataFolder} $0
+
+ ; Add our subdirectory, this is hardcoded as grandparent of the update directory in
+ ; several other places.
+ StrCpy $0 "$0\Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38"
+
+ Exch $0 ; Restore original $0 and put our $0 on the stack.
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetCommonDirectoryCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call GetCommonDirectory
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetCommonDirectoryCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.GetCommonDirectory
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetCommonDirectory
+ !ifndef un.GetCommonDirectory
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetCommonDirectory
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * The macros below will automatically prepend un. to the function names when
+ * they are defined (e.g. !define un.RegCleanMain).
+ */
+!verbose push
+!verbose 3
+!ifndef _MOZFUNC_VERBOSE
+ !define _MOZFUNC_VERBOSE 3
+!endif
+!verbose ${_MOZFUNC_VERBOSE}
+!define MOZFUNC_VERBOSE "!insertmacro MOZFUNC_VERBOSE"
+!define _MOZFUNC_UN
+!define _MOZFUNC_S
+!verbose pop
+
+!macro MOZFUNC_VERBOSE _VERBOSE
+ !verbose push
+ !verbose 3
+ !undef _MOZFUNC_VERBOSE
+ !define _MOZFUNC_VERBOSE ${_VERBOSE}
+ !verbose pop
+!macroend
+
+/**
+ * Displays a MessageBox and then calls abort to prevent continuing to the
+ * next page when the specified Window Class is found.
+ *
+ * @param _WINDOW_CLASS
+ * The Window Class to search for with FindWindow.
+ * @param _MSG
+ * The message text to display in the message box.
+ *
+ * $R7 = return value from FindWindow
+ * $R8 = _WINDOW_CLASS
+ * $R9 = _MSG
+ */
+!macro ManualCloseAppPrompt
+
+ !ifndef ${_MOZFUNC_UN}ManualCloseAppPrompt
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}ManualCloseAppPrompt "!insertmacro ${_MOZFUNC_UN}ManualCloseAppPromptCall"
+
+ Function ${_MOZFUNC_UN}ManualCloseAppPrompt
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Push $R7
+
+ FindWindow $R7 "$R8"
+ ${If} $R7 <> 0 ; integer comparison
+ MessageBox MB_OK|MB_ICONQUESTION "$R9"
+ Abort
+ ${EndIf}
+
+ Pop $R7
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro ManualCloseAppPromptCall _WINDOW_CLASS _MSG
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_WINDOW_CLASS}"
+ Push "${_MSG}"
+ Call ManualCloseAppPrompt
+ !verbose pop
+!macroend
+
+!macro un.ManualCloseAppPromptCall _WINDOW_CLASS _MSG
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_WINDOW_CLASS}"
+ Push "${_MSG}"
+ Call un.ManualCloseAppPrompt
+ !verbose pop
+!macroend
+
+!macro un.ManualCloseAppPrompt
+ !ifndef un.ManualCloseAppPrompt
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro ManualCloseAppPrompt
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+################################################################################
+# Macros for working with the registry
+
+/**
+ * Writes a registry string using SHCTX and the supplied params and logs the
+ * action to the install log and the uninstall log if _LOG_UNINSTALL equals 1.
+ *
+ * Define NO_LOG to prevent all logging when calling this from the uninstaller.
+ *
+ * @param _ROOT
+ * The registry key root as defined by NSIS (e.g. HKLM, HKCU, etc.).
+ * This will only be used for logging.
+ * @param _KEY
+ * The subkey in relation to the key root.
+ * @param _NAME
+ * The key value name to write to.
+ * @param _STR
+ * The string to write to the key value name.
+ * @param _LOG_UNINSTALL
+ * 0 = don't add to uninstall log, 1 = add to uninstall log.
+ *
+ * $R5 = _ROOT
+ * $R6 = _KEY
+ * $R7 = _NAME
+ * $R8 = _STR
+ * $R9 = _LOG_UNINSTALL
+ */
+!macro WriteRegStr2
+
+ !ifndef ${_MOZFUNC_UN}WriteRegStr2
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}WriteRegStr2 "!insertmacro ${_MOZFUNC_UN}WriteRegStr2Call"
+
+ Function ${_MOZFUNC_UN}WriteRegStr2
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Exch 3
+ Exch $R6
+ Exch 4
+ Exch $R5
+
+ ClearErrors
+ WriteRegStr SHCTX "$R6" "$R7" "$R8"
+
+ !ifndef NO_LOG
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Adding Registry String: $R5 | $R6 | $R7 | $R8 **"
+ ${Else}
+ ${If} $R9 == 1 ; add to the uninstall log?
+ ${LogUninstall} "RegVal: $R5 | $R6 | $R7"
+ ${EndIf}
+ ${LogMsg} "Added Registry String: $R5 | $R6 | $R7 | $R8"
+ ${EndIf}
+ !endif
+
+ Exch $R5
+ Exch 4
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro WriteRegStr2Call _ROOT _KEY _NAME _STR _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_NAME}"
+ Push "${_STR}"
+ Push "${_LOG_UNINSTALL}"
+ Call WriteRegStr2
+ !verbose pop
+!macroend
+
+!macro un.WriteRegStr2Call _ROOT _KEY _NAME _STR _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_NAME}"
+ Push "${_STR}"
+ Push "${_LOG_UNINSTALL}"
+ Call un.WriteRegStr2
+ !verbose pop
+!macroend
+
+!macro un.WriteRegStr2
+ !ifndef un.WriteRegStr2
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro WriteRegStr2
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Writes a registry dword using SHCTX and the supplied params and logs the
+ * action to the install log and the uninstall log if _LOG_UNINSTALL equals 1.
+ *
+ * Define NO_LOG to prevent all logging when calling this from the uninstaller.
+ *
+ * @param _ROOT
+ * The registry key root as defined by NSIS (e.g. HKLM, HKCU, etc.).
+ * This will only be used for logging.
+ * @param _KEY
+ * The subkey in relation to the key root.
+ * @param _NAME
+ * The key value name to write to.
+ * @param _DWORD
+ * The dword to write to the key value name.
+ * @param _LOG_UNINSTALL
+ * 0 = don't add to uninstall log, 1 = add to uninstall log.
+ *
+ * $R5 = _ROOT
+ * $R6 = _KEY
+ * $R7 = _NAME
+ * $R8 = _DWORD
+ * $R9 = _LOG_UNINSTALL
+ */
+!macro WriteRegDWORD2
+
+ !ifndef ${_MOZFUNC_UN}WriteRegDWORD2
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}WriteRegDWORD2 "!insertmacro ${_MOZFUNC_UN}WriteRegDWORD2Call"
+
+ Function ${_MOZFUNC_UN}WriteRegDWORD2
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Exch 3
+ Exch $R6
+ Exch 4
+ Exch $R5
+
+ ClearErrors
+ WriteRegDWORD SHCTX "$R6" "$R7" "$R8"
+
+ !ifndef NO_LOG
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Adding Registry DWord: $R5 | $R6 | $R7 | $R8 **"
+ ${Else}
+ ${If} $R9 == 1 ; add to the uninstall log?
+ ${LogUninstall} "RegVal: $R5 | $R6 | $R7"
+ ${EndIf}
+ ${LogMsg} "Added Registry DWord: $R5 | $R6 | $R7 | $R8"
+ ${EndIf}
+ !endif
+
+ Exch $R5
+ Exch 4
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro WriteRegDWORD2Call _ROOT _KEY _NAME _DWORD _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_NAME}"
+ Push "${_DWORD}"
+ Push "${_LOG_UNINSTALL}"
+ Call WriteRegDWORD2
+ !verbose pop
+!macroend
+
+!macro un.WriteRegDWORD2Call _ROOT _KEY _NAME _DWORD _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_NAME}"
+ Push "${_DWORD}"
+ Push "${_LOG_UNINSTALL}"
+ Call un.WriteRegDWORD2
+ !verbose pop
+!macroend
+
+!macro un.WriteRegDWORD2
+ !ifndef un.WriteRegDWORD2
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro WriteRegDWORD2
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Writes a registry string to HKCR using the supplied params and logs the
+ * action to the install log and the uninstall log if _LOG_UNINSTALL equals 1.
+ *
+ * Define NO_LOG to prevent all logging when calling this from the uninstaller.
+ *
+ * @param _ROOT
+ * The registry key root as defined by NSIS (e.g. HKLM, HKCU, etc.).
+ * This will only be used for logging.
+ * @param _KEY
+ * The subkey in relation to the key root.
+ * @param _NAME
+ * The key value name to write to.
+ * @param _STR
+ * The string to write to the key value name.
+ * @param _LOG_UNINSTALL
+ * 0 = don't add to uninstall log, 1 = add to uninstall log.
+ *
+ * $R5 = _ROOT
+ * $R6 = _KEY
+ * $R7 = _NAME
+ * $R8 = _STR
+ * $R9 = _LOG_UNINSTALL
+ */
+!macro WriteRegStrHKCR
+
+ !ifndef ${_MOZFUNC_UN}WriteRegStrHKCR
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}WriteRegStrHKCR "!insertmacro ${_MOZFUNC_UN}WriteRegStrHKCRCall"
+
+ Function ${_MOZFUNC_UN}WriteRegStrHKCR
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Exch 3
+ Exch $R6
+ Exch 4
+ Exch $R5
+
+ ClearErrors
+ WriteRegStr HKCR "$R6" "$R7" "$R8"
+
+ !ifndef NO_LOG
+ ${If} ${Errors}
+ ${LogMsg} "** ERROR Adding Registry String: $R5 | $R6 | $R7 | $R8 **"
+ ${Else}
+ ${If} $R9 == 1 ; add to the uninstall log?
+ ${LogUninstall} "RegVal: $R5 | $R6 | $R7"
+ ${EndIf}
+ ${LogMsg} "Added Registry String: $R5 | $R6 | $R7 | $R8"
+ ${EndIf}
+ !endif
+
+ Exch $R5
+ Exch 4
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro WriteRegStrHKCRCall _ROOT _KEY _NAME _STR _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_NAME}"
+ Push "${_STR}"
+ Push "${_LOG_UNINSTALL}"
+ Call WriteRegStrHKCR
+ !verbose pop
+!macroend
+
+!macro un.WriteRegStrHKCRCall _ROOT _KEY _NAME _STR _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_NAME}"
+ Push "${_STR}"
+ Push "${_LOG_UNINSTALL}"
+ Call un.WriteRegStrHKCR
+ !verbose pop
+!macroend
+
+!macro un.WriteRegStrHKCR
+ !ifndef un.WriteRegStrHKCR
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro WriteRegStrHKCR
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+!ifndef KEY_SET_VALUE
+ !define KEY_SET_VALUE 0x0002
+!endif
+!ifndef KEY_WOW64_64KEY
+ !define KEY_WOW64_64KEY 0x0100
+!endif
+!ifndef HAVE_64BIT_BUILD
+ !define CREATE_KEY_SAM ${KEY_SET_VALUE}
+!else
+ !define CREATE_KEY_SAM ${KEY_SET_VALUE}|${KEY_WOW64_64KEY}
+!endif
+
+/**
+ * Creates a registry key. This will log the actions to the install and
+ * uninstall logs. Alternatively you can set a registry value to create the key
+ * and then delete the value.
+ *
+ * Define NO_LOG to prevent all logging when calling this from the uninstaller.
+ *
+ * @param _ROOT
+ * The registry key root as defined by NSIS (e.g. HKLM, HKCU, etc.).
+ * @param _KEY
+ * The subkey in relation to the key root.
+ * @param _LOG_UNINSTALL
+ * 0 = don't add to uninstall log, 1 = add to uninstall log.
+ *
+ * $R4 = [out] handle to newly created registry key. If this is not a key
+ * located in one of the predefined registry keys this must be closed
+ * with RegCloseKey (this should not be needed unless someone decides to
+ * do something extremely squirrelly with NSIS).
+ * $R5 = return value from RegCreateKeyExW (represented by R5 in the system call).
+ * $R6 = [in] hKey passed to RegCreateKeyExW.
+ * $R7 = _ROOT
+ * $R8 = _KEY
+ * $R9 = _LOG_UNINSTALL
+ */
+!macro CreateRegKey
+
+ !ifndef ${_MOZFUNC_UN}CreateRegKey
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}CreateRegKey "!insertmacro ${_MOZFUNC_UN}CreateRegKeyCall"
+
+ Function ${_MOZFUNC_UN}CreateRegKey
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Push $R6
+ Push $R5
+ Push $R4
+
+ StrCmp $R7 "HKCR" +1 +2
+ StrCpy $R6 "0x80000000"
+ StrCmp $R7 "HKCU" +1 +2
+ StrCpy $R6 "0x80000001"
+ StrCmp $R7 "HKLM" +1 +2
+ StrCpy $R6 "0x80000002"
+
+ ; see definition of RegCreateKey
+ System::Call "Advapi32::RegCreateKeyExW(i R6, w R8, i 0, i 0, i 0,\
+ i ${CREATE_KEY_SAM}, i 0, *i .R4,\
+ i 0) i .R5"
+
+ !ifndef NO_LOG
+ ; if $R5 is not 0 then there was an error creating the registry key.
+ ${If} $R5 <> 0
+ ${LogMsg} "** ERROR Adding Registry Key: $R7 | $R8 **"
+ ${Else}
+ ${If} $R9 == 1 ; add to the uninstall log?
+ ${LogUninstall} "RegKey: $R7 | $R8"
+ ${EndIf}
+ ${LogMsg} "Added Registry Key: $R7 | $R8"
+ ${EndIf}
+ !endif
+
+ StrCmp $R5 0 +1 +2
+ System::Call "Advapi32::RegCloseKey(iR4)"
+
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CreateRegKeyCall _ROOT _KEY _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_LOG_UNINSTALL}"
+ Call CreateRegKey
+ !verbose pop
+!macroend
+
+!macro un.CreateRegKeyCall _ROOT _KEY _LOG_UNINSTALL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_ROOT}"
+ Push "${_KEY}"
+ Push "${_LOG_UNINSTALL}"
+ Call un.CreateRegKey
+ !verbose pop
+!macroend
+
+!macro un.CreateRegKey
+ !ifndef un.CreateRegKey
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro CreateRegKey
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Helper for checking for the existence of a registry key.
+ * SHCTX is the root key to search.
+ *
+ * @param _MAIN_KEY
+ * Sub key to iterate for the key in question
+ * @param _KEY
+ * Key name to search for
+ * @return _RESULT
+ * 'true' / 'false' result
+ */
+!macro CheckIfRegistryKeyExists
+ !ifndef CheckIfRegistryKeyExists
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define CheckIfRegistryKeyExists "!insertmacro CheckIfRegistryKeyExistsCall"
+
+ Function CheckIfRegistryKeyExists
+ ; stack: main key, key
+ Exch $R9 ; main key, stack: old R9, key
+ Exch 1 ; stack: key, old R9
+ Exch $R8 ; key, stack: old R8, old R9
+ Push $R7
+ Push $R6
+ Push $R5
+
+ StrCpy $R5 "false"
+ StrCpy $R7 "0" # loop index
+ ${Do}
+ EnumRegKey $R6 SHCTX "$R9" "$R7"
+ ${If} "$R6" == "$R8"
+ StrCpy $R5 "true"
+ ${Break}
+ ${EndIf}
+ IntOp $R7 $R7 + 1
+ ${LoopWhile} $R6 != ""
+ ClearErrors
+
+ StrCpy $R9 $R5
+
+ Pop $R5
+ Pop $R6
+ Pop $R7 ; stack: old R8, old R9
+ Pop $R8 ; stack: old R9
+ Exch $R9 ; stack: result
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CheckIfRegistryKeyExistsCall _MAIN_KEY _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Push "${_MAIN_KEY}"
+ Call CheckIfRegistryKeyExists
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+/**
+ * Read the value of an installer pref that's been set by the product.
+ *
+ * @param _KEY ($R1)
+ * Sub key containing all the installer prefs
+ * Usually "Software\Mozilla\${AppName}"
+ * @param _PREF ($R2)
+ * Name of the pref to look up
+ * @return _RESULT ($R3)
+ * 'true' or 'false' (only boolean prefs are supported)
+ * If no value exists for the requested pref, the result is 'false'
+ */
+!macro GetInstallerRegistryPref
+ !ifndef ${_MOZFUNC_UN}GetInstallerRegistryPref
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetInstallerRegistryPref "!insertmacro GetInstallerRegistryPrefCall"
+
+ Function ${_MOZFUNC_UN}GetInstallerRegistryPref
+ ; stack: key, pref
+ Exch $R1 ; key, stack: old R1, pref
+ Exch 1 ; stack: pref, old R1
+ Exch $R2 ; pref, stack: old R2, old R1
+ Push $R3
+
+ StrCpy $R3 0
+
+ ; These prefs are always stored in the native registry.
+ SetRegView 64
+
+ ClearErrors
+ ReadRegDWORD $R3 HKCU "$R1\Installer\$AppUserModelID" "$R2"
+
+ SetRegView lastused
+
+ ${IfNot} ${Errors}
+ ${AndIf} $R3 != 0
+ StrCpy $R1 "true"
+ ${Else}
+ StrCpy $R1 "false"
+ ${EndIf}
+
+ ; stack: old R3, old R2, old R1
+ Pop $R3 ; stack: old R2, old R1
+ Pop $R2 ; stack: old R1
+ Exch $R1 ; stack: result
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetInstallerRegistryPrefCall _KEY _PREF _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PREF}"
+ Push "${_KEY}"
+ Call GetInstallerRegistryPref
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetInstallerRegistryPrefCall _KEY _PREF _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PREF}"
+ Push "${_KEY}"
+ Call un.GetInstallerRegistryPref
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetInstallerRegistryPref
+ !ifndef un.GetInstallerRegistryPref
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetInstallerRegistryPref
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+################################################################################
+# Macros for adding file and protocol handlers
+
+/**
+ * Writes common registry values for a handler using SHCTX.
+ *
+ * @param _KEY
+ * The subkey in relation to the key root.
+ * @param _VALOPEN
+ * The path and args to launch the application.
+ * @param _VALICON
+ * The path to the binary that contains the icon group for the default icon
+ * followed by a comma and either the icon group's resource index or the icon
+ * group's resource id prefixed with a minus sign
+ * @param _DISPNAME
+ * The display name for the handler. If emtpy no value will be set.
+ * @param _ISPROTOCOL
+ * Sets protocol handler specific registry values when "true".
+ * Deletes protocol handler specific registry values when "delete".
+ * Otherwise doesn't touch handler specific registry values.
+ * @param _ISDDE
+ * Sets DDE specific registry values when "true".
+ *
+ * $R3 = string value of the current registry key path.
+ * $R4 = _KEY
+ * $R5 = _VALOPEN
+ * $R6 = _VALICON
+ * $R7 = _DISPNAME
+ * $R8 = _ISPROTOCOL
+ * $R9 = _ISDDE
+ */
+!macro AddHandlerValues
+
+ !ifndef ${_MOZFUNC_UN}AddHandlerValues
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}AddHandlerValues "!insertmacro ${_MOZFUNC_UN}AddHandlerValuesCall"
+
+ Function ${_MOZFUNC_UN}AddHandlerValues
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Exch 3
+ Exch $R6
+ Exch 4
+ Exch $R5
+ Exch 5
+ Exch $R4
+ Push $R3
+
+ StrCmp "$R7" "" +6 +1
+ ReadRegStr $R3 SHCTX "$R4" "FriendlyTypeName"
+
+ StrCmp "$R3" "" +1 +3
+ WriteRegStr SHCTX "$R4" "" "$R7"
+ WriteRegStr SHCTX "$R4" "FriendlyTypeName" "$R7"
+
+ StrCmp "$R8" "true" +1 +2
+ WriteRegStr SHCTX "$R4" "URL Protocol" ""
+ StrCmp "$R8" "delete" +1 +2
+ DeleteRegValue SHCTX "$R4" "URL Protocol"
+ StrCpy $R3 ""
+ ReadRegDWord $R3 SHCTX "$R4" "EditFlags"
+ StrCmp $R3 "" +1 +3 ; Only add EditFlags if a value doesn't exist
+ DeleteRegValue SHCTX "$R4" "EditFlags"
+ WriteRegDWord SHCTX "$R4" "EditFlags" 0x00000002
+
+ StrCmp "$R6" "" +2 +1
+ WriteRegStr SHCTX "$R4\DefaultIcon" "" "$R6"
+
+ StrCmp "$R5" "" +2 +1
+ WriteRegStr SHCTX "$R4\shell\open\command" "" "$R5"
+
+!ifdef DDEApplication
+ StrCmp "$R9" "true" +1 +11
+ WriteRegStr SHCTX "$R4\shell\open\ddeexec" "" "$\"%1$\",,0,0,,,,"
+ WriteRegStr SHCTX "$R4\shell\open\ddeexec" "NoActivateHandler" ""
+ WriteRegStr SHCTX "$R4\shell\open\ddeexec\Application" "" "${DDEApplication}"
+ WriteRegStr SHCTX "$R4\shell\open\ddeexec\Topic" "" "WWW_OpenURL"
+ ; The ifexec key may have been added by another application so try to
+ ; delete it to prevent it from breaking this app's shell integration.
+ ; Also, IE 6 and below doesn't remove this key when it sets itself as the
+ ; default handler and if this key exists IE's shell integration breaks.
+ DeleteRegKey HKLM "$R4\shell\open\ddeexec\ifexec"
+ DeleteRegKey HKCU "$R4\shell\open\ddeexec\ifexec"
+!endif
+
+ ClearErrors
+
+ Pop $R3
+ Exch $R4
+ Exch 5
+ Exch $R5
+ Exch 4
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro AddHandlerValuesCall _KEY _VALOPEN _VALICON _DISPNAME _ISPROTOCOL _ISDDE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Push "${_VALOPEN}"
+ Push "${_VALICON}"
+ Push "${_DISPNAME}"
+ Push "${_ISPROTOCOL}"
+ Push "${_ISDDE}"
+ Call AddHandlerValues
+ !verbose pop
+!macroend
+
+!macro un.AddHandlerValuesCall _KEY _VALOPEN _VALICON _DISPNAME _ISPROTOCOL _ISDDE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Push "${_VALOPEN}"
+ Push "${_VALICON}"
+ Push "${_DISPNAME}"
+ Push "${_ISPROTOCOL}"
+ Push "${_ISDDE}"
+ Call un.AddHandlerValues
+ !verbose pop
+!macroend
+
+!macro un.AddHandlerValues
+ !ifndef un.AddHandlerValues
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro AddHandlerValues
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Writes common registry values for a handler that DOES NOT use DDE using SHCTX.
+ *
+ * @param _KEY
+ * The key name in relation to the HKCR root. SOFTWARE\Classes is
+ * prefixed to this value when using SHCTX.
+ * @param _VALOPEN
+ * The path and args to launch the application.
+ * @param _VALICON
+ * The path to the binary that contains the icon group for the default icon
+ * followed by a comma and either the icon group's resource index or the icon
+ * group's resource id prefixed with a minus sign
+ * @param _DISPNAME
+ * The display name for the handler. If emtpy no value will be set.
+ * @param _ISPROTOCOL
+ * Sets protocol handler specific registry values when "true".
+ * Deletes protocol handler specific registry values when "delete".
+ * Otherwise doesn't touch handler specific registry values.
+ *
+ * $R3 = storage for SOFTWARE\Classes
+ * $R4 = string value of the current registry key path.
+ * $R5 = _KEY
+ * $R6 = _VALOPEN
+ * $R7 = _VALICON
+ * $R8 = _DISPNAME
+ * $R9 = _ISPROTOCOL
+ */
+!macro AddDisabledDDEHandlerValues
+
+ !ifndef ${_MOZFUNC_UN}AddDisabledDDEHandlerValues
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}AddDisabledDDEHandlerValues "!insertmacro ${_MOZFUNC_UN}AddDisabledDDEHandlerValuesCall"
+
+ Function ${_MOZFUNC_UN}AddDisabledDDEHandlerValues
+ Exch $R9 ; _ISPROTOCOL
+ Exch 1
+ Exch $R8 ; FriendlyTypeName
+ Exch 2
+ Exch $R7 ; icon index
+ Exch 3
+ Exch $R6 ; shell\open\command
+ Exch 4
+ Exch $R5 ; reg key
+ Push $R4 ;
+ Push $R3 ; base reg class
+
+ StrCpy $R3 "SOFTWARE\Classes"
+ StrCmp "$R8" "" +6 +1
+ ReadRegStr $R4 SHCTX "$R5" "FriendlyTypeName"
+
+ StrCmp "$R4" "" +1 +3
+ WriteRegStr SHCTX "$R3\$R5" "" "$R8"
+ WriteRegStr SHCTX "$R3\$R5" "FriendlyTypeName" "$R8"
+
+ StrCmp "$R9" "true" +1 +2
+ WriteRegStr SHCTX "$R3\$R5" "URL Protocol" ""
+ StrCmp "$R9" "delete" +1 +2
+ DeleteRegValue SHCTX "$R3\$R5" "URL Protocol"
+ StrCpy $R4 ""
+ ReadRegDWord $R4 SHCTX "$R3\$R5" "EditFlags"
+ StrCmp $R4 "" +1 +3 ; Only add EditFlags if a value doesn't exist
+ DeleteRegValue SHCTX "$R3\$R5" "EditFlags"
+ WriteRegDWord SHCTX "$R3\$R5" "EditFlags" 0x00000002
+
+ StrCmp "$R7" "" +2 +1
+ WriteRegStr SHCTX "$R3\$R5\DefaultIcon" "" "$R7"
+
+ ; Main command handler for the app
+ WriteRegStr SHCTX "$R3\$R5\shell" "" "open"
+ WriteRegStr SHCTX "$R3\$R5\shell\open\command" "" "$R6"
+
+ ; Drop support for DDE (bug 491947), and remove old dde entries if
+ ; they exist.
+ ;
+ ; Note, changes in SHCTX should propegate to hkey classes root when
+ ; current user or local machine entries are written. Windows will also
+ ; attempt to propegate entries when a handler is used. CR entries are a
+ ; combination of LM and CU, with CU taking priority.
+ ;
+ ; To disable dde, an empty shell/ddeexec key must be created in current
+ ; user or local machine. Unfortunately, settings have various different
+ ; behaviors depending on the windows version. The following code attempts
+ ; to address these differences.
+ ;
+ ; IE does not configure ddeexec, so issues with left over ddeexec keys
+ ; in LM are reduced. We configure an empty ddeexec key with an empty default
+ ; string in CU to be sure.
+ ;
+ DeleteRegKey SHCTX "SOFTWARE\Classes\$R5\shell\open\ddeexec"
+ WriteRegStr SHCTX "SOFTWARE\Classes\$R5\shell\open\ddeexec" "" ""
+
+ ClearErrors
+
+ Pop $R3
+ Pop $R4
+ Exch $R5
+ Exch 4
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro AddDisabledDDEHandlerValuesCall _KEY _VALOPEN _VALICON _DISPNAME _ISPROTOCOL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Push "${_VALOPEN}"
+ Push "${_VALICON}"
+ Push "${_DISPNAME}"
+ Push "${_ISPROTOCOL}"
+ Call AddDisabledDDEHandlerValues
+ !verbose pop
+!macroend
+
+!macro un.AddDisabledDDEHandlerValuesCall _KEY _VALOPEN _VALICON _DISPNAME _ISPROTOCOL
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Push "${_VALOPEN}"
+ Push "${_VALICON}"
+ Push "${_DISPNAME}"
+ Push "${_ISPROTOCOL}"
+ Call un.AddDisabledDDEHandlerValues
+ !verbose pop
+!macroend
+
+!macro un.AddDisabledDDEHandlerValues
+ !ifndef un.AddDisabledDDEHandlerValues
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro AddDisabledDDEHandlerValues
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+################################################################################
+# Macros for handling DLL registration
+
+!macro RegisterDLL DLL
+
+ ; The x64 regsvr32.exe registers x86 DLL's properly so just use it
+ ; when installing on an x64 systems even when installing an x86 application.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ${DisableX64FSRedirection}
+ ExecWait '"$SYSDIR\regsvr32.exe" /s "${DLL}"'
+ ${EnableX64FSRedirection}
+ ${Else}
+ RegDLL "${DLL}"
+ ${EndIf}
+
+!macroend
+
+!macro UnregisterDLL DLL
+
+ ; The x64 regsvr32.exe registers x86 DLL's properly so just use it
+ ; when installing on an x64 systems even when installing an x86 application.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ${DisableX64FSRedirection}
+ ExecWait '"$SYSDIR\regsvr32.exe" /s /u "${DLL}"'
+ ${EnableX64FSRedirection}
+ ${Else}
+ UnRegDLL "${DLL}"
+ ${EndIf}
+
+!macroend
+
+!define RegisterDLL "!insertmacro RegisterDLL"
+!define UnregisterDLL "!insertmacro UnregisterDLL"
+
+
+################################################################################
+# Macros for retrieving special folders
+
+/**
+ * These macro get special folder paths directly, without depending on
+ * SetShellVarContext.
+ *
+ * Usage:
+ * ${GetProgramsFolder} $0
+ * ${GetLocalAppDataFolder} $0
+ * ${GetCommonAppDataFolder} $0
+ *
+ */
+!macro GetSpecialFolder _ID _RESULT
+ ; This system call gets the directory path. The arguments are:
+ ; A null ptr for hwnd
+ ; t.s puts the output string on the NSIS stack
+ ; id indicates which dir to get
+ ; false for fCreate (i.e. Do not create the folder if it doesn't exist)
+ System::Call "Shell32::SHGetSpecialFolderPathW(p 0, t.s, i ${_ID}, i 0)"
+ Pop ${_RESULT}
+!macroend
+
+!define CSIDL_PROGRAMS 0x0002
+!define CSIDL_LOCAL_APPDATA 0x001c
+!define CSIDL_COMMON_APPDATA 0x0023
+
+; Current User's Start Menu Programs
+!define GetProgramsFolder "!insertmacro GetSpecialFolder ${CSIDL_PROGRAMS}"
+
+; Current User's Local App Data (e.g. C:\Users\<user>\AppData\Local)
+!define GetLocalAppDataFolder "!insertmacro GetSpecialFolder ${CSIDL_LOCAL_APPDATA}"
+
+; Common App Data (e.g. C:\ProgramData)
+!define GetCommonAppDataFolder "!insertmacro GetSpecialFolder ${CSIDL_COMMON_APPDATA}"
+
+################################################################################
+# Macros for retrieving existing install paths
+
+/**
+ * Finds a second installation of the application so we can make informed
+ * decisions about registry operations. This uses SHCTX to determine the
+ * registry hive so you must call SetShellVarContext first.
+ *
+ * @param _KEY
+ * The registry subkey (typically this will be Software\Mozilla).
+ * @return _RESULT
+ * false if a second install isn't found, path to the main exe if a
+ * second install is found.
+ *
+ * $R3 = stores the long path to $INSTDIR
+ * $R4 = counter for the outer loop's EnumRegKey
+ * $R5 = return value from ReadRegStr and RemoveQuotesFromPath
+ * $R6 = return value from GetParent
+ * $R7 = return value from the loop's EnumRegKey
+ * $R8 = storage for _KEY
+ * $R9 = _KEY and _RESULT
+ */
+!macro GetSecondInstallPath
+
+ !ifndef ${_MOZFUNC_UN}GetSecondInstallPath
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}RemoveQuotesFromPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetSecondInstallPath "!insertmacro ${_MOZFUNC_UN}GetSecondInstallPathCall"
+
+ Function ${_MOZFUNC_UN}GetSecondInstallPath
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R3
+
+ StrCpy $R4 0 ; set the counter for the loop to 0
+ StrCpy $R8 "$R9" ; Registry key path to search
+ StrCpy $R9 "false" ; default return value
+
+ loop:
+ EnumRegKey $R7 SHCTX $R8 $R4
+ StrCmp $R7 "" end +1 ; if empty there are no more keys to enumerate
+ IntOp $R4 $R4 + 1 ; increment the loop's counter
+ ClearErrors
+ ReadRegStr $R5 SHCTX "$R8\$R7\bin" "PathToExe"
+ IfErrors loop
+
+ ${${_MOZFUNC_UN}RemoveQuotesFromPath} "$R5" $R5
+
+ IfFileExists "$R5" +1 loop
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ${${_MOZFUNC_UN}GetParent} "$R5" $R6
+ StrCmp "$R6" "$R3" loop +1
+ StrCmp "$R6\${FileMainEXE}" "$R5" +1 loop
+ StrCpy $R9 "$R5"
+
+ end:
+ ClearErrors
+
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetSecondInstallPathCall _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call GetSecondInstallPath
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetSecondInstallPathCall _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call un.GetSecondInstallPath
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetSecondInstallPath
+ !ifndef un.GetSecondInstallPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetSecondInstallPath
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Finds an existing installation path for the application based on the
+ * application's executable name so we can default to using this path for the
+ * install. If there is zero or more than one installation of the application
+ * then we default to the default installation path. This uses SHCTX to
+ * determine the registry hive to read from so you must call SetShellVarContext
+ * first.
+ *
+ * @param _KEY
+ * The registry subkey (typically this will be Software\Mozilla\App Name).
+ * @return _RESULT
+ * false if a single install location for this app name isn't found,
+ * path to the install directory if a single install location is found.
+ *
+ * $R5 = counter for the loop's EnumRegKey
+ * $R6 = return value from EnumRegKey
+ * $R7 = return value from ReadRegStr
+ * $R8 = storage for _KEY
+ * $R9 = _KEY and _RESULT
+ */
+!macro GetSingleInstallPath
+
+ !ifndef ${_MOZFUNC_UN}GetSingleInstallPath
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}RemoveQuotesFromPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetSingleInstallPath "!insertmacro ${_MOZFUNC_UN}GetSingleInstallPathCall"
+
+ Function ${_MOZFUNC_UN}GetSingleInstallPath
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ StrCpy $R8 $R9
+ StrCpy $R9 "false"
+ StrCpy $R5 0 ; set the counter for the loop to 0
+
+ loop:
+ ClearErrors
+ EnumRegKey $R6 SHCTX $R8 $R5
+ IfErrors cleanup
+ StrCmp $R6 "" cleanup +1 ; if empty there are no more keys to enumerate
+ IntOp $R5 $R5 + 1 ; increment the loop's counter
+ ClearErrors
+ ReadRegStr $R7 SHCTX "$R8\$R6\Main" "PathToExe"
+ IfErrors loop
+ ${${_MOZFUNC_UN}RemoveQuotesFromPath} "$R7" $R7
+ GetFullPathName $R7 "$R7"
+ IfErrors loop
+
+ StrCmp "$R9" "false" +1 +3
+ StrCpy $R9 "$R7"
+ GoTo Loop
+
+ StrCpy $R9 "false"
+
+ cleanup:
+ StrCmp $R9 "false" end +1
+ ${${_MOZFUNC_UN}GetLongPath} "$R9" $R9
+ ${${_MOZFUNC_UN}GetParent} "$R9" $R9
+
+ end:
+ ClearErrors
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetSingleInstallPathCall _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call GetSingleInstallPath
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetSingleInstallPathCall _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call un.GetSingleInstallPath
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.GetSingleInstallPath
+ !ifndef un.GetSingleInstallPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetSingleInstallPath
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Find the first existing installation for the application.
+ * This is similar to GetSingleInstallPath, except that it always returns the
+ * first path it finds, instead of an error when more than one path exists.
+ *
+ * The shell context and the registry view should already have been set.
+ *
+ * @param _KEY
+ * The registry subkey (typically Software\Mozilla\App Name).
+ * @return _RESULT
+ * path to the install directory of the first location found, or
+ * the string "false" if no existing installation was found.
+ *
+ * $R5 = counter for the loop's EnumRegKey
+ * $R6 = return value from EnumRegKey
+ * $R7 = return value from ReadRegStr
+ * $R8 = storage for _KEY
+ * $R9 = _KEY and _RESULT
+ */
+!macro GetFirstInstallPath
+ !ifndef ${_MOZFUNC_UN}GetFirstInstallPath
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}RemoveQuotesFromPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetFirstInstallPath "!insertmacro ${_MOZFUNC_UN}__GetFirstInstallPathCall"
+
+ Function ${_MOZFUNC_UN}__GetFirstInstallPath
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ StrCpy $R8 $R9
+ StrCpy $R9 "false"
+ StrCpy $R5 0
+
+ ${Do}
+ ClearErrors
+ EnumRegKey $R6 SHCTX $R8 $R5
+ ${If} ${Errors}
+ ${OrIf} $R6 == ""
+ ${Break}
+ ${EndIf}
+
+ IntOp $R5 $R5 + 1
+
+ ReadRegStr $R7 SHCTX "$R8\$R6\Main" "PathToExe"
+ ${If} ${Errors}
+ ${Continue}
+ ${EndIf}
+
+ ${${_MOZFUNC_UN}RemoveQuotesFromPath} "$R7" $R7
+ GetFullPathName $R7 "$R7"
+ ${If} ${Errors}
+ ${Continue}
+ ${EndIf}
+
+ StrCpy $R9 "$R7"
+ ${Break}
+ ${Loop}
+
+ ${If} $R9 != "false"
+ ${${_MOZFUNC_UN}GetLongPath} "$R9" $R9
+ ${${_MOZFUNC_UN}GetParent} "$R9" $R9
+ ${EndIf}
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro __GetFirstInstallPathCall _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call __GetFirstInstallPath
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.__GetFirstInstallPathCall _KEY _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call un.__GetFirstInstallPath
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.__GetFirstInstallPath
+ !ifndef un.__GetFirstInstallPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro __GetFirstInstallPath
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+################################################################################
+# Macros for working with the file system
+
+/**
+ * Attempts to delete a file if it exists. This will fail if the file is in use.
+ *
+ * @param _FILE
+ * The path to the file that is to be deleted.
+ */
+!macro DeleteFile _FILE
+ ${If} ${FileExists} "${_FILE}"
+ Delete "${_FILE}"
+ ${EndIf}
+!macroend
+!define DeleteFile "!insertmacro DeleteFile"
+
+/**
+ * Removes a directory if it exists and is empty.
+ *
+ * @param _DIR
+ * The path to the directory that is to be removed.
+ */
+!macro RemoveDir _DIR
+ ${If} ${FileExists} "${_DIR}"
+ RmDir "${_DIR}"
+ ${EndIf}
+!macroend
+!define RemoveDir "!insertmacro RemoveDir"
+
+/**
+ * Checks whether it is possible to create and delete a directory and a file in
+ * the install directory. Creation and deletion of files and directories are
+ * checked since a user may have rights for one and not the other. If creation
+ * and deletion of a file and a directory are successful this macro will return
+ * true... if not, this it return false.
+ *
+ * @return _RESULT
+ * true if files and directories can be created and deleted in the
+ * install directory otherwise false.
+ *
+ * $R8 = temporary filename in the installation directory returned from
+ * GetTempFileName.
+ * $R9 = _RESULT
+ */
+!macro CanWriteToInstallDir
+
+ !ifndef ${_MOZFUNC_UN}CanWriteToInstallDir
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}CanWriteToInstallDir "!insertmacro ${_MOZFUNC_UN}CanWriteToInstallDirCall"
+
+ Function ${_MOZFUNC_UN}CanWriteToInstallDir
+ Push $R9
+ Push $R8
+
+ StrCpy $R9 "true"
+
+ ; IfFileExists returns false for $INSTDIR when $INSTDIR is the root of a
+ ; UNC path so always try to create $INSTDIR
+ CreateDirectory "$INSTDIR\"
+ GetTempFileName $R8 "$INSTDIR\"
+
+ ${Unless} ${FileExists} $R8 ; Can files be created?
+ StrCpy $R9 "false"
+ Goto done
+ ${EndUnless}
+
+ Delete $R8
+ ${If} ${FileExists} $R8 ; Can files be deleted?
+ StrCpy $R9 "false"
+ Goto done
+ ${EndIf}
+
+ CreateDirectory $R8
+ ${Unless} ${FileExists} $R8 ; Can directories be created?
+ StrCpy $R9 "false"
+ Goto done
+ ${EndUnless}
+
+ RmDir $R8
+ ${If} ${FileExists} $R8 ; Can directories be deleted?
+ StrCpy $R9 "false"
+ Goto done
+ ${EndIf}
+
+ done:
+
+ RmDir "$INSTDIR\" ; Only remove $INSTDIR if it is empty
+ ClearErrors
+
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CanWriteToInstallDirCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call CanWriteToInstallDir
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.CanWriteToInstallDirCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.CanWriteToInstallDir
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.CanWriteToInstallDir
+ !ifndef un.CanWriteToInstallDir
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro CanWriteToInstallDir
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Checks whether there is sufficient free space available for the installation
+ * directory using GetDiskFreeSpaceExW which respects disk quotas. This macro
+ * will calculate the size of all sections that are selected, compare that with
+ * the free space available, and if there is sufficient free space it will
+ * return true... if not, it will return false.
+ *
+ * @return _RESULT
+ * "true" if there is sufficient free space otherwise "false".
+ *
+ * $R5 = return value from SectionGetSize
+ * $R6 = return value from SectionGetFlags
+ * return value from an 'and' comparison of SectionGetFlags (1=selected)
+ * return value for lpFreeBytesAvailable from GetDiskFreeSpaceExW
+ * return value for System::Int64Op $R6 / 1024
+ * return value for System::Int64Op $R6 > $R8
+ * $R7 = the counter for enumerating the sections
+ * the temporary file name for the directory created under $INSTDIR passed
+ * to GetDiskFreeSpaceExW.
+ * $R8 = sum in KB of all selected sections
+ * $R9 = _RESULT
+ */
+!macro CheckDiskSpace
+
+ !ifndef ${_MOZFUNC_UN}CheckDiskSpace
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}CheckDiskSpace "!insertmacro ${_MOZFUNC_UN}CheckDiskSpaceCall"
+
+ Function ${_MOZFUNC_UN}CheckDiskSpace
+ Push $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ ClearErrors
+
+ StrCpy $R9 "true" ; default return value
+ StrCpy $R8 "0" ; sum in KB of all selected sections
+ StrCpy $R7 "0" ; counter for enumerating sections
+
+ ; Enumerate the sections and sum up the sizes of the sections that are
+ ; selected.
+ SectionGetFlags $R7 $R6
+ IfErrors +7 +1
+ IntOp $R6 ${SF_SELECTED} & $R6
+ IntCmp $R6 0 +3 +1 +1
+ SectionGetSize $R7 $R5
+ IntOp $R8 $R8 + $R5
+ IntOp $R7 $R7 + 1
+ GoTo -7
+
+ ; The directory passed to GetDiskFreeSpaceExW must exist for the call to
+ ; succeed. Since the CanWriteToInstallDir macro is called prior to this
+ ; macro the call to CreateDirectory will always succeed.
+
+ ; IfFileExists returns false for $INSTDIR when $INSTDIR is the root of a
+ ; UNC path so always try to create $INSTDIR
+ CreateDirectory "$INSTDIR\"
+ GetTempFileName $R7 "$INSTDIR\"
+ Delete "$R7"
+ CreateDirectory "$R7"
+
+ System::Call 'kernel32::GetDiskFreeSpaceExW(w, *l, *l, *l) i(R7, .R6, ., .) .'
+
+ ; Convert to KB for comparison with $R8 which is in KB
+ System::Int64Op $R6 / 1024
+ Pop $R6
+
+ System::Int64Op $R6 > $R8
+ Pop $R6
+
+ IntCmp $R6 1 end +1 +1
+ StrCpy $R9 "false"
+
+ end:
+ RmDir "$R7"
+ RmDir "$INSTDIR\" ; Only remove $INSTDIR if it is empty
+
+ ClearErrors
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CheckDiskSpaceCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call CheckDiskSpace
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.CheckDiskSpaceCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.CheckDiskSpace
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro un.CheckDiskSpace
+ !ifndef un.CheckDiskSpace
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro CheckDiskSpace
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+* Returns the path found within a passed in string. The path is quoted or not
+* with the exception of an unquoted non 8dot3 path without arguments that is
+* also not a DefaultIcon path, is a 8dot3 path or not, has command line
+* arguments, or is a registry DefaultIcon path (e.g. <path to binary>,# where #
+* is the icon's resuorce id). The string does not need to be a valid path or
+* exist. It is up to the caller to pass in a string of one of the forms noted
+* above and to verify existence if necessary.
+*
+* Examples:
+* In: C:\PROGRA~1\MOZILL~1\FIREFOX.EXE -flag "%1"
+* In: C:\PROGRA~1\MOZILL~1\FIREFOX.EXE,0
+* In: C:\PROGRA~1\MOZILL~1\FIREFOX.EXE
+* In: "C:\PROGRA~1\MOZILL~1\FIREFOX.EXE"
+* In: "C:\PROGRA~1\MOZILL~1\FIREFOX.EXE" -flag "%1"
+* Out: C:\PROGRA~1\MOZILL~1\FIREFOX.EXE
+*
+* In: "C:\Program Files\Mozilla Firefox\firefox.exe" -flag "%1"
+* In: C:\Program Files\Mozilla Firefox\firefox.exe,0
+* In: "C:\Program Files\Mozilla Firefox\firefox.exe"
+* Out: C:\Program Files\Mozilla Firefox\firefox.exe
+*
+* @param _IN_PATH
+* The string containing the path.
+* @param _OUT_PATH
+* The register to store the path to.
+*
+* $R7 = counter for the outer loop's EnumRegKey
+* $R8 = return value from ReadRegStr
+* $R9 = _IN_PATH and _OUT_PATH
+*/
+!macro GetPathFromString
+
+ !ifndef ${_MOZFUNC_UN}GetPathFromString
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetPathFromString "!insertmacro ${_MOZFUNC_UN}GetPathFromStringCall"
+
+ Function ${_MOZFUNC_UN}GetPathFromString
+ Exch $R9
+ Push $R8
+ Push $R7
+
+ StrCpy $R7 0 ; Set the counter to 0.
+
+ ; Handle quoted paths with arguments.
+ StrCpy $R8 $R9 1 ; Copy the first char.
+ StrCmp $R8 '"' +2 +1 ; Is it a "?
+ StrCmp $R8 "'" +1 +9 ; Is it a '?
+ StrCpy $R9 $R9 "" 1 ; Remove the first char.
+ IntOp $R7 $R7 + 1 ; Increment the counter.
+ StrCpy $R8 $R9 1 $R7 ; Starting from the counter copy the next char.
+ StrCmp $R8 "" end +1 ; Are there no more chars?
+ StrCmp $R8 '"' +2 +1 ; Is it a " char?
+ StrCmp $R8 "'" +1 -4 ; Is it a ' char?
+ StrCpy $R9 $R9 $R7 ; Copy chars up to the counter.
+ GoTo end
+
+ ; Handle DefaultIcon paths. DefaultIcon paths are not quoted and end with
+ ; a , and a number.
+ IntOp $R7 $R7 - 1 ; Decrement the counter.
+ StrCpy $R8 $R9 1 $R7 ; Copy one char from the end minus the counter.
+ StrCmp $R8 '' +4 +1 ; Are there no more chars?
+ StrCmp $R8 ',' +1 -3 ; Is it a , char?
+ StrCpy $R9 $R9 $R7 ; Copy chars up to the end minus the counter.
+ GoTo end
+
+ ; Handle unquoted paths with arguments. An unquoted path with arguments
+ ; must be an 8dot3 path.
+ StrCpy $R7 -1 ; Set the counter to -1 so it will start at 0.
+ IntOp $R7 $R7 + 1 ; Increment the counter.
+ StrCpy $R8 $R9 1 $R7 ; Starting from the counter copy the next char.
+ StrCmp $R8 "" end +1 ; Are there no more chars?
+ StrCmp $R8 " " +1 -3 ; Is it a space char?
+ StrCpy $R9 $R9 $R7 ; Copy chars up to the counter.
+
+ end:
+ ClearErrors
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetPathFromStringCall _IN_PATH _OUT_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_IN_PATH}"
+ Call GetPathFromString
+ Pop ${_OUT_PATH}
+ !verbose pop
+!macroend
+
+!macro un.GetPathFromStringCall _IN_PATH _OUT_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_IN_PATH}"
+ Call un.GetPathFromString
+ Pop ${_OUT_PATH}
+ !verbose pop
+!macroend
+
+!macro un.GetPathFromString
+ !ifndef un.GetPathFromString
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetPathFromString
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Removes the quotes from each end of a string if present.
+ *
+ * @param _IN_PATH
+ * The string containing the path.
+ * @param _OUT_PATH
+ * The register to store the long path.
+ *
+ * $R7 = storage for single character comparison
+ * $R8 = storage for _IN_PATH
+ * $R9 = _IN_PATH and _OUT_PATH
+ */
+!macro RemoveQuotesFromPath
+
+ !ifndef ${_MOZFUNC_UN}RemoveQuotesFromPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RemoveQuotesFromPath "!insertmacro ${_MOZFUNC_UN}RemoveQuotesFromPathCall"
+
+ Function ${_MOZFUNC_UN}RemoveQuotesFromPath
+ Exch $R9
+ Push $R8
+ Push $R7
+
+ StrCpy $R7 "$R9" 1
+ StrCmp $R7 "$\"" +1 +2
+ StrCpy $R9 "$R9" "" 1
+
+ StrCpy $R7 "$R9" "" -1
+ StrCmp $R7 "$\"" +1 +2
+ StrCpy $R9 "$R9" -1
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RemoveQuotesFromPathCall _IN_PATH _OUT_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_IN_PATH}"
+ Call RemoveQuotesFromPath
+ Pop ${_OUT_PATH}
+ !verbose pop
+!macroend
+
+!macro un.RemoveQuotesFromPathCall _IN_PATH _OUT_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_IN_PATH}"
+ Call un.RemoveQuotesFromPath
+ Pop ${_OUT_PATH}
+ !verbose pop
+!macroend
+
+!macro un.RemoveQuotesFromPath
+ !ifndef un.RemoveQuotesFromPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RemoveQuotesFromPath
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Returns the long path for an existing file or directory. GetLongPathNameW
+ * may not be available on Win95 if Microsoft Layer for Unicode is not
+ * installed and GetFullPathName only returns a long path for the last file or
+ * directory that doesn't end with a \ in the path that it is passed. If the
+ * path does not exist on the file system this will return an empty string. To
+ * provide a consistent result trailing back-slashes are always removed.
+ *
+ * Note: 1024 used by GetLongPathNameW is the maximum NSIS string length.
+ *
+ * @param _IN_PATH
+ * The string containing the path.
+ * @param _OUT_PATH
+ * The register to store the long path.
+ *
+ * $R4 = counter value when the previous \ was found
+ * $R5 = directory or file name found during loop
+ * $R6 = return value from GetLongPathNameW and loop counter
+ * $R7 = long path from GetLongPathNameW and single char from path for comparison
+ * $R8 = storage for _IN_PATH
+ * $R9 = _IN_PATH _OUT_PATH
+ */
+!macro GetLongPath
+
+ !ifndef ${_MOZFUNC_UN}GetLongPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetLongPath "!insertmacro ${_MOZFUNC_UN}GetLongPathCall"
+
+ Function ${_MOZFUNC_UN}GetLongPath
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+
+ ClearErrors
+
+ GetFullPathName $R8 "$R9"
+ IfErrors end_GetLongPath +1 ; If the path doesn't exist return an empty string.
+
+ ; Make the drive letter uppercase.
+ StrCpy $R9 "$R8" 1 ; Copy the first char.
+ StrCpy $R8 "$R8" "" 1 ; Copy everything after the first char.
+ ; Convert the first char to uppercase.
+ System::Call "User32::CharUpper(w R9 R9)i"
+ StrCpy $R8 "$R9$R8" ; Copy the uppercase char and the rest of the chars.
+
+ ; Do it the hard way.
+ StrCpy $R4 0 ; Stores the position in the string of the last \ found.
+ StrCpy $R6 -1 ; Set the counter to -1 so it will start at 0.
+
+ loop_GetLongPath:
+ IntOp $R6 $R6 + 1 ; Increment the counter.
+ StrCpy $R7 $R8 1 $R6 ; Starting from the counter copy the next char.
+ StrCmp $R7 "" +2 +1 ; Are there no more chars?
+ StrCmp $R7 "\" +1 -3 ; Is it a \?
+
+ ; Copy chars starting from the previously found \ to the counter.
+ StrCpy $R5 $R8 $R6 $R4
+
+ ; If this is the first \ found we want to swap R9 with R5 so a \ will
+ ; be appended to the drive letter and colon (e.g. C: will become C:\).
+ StrCmp $R4 0 +1 +3
+ StrCpy $R9 $R5
+ StrCpy $R5 ""
+
+ GetFullPathName $R9 "$R9\$R5"
+
+ StrCmp $R7 "" end_GetLongPath +1 ; Are there no more chars?
+
+ ; Store the counter for the current \ and prefix it for StrCpy operations.
+ StrCpy $R4 "+$R6"
+ IntOp $R6 $R6 + 1 ; Increment the counter so we skip over the \.
+ StrCpy $R8 $R8 "" $R6 ; Copy chars starting from the counter to the end.
+ StrCpy $R6 -1 ; Reset the counter to -1 so it will start over at 0.
+ GoTo loop_GetLongPath
+
+ end_GetLongPath:
+ ; If there is a trailing slash remove it
+ StrCmp $R9 "" +4 +1
+ StrCpy $R8 "$R9" "" -1
+ StrCmp $R8 "\" +1 +2
+ StrCpy $R9 "$R9" -1
+
+ ClearErrors
+
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetLongPathCall _IN_PATH _OUT_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_IN_PATH}"
+ Call GetLongPath
+ Pop ${_OUT_PATH}
+ !verbose pop
+!macroend
+
+!macro un.GetLongPathCall _IN_PATH _OUT_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_IN_PATH}"
+ Call un.GetLongPath
+ Pop ${_OUT_PATH}
+ !verbose pop
+!macroend
+
+!macro un.GetLongPath
+ !ifndef un.GetLongPath
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetLongPath
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+################################################################################
+# Macros for cleaning up the registry and file system
+
+/**
+ * Removes registry keys that reference this install location and for paths that
+ * no longer exist. This uses SHCTX to determine the registry hive so you must
+ * call SetShellVarContext first.
+ *
+ * @param _KEY
+ * The registry subkey (typically this will be Software\Mozilla).
+ *
+ * $0 = loop counter
+ * $1 = temporary value used for string searches
+ * $R0 = on x64 systems set to 'false' at the beginning of the macro when
+ * enumerating the x86 registry view and set to 'true' when enumerating
+ * the x64 registry view.
+ * $R1 = stores the long path to $INSTDIR
+ * $R2 = return value from the stack from the GetParent and GetLongPath macros
+ * $R3 = return value from the outer loop's EnumRegKey and ESR string
+ * $R4 = return value from the inner loop's EnumRegKey
+ * $R5 = return value from ReadRegStr
+ * $R6 = counter for the outer loop's EnumRegKey
+ * $R7 = counter for the inner loop's EnumRegKey
+ * $R8 = return value from the stack from the RemoveQuotesFromPath macro
+ * $R9 = _KEY
+ */
+!macro RegCleanMain
+
+ !ifndef ${_MOZFUNC_UN}RegCleanMain
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}RemoveQuotesFromPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RegCleanMain "!insertmacro ${_MOZFUNC_UN}RegCleanMainCall"
+
+ Function ${_MOZFUNC_UN}RegCleanMain
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+ Push $R2
+ Push $R1
+ Push $R0
+ Push $0
+ Push $1
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R1
+ StrCpy $R6 0 ; set the counter for the outer loop to 0
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ StrCpy $R0 "false"
+ ; Set the registry to the 32 bit registry for 64 bit installations or to
+ ; the 64 bit registry for 32 bit installations at the beginning so it can
+ ; easily be set back to the correct registry view when finished.
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 32
+ !else
+ SetRegView 64
+ !endif
+ ${EndIf}
+
+ outerloop:
+ EnumRegKey $R3 SHCTX $R9 $R6
+ StrCmp $R3 "" end +1 ; if empty there are no more keys to enumerate
+ IntOp $R6 $R6 + 1 ; increment the outer loop's counter
+ ClearErrors
+ ReadRegStr $R5 SHCTX "$R9\$R3\bin" "PathToExe"
+ IfErrors 0 outercontinue
+ StrCpy $R7 0 ; set the counter for the inner loop to 0
+
+ innerloop:
+ EnumRegKey $R4 SHCTX "$R9\$R3" $R7
+ StrCmp $R4 "" outerloop +1 ; if empty there are no more keys to enumerate
+ IntOp $R7 $R7 + 1 ; increment the inner loop's counter
+ ClearErrors
+ ReadRegStr $R5 SHCTX "$R9\$R3\$R4\Main" "PathToExe"
+ IfErrors innerloop
+
+ ${${_MOZFUNC_UN}RemoveQuotesFromPath} "$R5" $R8
+ ${${_MOZFUNC_UN}GetParent} "$R8" $R2
+ ${${_MOZFUNC_UN}GetLongPath} "$R2" $R2
+ IfFileExists "$R2" +1 innerloop
+ StrCmp "$R2" "$R1" +1 innerloop
+
+ ClearErrors
+ DeleteRegKey SHCTX "$R9\$R3\$R4"
+ IfErrors innerloop
+ IntOp $R7 $R7 - 1 ; decrement the inner loop's counter when the key is deleted successfully.
+ ClearErrors
+ DeleteRegKey /ifempty SHCTX "$R9\$R3"
+ IfErrors innerloop outerdecrement
+
+ outercontinue:
+ ${${_MOZFUNC_UN}RemoveQuotesFromPath} "$R5" $R8
+ ${${_MOZFUNC_UN}GetParent} "$R8" $R2
+ ${${_MOZFUNC_UN}GetLongPath} "$R2" $R2
+ IfFileExists "$R2" +1 outerloop
+ StrCmp "$R2" "$R1" +1 outerloop
+
+ ClearErrors
+ DeleteRegKey SHCTX "$R9\$R3"
+ IfErrors outerloop
+
+ outerdecrement:
+ IntOp $R6 $R6 - 1 ; decrement the outer loop's counter when the key is deleted successfully.
+ GoTo outerloop
+
+ end:
+ ; Check if _KEY\${BrandFullNameInternal} refers to a key that's been
+ ; removed, either just now by this function or earlier by something else,
+ ; and if so either update it to a key that does exist or remove it if we
+ ; can't find anything to update it to.
+ ; We'll run this check twice, once looking for non-ESR keys and then again
+ ; looking specifically for the separate ESR keys.
+ StrCpy $R3 ""
+ ${For} $0 0 1
+ ClearErrors
+ ReadRegStr $R5 SHCTX "$R9\${BrandFullNameInternal}$R3" "CurrentVersion"
+ ${IfNot} ${Errors}
+ ReadRegStr $R5 SHCTX "$R9\${BrandFullNameInternal}\$R5" ""
+ ${If} ${Errors}
+ ; Key doesn't exist, update or remove CurrentVersion and default.
+ StrCpy $R5 ""
+ StrCpy $R6 0
+ EnumRegKey $R4 SHCTX "$R9\${BrandFullNameInternal}" $R6
+ ${While} $R4 != ""
+ ClearErrors
+ ${WordFind} "$R4" "esr" "E#" $1
+ ${If} $R3 == ""
+ ; The key we're looking to update is a non-ESR, so we need to
+ ; select only another non-ESR to update it with.
+ ${If} ${Errors}
+ StrCpy $R5 "$R4"
+ ${Break}
+ ${EndIf}
+ ${Else}
+ ; The key we're looking to update is an ESR, so we need to
+ ; select only another ESR to update it with.
+ ${IfNot} ${Errors}
+ StrCpy $R5 "$R4"
+ ${Break}
+ ${EndIf}
+ ${EndIf}
+
+ IntOp $R6 $R6 + 1
+ EnumRegKey $R4 SHCTX "$R9\${BrandFullNameInternal}" $R6
+ ${EndWhile}
+
+ ${If} $R5 == ""
+ ; We didn't find an install to update the key with, so delete the
+ ; CurrentVersion value and the entire key if it has no subkeys.
+ DeleteRegValue SHCTX "$R9\${BrandFullNameInternal}$R3" "CurrentVersion"
+ DeleteRegValue SHCTX "$R9\${BrandFullNameInternal}$R3" ""
+ DeleteRegKey /ifempty SHCTX "$R9\${BrandFullNameInternal}$R3"
+ ${Else}
+ ; We do have another still-existing install, so update the key to
+ ; that version.
+ WriteRegStr SHCTX "$R9\${BrandFullNameInternal}$R3" \
+ "CurrentVersion" "$R5"
+ ${WordFind} "$R5" " " "+1{" $R5
+ WriteRegStr SHCTX "$R9\${BrandFullNameInternal}$R3" "" "$R5"
+ ${EndIf}
+ ${EndIf}
+ ; Else, the key referenced in CurrentVersion still exists,
+ ; so there's nothing to update or remove.
+ ${EndIf}
+
+ ; Set up for the second iteration of the loop, where we'll be looking
+ ; for the separate ESR keys.
+ StrCpy $R3 " ESR"
+ ${Next}
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ${If} "$R0" == "false"
+ ; Set the registry to the correct view.
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 64
+ !else
+ SetRegView 32
+ !endif
+
+ StrCpy $R6 0 ; set the counter for the outer loop to 0
+ StrCpy $R0 "true"
+ GoTo outerloop
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $1
+ Pop $0
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RegCleanMainCall _KEY
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call RegCleanMain
+ !verbose pop
+!macroend
+
+!macro un.RegCleanMainCall _KEY
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call un.RegCleanMain
+ !verbose pop
+!macroend
+
+!macro un.RegCleanMain
+ !ifndef un.RegCleanMain
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RegCleanMain
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+/**
+ * Removes registry keys that reference this install location and for paths that
+ * no longer exist.
+ *
+ * @param _KEY ($R1)
+ * The registry subkey
+ * (typically this will be Software\Mozilla\${AppName}).
+ */
+!macro RegCleanPrefs
+ !ifndef ${_MOZFUNC_UN}RegCleanPrefs
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RegCleanPrefs "!insertmacro ${_MOZFUNC_UN}RegCleanPrefsCall"
+
+ Function ${_MOZFUNC_UN}RegCleanPrefs
+ Exch $R1 ; get _KEY from the stack
+
+ ; These prefs are always stored in the native registry.
+ SetRegView 64
+
+ ; Delete the installer prefs key for this installation, if one exists.
+ DeleteRegKey HKCU "$R1\Installer\$AppUserModelID"
+
+ ; If there aren't any more installer prefs keys, delete the parent key.
+ DeleteRegKey /ifempty HKCU "$R1\Installer"
+
+ SetRegView lastused
+
+ Pop $R1 ; restore the previous $R1
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RegCleanPrefsCall _KEY
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call RegCleanPrefs
+ !verbose pop
+!macroend
+
+!macro un.RegCleanPrefsCall _KEY
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_KEY}"
+ Call un.RegCleanPrefs
+ !verbose pop
+!macroend
+
+!macro un.RegCleanPrefs
+ !ifndef un.RegCleanPrefs
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RegCleanPrefs
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Removes all registry keys from \Software\Windows\CurrentVersion\Uninstall
+ * that reference this install location in both the 32 bit and 64 bit registry
+ * view. This macro uses SHCTX to determine the registry hive so you must call
+ * SetShellVarContext first.
+ *
+ * $R3 = on x64 systems set to 'false' at the beginning of the macro when
+ * enumerating the x86 registry view and set to 'true' when enumerating
+ * the x64 registry view.
+ * $R4 = stores the long path to $INSTDIR
+ * $R5 = return value from ReadRegStr
+ * $R6 = string for the base reg key (e.g. Software\Microsoft\Windows\CurrentVersion\Uninstall)
+ * $R7 = return value from EnumRegKey
+ * $R8 = counter for EnumRegKey
+ * $R9 = return value from the stack from the RemoveQuotesFromPath and GetLongPath macros
+ */
+!macro RegCleanUninstall
+
+ !ifndef ${_MOZFUNC_UN}RegCleanUninstall
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}RemoveQuotesFromPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RegCleanUninstall "!insertmacro ${_MOZFUNC_UN}RegCleanUninstallCall"
+
+ Function ${_MOZFUNC_UN}RegCleanUninstall
+ Push $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R4
+ StrCpy $R6 "Software\Microsoft\Windows\CurrentVersion\Uninstall"
+ StrCpy $R7 ""
+ StrCpy $R8 0
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ StrCpy $R3 "false"
+ ; Set the registry to the 32 bit registry for 64 bit installations or to
+ ; the 64 bit registry for 32 bit installations at the beginning so it can
+ ; easily be set back to the correct registry view when finished.
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 32
+ !else
+ SetRegView 64
+ !endif
+ ${EndIf}
+
+ loop:
+ EnumRegKey $R7 SHCTX $R6 $R8
+ StrCmp $R7 "" end +1
+ IntOp $R8 $R8 + 1 ; Increment the counter
+ ClearErrors
+ ReadRegStr $R5 SHCTX "$R6\$R7" "InstallLocation"
+ IfErrors loop
+ ${${_MOZFUNC_UN}RemoveQuotesFromPath} "$R5" $R9
+
+ ; Detect when the path is just a drive letter without a trailing
+ ; backslash (e.g., "C:"), and add a backslash. If we don't, the Win32
+ ; calls in GetLongPath will interpret that syntax as a shorthand
+ ; for the working directory, because that's the DOS 2.0 convention,
+ ; and will return the path to that directory instead of just the drive.
+ ; Back here, we would then successfully match that with our $INSTDIR,
+ ; and end up deleting a registry key that isn't really ours.
+ StrLen $R5 "$R9"
+ ${If} $R5 == 2
+ StrCpy $R5 "$R9" 1 1
+ ${If} "$R5" == ":"
+ StrCpy $R9 "$R9\"
+ ${EndIf}
+ ${EndIf}
+
+ ${${_MOZFUNC_UN}GetLongPath} "$R9" $R9
+ StrCmp "$R9" "$R4" +1 loop
+ ClearErrors
+ DeleteRegKey SHCTX "$R6\$R7"
+ IfErrors loop +1
+ IntOp $R8 $R8 - 1 ; Decrement the counter on successful deletion
+ GoTo loop
+
+ end:
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ ${If} "$R3" == "false"
+ ; Set the registry to the correct view.
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 64
+ !else
+ SetRegView 32
+ !endif
+
+ StrCpy $R7 ""
+ StrCpy $R8 0
+ StrCpy $R3 "true"
+ GoTo loop
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Pop $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RegCleanUninstallCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call RegCleanUninstall
+ !verbose pop
+!macroend
+
+!macro un.RegCleanUninstallCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.RegCleanUninstall
+ !verbose pop
+!macroend
+
+!macro un.RegCleanUninstall
+ !ifndef un.RegCleanUninstall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RegCleanUninstall
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Removes an application specific handler registry key under Software\Classes
+ * for both HKCU and HKLM when its open command refers to this install
+ * location or the install location doesn't exist.
+ *
+ * @param _HANDLER_NAME
+ * The registry name for the handler.
+ *
+ * $R7 = stores the long path to the $INSTDIR
+ * $R8 = stores the path to the open command's parent directory
+ * $R9 = _HANDLER_NAME
+ */
+!macro RegCleanAppHandler
+
+ !ifndef ${_MOZFUNC_UN}RegCleanAppHandler
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}GetPathFromString
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RegCleanAppHandler "!insertmacro ${_MOZFUNC_UN}RegCleanAppHandlerCall"
+
+ Function ${_MOZFUNC_UN}RegCleanAppHandler
+ Exch $R9
+ Push $R8
+ Push $R7
+
+ ClearErrors
+ ReadRegStr $R8 HKCU "Software\Classes\$R9\shell\open\command" ""
+ IfErrors next +1
+ ${${_MOZFUNC_UN}GetPathFromString} "$R8" $R8
+ ${${_MOZFUNC_UN}GetParent} "$R8" $R8
+ IfFileExists "$R8" +3 +1
+ DeleteRegKey HKCU "Software\Classes\$R9"
+ GoTo next
+
+ ${${_MOZFUNC_UN}GetLongPath} "$R8" $R8
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R7
+ StrCmp "$R7" "$R8" +1 next
+ DeleteRegKey HKCU "Software\Classes\$R9"
+
+ next:
+ ReadRegStr $R8 HKLM "Software\Classes\$R9\shell\open\command" ""
+ IfErrors end
+ ${${_MOZFUNC_UN}GetPathFromString} "$R8" $R8
+ ${${_MOZFUNC_UN}GetParent} "$R8" $R8
+ IfFileExists "$R8" +3 +1
+ DeleteRegKey HKLM "Software\Classes\$R9"
+ GoTo end
+
+ ${${_MOZFUNC_UN}GetLongPath} "$R8" $R8
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R7
+ StrCmp "$R7" "$R8" +1 end
+ DeleteRegKey HKLM "Software\Classes\$R9"
+
+ end:
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RegCleanAppHandlerCall _HANDLER_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_HANDLER_NAME}"
+ Call RegCleanAppHandler
+ !verbose pop
+!macroend
+
+!macro un.RegCleanAppHandlerCall _HANDLER_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_HANDLER_NAME}"
+ Call un.RegCleanAppHandler
+ !verbose pop
+!macroend
+
+!macro un.RegCleanAppHandler
+ !ifndef un.RegCleanAppHandler
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RegCleanAppHandler
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Cleans up the registry for a protocol handler when its open command
+ * refers to this install location. For HKCU the registry key is deleted
+ * and for HKLM the values set by the application are deleted.
+ *
+ * @param _HANDLER_NAME
+ * The registry name for the handler.
+ *
+ * $R7 = stores the long path to $INSTDIR
+ * $R8 = stores the the long path to the open command's parent directory
+ * $R9 = _HANDLER_NAME
+ */
+!macro un.RegCleanProtocolHandler
+
+ !ifndef un.RegCleanProtocolHandler
+ !insertmacro un.GetLongPath
+ !insertmacro un.GetParent
+ !insertmacro un.GetPathFromString
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define un.RegCleanProtocolHandler "!insertmacro un.RegCleanProtocolHandlerCall"
+
+ Function un.RegCleanProtocolHandler
+ Exch $R9
+ Push $R8
+ Push $R7
+
+ ReadRegStr $R8 HKCU "Software\Classes\$R9\shell\open\command" ""
+ ${un.GetLongPath} "$INSTDIR" $R7
+
+ StrCmp "$R8" "" next +1
+ ${un.GetPathFromString} "$R8" $R8
+ ${un.GetParent} "$R8" $R8
+ ${un.GetLongPath} "$R8" $R8
+ StrCmp "$R7" "$R8" +1 next
+ DeleteRegKey HKCU "Software\Classes\$R9"
+
+ next:
+ ReadRegStr $R8 HKLM "Software\Classes\$R9\shell\open\command" ""
+ StrCmp "$R8" "" end +1
+ ${un.GetLongPath} "$INSTDIR" $R7
+ ${un.GetPathFromString} "$R8" $R8
+ ${un.GetParent} "$R8" $R8
+ ${un.GetLongPath} "$R8" $R8
+ StrCmp "$R7" "$R8" +1 end
+ DeleteRegValue HKLM "Software\Classes\$R9\DefaultIcon" ""
+ DeleteRegValue HKLM "Software\Classes\$R9\shell\open" ""
+ DeleteRegValue HKLM "Software\Classes\$R9\shell\open\command" ""
+ DeleteRegValue HKLM "Software\Classes\$R9\shell\ddeexec" ""
+ DeleteRegValue HKLM "Software\Classes\$R9\shell\ddeexec\Application" ""
+ DeleteRegValue HKLM "Software\Classes\$R9\shell\ddeexec\Topic" ""
+
+ end:
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro un.RegCleanProtocolHandlerCall _HANDLER_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_HANDLER_NAME}"
+ Call un.RegCleanProtocolHandler
+ !verbose pop
+!macroend
+
+/**
+ * Cleans up the registry for a file handler when the passed in value equals
+ * the default value for the file handler. For HKCU the registry key is deleted
+ * and for HKLM the default value is deleted.
+ *
+ * @param _HANDLER_NAME
+ * The registry name for the handler.
+ * @param _DEFAULT_VALUE
+ * The value to check for against the handler's default value.
+ *
+ * $R6 = stores the long path to $INSTDIR
+ * $R7 = _DEFAULT_VALUE
+ * $R9 = _HANDLER_NAME
+ */
+!macro RegCleanFileHandler
+
+ !ifndef ${_MOZFUNC_UN}RegCleanFileHandler
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}GetPathFromString
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RegCleanFileHandler "!insertmacro ${_MOZFUNC_UN}RegCleanFileHandlerCall"
+
+ Function ${_MOZFUNC_UN}RegCleanFileHandler
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Push $R7
+
+ DeleteRegValue HKCU "Software\Classes\$R9\OpenWithProgids" $R8
+ EnumRegValue $R7 HKCU "Software\Classes\$R9\OpenWithProgids" 0
+ StrCmp "$R7" "" +1 +2
+ DeleteRegKey HKCU "Software\Classes\$R9\OpenWithProgids"
+ ReadRegStr $R7 HKCU "Software\Classes\$R9" ""
+ StrCmp "$R7" "$R8" +1 +2
+ DeleteRegKey HKCU "Software\Classes\$R9"
+
+ DeleteRegValue HKLM "Software\Classes\$R9\OpenWithProgids" $R8
+ EnumRegValue $R7 HKLM "Software\Classes\$R9\OpenWithProgids" 0
+ StrCmp "$R7" "" +1 +2
+ DeleteRegKey HKLM "Software\Classes\$R9\OpenWithProgids"
+ ReadRegStr $R7 HKLM "Software\Classes\$R9" ""
+ StrCmp "$R7" "$R8" +1 +2
+ DeleteRegValue HKLM "Software\Classes\$R9" ""
+
+ ClearErrors
+
+ Pop $R7
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RegCleanFileHandlerCall _HANDLER_NAME _DEFAULT_VALUE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_DEFAULT_VALUE}"
+ Push "${_HANDLER_NAME}"
+ Call RegCleanFileHandler
+ !verbose pop
+!macroend
+
+!macro un.RegCleanFileHandlerCall _HANDLER_NAME _DEFAULT_VALUE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_DEFAULT_VALUE}"
+ Push "${_HANDLER_NAME}"
+ Call un.RegCleanFileHandler
+ !verbose pop
+!macroend
+
+!macro un.RegCleanFileHandler
+ !ifndef un.RegCleanFileHandler
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RegCleanFileHandler
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Checks if a handler's open command points to this installation directory.
+ * Uses SHCTX to determine the registry hive (e.g. HKLM or HKCU) to check.
+ *
+ * @param _HANDLER_NAME
+ * The registry name for the handler.
+ * @param _RESULT
+ * true if it is the handler's open command points to this
+ * installation directory and false if it does not.
+ *
+ * $R7 = stores the value of the open command and the path macros return values
+ * $R8 = stores the handler's registry key name
+ * $R9 = _DEFAULT_VALUE and _RESULT
+ */
+!macro IsHandlerForInstallDir
+
+ !ifndef ${_MOZFUNC_UN}IsHandlerForInstallDir
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}GetPathFromString
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}IsHandlerForInstallDir "!insertmacro ${_MOZFUNC_UN}IsHandlerForInstallDirCall"
+
+ Function ${_MOZFUNC_UN}IsHandlerForInstallDir
+ Exch $R9
+ Push $R8
+ Push $R7
+
+ StrCpy $R8 "$R9"
+ StrCpy $R9 "false"
+ ReadRegStr $R7 SHCTX "Software\Classes\$R8\shell\open\command" ""
+
+ ${If} $R7 != ""
+ ${GetPathFromString} "$R7" $R7
+ ${GetParent} "$R7" $R7
+ ${GetLongPath} "$R7" $R7
+ ${If} $R7 == $INSTDIR
+ StrCpy $R9 "true"
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro IsHandlerForInstallDirCall _HANDLER_NAME _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_HANDLER_NAME}"
+ Call IsHandlerForInstallDir
+ Pop "${_RESULT}"
+ !verbose pop
+!macroend
+
+!macro un.IsHandlerForInstallDirCall _HANDLER_NAME _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_HANDLER_NAME}"
+ Call un.IsHandlerForInstallDir
+ Pop "${_RESULT}"
+ !verbose pop
+!macroend
+
+!macro un.IsHandlerForInstallDir
+ !ifndef un.IsHandlerForInstallDir
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro IsHandlerForInstallDir
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Removes the application's VirtualStore directory if present when the
+ * installation directory is a sub-directory of the program files directory.
+ *
+ * $R3 = Local App Data dir
+ * $R4 = $PROGRAMFILES/$PROGRAMFILES64 for CleanVirtualStore_Internal
+ * $R5 = various path values.
+ * $R6 = length of the long path to $PROGRAMFILES32 or $PROGRAMFILES64
+ * $R7 = long path to $PROGRAMFILES32 or $PROGRAMFILES64
+ * $R8 = length of the long path to $INSTDIR
+ * $R9 = long path to $INSTDIR
+ */
+!macro CleanVirtualStore
+
+ !ifndef ${_MOZFUNC_UN}CleanVirtualStore
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}CleanVirtualStore "!insertmacro ${_MOZFUNC_UN}CleanVirtualStoreCall"
+
+ Function ${_MOZFUNC_UN}CleanVirtualStore
+ Push $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R9
+ ${If} "$R9" != ""
+ StrLen $R8 "$R9"
+
+ StrCpy $R4 $PROGRAMFILES32
+ Call ${_MOZFUNC_UN}CleanVirtualStore_Internal
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ StrCpy $R4 $PROGRAMFILES64
+ Call ${_MOZFUNC_UN}CleanVirtualStore_Internal
+ ${EndIf}
+
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Pop $R9
+ FunctionEnd
+
+ Function ${_MOZFUNC_UN}CleanVirtualStore_Internal
+ ${${_MOZFUNC_UN}GetLongPath} "" $R7
+ ${If} "$R7" != ""
+ StrLen $R6 "$R7"
+ ${If} $R8 < $R6
+ ; Copy from the start of $INSTDIR the length of $PROGRAMFILES64
+ StrCpy $R5 "$R9" $R6
+ ${If} "$R5" == "$R7"
+ ; Remove the drive letter and colon from the $INSTDIR long path
+ StrCpy $R5 "$R9" "" 2
+ ${GetLocalAppDataFolder} $R3
+ StrCpy $R5 "$R3\VirtualStore$R5"
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ${If} "$R5" != ""
+ ${AndIf} ${FileExists} "$R5"
+ RmDir /r "$R5"
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CleanVirtualStoreCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call CleanVirtualStore
+ !verbose pop
+!macroend
+
+!macro un.CleanVirtualStoreCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.CleanVirtualStore
+ !verbose pop
+!macroend
+
+!macro un.CleanVirtualStore
+ !ifndef un.CleanVirtualStore
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro CleanVirtualStore
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Cleans up some old logs in the Maintenance Service directory.
+ *
+ * @param _OLD_REL_PATH
+ * The relative path to the profile directory from Local AppData.
+ * Calculated for the old update directory not based on a hash.
+ *
+ * $R7 = _OLD_REL_PATH
+ * $R1 = taskBar ID hash located in registry at SOFTWARE\_OLD_REL_PATH\TaskBarIDs
+ * $R6 = long path to $INSTDIR
+ */
+!macro CleanMaintenanceServiceLogs
+
+ !ifndef ${_MOZFUNC_UN}CleanMaintenanceServiceLogs
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetCommonDirectory
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}CleanMaintenanceServiceLogs "!insertmacro ${_MOZFUNC_UN}CleanMaintenanceServiceLogsCall"
+
+ Function ${_MOZFUNC_UN}CleanMaintenanceServiceLogs
+ Exch $R7
+ Push $R6
+ Push $R1
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR" $R6
+
+ ${If} $R7 != "" ; _OLD_REL_PATH was passed
+ ${AndIf} $R6 != "" ; We have the install dir path
+ ; Get the taskbar ID hash for this installation path
+ ReadRegStr $R1 HKLM "SOFTWARE\$R7\TaskBarIDs" $R6
+ ${If} $R1 == ""
+ ReadRegStr $R1 HKCU "SOFTWARE\$R7\TaskBarIDs" $R6
+ ${EndIf}
+
+ ${If} $R1 != ""
+ ; Remove the secure log files that our updater may have created
+ ; inside the maintenance service path. There are several files named
+ ; with the install hash and an extension indicating the kind of file.
+ ; so use a wildcard to delete them all.
+ Delete "$PROGRAMFILES32\Mozilla Maintenance Service\UpdateLogs\$R1.*"
+
+ ; If the UpdateLogs directory is now empty, then delete it.
+ ; The Maintenance Service uninstaller should do this, but it may not
+ ; be up to date enough because of bug 1665193, so doing this here as
+ ; well lets us make sure it really happens.
+ RmDir "$PROGRAMFILES32\Mozilla Maintenance Service\UpdateLogs"
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R1
+ Pop $R6
+ Exch $R7
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CleanMaintenanceServiceLogsCall _OLD_REL_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_OLD_REL_PATH}"
+ Call CleanMaintenanceServiceLogs
+ !verbose pop
+!macroend
+
+!macro un.CleanMaintenanceServiceLogsCall _OLD_REL_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_OLD_REL_PATH}"
+ Call un.CleanMaintenanceServiceLogs
+ !verbose pop
+!macroend
+
+!macro un.CleanMaintenanceServiceLogs
+ !ifndef un.CleanMaintenanceServiceLogs
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro CleanMaintenanceServiceLogs
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Removes relevant shortcuts from a given shortcuts log.
+ *
+ * $R3 = return value from ShellLink::GetShortCutWorkingDirectory
+ * $R4 = counter for appending to Shortcut for enumerating the ini file entries
+ * $R5 = return value from ShellLink::GetShortCutTarget
+ * $R6 = the long path to the Start Menu Programs directory (e.g. $SMPROGRAMS)
+ * $R7 = the return value from ReadINIStr for the relative path to the applications
+ * directory under the Start Menu Programs directory and the long path to this
+ * directory
+ * $R8 = the return value from ReadINIStr for enumerating shortcuts
+ * $R9 = [in] long path to the shortcuts ini file
+ */
+!macro DeleteShortcutsFromLog
+ !ifndef ${_MOZFUNC_UN}DeleteShortcutsFromLog
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}GetCommonDirectory
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}DeleteShortcutsFromLog "!insertmacro ${_MOZFUNC_UN}DeleteShortcutsFromLogCall"
+
+ Function ${_MOZFUNC_UN}DeleteShortcutsFromLog
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+
+ ${If} ${FileExists} "$R9"
+ ; Delete Start Menu shortcuts for this application
+ StrCpy $R4 -1
+ ${Do}
+ IntOp $R4 $R4 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R8 "$R9" "STARTMENU" "Shortcut$R4"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$SMPROGRAMS\$R8"
+ ShellLink::GetShortCutTarget "$SMPROGRAMS\$R8"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ; Shortcuts created outside of the installer may
+ ; have metadata set on them such that GetShortCutTarget
+ ; returns the wrong path on 64-bit systems.
+ ; GetShortCutWorkingDirectory is not _quite_ as good
+ ; of a test (because it may be unset), but it's not
+ ; subject to this bug, and is therefore the next best thing.
+ ; https://phabricator.services.mozilla.com/D138197
+ ; contains some additional background on this.
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R3
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ ${OrIf} "$INSTDIR" == "$R3"
+ Delete "$SMPROGRAMS\$R8"
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+ ; There might also be a shortcut with a different name created by a
+ ; previous version of the installer.
+ ${If} ${FileExists} "$SMPROGRAMS\${BrandFullName}.lnk"
+ ShellLink::GetShortCutTarget "$SMPROGRAMS\${BrandFullName}.lnk"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R3
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ ${OrIf} "$INSTDIR" == "$R3"
+ Delete "$SMPROGRAMS\${BrandFullName}.lnk"
+ ${EndIf}
+ ${EndIf}
+
+ ; Delete Quick Launch shortcuts for this application
+ StrCpy $R4 -1
+ ${Do}
+ IntOp $R4 $R4 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R8 "$R9" "QUICKLAUNCH" "Shortcut$R4"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$QUICKLAUNCH\$R8"
+ ShellLink::GetShortCutTarget "$QUICKLAUNCH\$R8"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R3
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ ${OrIf} "$INSTDIR" == "$R3"
+ Delete "$QUICKLAUNCH\$R8"
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+ ; There might also be a shortcut with a different name created by a
+ ; previous version of the installer.
+ ${If} ${FileExists} "$QUICKLAUNCH\${BrandFullName}.lnk"
+ ShellLink::GetShortCutTarget "$QUICKLAUNCH\${BrandFullName}.lnk"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R3
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ ${OrIf} "$INSTDIR" == "$R3"
+ Delete "$QUICKLAUNCH\${BrandFullName}.lnk"
+ ${EndIf}
+ ${EndIf}
+
+ ; Delete Desktop shortcuts for this application
+ StrCpy $R4 -1
+ ${Do}
+ IntOp $R4 $R4 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R8 "$R9" "DESKTOP" "Shortcut$R4"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$DESKTOP\$R8"
+ ShellLink::GetShortCutTarget "$DESKTOP\$R8"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R3
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ ${OrIf} "$INSTDIR" == "$R3"
+ Delete "$DESKTOP\$R8"
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+ ; There might also be a shortcut with a different name created by a
+ ; previous version of the installer.
+ ${If} ${FileExists} "$DESKTOP\${BrandFullName}.lnk"
+ ShellLink::GetShortCutTarget "$DESKTOP\${BrandFullName}.lnk"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ; We don't bother checking working directory here because we know
+ ; these shortcuts were only created by the installer, and thus
+ ; not subject to the bug described above.
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ Delete "$DESKTOP\${BrandFullName}.lnk"
+ ${EndIf}
+ ${EndIf}
+
+ ${${_MOZFUNC_UN}GetLongPath} "$SMPROGRAMS" $R6
+
+ ; Delete Start Menu Programs shortcuts for this application
+ ClearErrors
+ ReadINIStr $R7 "$R9" "SMPROGRAMS" "RelativePathToDir"
+ ${${_MOZFUNC_UN}GetLongPath} "$R6\$R7" $R7
+ ${Unless} "$R7" == ""
+ StrCpy $R4 -1
+ ${Do}
+ IntOp $R4 $R4 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R8 "$R9" "SMPROGRAMS" "Shortcut$R4"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$R7\$R8"
+ ShellLink::GetShortCutTarget "$R7\$R8"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ${If} "$INSTDIR\${FileMainEXE}" == "$R5"
+ Delete "$R7\$R8"
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+
+ ; Delete Start Menu Programs directories for this application
+ ${Do}
+ ClearErrors
+ ${If} "$R6" == "$R7"
+ ${ExitDo}
+ ${EndIf}
+ RmDir "$R7"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${${_MOZFUNC_UN}GetParent} "$R7" $R7
+ ${Loop}
+ ${EndUnless}
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro DeleteShortcutsFromLogCall _SHORTCUTS_LOG
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push ${_SHORTCUTS_LOG}
+ Call DeleteShortcutsFromLog
+ !verbose pop
+!macroend
+
+!macro un.DeleteShortcutsFromLogCall _SHORTCUTS_LOG
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push ${_SHORTCUTS_LOG}
+ Call un.DeleteShortcutsFromLog
+ !verbose pop
+!macroend
+
+!macro un.DeleteShortcutsFromLog
+ !ifndef un.DeleteShortcutsFromLog
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro DeleteShortcutsFromLog
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Deletes shortcuts and Start Menu directories under Programs as specified by
+ * the shortcuts log ini files and on Windows 7 unpins TaskBar and Start Menu
+ * shortcuts. The shortcuts will not be deleted if the shortcut target isn't for
+ * this install location which is determined by the shortcut having a target of
+ * $INSTDIR\${FileMainEXE} _or_ the working directory of the shortcut being
+ * equal to $INSTDIR. The context (All Users or Current User) of the
+ * $DESKTOP and $SMPROGRAMS constants depends on the
+ * SetShellVarContext setting and must be set by the caller of this macro. There
+ * is no All Users context for $QUICKLAUNCH but this will not cause a problem
+ * since the macro will just continue past the $QUICKLAUNCH shortcut deletion
+ * section on subsequent calls.
+ *
+ * The meat of this is farmed out to DeleteShortcutsFromLog to facilitate
+ * the processing of multiple shortcuts logs.
+ *
+ * The ini file sections must have the following format (the order of the
+ * sections in the ini file is not important):
+ * [SMPROGRAMS] -- this section is optional and largely irrelevant these days
+ * ; RelativePath is the directory relative from the Start Menu
+ * ; Programs directory.
+ * RelativePath=Mozilla App
+ * ; Shortcut1 is the first shortcut, Shortcut2 is the second shortcut, and so
+ * ; on. There must not be a break in the sequence of the numbers.
+ * Shortcut1=Mozilla App.lnk
+ * Shortcut2=Mozilla App (Safe Mode).lnk
+ * [DESKTOP]
+ * ; Shortcut1 is the first shortcut, Shortcut2 is the second shortcut, and so
+ * ; on. There must not be a break in the sequence of the numbers.
+ * Shortcut1=Mozilla App.lnk
+ * Shortcut2=Mozilla App (Safe Mode).lnk
+ * [QUICKLAUNCH]
+ * ; Shortcut1 is the first shortcut, Shortcut2 is the second shortcut, and so
+ * ; on. There must not be a break in the sequence of the numbers for the
+ * ; suffix.
+ * Shortcut1=Mozilla App.lnk
+ * Shortcut2=Mozilla App (Safe Mode).lnk
+ * [STARTMENU]
+ * ; Shortcut1 is the first shortcut, Shortcut2 is the second shortcut, and so
+ * ; on. There must not be a break in the sequence of the numbers for the
+ * ; suffix.
+ * Shortcut1=Mozilla App.lnk
+ * Shortcut2=Mozilla App (Safe Mode).lnk
+ *
+ * $R4 = return value from ShellLink::GetShortCutWorkingDirectory
+ * $R5 = return value from ShellLink::GetShortCutTarget and
+ * ApplicationID::UninstallPinnedItem
+ * $R6 = find handle
+ * $R7 = path to the $QUICKLAUNCH\User Pinned directory
+ * $R8 = return filename from FindFirst / FindNext
+ * $R9 = long path to the shortcut log ini file and path to the ProgramData
+ * directory that the application stores additional shortcut logs in
+ * (Typically c:\ProgramData\Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38)
+ */
+!macro DeleteShortcuts
+
+ !ifndef ${_MOZFUNC_UN}DeleteShortcuts
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}DeleteShortcutsFromLog
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}DeleteShortcuts "!insertmacro ${_MOZFUNC_UN}DeleteShortcutsCall"
+
+ Function ${_MOZFUNC_UN}DeleteShortcuts
+ Push $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+
+ ; We call ApplicationID::UninstallPinnedItem once per shortcut here
+ ; (and explicitly not in DeleteShortcutsFromLog). Calling it again later
+ ; would remove the association of side by side installations.
+ ; Since shortcuts that are pinned can later be removed without removing
+ ; the pinned shortcut unpin the pinned shortcuts for the application's
+ ; main exe using the pinned shortcuts themselves.
+ StrCpy $R7 "$QUICKLAUNCH\User Pinned"
+
+ ${If} ${FileExists} "$R7\TaskBar"
+ ; Delete TaskBar pinned shortcuts for the application's main exe
+ FindFirst $R6 $R8 "$R7\TaskBar\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$R7\TaskBar\$R8"
+ ShellLink::GetShortCutTarget "$R7\TaskBar\$R8"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R4
+ ${If} "$R5" == "$INSTDIR\${FileMainEXE}"
+ ${OrIf} "$R4" == "$INSTDIR"
+ ApplicationID::UninstallPinnedItem "$R7\TaskBar\$R8"
+ Pop $R5
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R6 $R8
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R6
+ ${EndIf}
+
+ ${If} ${FileExists} "$R7\StartMenu"
+ ; Delete Start Menu pinned shortcuts for the application's main exe
+ FindFirst $R6 $R8 "$R7\StartMenu\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$R7\StartMenu\$R8"
+ ShellLink::GetShortCutTarget "$R7\StartMenu\$R8"
+ Pop $R5
+ ${${_MOZFUNC_UN}GetLongPath} "$R5" $R5
+ ShellLink::GetShortCutWorkingDirectory "$SMPROGRAMS\$R8"
+ Pop $R4
+ ${If} "$R5" == "$INSTDIR\${FileMainEXE}"
+ ${OrIf} "$R4" == "$INSTDIR"
+ ApplicationID::UninstallPinnedItem "$R7\StartMenu\$R8"
+ Pop $R5
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R6 $R8
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R6
+ ${EndIf}
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR\uninstall\${SHORTCUTS_LOG}" $R9
+ ${${_MOZFUNC_UN}DeleteShortcutsFromLog} $R9
+
+ ${${_MOZFUNC_UN}GetCommonDirectory} $R9
+ ; Shortcut logs created by an application are in a different directory,
+ ; and named after both the application and the user SID, eg:
+ ; Firefox_S-1-5-21-1004336348-1177238915-682003330-512_shortcuts.ini
+ FindFirst $R6 $R8 "$R9\*_shortcuts.ini"
+ ${DoUntil} ${Errors}
+ ${If} ${FileExists} "$R9\$R8"
+ ${${_MOZFUNC_UN}DeleteShortcutsFromLog} "$R9\$R8"
+ ${EndIf}
+ ClearErrors
+ FindNext $R6 $R8
+ ${Loop}
+
+ ClearErrors
+
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Pop $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro DeleteShortcutsCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call DeleteShortcuts
+ !verbose pop
+!macroend
+
+!macro un.DeleteShortcutsCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.DeleteShortcuts
+ !verbose pop
+!macroend
+
+!macro un.DeleteShortcuts
+ !ifndef un.DeleteShortcuts
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro DeleteShortcuts
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+################################################################################
+# Macros for parsing and updating the uninstall.log
+
+/**
+ * Updates the uninstall.log with new files added by software update.
+ *
+ * When modifying this macro be aware that LineFind uses all registers except
+ * $R0-$R3 and TextCompareNoDetails uses all registers except $R0-$R9 so be
+ * cautious. Callers of this macro are not affected.
+ */
+!macro UpdateUninstallLog
+
+ !ifndef UpdateUninstallLog
+ !insertmacro FileJoin
+ !insertmacro LineFind
+ !insertmacro TextCompareNoDetails
+ !insertmacro TrimNewLines
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define UpdateUninstallLog "!insertmacro UpdateUninstallLogCall"
+
+ Function UpdateUninstallLog
+ Push $R3
+ Push $R2
+ Push $R1
+ Push $R0
+
+ ClearErrors
+
+ GetFullPathName $R3 "$INSTDIR\uninstall"
+ ${If} ${FileExists} "$R3\uninstall.update"
+ ${LineFind} "$R3\uninstall.update" "" "1:-1" "CleanupUpdateLog"
+
+ GetTempFileName $R2 "$R3"
+ FileOpen $R1 "$R2" w
+ ${TextCompareNoDetails} "$R3\uninstall.update" "$R3\uninstall.log" "SlowDiff" "CreateUpdateDiff"
+ FileClose $R1
+
+ IfErrors +2 0
+ ${FileJoin} "$R3\uninstall.log" "$R2" "$R3\uninstall.log"
+
+ ${DeleteFile} "$R2"
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Pop $R3
+ FunctionEnd
+
+ ; This callback MUST use labels vs. relative line numbers.
+ Function CleanupUpdateLog
+ StrCpy $R2 "$R9" 12
+ StrCmp "$R2" "EXECUTE ADD " +1 skip
+ StrCpy $R9 "$R9" "" 12
+
+ Push $R6
+ Push $R5
+ Push $R4
+ StrCpy $R4 "" ; Initialize to an empty string.
+ StrCpy $R6 -1 ; Set the counter to -1 so it will start at 0.
+
+ loop:
+ IntOp $R6 $R6 + 1 ; Increment the counter.
+ StrCpy $R5 $R9 1 $R6 ; Starting from the counter copy the next char.
+ StrCmp $R5 "" copy ; Are there no more chars?
+ StrCmp $R5 "/" +1 +2 ; Is the char a /?
+ StrCpy $R5 "\" ; Replace the char with a \.
+
+ StrCpy $R4 "$R4$R5"
+ GoTo loop
+
+ copy:
+ StrCpy $R9 "File: \$R4"
+ Pop $R6
+ Pop $R5
+ Pop $R4
+ GoTo end
+
+ skip:
+ StrCpy $0 "SkipWrite"
+
+ end:
+ Push $0
+ FunctionEnd
+
+ Function CreateUpdateDiff
+ ${TrimNewLines} "$9" $9
+ ${If} $9 != ""
+ FileWrite $R1 "$9$\r$\n"
+ ${EndIf}
+
+ Push 0
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro UpdateUninstallLogCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call UpdateUninstallLog
+ !verbose pop
+!macroend
+
+/**
+ * Copies files from a source directory to a destination directory with logging
+ * to the uninstall.log. If any destination files are in use a reboot will be
+ * necessary to complete the installation and the reboot flag (see IfRebootFlag
+ * in the NSIS documentation).
+ *
+ * @param _PATH_TO_SOURCE
+ * Source path to copy the files from. This must not end with a \.
+ *
+ * @param _PATH_TO_DESTINATION
+ * Destination path to copy the files to. This must not end with a \.
+ *
+ * @param _PREFIX_ERROR_CREATEDIR
+ * Prefix for the directory creation error message. The directory path
+ * will be inserted below this string.
+ *
+ * @param _SUFFIX_ERROR_CREATEDIR
+ * Suffix for the directory creation error message. The directory path
+ * will be inserted above this string.
+ *
+ * $0 = destination file's parent directory used in the create_dir label
+ * $R0 = copied value from $R6 (e.g. _PATH_TO_SOURCE)
+ * $R1 = copied value from $R7 (e.g. _PATH_TO_DESTINATION)
+ * $R2 = string length of the path to source
+ * $R3 = relative path from the path to source
+ * $R4 = copied value from $R8 (e.g. _PREFIX_ERROR_CREATEDIR)
+ * $R5 = copied value from $R9 (e.g. _SUFFIX_ERROR_CREATEDIR)
+ * note: the LocateNoDetails macro uses these registers so we copy the values
+ * to other registers.
+ * $R6 = initially _PATH_TO_SOURCE and then set to "size" ($R6="" if directory,
+ * $R6="0" if file with /S=)"path\name" in callback
+ * $R7 = initially _PATH_TO_DESTINATION and then set to "name" in callback
+ * $R8 = initially _PREFIX_ERROR_CREATEDIR and then set to "path" in callback
+ * $R9 = initially _SUFFIX_ERROR_CREATEDIR and then set to "path\name" in
+ * callback
+ */
+!macro CopyFilesFromDir
+
+ !ifndef CopyFilesFromDir
+ !insertmacro LocateNoDetails
+ !insertmacro OnEndCommon
+ !insertmacro WordReplace
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define CopyFilesFromDir "!insertmacro CopyFilesFromDirCall"
+
+ Function CopyFilesFromDir
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Exch 3
+ Exch $R6
+ Push $R5
+ Push $R4
+ Push $R3
+ Push $R2
+ Push $R1
+ Push $R0
+ Push $0
+
+ StrCpy $R0 "$R6"
+ StrCpy $R1 "$R7"
+ StrCpy $R4 "$R8"
+ StrCpy $R5 "$R9"
+
+ StrLen $R2 "$R0"
+
+ ${LocateNoDetails} "$R0" "/L=FD" "CopyFileCallback"
+
+ Pop $0
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ Function CopyFileCallback
+ StrCpy $R3 $R8 "" $R2 ; $R3 always begins with a \.
+
+ retry:
+ ClearErrors
+ StrCmp $R6 "" +1 copy_file
+ IfFileExists "$R1$R3\$R7" end +1
+ StrCpy $0 "$R1$R3\$R7"
+
+ create_dir:
+ ClearErrors
+ CreateDirectory "$0"
+ IfFileExists "$0" +1 err_create_dir ; protect against looping.
+ ${LogMsg} "Created Directory: $0"
+ StrCmp $R6 "" end copy_file
+
+ err_create_dir:
+ ${LogMsg} "** ERROR Creating Directory: $0 **"
+ MessageBox MB_RETRYCANCEL|MB_ICONQUESTION "$R4$\r$\n$\r$\n$0$\r$\n$\r$\n$R5" IDRETRY retry
+ ${OnEndCommon}
+ Quit
+
+ copy_file:
+ StrCpy $0 "$R1$R3"
+ StrCmp "$0" "$INSTDIR" +2 +1
+ IfFileExists "$0" +1 create_dir
+
+ ClearErrors
+ ${DeleteFile} "$R1$R3\$R7"
+ IfErrors +1 dest_clear
+ ClearErrors
+ Rename "$R1$R3\$R7" "$R1$R3\$R7.moz-delete"
+ IfErrors +1 reboot_delete
+
+ ; file will replace destination file on reboot
+ Rename "$R9" "$R9.moz-upgrade"
+ CopyFiles /SILENT "$R9.moz-upgrade" "$R1$R3"
+ Rename /REBOOTOK "$R1$R3\$R7.moz-upgrade" "$R1$R3\$R7"
+ ${LogMsg} "Copied File: $R1$R3\$R7.moz-upgrade"
+ ${LogMsg} "Delayed Install File (Reboot Required): $R1$R3\$R7"
+ GoTo log_uninstall
+
+ ; file will be deleted on reboot
+ reboot_delete:
+ CopyFiles /SILENT $R9 "$R1$R3"
+ Delete /REBOOTOK "$R1$R3\$R7.moz-delete"
+ ${LogMsg} "Installed File: $R1$R3\$R7"
+ ${LogMsg} "Delayed Delete File (Reboot Required): $R1$R3\$R7.moz-delete"
+ GoTo log_uninstall
+
+ ; destination file doesn't exist - coast is clear
+ dest_clear:
+ CopyFiles /SILENT $R9 "$R1$R3"
+ ${LogMsg} "Installed File: $R1$R3\$R7"
+
+ log_uninstall:
+ ; If the file is installed into the installation directory remove the
+ ; installation directory's path from the file path when writing to the
+ ; uninstall.log so it will be a relative path. This allows the same
+ ; helper.exe to be used with zip builds if we supply an uninstall.log.
+ ${WordReplace} "$R1$R3\$R7" "$INSTDIR" "" "+" $R3
+ ${LogUninstall} "File: $R3"
+
+ end:
+ Push 0
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CopyFilesFromDirCall _PATH_TO_SOURCE _PATH_TO_DESTINATION \
+ _PREFIX_ERROR_CREATEDIR _SUFFIX_ERROR_CREATEDIR
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_SOURCE}"
+ Push "${_PATH_TO_DESTINATION}"
+ Push "${_PREFIX_ERROR_CREATEDIR}"
+ Push "${_SUFFIX_ERROR_CREATEDIR}"
+ Call CopyFilesFromDir
+ !verbose pop
+!macroend
+
+/**
+ * Parses the uninstall.log on install to first remove a previous installation's
+ * files and then their directories if empty prior to installing.
+ *
+ * When modifying this macro be aware that LineFind uses all registers except
+ * $R0-$R3 so be cautious. Callers of this macro are not affected.
+ */
+!macro OnInstallUninstall
+
+ !ifndef OnInstallUninstall
+ !insertmacro GetParent
+ !insertmacro LineFind
+ !insertmacro TrimNewLines
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define OnInstallUninstall "!insertmacro OnInstallUninstallCall"
+
+ Function OnInstallUninstall
+ Push $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+ Push $R2
+ Push $R1
+ Push $R0
+ Push $TmpVal
+
+ IfFileExists "$INSTDIR\uninstall\uninstall.log" +1 end
+
+ ${LogHeader} "Removing Previous Installation"
+
+ ; Copy the uninstall log file to a temporary file
+ GetTempFileName $TmpVal
+ CopyFiles /SILENT /FILESONLY "$INSTDIR\uninstall\uninstall.log" "$TmpVal"
+
+ ; Delete files
+ ${LineFind} "$TmpVal" "/NUL" "1:-1" "RemoveFilesCallback"
+
+ ; Remove empty directories
+ ${LineFind} "$TmpVal" "/NUL" "1:-1" "RemoveDirsCallback"
+
+ ; Delete the temporary uninstall log file
+ Delete /REBOOTOK "$TmpVal"
+
+ ; Delete the uninstall log file
+ Delete "$INSTDIR\uninstall\uninstall.log"
+
+ end:
+ ClearErrors
+
+ Pop $TmpVal
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Pop $R9
+ FunctionEnd
+
+ Function RemoveFilesCallback
+ ${TrimNewLines} "$R9" $R9
+ StrCpy $R1 "$R9" 5 ; Copy the first five chars
+
+ StrCmp "$R1" "File:" +1 end
+ StrCpy $R9 "$R9" "" 6 ; Copy string starting after the 6th char
+ StrCpy $R0 "$R9" 1 ; Copy the first char
+
+ StrCmp "$R0" "\" +1 end ; If this isn't a relative path goto end
+ StrCmp "$R9" "\install.log" end +1 ; Skip the install.log
+ StrCmp "$R9" "\MapiProxy_InUse.dll" end +1 ; Skip the MapiProxy_InUse.dll
+ StrCmp "$R9" "\mozMapi32_InUse.dll" end +1 ; Skip the mozMapi32_InUse.dll
+
+ StrCpy $R1 "$INSTDIR$R9" ; Copy the install dir path and suffix it with the string
+ IfFileExists "$R1" +1 end
+
+ ClearErrors
+ Delete "$R1"
+ ${Unless} ${Errors}
+ ${LogMsg} "Deleted File: $R1"
+ Goto end
+ ${EndUnless}
+
+ ClearErrors
+ Rename "$R1" "$R1.moz-delete"
+ ${Unless} ${Errors}
+ Delete /REBOOTOK "$R1.moz-delete"
+ ${LogMsg} "Delayed Delete File (Reboot Required): $R1.moz-delete"
+ GoTo end
+ ${EndUnless}
+
+ ; Check if the file exists in the source. If it does the new file will
+ ; replace the existing file when the system is rebooted. If it doesn't
+ ; the file will be deleted when the system is rebooted.
+ ${Unless} ${FileExists} "$EXEDIR\core$R9"
+ ${AndUnless} ${FileExists} "$EXEDIR\optional$R9"
+ Delete /REBOOTOK "$R1"
+ ${LogMsg} "Delayed Delete File (Reboot Required): $R1"
+ ${EndUnless}
+
+ end:
+ ClearErrors
+
+ Push 0
+ FunctionEnd
+
+ ; Using locate will leave file handles open to some of the directories
+ ; which will prevent the deletion of these directories. This parses the
+ ; uninstall.log and uses the file entries to find / remove empty
+ ; directories.
+ Function RemoveDirsCallback
+ ${TrimNewLines} "$R9" $R9
+ StrCpy $R0 "$R9" 5 ; Copy the first five chars
+ StrCmp "$R0" "File:" +1 end
+
+ StrCpy $R9 "$R9" "" 6 ; Copy string starting after the 6th char
+ StrCpy $R0 "$R9" 1 ; Copy the first char
+
+ StrCpy $R1 "$INSTDIR$R9" ; Copy the install dir path and suffix it with the string
+ StrCmp "$R0" "\" loop end ; If this isn't a relative path goto end
+
+ loop:
+ ${GetParent} "$R1" $R1 ; Get the parent directory for the path
+ StrCmp "$R1" "$INSTDIR" end +1 ; If the directory is the install dir goto end
+
+ IfFileExists "$R1" +1 loop ; Only try to remove the dir if it exists
+ ClearErrors
+ RmDir "$R1" ; Remove the dir
+ IfErrors end +1 ; If we fail there is no use trying to remove its parent dir
+ ${LogMsg} "Deleted Directory: $R1"
+ GoTo loop
+
+ end:
+ ClearErrors
+
+ Push 0
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro OnInstallUninstallCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call OnInstallUninstall
+ !verbose pop
+!macroend
+
+/**
+ * Parses the precomplete file to remove an installation's files and
+ * directories.
+ *
+ * @param _CALLBACK
+ * The function address of a callback function for progress or "false"
+ * if there is no callback function.
+ *
+ * $R3 = false if all files were deleted or moved to the tobedeleted directory.
+ * true if file(s) could not be moved to the tobedeleted directory.
+ * $R4 = Path to temporary precomplete file.
+ * $R5 = File handle for the temporary precomplete file.
+ * $R6 = String returned from FileRead.
+ * $R7 = First seven characters of the string returned from FileRead.
+ * $R8 = Temporary file path used to rename files that are in use.
+ * $R9 = _CALLBACK
+ */
+!macro RemovePrecompleteEntries
+
+ !ifndef ${_MOZFUNC_UN}RemovePrecompleteEntries
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParent
+ !insertmacro ${_MOZFUNC_UN_TMP}TrimNewLines
+ !insertmacro ${_MOZFUNC_UN_TMP}WordReplace
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}RemovePrecompleteEntries "!insertmacro ${_MOZFUNC_UN}RemovePrecompleteEntriesCall"
+
+ Function ${_MOZFUNC_UN}RemovePrecompleteEntries
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+
+ ${If} ${FileExists} "$INSTDIR\precomplete"
+ StrCpy $R3 "false"
+
+ RmDir /r "$INSTDIR\${TO_BE_DELETED}"
+ CreateDirectory "$INSTDIR\${TO_BE_DELETED}"
+ GetTempFileName $R4 "$INSTDIR\${TO_BE_DELETED}"
+ Delete "$R4"
+ Rename "$INSTDIR\precomplete" "$R4"
+
+ ClearErrors
+ ; Rename and then remove files
+ FileOpen $R5 "$R4" r
+ ${Do}
+ FileRead $R5 $R6
+ ${If} ${Errors}
+ ${Break}
+ ${EndIf}
+
+ ${${_MOZFUNC_UN}TrimNewLines} "$R6" $R6
+ ; Replace all occurrences of "/" with "\".
+ ${${_MOZFUNC_UN}WordReplace} "$R6" "/" "\" "+" $R6
+
+ ; Copy the first 7 chars
+ StrCpy $R7 "$R6" 7
+ ${If} "$R7" == "remove "
+ ; Copy the string starting after the 8th char
+ StrCpy $R6 "$R6" "" 8
+ ; Copy all but the last char to remove the double quote.
+ StrCpy $R6 "$R6" -1
+ ${If} ${FileExists} "$INSTDIR\$R6"
+ ${Unless} "$R9" == "false"
+ Call $R9
+ ${EndUnless}
+
+ ClearErrors
+ Delete "$INSTDIR\$R6"
+ ${If} ${Errors}
+ GetTempFileName $R8 "$INSTDIR\${TO_BE_DELETED}"
+ Delete "$R8"
+ ClearErrors
+ Rename "$INSTDIR\$R6" "$R8"
+ ${Unless} ${Errors}
+ Delete /REBOOTOK "$R8"
+
+ ClearErrors
+ ${EndUnless}
+!ifdef __UNINSTALL__
+ ${If} ${Errors}
+ Delete /REBOOTOK "$INSTDIR\$R6"
+ StrCpy $R3 "true"
+ ClearErrors
+ ${EndIf}
+!endif
+ ${EndIf}
+ ${EndIf}
+ ${ElseIf} "$R7" == "rmdir $\""
+ ; Copy the string starting after the 7th char.
+ StrCpy $R6 "$R6" "" 7
+ ; Copy all but the last two chars to remove the slash and the double quote.
+ StrCpy $R6 "$R6" -2
+ ${If} ${FileExists} "$INSTDIR\$R6"
+ ; Ignore directory removal errors
+ RmDir "$INSTDIR\$R6"
+ ClearErrors
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+ FileClose $R5
+
+ ; Delete the temporary precomplete file
+ Delete /REBOOTOK "$R4"
+
+ RmDir /r /REBOOTOK "$INSTDIR\${TO_BE_DELETED}"
+
+ ${If} ${RebootFlag}
+ ${AndIf} "$R3" == "false"
+ ; Clear the reboot flag if all files were deleted or moved to the
+ ; tobedeleted directory.
+ SetRebootFlag false
+ ${EndIf}
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro RemovePrecompleteEntriesCall _CALLBACK
+ !verbose push
+ Push "${_CALLBACK}"
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call RemovePrecompleteEntries
+ !verbose pop
+!macroend
+
+!macro un.RemovePrecompleteEntriesCall _CALLBACK
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_CALLBACK}"
+ Call un.RemovePrecompleteEntries
+ !verbose pop
+!macroend
+
+!macro un.RemovePrecompleteEntries
+ !ifndef un.RemovePrecompleteEntries
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro RemovePrecompleteEntries
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Parses the uninstall.log to unregister dll's, remove files, and remove
+ * empty directories for this installation.
+ *
+ * When modifying this macro be aware that LineFind uses all registers except
+ * $R0-$R3 so be cautious. Callers of this macro are not affected.
+ */
+!macro un.ParseUninstallLog
+
+ !ifndef un.ParseUninstallLog
+ !insertmacro un.GetParent
+ !insertmacro un.LineFind
+ !insertmacro un.TrimNewLines
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define un.ParseUninstallLog "!insertmacro un.ParseUninstallLogCall"
+
+ Function un.ParseUninstallLog
+ Push $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+ Push $R2
+ Push $R1
+ Push $R0
+ Push $TmpVal
+
+ IfFileExists "$INSTDIR\uninstall\uninstall.log" +1 end
+
+ ; Copy the uninstall log file to a temporary file
+ GetTempFileName $TmpVal
+ CopyFiles /SILENT /FILESONLY "$INSTDIR\uninstall\uninstall.log" "$TmpVal"
+
+ ; Unregister DLL's
+ ${un.LineFind} "$TmpVal" "/NUL" "1:-1" "un.UnRegDLLsCallback"
+
+ ; Delete files
+ ${un.LineFind} "$TmpVal" "/NUL" "1:-1" "un.RemoveFilesCallback"
+
+ ; Remove empty directories
+ ${un.LineFind} "$TmpVal" "/NUL" "1:-1" "un.RemoveDirsCallback"
+
+ ; Delete the temporary uninstall log file
+ Delete /REBOOTOK "$TmpVal"
+
+ end:
+
+ Pop $TmpVal
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Pop $R9
+ FunctionEnd
+
+ Function un.RemoveFilesCallback
+ ${un.TrimNewLines} "$R9" $R9
+ StrCpy $R1 "$R9" 5
+
+ StrCmp "$R1" "File:" +1 end
+ StrCpy $R9 "$R9" "" 6
+ StrCpy $R0 "$R9" 1
+
+ StrCpy $R1 "$INSTDIR$R9"
+ StrCmp "$R0" "\" +2 +1
+ StrCpy $R1 "$R9"
+
+ IfFileExists "$R1" +1 end
+ Delete "$R1"
+ IfErrors +1 end
+ ClearErrors
+ Rename "$R1" "$R1.moz-delete"
+ IfErrors +1 +3
+ Delete /REBOOTOK "$R1"
+ GoTo end
+
+ Delete /REBOOTOK "$R1.moz-delete"
+
+ end:
+ ClearErrors
+
+ Push 0
+ FunctionEnd
+
+ Function un.UnRegDLLsCallback
+ ${un.TrimNewLines} "$R9" $R9
+ StrCpy $R1 "$R9" 7
+
+ StrCmp $R1 "DLLReg:" +1 end
+ StrCpy $R9 "$R9" "" 8
+ StrCpy $R0 "$R9" 1
+
+ StrCpy $R1 "$INSTDIR$R9"
+ StrCmp $R0 "\" +2 +1
+ StrCpy $R1 "$R9"
+
+ ${UnregisterDLL} $R1
+
+ end:
+ ClearErrors
+
+ Push 0
+ FunctionEnd
+
+ ; Using locate will leave file handles open to some of the directories
+ ; which will prevent the deletion of these directories. This parses the
+ ; uninstall.log and uses the file entries to find / remove empty
+ ; directories.
+ Function un.RemoveDirsCallback
+ ${un.TrimNewLines} "$R9" $R9
+ StrCpy $R0 "$R9" 5 ; Copy the first five chars
+ StrCmp "$R0" "File:" +1 end
+
+ StrCpy $R9 "$R9" "" 6 ; Copy string starting after the 6th char
+ StrCpy $R0 "$R9" 1 ; Copy the first char
+
+ StrCpy $R1 "$INSTDIR$R9" ; Copy the install dir path and suffix it with the string
+ StrCmp "$R0" "\" loop ; If this is a relative path goto the loop
+ StrCpy $R1 "$R9" ; Already a full path so copy the string
+
+ loop:
+ ${un.GetParent} "$R1" $R1 ; Get the parent directory for the path
+ StrCmp "$R1" "$INSTDIR" end ; If the directory is the install dir goto end
+
+ ; We only try to remove empty directories but the Desktop, StartMenu, and
+ ; QuickLaunch directories can be empty so guard against removing them.
+ SetShellVarContext all ; Set context to all users
+ StrCmp "$R1" "$DESKTOP" end ; All users desktop
+ StrCmp "$R1" "$STARTMENU" end ; All users start menu
+
+ SetShellVarContext current ; Set context to all users
+ StrCmp "$R1" "$DESKTOP" end ; Current user desktop
+ StrCmp "$R1" "$STARTMENU" end ; Current user start menu
+ StrCmp "$R1" "$QUICKLAUNCH" end ; Current user quick launch
+
+ IfFileExists "$R1" +1 +3 ; Only try to remove the dir if it exists
+ ClearErrors
+ RmDir "$R1" ; Remove the dir
+ IfErrors end ; If we fail there is no use trying to remove its parent dir
+
+ StrCmp "$R0" "\" loop end ; Only loop when the path is relative to the install dir
+
+ end:
+ ClearErrors
+
+ Push 0
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro un.ParseUninstallLogCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.ParseUninstallLog
+ !verbose pop
+!macroend
+
+/**
+ * Finds a valid Start Menu shortcut in the uninstall log and returns the
+ * relative path from the Start Menu's Programs directory to the shortcut's
+ * directory.
+ *
+ * When modifying this macro be aware that LineFind uses all registers except
+ * $R0-$R3 so be cautious. Callers of this macro are not affected.
+ *
+ * @return _REL_PATH_TO_DIR
+ * The relative path to the application's Start Menu directory from the
+ * Start Menu's Programs directory.
+ */
+!macro FindSMProgramsDir
+
+ !ifndef FindSMProgramsDir
+ !insertmacro GetParent
+ !insertmacro LineFind
+ !insertmacro TrimNewLines
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define FindSMProgramsDir "!insertmacro FindSMProgramsDirCall"
+
+ Function FindSMProgramsDir
+ Exch $R3
+ Push $R2
+ Push $R1
+ Push $R0
+
+ StrCpy $R3 ""
+ ${If} ${FileExists} "$INSTDIR\uninstall\uninstall.log"
+ ${LineFind} "$INSTDIR\uninstall\uninstall.log" "/NUL" "1:-1" "FindSMProgramsDirRelPath"
+ ${EndIf}
+ ClearErrors
+
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Exch $R3
+ FunctionEnd
+
+ ; This callback MUST use labels vs. relative line numbers.
+ Function FindSMProgramsDirRelPath
+ Push 0
+ ${TrimNewLines} "$R9" $R9
+ StrCpy $R4 "$R9" 5
+
+ StrCmp "$R4" "File:" +1 end_FindSMProgramsDirRelPath
+ StrCpy $R9 "$R9" "" 6
+ StrCpy $R4 "$R9" 1
+
+ StrCmp "$R4" "\" end_FindSMProgramsDirRelPath +1
+
+ SetShellVarContext all
+ ${GetLongPath} "$SMPROGRAMS" $R4
+ StrLen $R2 "$R4"
+ StrCpy $R1 "$R9" $R2
+ StrCmp "$R1" "$R4" +1 end_FindSMProgramsDirRelPath
+ IfFileExists "$R9" +1 end_FindSMProgramsDirRelPath
+ ShellLink::GetShortCutTarget "$R9"
+ Pop $R0
+ StrCmp "$INSTDIR\${FileMainEXE}" "$R0" +1 end_FindSMProgramsDirRelPath
+ ${GetParent} "$R9" $R3
+ IntOp $R2 $R2 + 1
+ StrCpy $R3 "$R3" "" $R2
+
+ Pop $R4 ; Remove the previously pushed 0 from the stack and
+ push "StopLineFind" ; push StopLineFind to stop finding more lines.
+
+ end_FindSMProgramsDirRelPath:
+ ClearErrors
+
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro FindSMProgramsDirCall _REL_PATH_TO_DIR
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call FindSMProgramsDir
+ Pop ${_REL_PATH_TO_DIR}
+ !verbose pop
+!macroend
+
+
+################################################################################
+# Macros for custom branding
+
+/**
+ * Sets BrandFullName and / or BrandShortName to values provided in the specified
+ * ini file and defaults to BrandShortName and BrandFullName as defined in
+ * branding.nsi when the associated ini file entry is not specified.
+ *
+ * ini file format:
+ * [Branding]
+ * BrandFullName=Custom Full Name
+ * BrandShortName=Custom Short Name
+ *
+ * @param _PATH_TO_INI
+ * Path to the ini file.
+ *
+ * $R6 = return value from ReadINIStr
+ * $R7 = stores BrandShortName
+ * $R8 = stores BrandFullName
+ * $R9 = _PATH_TO_INI
+ */
+!macro SetBrandNameVars
+
+ !ifndef ${_MOZFUNC_UN}SetBrandNameVars
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}WordReplace
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ ; Prevent declaring vars twice when the SetBrandNameVars macro is
+ ; inserted into both the installer and uninstaller.
+ !ifndef SetBrandNameVars
+ Var BrandFullName
+ Var BrandFullNameDA
+ Var BrandShortName
+ Var BrandProductName
+ !endif
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}SetBrandNameVars "!insertmacro ${_MOZFUNC_UN}SetBrandNameVarsCall"
+
+ Function ${_MOZFUNC_UN}SetBrandNameVars
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ StrCpy $R8 "${BrandFullName}"
+ StrCpy $R7 "${BrandShortName}"
+ StrCpy $R6 "${BrandProductName}"
+
+ IfFileExists "$R9" +1 finish
+
+ ClearErrors
+ ReadINIStr $R5 $R9 "Branding" "BrandFullName"
+ IfErrors +2 +1
+ StrCpy $R8 "$R5"
+
+ ClearErrors
+ ReadINIStr $R5 $R9 "Branding" "BrandShortName"
+ IfErrors +2 +1
+ StrCpy $R7 "$R5"
+
+ ClearErrors
+ ReadINIStr $R5 $R9 "Branding" "BrandProductName"
+ IfErrors +2 +1
+ StrCpy $R6 "$R5"
+
+ finish:
+ StrCpy $BrandFullName "$R8"
+ ${${_MOZFUNC_UN}WordReplace} "$R8" "&" "&&" "+" $R8
+ StrCpy $BrandFullNameDA "$R8"
+ StrCpy $BrandShortName "$R7"
+ StrCpy $BrandProductName "$R6"
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro SetBrandNameVarsCall _PATH_TO_INI
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_INI}"
+ Call SetBrandNameVars
+ !verbose pop
+!macroend
+
+!macro un.SetBrandNameVarsCall _PATH_TO_INI
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_INI}"
+ Call un.SetBrandNameVars
+ !verbose pop
+!macroend
+
+!macro un.SetBrandNameVars
+ !ifndef un.SetBrandNameVars
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro SetBrandNameVars
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Replaces the wizard's header image with the one specified.
+ *
+ * @param _PATH_TO_IMAGE
+ * Fully qualified path to the bitmap to use for the header image.
+ *
+ * $R8 = hwnd for the control returned from GetDlgItem.
+ * $R9 = _PATH_TO_IMAGE
+ */
+!macro ChangeMUIHeaderImage
+
+ !ifndef ${_MOZFUNC_UN}ChangeMUIHeaderImage
+ Var hHeaderBitmap
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}ChangeMUIHeaderImage "!insertmacro ${_MOZFUNC_UN}ChangeMUIHeaderImageCall"
+
+ Function ${_MOZFUNC_UN}ChangeMUIHeaderImage
+ Exch $R9
+ Push $R8
+
+ GetDlgItem $R8 $HWNDPARENT 1046
+ ${SetStretchedImageOLE} $R8 "$R9" $hHeaderBitmap
+ ; There is no way to specify a show function for a custom page so hide
+ ; and then show the control to force the bitmap to redraw.
+ ShowWindow $R8 ${SW_HIDE}
+ ShowWindow $R8 ${SW_SHOW}
+
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro ChangeMUIHeaderImageCall _PATH_TO_IMAGE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_IMAGE}"
+ Call ChangeMUIHeaderImage
+ !verbose pop
+!macroend
+
+!macro un.ChangeMUIHeaderImageCall _PATH_TO_IMAGE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_IMAGE}"
+ Call un.ChangeMUIHeaderImage
+ !verbose pop
+!macroend
+
+!macro un.ChangeMUIHeaderImage
+ !ifndef un.ChangeMUIHeaderImage
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro ChangeMUIHeaderImage
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Replaces the sidebar image on the wizard's welcome and finish pages.
+ *
+ * @param _PATH_TO_IMAGE
+ * Fully qualified path to the bitmap to use for the header image.
+ *
+ * $R8 = hwnd for the bitmap control
+ * $R9 = _PATH_TO_IMAGE
+ */
+!macro ChangeMUISidebarImage
+
+ !ifndef ${_MOZFUNC_UN}ChangeMUISidebarImage
+ Var hSidebarBitmap
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}ChangeMUISidebarImage "!insertmacro ${_MOZFUNC_UN}ChangeMUISidebarImageCall"
+
+ Function ${_MOZFUNC_UN}ChangeMUISidebarImage
+ Exch $R9
+ Push $R8
+
+ ; Make sure we're not about to leak an existing handle.
+ ${If} $hSidebarBitmap <> 0
+ System::Call "gdi32::DeleteObject(p $hSidebarBitmap)"
+ StrCpy $hSidebarBitmap 0
+ ${EndIf}
+ ; The controls on the welcome and finish pages aren't in the dialog
+ ; template, they're always created manually from the INI file, so we need
+ ; to query it to find the right HWND.
+ ReadINIStr $R8 "$PLUGINSDIR\ioSpecial.ini" "Field 1" "HWND"
+ ${SetStretchedImageOLE} $R8 "$R9" $hSidebarBitmap
+
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro ChangeMUISidebarImageCall _PATH_TO_IMAGE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_IMAGE}"
+ Call ChangeMUISidebarImage
+ !verbose pop
+!macroend
+
+!macro un.ChangeMUISidebarImageCall _PATH_TO_IMAGE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_PATH_TO_IMAGE}"
+ Call un.ChangeMUISidebarImage
+ !verbose pop
+!macroend
+
+!macro un.ChangeMUISidebarImage
+ !ifndef un.ChangeMUISidebarImage
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro ChangeMUISidebarImage
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+################################################################################
+# User interface callback helper defines and macros
+
+/* Install type defines */
+!ifndef INSTALLTYPE_BASIC
+ !define INSTALLTYPE_BASIC 1
+!endif
+
+!ifndef INSTALLTYPE_CUSTOM
+ !define INSTALLTYPE_CUSTOM 2
+!endif
+
+/**
+ * Checks whether to display the current page (e.g. if not performing a custom
+ * install don't display the custom pages).
+ */
+!macro CheckCustomCommon
+
+ !ifndef CheckCustomCommon
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define CheckCustomCommon "!insertmacro CheckCustomCommonCall"
+
+ Function CheckCustomCommon
+
+ ; Abort if not a custom install
+ IntCmp $InstallType ${INSTALLTYPE_CUSTOM} +2 +1 +1
+ Abort
+
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro CheckCustomCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call CheckCustomCommon
+ !verbose pop
+!macroend
+
+/**
+ * Unloads dll's and releases references when the installer and uninstaller
+ * exit.
+ */
+!macro OnEndCommon
+
+ !ifndef ${_MOZFUNC_UN}OnEndCommon
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}UnloadUAC
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}OnEndCommon "!insertmacro ${_MOZFUNC_UN}OnEndCommonCall"
+
+ Function ${_MOZFUNC_UN}OnEndCommon
+
+ ${${_MOZFUNC_UN}UnloadUAC}
+ StrCmp $hHeaderBitmap "" +3 +1
+ System::Call "gdi32::DeleteObject(i s)" $hHeaderBitmap
+ StrCpy $hHeaderBitmap ""
+ ; If ChangeMUISidebarImage was called, then we also need to clean up the
+ ; GDI bitmap handle that it would have created.
+ !ifdef ${_MOZFUNC_UN}ChangeMUISidebarImage
+ StrCmp $hSidebarBitmap "" +3 +1
+ System::Call "gdi32::DeleteObject(i s)" $hSidebarBitmap
+ StrCpy $hSidebarBitmap ""
+ !endif
+
+ System::Free 0
+
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro OnEndCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call OnEndCommon
+ !verbose pop
+!macroend
+
+!macro un.OnEndCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.OnEndCommon
+ !verbose pop
+!macroend
+
+!macro un.OnEndCommon
+ !ifndef un.OnEndCommon
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro OnEndCommon
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Reads a flag option from the command line and sets a variable with its state,
+ * if the option is present on the command line.
+ *
+ * @param FULL_COMMAND_LINE
+ * The entire installer command line, such as from ${GetParameters}
+ * @param OPTION
+ * Name of the option to look for
+ * @param OUTPUT
+ * Variable/register to write the output to. Will be set to "0" if the
+ * option was present with the value "false", will be set to "1" if the
+ * option was present with another value, and will be untouched if the
+ * option was not on the command line at all.
+ */
+!macro InstallGetOption FULL_COMMAND_LINE OPTION OUTPUT
+ Push $0
+ ClearErrors
+ ${GetOptions} ${FULL_COMMAND_LINE} "/${OPTION}" $0
+ ${IfNot} ${Errors}
+ ; Any valid command-line option triggers a silent installation.
+ SetSilent silent
+
+ ${If} $0 == "=false"
+ StrCpy ${OUTPUT} "0"
+ ${Else}
+ StrCpy ${OUTPUT} "1"
+ ${EndIf}
+ ${EndIf}
+ Pop $0
+!macroend
+!define InstallGetOption "!insertmacro InstallGetOption"
+
+/**
+ * Called from the installer's .onInit function not to be confused with the
+ * uninstaller's .onInit or the uninstaller's un.onInit functions.
+ *
+ * @param _WARN_UNSUPPORTED_MSG
+ * Message displayed when the Windows version is not supported.
+ *
+ * $R4 = keeps track of whether a custom install path was specified on either
+ * the command line or in an INI file
+ * $R5 = return value from the GetSize macro
+ * $R6 = general string values, return value from GetTempFileName, return
+ * value from the GetSize macro
+ * $R7 = full path to the configuration ini file
+ * $R8 = used for OS Version and Service Pack detection and the return value
+ * from the GetParameters macro
+ * $R9 = _WARN_UNSUPPORTED_MSG
+ */
+!macro InstallOnInitCommon
+
+ !ifndef InstallOnInitCommon
+ !insertmacro ElevateUAC
+ !insertmacro GetOptions
+ !insertmacro GetParameters
+ !insertmacro GetSize
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define InstallOnInitCommon "!insertmacro InstallOnInitCommonCall"
+
+ Function InstallOnInitCommon
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+
+ ; Don't install on systems that don't support SSE2. The parameter value of
+ ; 10 is for PF_XMMI64_INSTRUCTIONS_AVAILABLE which will check whether the
+ ; SSE2 instruction set is available.
+ System::Call "kernel32::IsProcessorFeaturePresent(i 10)i .R8"
+ ${If} "$R8" == "0"
+ MessageBox MB_OK|MB_ICONSTOP "$R9"
+ ; Nothing initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+
+ ; Windows 8.1/Server 2012 R2 and lower are not supported.
+ ${Unless} ${AtLeastWin10}
+ MessageBox MB_OK|MB_ICONSTOP "$R9"
+ ; Nothing initialized so no need to call OnEndCommon
+ Quit
+ ${EndUnless}
+
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 64
+ !endif
+
+ StrCpy $R4 0 ; will be set to 1 if a custom install path is set
+
+ ${GetParameters} $R8
+ ${If} $R8 != ""
+ ; Default install type
+ StrCpy $InstallType ${INSTALLTYPE_BASIC}
+
+ ${Unless} ${Silent}
+ ; NSIS should check for /S for us, but we've had issues with it such
+ ; as bug 506867 in the past, so we'll check for it ourselves also.
+ ClearErrors
+ ${GetOptions} "$R8" "/S" $R7
+ ${Unless} ${Errors}
+ SetSilent silent
+ ${Else}
+ ; NSIS dropped support for the deprecated -ms argument, but we don't
+ ; want to break backcompat, so we'll check for it here too.
+ ClearErrors
+ ${GetOptions} "$R8" "-ms" $R7
+ ${Unless} ${Errors}
+ SetSilent silent
+ ${EndUnless}
+ ${EndUnless}
+ ${EndUnless}
+
+ ; Support for specifying an installation configuration file.
+ ClearErrors
+ ${GetOptions} "$R8" "/INI=" $R7
+ ${Unless} ${Errors}
+ ; The configuration file must also exist
+ ${If} ${FileExists} "$R7"
+ ; Any valid command-line option triggers a silent installation.
+ SetSilent silent
+
+ ReadINIStr $R8 $R7 "Install" "InstallDirectoryName"
+ ${If} $R8 != ""
+ StrCpy $R4 1
+ !ifdef HAVE_64BIT_BUILD
+ StrCpy $INSTDIR "$PROGRAMFILES64\$R8"
+ !else
+ StrCpy $INSTDIR "$PROGRAMFILES32\$R8"
+ !endif
+ ${Else}
+ ReadINIStr $R8 $R7 "Install" "InstallDirectoryPath"
+ ${If} $R8 != ""
+ StrCpy $R4 1
+ StrCpy $INSTDIR "$R8"
+ ${EndIf}
+ ${EndIf}
+
+ ReadINIStr $R8 $R7 "Install" "DesktopShortcut"
+ ${If} $R8 == "false"
+ StrCpy $AddDesktopSC "0"
+ ${Else}
+ StrCpy $AddDesktopSC "1"
+ ${EndIf}
+
+ ReadINIStr $R8 $R7 "Install" "StartMenuShortcuts"
+ ${If} $R8 == "false"
+ StrCpy $AddStartMenuSC "0"
+ ${Else}
+ StrCpy $AddStartMenuSC "1"
+ ${EndIf}
+
+ ; We still accept the plural version for backwards compatibility,
+ ; but the singular version takes priority.
+ ClearErrors
+ ReadINIStr $R8 $R7 "Install" "StartMenuShortcut"
+ ${If} $R8 == "false"
+ StrCpy $AddStartMenuSC "0"
+ ${ElseIfNot} ${Errors}
+ StrCpy $AddStartMenuSC "1"
+ ${EndIf}
+
+ !ifdef MOZ_PRIVATE_BROWSING
+ ReadINIStr $R8 $R7 "Install" "PrivateBrowsingShortcut"
+ ${If} $R8 == "false"
+ StrCpy $AddPrivateBrowsingSC "0"
+ ${ElseIfNot} ${Errors}
+ StrCpy $AddPrivateBrowsingSC "1"
+ ${EndIf}
+ !endif
+
+ ReadINIStr $R8 $R7 "Install" "TaskbarShortcut"
+ ${If} $R8 == "false"
+ StrCpy $AddTaskbarSC "0"
+ ${Else}
+ StrCpy $AddTaskbarSC "1"
+ ${EndIf}
+
+ ReadINIStr $R8 $R7 "Install" "MaintenanceService"
+ ${If} $R8 == "false"
+ StrCpy $InstallMaintenanceService "0"
+ ${Else}
+ StrCpy $InstallMaintenanceService "1"
+ ${EndIf}
+
+ ReadINIStr $R8 $R7 "Install" "RegisterDefaultAgent"
+ ${If} $R8 == "false"
+ StrCpy $RegisterDefaultAgent "0"
+ ${Else}
+ StrCpy $RegisterDefaultAgent "1"
+ ${EndIf}
+
+ !ifdef MOZ_OPTIONAL_EXTENSIONS
+ ReadINIStr $R8 $R7 "Install" "OptionalExtensions"
+ ${If} $R8 == "false"
+ StrCpy $InstallOptionalExtensions "0"
+ ${Else}
+ StrCpy $InstallOptionalExtensions "1"
+ ${EndIf}
+ !endif
+
+ !ifndef NO_STARTMENU_DIR
+ ReadINIStr $R8 $R7 "Install" "StartMenuDirectoryName"
+ ${If} $R8 != ""
+ StrCpy $StartMenuDir "$R8"
+ ${EndIf}
+ !endif
+ ${EndIf}
+ ${EndUnless}
+
+ ; Check for individual command line parameters after evaluating the INI
+ ; file, because these should override the INI entires.
+ ${GetParameters} $R8
+ ${GetOptions} $R8 "/InstallDirectoryName=" $R7
+ ${If} $R7 != ""
+ StrCpy $R4 1
+ !ifdef HAVE_64BIT_BUILD
+ StrCpy $INSTDIR "$PROGRAMFILES64\$R7"
+ !else
+ StrCpy $INSTDIR "$PROGRAMFILES32\$R7"
+ !endif
+ ${Else}
+ ${GetOptions} $R8 "/InstallDirectoryPath=" $R7
+ ${If} $R7 != ""
+ StrCpy $R4 1
+ StrCpy $INSTDIR "$R7"
+ ${EndIf}
+ ${EndIf}
+
+ ${InstallGetOption} $R8 "DesktopShortcut" $AddDesktopSC
+ ${InstallGetOption} $R8 "StartMenuShortcuts" $AddStartMenuSC
+ ; We still accept the plural version for backwards compatibility,
+ ; but the singular version takes priority.
+ ${InstallGetOption} $R8 "StartMenuShortcut" $AddStartMenuSC
+ !ifdef MOZ_PRIVATE_BROWSING
+ ${InstallGetOption} $R8 "PrivateBrowsingShortcut" $AddPrivateBrowsingSC
+ !endif
+ ${InstallGetOption} $R8 "TaskbarShortcut" $AddTaskbarSC
+ ${InstallGetOption} $R8 "MaintenanceService" $InstallMaintenanceService
+ ${InstallGetOption} $R8 "RegisterDefaultAgent" $RegisterDefaultAgent
+ !ifdef MOZ_OPTIONAL_EXTENSIONS
+ ${InstallGetOption} $R8 "OptionalExtensions" $InstallOptionalExtensions
+ !endif
+
+ ; Installing the service always requires elevated privileges.
+ ${If} $InstallMaintenanceService == "1"
+ ${ElevateUAC}
+ ${EndIf}
+ ${EndIf}
+
+ ${If} $R4 == 1
+ ; Any valid command-line option triggers a silent installation.
+ SetSilent silent
+
+ ; Quit if we are unable to create the installation directory or we are
+ ; unable to write to a file in the installation directory.
+ ClearErrors
+ ${If} ${FileExists} "$INSTDIR"
+ GetTempFileName $R6 "$INSTDIR"
+ FileOpen $R5 "$R6" w
+ FileWrite $R5 "Write Access Test"
+ FileClose $R5
+ Delete $R6
+ ${If} ${Errors}
+ ; Attempt to elevate and then try again.
+ ${ElevateUAC}
+ GetTempFileName $R6 "$INSTDIR"
+ FileOpen $R5 "$R6" w
+ FileWrite $R5 "Write Access Test"
+ FileClose $R5
+ Delete $R6
+ ${If} ${Errors}
+ ; Nothing initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+ ${EndIf}
+ ${Else}
+ CreateDirectory "$INSTDIR"
+ ${If} ${Errors}
+ ; Attempt to elevate and then try again.
+ ${ElevateUAC}
+ CreateDirectory "$INSTDIR"
+ ${If} ${Errors}
+ ; Nothing initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${Else}
+ ; If we weren't given a custom path parameter, then try to elevate now.
+ ; We'll check the user's permission level later on to determine the
+ ; default install path (which will be the real install path for /S).
+ ; If an INI file is used, we try to elevate down that path when needed.
+ ${ElevateUAC}
+ ${EndIf}
+
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro InstallOnInitCommonCall _WARN_UNSUPPORTED_MSG
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_WARN_UNSUPPORTED_MSG}"
+ Call InstallOnInitCommon
+ !verbose pop
+!macroend
+
+/**
+ * Called from the uninstaller's .onInit function not to be confused with the
+ * installer's .onInit or the uninstaller's un.onInit functions.
+ */
+!macro UninstallOnInitCommon
+
+ !ifndef UninstallOnInitCommon
+ !insertmacro ElevateUAC
+ !insertmacro GetLongPath
+ !insertmacro GetOptions
+ !insertmacro GetParameters
+ !insertmacro GetParent
+ !insertmacro UnloadUAC
+ !insertmacro UpdateShortcutAppModelIDs
+ !insertmacro UpdateUninstallLog
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define UninstallOnInitCommon "!insertmacro UninstallOnInitCommonCall"
+
+ Function UninstallOnInitCommon
+ ; Prevents breaking apps that don't use SetBrandNameVars
+ !ifdef SetBrandNameVars
+ ${SetBrandNameVars} "$EXEDIR\distribution\setup.ini"
+ !endif
+
+ ; Prevent launching the application when a reboot is required and this
+ ; executable is the main application executable
+ IfFileExists "$EXEDIR\${FileMainEXE}.moz-upgrade" +1 +4
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION "$(WARN_RESTART_REQUIRED_UPGRADE)" IDNO +2
+ Reboot
+ Quit ; Nothing initialized so no need to call OnEndCommon
+
+ ${GetParent} "$EXEDIR" $INSTDIR
+ ${GetLongPath} "$INSTDIR" $INSTDIR
+ IfFileExists "$INSTDIR\${FileMainEXE}" +2 +1
+ Quit ; Nothing initialized so no need to call OnEndCommon
+
+!ifmacrodef InitHashAppModelId
+ ; setup the application model id registration value
+ !ifdef AppName
+ ${InitHashAppModelId} "$INSTDIR" "Software\Mozilla\${AppName}\TaskBarIDs"
+ !endif
+!endif
+
+ ; Prevents breaking apps that don't use SetBrandNameVars
+ !ifdef SetBrandNameVars
+ ${SetBrandNameVars} "$INSTDIR\distribution\setup.ini"
+ !endif
+
+ ; Application update uses a directory named tobedeleted in the $INSTDIR to
+ ; delete files on OS reboot when they are in use. Try to delete this
+ ; directory if it exists.
+ ${If} ${FileExists} "$INSTDIR\${TO_BE_DELETED}"
+ RmDir /r "$INSTDIR\${TO_BE_DELETED}"
+ ${EndIf}
+
+ ; Prevent all operations (e.g. set as default, postupdate, etc.) when a
+ ; reboot is required and the executable launched is helper.exe
+ IfFileExists "$INSTDIR\${FileMainEXE}.moz-upgrade" +1 +4
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION "$(WARN_RESTART_REQUIRED_UPGRADE)" IDNO +2
+ Reboot
+ Quit ; Nothing initialized so no need to call OnEndCommon
+
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 64
+ !endif
+
+ ${GetParameters} $R0
+
+ ${Unless} ${Silent}
+ ; Manually check for /S in the command line due to Bug 506867
+ ClearErrors
+ ${GetOptions} "$R0" "/S" $R2
+ ${Unless} ${Errors}
+ SetSilent silent
+ ${Else}
+ ; Support for the deprecated -ms command line argument.
+ ClearErrors
+ ${GetOptions} "$R0" "-ms" $R2
+ ${Unless} ${Errors}
+ SetSilent silent
+ ${EndUnless}
+ ${EndUnless}
+ ${EndUnless}
+
+ StrCmp "$R0" "" continue +1
+
+ ; Require elevation if the user can elevate
+ hideshortcuts:
+ ClearErrors
+ ${GetOptions} "$R0" "/HideShortcuts" $R2
+ IfErrors showshortcuts +1
+!ifndef NONADMIN_ELEVATE
+ ${ElevateUAC}
+!endif
+ ${HideShortcuts}
+ GoTo finish
+
+ ; Require elevation if the user can elevate
+ showshortcuts:
+ ClearErrors
+ ${GetOptions} "$R0" "/ShowShortcuts" $R2
+ IfErrors defaultappuser +1
+!ifndef NONADMIN_ELEVATE
+ ${ElevateUAC}
+!endif
+ ${ShowShortcuts}
+ GoTo finish
+
+ ; Require elevation if the the StartMenuInternet registry keys require
+ ; updating and the user can elevate
+ defaultappuser:
+ ClearErrors
+ ${GetOptions} "$R0" "/SetAsDefaultAppUser" $R2
+ IfErrors defaultappglobal +1
+ ${SetAsDefaultAppUser}
+ GoTo finish
+
+ ; Require elevation if the user can elevate
+ defaultappglobal:
+ ClearErrors
+ ${GetOptions} "$R0" "/SetAsDefaultAppGlobal" $R2
+ IfErrors postupdate +1
+ ${ElevateUAC}
+ ${SetAsDefaultAppGlobal}
+ GoTo finish
+
+ ; Do not attempt to elevate. The application launching this executable is
+ ; responsible for elevation if it is required.
+ postupdate:
+ ${WordReplace} "$R0" "$\"" "" "+" $R0
+ ClearErrors
+ ${GetOptions} "$R0" "/PostUpdate" $R2
+ IfErrors continue +1
+ ; If the uninstall.log does not exist don't perform post update
+ ; operations. This prevents updating the registry for zip builds.
+ IfFileExists "$EXEDIR\uninstall.log" +2 +1
+ Quit ; Nothing initialized so no need to call OnEndCommon
+ ${PostUpdate}
+ ClearErrors
+ ${GetOptions} "$R0" "/UninstallLog=" $R2
+ IfErrors updateuninstalllog +1
+ StrCmp "$R2" "" finish +1
+ GetFullPathName $R3 "$R2"
+ IfFileExists "$R3" +1 finish
+ Delete "$INSTDIR\uninstall\*wizard*"
+ Delete "$INSTDIR\uninstall\uninstall.log"
+ CopyFiles /SILENT /FILESONLY "$R3" "$INSTDIR\uninstall\"
+ ${GetParent} "$R3" $R4
+ Delete "$R3"
+ RmDir "$R4"
+ GoTo finish
+
+ ; Do not attempt to elevate. The application launching this executable is
+ ; responsible for elevation if it is required.
+ updateuninstalllog:
+ ${UpdateUninstallLog}
+
+ finish:
+ ${UnloadUAC}
+ ${RefreshShellIcons}
+ Quit ; Nothing initialized so no need to call OnEndCommon
+
+ continue:
+
+ ; If the uninstall.log does not exist don't perform uninstall
+ ; operations. This prevents running the uninstaller for zip builds.
+ IfFileExists "$INSTDIR\uninstall\uninstall.log" +2 +1
+ Quit ; Nothing initialized so no need to call OnEndCommon
+
+ ; When silent, try to avoid elevation if we have a chance to succeed. We
+ ; can succeed when we can write to (hence delete from) the install
+ ; directory and when we can clean up all registry entries. Now, the
+ ; installer when elevated writes privileged registry entries for the use
+ ; of the Maintenance Service, even when the service is not and will not be
+ ; installed. (In fact, even when a service installed in the future will
+ ; never update the installation, for example due to not being in a
+ ; privileged location.) In practice this means we can only truly silently
+ ; remove an unelevated install: an elevated installer writing to an
+ ; unprivileged install directory will still write privileged registry
+ ; entries, requiring an elevated uninstaller to completely clean up.
+ ;
+ ; This avoids a wrinkle, whereby an uninstaller which runs unelevated will
+ ; never itself launch the Maintenance Service uninstaller, because it will
+ ; fail to remove its own service registration (removing the relevant
+ ; registry key would require elevation). Therefore the check for the
+ ; service being unused will fail, which will prevent running the service
+ ; uninstaller. That's both subtle and possibly leaves the service
+ ; registration hanging around, which might be a security risk.
+ ;
+ ; That is why we look for a privileged service registration for this
+ ; installation when deciding to elevate, and elevate unconditionally if we
+ ; find one, regardless of the result of the write check that would avoid
+ ; elevation.
+
+ ; The reason for requiring elevation, or "" for not required.
+ StrCpy $R4 ""
+
+ ${IfNot} ${Silent}
+ ; In normal operation, require elevation if the user can elevate so that
+ ; we are most likely to succeed.
+ StrCpy $R4 "not silent"
+ ${EndIf}
+
+ GetTempFileName $R6 "$INSTDIR"
+ FileOpen $R5 "$R6" w
+ FileWrite $R5 "Write Access Test"
+ FileClose $R5
+ Delete $R6
+ ${If} ${Errors}
+ StrCpy $R4 "write"
+ ${EndIf}
+
+ !ifdef MOZ_MAINTENANCE_SERVICE
+ ; We don't necessarily have $MaintCertKey, so use temporary registers.
+ ServicesHelper::PathToUniqueRegistryPath "$INSTDIR"
+ Pop $R5
+
+ ${If} $R5 != ""
+ ; Always use the 64bit registry for certs on 64bit systems.
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${EndIf}
+
+ EnumRegKey $R6 HKLM $R5 0
+ ClearErrors
+
+ ${If} ${RunningX64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView lastused
+ ${EndIf}
+
+ ${IfNot} "$R6" == ""
+ StrCpy $R4 "mms"
+ ${EndIf}
+ ${EndIf}
+ !endif
+
+ ${If} "$R4" != ""
+ ; In the future, we might not try to elevate to remain truly silent. Or
+ ; we might add a command line arguments to specify behaviour. One
+ ; reason to not do that immediately is that we have no great way to
+ ; signal that we exited without taking action.
+ ${ElevateUAC}
+ ${EndIf}
+
+ ; Now we've elevated, try the write access test again.
+ ClearErrors
+ GetTempFileName $R6 "$INSTDIR"
+ FileOpen $R5 "$R6" w
+ FileWrite $R5 "Write Access Test"
+ FileClose $R5
+ Delete $R6
+ ${If} ${Errors}
+ ; Nothing initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+
+ !ifdef MOZ_MAINTENANCE_SERVICE
+ ; And verify that if we need to, we're going to clean up the registry
+ ; correctly.
+ ${If} "$R4" == "mms"
+ WriteRegStr HKLM "Software\Mozilla" "${BrandShortName}InstallerTest" "Write Test"
+ ${If} ${Errors}
+ ; Nothing initialized so no need to call OnEndCommon
+ Quit
+ ${Endif}
+ DeleteRegValue HKLM "Software\Mozilla" "${BrandShortName}InstallerTest"
+ ${EndIf}
+ !endif
+
+ ; If we made it this far then this installer is being used as an uninstaller.
+ WriteUninstaller "$EXEDIR\uninstaller.exe"
+
+ ${If} ${Silent}
+ StrCpy $R1 "$\"$EXEDIR\uninstaller.exe$\" /S"
+ ${Else}
+ StrCpy $R1 "$\"$EXEDIR\uninstaller.exe$\""
+ ${EndIf}
+
+ ; When the uninstaller is launched it copies itself to the temp directory
+ ; so it won't be in use so it can delete itself.
+ ExecWait $R1
+ ${DeleteFile} "$EXEDIR\uninstaller.exe"
+ ${UnloadUAC}
+ SetErrorLevel 0
+ Quit ; Nothing initialized so no need to call OnEndCommon
+
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro UninstallOnInitCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call UninstallOnInitCommon
+ !verbose pop
+!macroend
+
+/**
+ * Called from the uninstaller's un.onInit function not to be confused with the
+ * installer's .onInit or the uninstaller's .onInit functions.
+ */
+!macro un.UninstallUnOnInitCommon
+
+ !ifndef un.UninstallUnOnInitCommon
+ !insertmacro un.GetLongPath
+ !insertmacro un.GetParent
+ !insertmacro un.SetBrandNameVars
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define un.UninstallUnOnInitCommon "!insertmacro un.UninstallUnOnInitCommonCall"
+
+ Function un.UninstallUnOnInitCommon
+ ${un.GetParent} "$INSTDIR" $INSTDIR
+ ${un.GetLongPath} "$INSTDIR" $INSTDIR
+ ${Unless} ${FileExists} "$INSTDIR\${FileMainEXE}"
+ Abort
+ ${EndUnless}
+
+ !ifdef HAVE_64BIT_BUILD
+ SetRegView 64
+ !endif
+
+ ; Prevents breaking apps that don't use SetBrandNameVars
+ !ifdef un.SetBrandNameVars
+ ${un.SetBrandNameVars} "$INSTDIR\distribution\setup.ini"
+ !endif
+
+ ; Initialize $hHeaderBitmap to prevent redundant changing of the bitmap if
+ ; the user clicks the back button
+ StrCpy $hHeaderBitmap ""
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro un.UninstallUnOnInitCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.UninstallUnOnInitCommon
+ !verbose pop
+!macroend
+
+/**
+ * Called from the MUI leaveOptions function to set the value of $INSTDIR.
+ */
+!macro LeaveOptionsCommon
+
+ !ifndef LeaveOptionsCommon
+ !insertmacro CanWriteToInstallDir
+ !insertmacro GetLongPath
+
+!ifndef NO_INSTDIR_FROM_REG
+ !insertmacro GetSingleInstallPath
+!endif
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LeaveOptionsCommon "!insertmacro LeaveOptionsCommonCall"
+
+ Function LeaveOptionsCommon
+ Push $R9
+
+ StrCpy $R9 "false"
+
+!ifndef NO_INSTDIR_FROM_REG
+ SetShellVarContext all ; Set SHCTX to HKLM
+
+ ${If} ${IsNativeAMD64}
+ ${OrIf} ${IsNativeARM64}
+ SetRegView 64
+ ${GetSingleInstallPath} "Software\Mozilla\${BrandFullNameInternal}" $R9
+ SetRegView lastused
+ ${EndIf}
+
+ StrCmp "$R9" "false" +1 finish_get_install_dir
+
+ SetRegView 32
+ ${GetSingleInstallPath} "Software\Mozilla\${BrandFullNameInternal}" $R9
+ SetRegView lastused
+
+ StrCmp "$R9" "false" +1 finish_get_install_dir
+
+ SetShellVarContext current ; Set SHCTX to HKCU
+ ${GetSingleInstallPath} "Software\Mozilla\${BrandFullNameInternal}" $R9
+
+ finish_get_install_dir:
+ StrCmp "$R9" "false" +2 +1
+ StrCpy $INSTDIR "$R9"
+!endif
+
+ ; If the user doesn't have write access to the installation directory set
+ ; the installation directory to a subdirectory of the user's local
+ ; application directory (e.g. non-roaming).
+ ${CanWriteToInstallDir} $R9
+ ${If} "$R9" == "false"
+ ; NOTE: This SetShellVarContext isn't directly needed anymore, but to
+ ; leave the state consistent with earlier code I'm leaving it here.
+ SetShellVarContext all ; Set SHCTX to All Users
+ ${GetLocalAppDataFolder} $R9
+ StrCpy $INSTDIR "$R9\${BrandFullName}\"
+ ${CanWriteToInstallDir} $R9
+ ${EndIf}
+
+ IfFileExists "$INSTDIR" +3 +1
+ Pop $R9
+ Return
+
+ ; Always display the long path if the path already exists.
+ ${GetLongPath} "$INSTDIR" $INSTDIR
+
+ ; The call to GetLongPath returns a long path without a trailing
+ ; back-slash. Append a \ to the path to prevent the directory
+ ; name from being appended when using the NSIS create new folder.
+ ; http://www.nullsoft.com/free/nsis/makensis.htm#InstallDir
+ StrCpy $INSTDIR "$INSTDIR\"
+
+ Pop $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LeaveOptionsCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call LeaveOptionsCommon
+ !verbose pop
+!macroend
+
+/**
+ * Called from the MUI preDirectory function to verify there is enough disk
+ * space for the installation and the installation directory is writable.
+ *
+ * $R9 = returned value from CheckDiskSpace and CanWriteToInstallDir macros
+ */
+!macro PreDirectoryCommon
+
+ !ifndef PreDirectoryCommon
+ !insertmacro CanWriteToInstallDir
+ !insertmacro CheckDiskSpace
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define PreDirectoryCommon "!insertmacro PreDirectoryCommonCall"
+
+ Function PreDirectoryCommon
+ Push $R9
+
+ IntCmp $InstallType ${INSTALLTYPE_CUSTOM} end +1 +1
+ ${CanWriteToInstallDir} $R9
+ StrCmp "$R9" "false" end +1
+ ${CheckDiskSpace} $R9
+ StrCmp "$R9" "false" end +1
+ Abort
+
+ end:
+
+ Pop $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro PreDirectoryCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call PreDirectoryCommon
+ !verbose pop
+!macroend
+
+/**
+ * Called from the MUI leaveDirectory function
+ *
+ * @param _WARN_DISK_SPACE
+ * Message displayed when there isn't enough disk space to perform the
+ * installation.
+ * @param _WARN_WRITE_ACCESS
+ * Message displayed when the installer does not have write access to
+ * $INSTDIR.
+ *
+ * $R7 = returned value from CheckDiskSpace and CanWriteToInstallDir macros
+ * $R8 = _WARN_DISK_SPACE
+ * $R9 = _WARN_WRITE_ACCESS
+ */
+!macro LeaveDirectoryCommon
+
+ !ifndef LeaveDirectoryCommon
+ !insertmacro CheckDiskSpace
+ !insertmacro CanWriteToInstallDir
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LeaveDirectoryCommon "!insertmacro LeaveDirectoryCommonCall"
+
+ Function LeaveDirectoryCommon
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Push $R7
+
+ ${CanWriteToInstallDir} $R7
+ ${If} $R7 == "false"
+ MessageBox MB_OK|MB_ICONEXCLAMATION "$R9"
+ Abort
+ ${EndIf}
+
+ ${CheckDiskSpace} $R7
+ ${If} $R7 == "false"
+ MessageBox MB_OK|MB_ICONEXCLAMATION "$R8"
+ Abort
+ ${EndIf}
+
+ Pop $R7
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LeaveDirectoryCommonCall _WARN_DISK_SPACE _WARN_WRITE_ACCESS
+ !verbose push
+ Push "${_WARN_DISK_SPACE}"
+ Push "${_WARN_WRITE_ACCESS}"
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call LeaveDirectoryCommon
+ !verbose pop
+!macroend
+
+
+################################################################################
+# Install Section common macros.
+
+/**
+ * Performs common cleanup operations prior to the actual installation.
+ * This macro should be called first when installation starts.
+ */
+!macro InstallStartCleanupCommon
+
+ !ifndef InstallStartCleanupCommon
+ !insertmacro CleanVirtualStore
+ !insertmacro EndUninstallLog
+ !insertmacro OnInstallUninstall
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define InstallStartCleanupCommon "!insertmacro InstallStartCleanupCommonCall"
+
+ Function InstallStartCleanupCommon
+
+ ; Remove files not removed by parsing the uninstall.log
+ Delete "$INSTDIR\install_wizard.log"
+ Delete "$INSTDIR\install_status.log"
+
+ RmDir /r "$INSTDIR\updates"
+ Delete "$INSTDIR\updates.xml"
+ Delete "$INSTDIR\active-update.xml"
+
+ ; Remove files from the uninstall directory.
+ ${If} ${FileExists} "$INSTDIR\uninstall"
+ Delete "$INSTDIR\uninstall\*wizard*"
+ Delete "$INSTDIR\uninstall\uninstall.ini"
+ Delete "$INSTDIR\uninstall\cleanup.log"
+ Delete "$INSTDIR\uninstall\uninstall.update"
+ ${OnInstallUninstall}
+ ${EndIf}
+
+ ; Since we write to the uninstall.log in this directory during the
+ ; installation create the directory if it doesn't already exist.
+ IfFileExists "$INSTDIR\uninstall" +2 +1
+ CreateDirectory "$INSTDIR\uninstall"
+
+ ; Application update uses a directory named tobedeleted in the $INSTDIR to
+ ; delete files on OS reboot when they are in use. Try to delete this
+ ; directory if it exists.
+ ${If} ${FileExists} "$INSTDIR\${TO_BE_DELETED}"
+ RmDir /r "$INSTDIR\${TO_BE_DELETED}"
+ ${EndIf}
+
+ ; Remove files that may be left behind by the application in the
+ ; VirtualStore directory.
+ ${CleanVirtualStore}
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro InstallStartCleanupCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call InstallStartCleanupCommon
+ !verbose pop
+!macroend
+
+/**
+ * Performs common cleanup operations after the actual installation.
+ * This macro should be called last during the installation.
+ */
+!macro InstallEndCleanupCommon
+
+ !ifndef InstallEndCleanupCommon
+ !insertmacro EndUninstallLog
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define InstallEndCleanupCommon "!insertmacro InstallEndCleanupCommonCall"
+
+ Function InstallEndCleanupCommon
+
+ ; Close the file handle to the uninstall.log
+ ${EndUninstallLog}
+
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro InstallEndCleanupCommonCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call InstallEndCleanupCommon
+ !verbose pop
+!macroend
+
+
+################################################################################
+# UAC Related Macros
+
+/**
+ * Provides UAC elevation support (requires the UAC plugin).
+ *
+ * $0 = return values from calls to the UAC plugin (always uses $0)
+ * $R9 = return values from GetParameters and GetOptions macros
+ */
+!macro ElevateUAC
+
+ !ifndef ${_MOZFUNC_UN}ElevateUAC
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetOptions
+ !insertmacro ${_MOZFUNC_UN_TMP}GetParameters
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}ElevateUAC "!insertmacro ${_MOZFUNC_UN}ElevateUACCall"
+
+ Function ${_MOZFUNC_UN}ElevateUAC
+ Push $R9
+ Push $0
+
+!ifndef NONADMIN_ELEVATE
+ UAC::IsAdmin
+ ; If the user is not an admin already
+ ${If} "$0" != "1"
+ UAC::SupportsUAC
+ ; If the system supports UAC
+ ${If} "$0" == "1"
+ UAC::GetElevationType
+ ; If the user account has a split token
+ ${If} "$0" == "3"
+ UAC::RunElevated
+ UAC::Unload
+ ; Nothing besides UAC initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+ ${EndIf}
+ ${Else}
+ ${GetParameters} $R9
+ ${If} $R9 != ""
+ ClearErrors
+ ${GetOptions} "$R9" "/UAC:" $0
+ ; If the command line contains /UAC then we need to initialize
+ ; the UAC plugin to use UAC::ExecCodeSegment to execute code in
+ ; the non-elevated context.
+ ${Unless} ${Errors}
+ UAC::RunElevated
+ ${EndUnless}
+ ${EndIf}
+ ${EndIf}
+!else
+ UAC::IsAdmin
+ ; If the user is not an admin already
+ ${If} "$0" != "1"
+ UAC::SupportsUAC
+ ; If the system supports UAC require that the user elevate
+ ${If} "$0" == "1"
+ UAC::GetElevationType
+ ; If the user account has a split token
+ ${If} "$0" == "3"
+ UAC::RunElevated
+ ${If} "$0" == "0" ; Was elevation successful
+ UAC::Unload
+ ; Nothing besides UAC initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+ ; Unload UAC since the elevation request was not successful and
+ ; install anyway.
+ UAC::Unload
+ ${EndIf}
+ ${Else}
+ ; Check if UAC is enabled. If the user has turned UAC on or off
+ ; without rebooting this value will be incorrect. This is an
+ ; edgecase that we have to live with when trying to allow
+ ; installing when the user doesn't have privileges such as a public
+ ; computer while trying to also achieve UAC elevation. When this
+ ; happens the user will be presented with the runas dialog if the
+ ; value is 1 and won't be presented with the UAC dialog when the
+ ; value is 0.
+ ReadRegDWord $R9 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" "EnableLUA"
+ ${If} "$R9" == "1"
+ ; This will display the UAC version of the runas dialog which
+ ; requires a password for an existing user account.
+ UAC::RunElevated
+ ${If} "$0" == "0" ; Was elevation successful
+ UAC::Unload
+ ; Nothing besides UAC initialized so no need to call OnEndCommon
+ Quit
+ ${EndIf}
+ ; Unload UAC since the elevation request was not successful and
+ ; install anyway.
+ UAC::Unload
+ ${EndIf}
+ ${EndIf}
+ ${Else}
+ ClearErrors
+ ${${_MOZFUNC_UN}GetParameters} $R9
+ ${${_MOZFUNC_UN}GetOptions} "$R9" "/UAC:" $R9
+ ; If the command line contains /UAC then we need to initialize the UAC
+ ; plugin to use UAC::ExecCodeSegment to execute code in the
+ ; non-elevated context.
+ ${Unless} ${Errors}
+ UAC::RunElevated
+ ${EndUnless}
+ ${EndIf}
+!endif
+
+ ClearErrors
+
+ Pop $0
+ Pop $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro ElevateUACCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call ElevateUAC
+ !verbose pop
+!macroend
+
+!macro un.ElevateUACCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.ElevateUAC
+ !verbose pop
+!macroend
+
+!macro un.ElevateUAC
+ !ifndef un.ElevateUAC
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro ElevateUAC
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Unloads the UAC plugin so the NSIS plugins can be removed when the installer
+ * and uninstaller exit.
+ *
+ * $R9 = return values from GetParameters and GetOptions macros
+ */
+!macro UnloadUAC
+
+ !ifndef ${_MOZFUNC_UN}UnloadUAC
+ !define _MOZFUNC_UN_TMP_UnloadUAC ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP_UnloadUAC}GetOptions
+ !insertmacro ${_MOZFUNC_UN_TMP_UnloadUAC}GetParameters
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP_UnloadUAC}
+ !undef _MOZFUNC_UN_TMP_UnloadUAC
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}UnloadUAC "!insertmacro ${_MOZFUNC_UN}UnloadUACCall"
+
+ Function ${_MOZFUNC_UN}UnloadUAC
+ Push $R9
+
+ ClearErrors
+ ${${_MOZFUNC_UN}GetParameters} $R9
+ ${${_MOZFUNC_UN}GetOptions} "$R9" "/UAC:" $R9
+ ; If the command line contains /UAC then we need to unload the UAC plugin
+ IfErrors +2 +1
+ UAC::Unload
+
+ ClearErrors
+
+ Pop $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro UnloadUACCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call UnloadUAC
+ !verbose pop
+!macroend
+
+!macro un.UnloadUACCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.UnloadUAC
+ !verbose pop
+!macroend
+
+!macro un.UnloadUAC
+ !ifndef un.UnloadUAC
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro UnloadUAC
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+
+################################################################################
+# Macros for uninstall.log and install.log logging
+#
+# Since these are used by other macros they should be inserted first. All of
+# these macros can be easily inserted using the _LoggingCommon macro.
+
+/**
+ * Adds all logging macros in the correct order in one fell swoop as well as
+ * the vars for the install.log and uninstall.log file handles.
+ */
+!macro _LoggingCommon
+ Var /GLOBAL fhInstallLog
+ Var /GLOBAL fhUninstallLog
+ !insertmacro StartInstallLog
+ !insertmacro EndInstallLog
+ !insertmacro StartUninstallLog
+ !insertmacro EndUninstallLog
+!macroend
+!define _LoggingCommon "!insertmacro _LoggingCommon"
+
+/**
+ * Creates a file named install.log in the install directory (e.g. $INSTDIR)
+ * and adds the installation started message to the install.log for this
+ * installation. This also adds the fhInstallLog and fhUninstallLog vars used
+ * for logging.
+ *
+ * $fhInstallLog = filehandle for $INSTDIR\install.log
+ *
+ * @param _APP_NAME
+ * Typically the BrandFullName
+ * @param _AB_CD
+ * The locale identifier
+ * @param _APP_VERSION
+ * The application version
+ * @param _GRE_VERSION
+ * The Gecko Runtime Engine version
+ *
+ * $R6 = _APP_NAME
+ * $R7 = _AB_CD
+ * $R8 = _APP_VERSION
+ * $R9 = _GRE_VERSION
+ */
+!macro StartInstallLog
+
+ !ifndef StartInstallLog
+ !insertmacro GetTime
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define StartInstallLog "!insertmacro StartInstallLogCall"
+
+ Function StartInstallLog
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Exch 2
+ Exch $R7
+ Exch 3
+ Exch $R6
+ Push $R5
+ Push $R4
+ Push $R3
+ Push $R2
+ Push $R1
+ Push $R0
+ Push $9
+
+ ${DeleteFile} "$INSTDIR\install.log"
+ FileOpen $fhInstallLog "$INSTDIR\install.log" w
+ FileWriteWord $fhInstallLog "65279"
+
+ ${GetTime} "" "L" $9 $R0 $R1 $R2 $R3 $R4 $R5
+ FileWriteUTF16LE $fhInstallLog "$R6 Installation Started: $R1-$R0-$9 $R3:$R4:$R5"
+ ${WriteLogSeparator}
+
+ ${LogHeader} "Installation Details"
+ ${LogMsg} "Install Dir: $INSTDIR"
+ ${LogMsg} "Locale : $R7"
+ ${LogMsg} "App Version: $R8"
+ ${LogMsg} "GRE Version: $R9"
+
+ ${If} ${IsWin10}
+ ${LogMsg} "OS Name : Windows 10"
+ ${ElseIf} ${AtLeastWin10}
+ ${LogMsg} "OS Name : Above Windows 10"
+ ${Else}
+ ${LogMsg} "OS Name : Unable to detect"
+ ${EndIf}
+
+ ${LogMsg} "Target CPU : ${ARCH}"
+
+ Pop $9
+ Pop $R0
+ Pop $R1
+ Pop $R2
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Exch $R6
+ Exch 3
+ Exch $R7
+ Exch 2
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro StartInstallLogCall _APP_NAME _AB_CD _APP_VERSION _GRE_VERSION
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_APP_NAME}"
+ Push "${_AB_CD}"
+ Push "${_APP_VERSION}"
+ Push "${_GRE_VERSION}"
+ Call StartInstallLog
+ !verbose pop
+!macroend
+
+/**
+ * Writes the installation finished message to the install.log and closes the
+ * file handles to the install.log and uninstall.log
+ *
+ * @param _APP_NAME
+ *
+ * $R9 = _APP_NAME
+ */
+!macro EndInstallLog
+
+ !ifndef EndInstallLog
+ !insertmacro GetTime
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define EndInstallLog "!insertmacro EndInstallLogCall"
+
+ Function EndInstallLog
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3
+ Push $R2
+
+ ${WriteLogSeparator}
+ ${GetTime} "" "L" $R2 $R3 $R4 $R5 $R6 $R7 $R8
+ FileWriteUTF16LE $fhInstallLog "$R9 Installation Finished: $R4-$R3-$R2 $R6:$R7:$R8$\r$\n"
+ FileClose $fhInstallLog
+
+ Pop $R2
+ Pop $R3
+ Pop $R4
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro EndInstallLogCall _APP_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_APP_NAME}"
+ Call EndInstallLog
+ !verbose pop
+!macroend
+
+/**
+ * Opens the file handle to the uninstall.log.
+ *
+ * $fhUninstallLog = filehandle for $INSTDIR\uninstall\uninstall.log
+ */
+!macro StartUninstallLog
+
+ !ifndef StartUninstallLog
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define StartUninstallLog "!insertmacro StartUninstallLogCall"
+
+ Function StartUninstallLog
+ FileOpen $fhUninstallLog "$INSTDIR\uninstall\uninstall.log" w
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro StartUninstallLogCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call StartUninstallLog
+ !verbose pop
+!macroend
+
+/**
+ * Closes the file handle to the uninstall.log.
+ */
+!macro EndUninstallLog
+
+ !ifndef EndUninstallLog
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define EndUninstallLog "!insertmacro EndUninstallLogCall"
+
+ Function EndUninstallLog
+ FileClose $fhUninstallLog
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro EndUninstallLogCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call EndUninstallLog
+ !verbose pop
+!macroend
+
+/**
+ * Adds a section header to the human readable log.
+ *
+ * @param _HEADER
+ * The header text to write to the log.
+ */
+!macro LogHeader _HEADER
+ ${WriteLogSeparator}
+ FileWriteUTF16LE $fhInstallLog "${_HEADER}"
+ ${WriteLogSeparator}
+!macroend
+!define LogHeader "!insertmacro LogHeader"
+
+/**
+ * Adds a section message to the human readable log.
+ *
+ * @param _MSG
+ * The message text to write to the log.
+ */
+!macro LogMsg _MSG
+ FileWriteUTF16LE $fhInstallLog " ${_MSG}$\r$\n"
+!macroend
+!define LogMsg "!insertmacro LogMsg"
+
+/**
+ * Adds an uninstall entry to the uninstall log.
+ *
+ * @param _MSG
+ * The message text to write to the log.
+ */
+!macro LogUninstall _MSG
+ FileWrite $fhUninstallLog "${_MSG}$\r$\n"
+!macroend
+!define LogUninstall "!insertmacro LogUninstall"
+
+/**
+ * Adds a section divider to the human readable log.
+ */
+!macro WriteLogSeparator
+ FileWriteUTF16LE $fhInstallLog "$\r$\n----------------------------------------\
+ ---------------------------------------$\r$\n"
+!macroend
+!define WriteLogSeparator "!insertmacro WriteLogSeparator"
+
+
+################################################################################
+# Macros for managing the shortcuts log ini file
+
+/**
+ * Adds the most commonly used shortcut logging macros for the installer in one
+ * fell swoop.
+ */
+!macro _LoggingShortcutsCommon
+ !insertmacro LogDesktopShortcut
+ !insertmacro LogQuickLaunchShortcut
+ !insertmacro LogSMProgramsShortcut
+!macroend
+!define _LoggingShortcutsCommon "!insertmacro _LoggingShortcutsCommon"
+
+/**
+ * Creates the shortcuts log ini file with a UTF-16LE BOM if it doesn't exist.
+ */
+!macro initShortcutsLog
+ Push $R9
+
+ IfFileExists "$INSTDIR\uninstall\${SHORTCUTS_LOG}" +4 +1
+ FileOpen $R9 "$INSTDIR\uninstall\${SHORTCUTS_LOG}" w
+ FileWriteWord $R9 "65279"
+ FileClose $R9
+
+ Pop $R9
+!macroend
+!define initShortcutsLog "!insertmacro initShortcutsLog"
+
+/**
+ * Adds shortcut entries to the shortcuts log ini file. This macro is primarily
+ * a helper used by the LogDesktopShortcut, LogQuickLaunchShortcut, and
+ * LogSMProgramsShortcut macros but it can be used by other code if desired. If
+ * the value already exists the the value is not written to the file.
+ *
+ * @param _SECTION_NAME
+ * The section name to write to in the shortcut log ini file
+ * @param _FILE_NAME
+ * The shortcut's file name
+ *
+ * $R6 = return value from ReadIniStr for the shortcut file name
+ * $R7 = counter for supporting multiple shortcuts in the same location
+ * $R8 = _SECTION_NAME
+ * $R9 = _FILE_NAME
+ */
+!macro LogShortcut
+
+ !ifndef LogShortcut
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LogShortcut "!insertmacro LogShortcutCall"
+
+ Function LogShortcut
+ Exch $R9
+ Exch 1
+ Exch $R8
+ Push $R7
+ Push $R6
+
+ ClearErrors
+
+ !insertmacro initShortcutsLog
+
+ StrCpy $R6 ""
+ StrCpy $R7 -1
+
+ StrCmp "$R6" "$R9" +5 +1 ; if the shortcut already exists don't add it
+ IntOp $R7 $R7 + 1 ; increment the counter
+ ReadIniStr $R6 "$INSTDIR\uninstall\${SHORTCUTS_LOG}" "$R8" "Shortcut$R7"
+ IfErrors +1 -3
+ WriteINIStr "$INSTDIR\uninstall\${SHORTCUTS_LOG}" "$R8" "Shortcut$R7" "$R9"
+
+ ClearErrors
+
+ Pop $R6
+ Pop $R7
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LogShortcutCall _SECTION_NAME _FILE_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_SECTION_NAME}"
+ Push "${_FILE_NAME}"
+ Call LogShortcut
+ !verbose pop
+!macroend
+
+/**
+ * Adds a Desktop shortcut entry to the shortcuts log ini file.
+ *
+ * @param _FILE_NAME
+ * The shortcut file name (e.g. shortcut.lnk)
+ */
+!macro LogDesktopShortcut
+
+ !ifndef LogDesktopShortcut
+ !insertmacro LogShortcut
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LogDesktopShortcut "!insertmacro LogDesktopShortcutCall"
+
+ Function LogDesktopShortcut
+ Call LogShortcut
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LogDesktopShortcutCall _FILE_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "DESKTOP"
+ Push "${_FILE_NAME}"
+ Call LogDesktopShortcut
+ !verbose pop
+!macroend
+
+/**
+ * Adds a QuickLaunch shortcut entry to the shortcuts log ini file.
+ *
+ * @param _FILE_NAME
+ * The shortcut file name (e.g. shortcut.lnk)
+ */
+!macro LogQuickLaunchShortcut
+
+ !ifndef LogQuickLaunchShortcut
+ !insertmacro LogShortcut
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LogQuickLaunchShortcut "!insertmacro LogQuickLaunchShortcutCall"
+
+ Function LogQuickLaunchShortcut
+ Call LogShortcut
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LogQuickLaunchShortcutCall _FILE_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "QUICKLAUNCH"
+ Push "${_FILE_NAME}"
+ Call LogQuickLaunchShortcut
+ !verbose pop
+!macroend
+
+/**
+ * Adds a Start Menu shortcut entry to the shortcuts log ini file.
+ *
+ * @param _FILE_NAME
+ * The shortcut file name (e.g. shortcut.lnk)
+ */
+!macro LogStartMenuShortcut
+
+ !ifndef LogStartMenuShortcut
+ !insertmacro LogShortcut
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LogStartMenuShortcut "!insertmacro LogStartMenuShortcutCall"
+
+ Function LogStartMenuShortcut
+ Call LogShortcut
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LogStartMenuShortcutCall _FILE_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "STARTMENU"
+ Push "${_FILE_NAME}"
+ Call LogStartMenuShortcut
+ !verbose pop
+!macroend
+
+/**
+ * Adds a Start Menu Programs shortcut entry to the shortcuts log ini file.
+ *
+ * @param _FILE_NAME
+ * The shortcut file name (e.g. shortcut.lnk)
+ */
+!macro LogSMProgramsShortcut
+
+ !ifndef LogSMProgramsShortcut
+ !insertmacro LogShortcut
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define LogSMProgramsShortcut "!insertmacro LogSMProgramsShortcutCall"
+
+ Function LogSMProgramsShortcut
+ Call LogShortcut
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro LogSMProgramsShortcutCall _FILE_NAME
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "SMPROGRAMS"
+ Push "${_FILE_NAME}"
+ Call LogSMProgramsShortcut
+ !verbose pop
+!macroend
+
+/**
+ * Adds the relative path from the Start Menu Programs directory for the
+ * application's Start Menu directory if it is different from the existing value
+ * to the shortcuts log ini file.
+ *
+ * @param _REL_PATH_TO_DIR
+ * The relative path from the Start Menu Programs directory to the
+ * program's directory.
+ *
+ * $R9 = _REL_PATH_TO_DIR
+ */
+!macro LogSMProgramsDirRelPath _REL_PATH_TO_DIR
+ Push $R9
+
+ !insertmacro initShortcutsLog
+
+ ReadINIStr $R9 "$INSTDIR\uninstall\${SHORTCUTS_LOG}" "SMPROGRAMS" "RelativePathToDir"
+ StrCmp "$R9" "${_REL_PATH_TO_DIR}" +2 +1
+ WriteINIStr "$INSTDIR\uninstall\${SHORTCUTS_LOG}" "SMPROGRAMS" "RelativePathToDir" "${_REL_PATH_TO_DIR}"
+
+ Pop $R9
+!macroend
+!define LogSMProgramsDirRelPath "!insertmacro LogSMProgramsDirRelPath"
+
+/**
+ * Copies the value for the relative path from the Start Menu programs directory
+ * (e.g. $SMPROGRAMS) to the Start Menu directory as it is stored in the
+ * shortcuts log ini file to the variable specified in the first parameter.
+ */
+!macro GetSMProgramsDirRelPath _VAR
+ ReadINIStr ${_VAR} "$INSTDIR\uninstall\${SHORTCUTS_LOG}" "SMPROGRAMS" \
+ "RelativePathToDir"
+!macroend
+!define GetSMProgramsDirRelPath "!insertmacro GetSMProgramsDirRelPath"
+
+/**
+ * Copies the shortcuts log ini file path to the variable specified in the
+ * first parameter.
+ */
+!macro GetShortcutsLogPath _VAR
+ StrCpy ${_VAR} "$INSTDIR\uninstall\${SHORTCUTS_LOG}"
+!macroend
+!define GetShortcutsLogPath "!insertmacro GetShortcutsLogPath"
+
+
+################################################################################
+# Macros for managing specific Windows version features
+
+/**
+ * Sets the permitted layered service provider (LSP) categories
+ * for the application. Consumers should call this after an
+ * installation log section has completed since this macro will log the results
+ * to the installation log along with a header.
+ *
+ * !IMPORTANT - When calling this macro from an uninstaller do not specify a
+ * parameter. The paramter is hardcoded with 0x00000000 to remove
+ * the LSP category for the application when performing an
+ * uninstall.
+ *
+ * @param _LSP_CATEGORIES
+ * The permitted LSP categories for the application. When called by an
+ * uninstaller this will always be 0x00000000.
+ *
+ * $R5 = error code popped from the stack for the WSCSetApplicationCategory call
+ * $R6 = return value from the WSCSetApplicationCategory call
+ * $R7 = string length for the long path to the main application executable
+ * $R8 = long path to the main application executable
+ * $R9 = _LSP_CATEGORIES
+ */
+!macro SetAppLSPCategories
+
+ !ifndef ${_MOZFUNC_UN}SetAppLSPCategories
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}SetAppLSPCategories "!insertmacro ${_MOZFUNC_UN}SetAppLSPCategoriesCall"
+
+ Function ${_MOZFUNC_UN}SetAppLSPCategories
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR\${FileMainEXE}" $R8
+ StrLen $R7 "$R8"
+
+ ; Remove existing categories by setting the permitted categories to
+ ; 0x00000000 since new categories are ANDed with existing categories. If
+ ; the param value stored in $R9 is 0x00000000 then skip the removal since
+ ; the categories will be removed by the second call to
+ ; WSCSetApplicationCategory.
+ StrCmp "$R9" "0x00000000" +2 +1
+ System::Call "Ws2_32::WSCSetApplicationCategory(w R8, i R7, w n, i 0,\
+ i 0x00000000, i n, *i) i"
+
+ ; Set the permitted LSP categories
+ System::Call "Ws2_32::WSCSetApplicationCategory(w R8, i R7, w n, i 0,\
+ i R9, i n, *i .s) i.R6"
+ Pop $R5
+
+!ifndef NO_LOG
+ ${LogHeader} "Setting Permitted LSP Categories"
+ StrCmp "$R6" 0 +3 +1
+ ${LogMsg} "** ERROR Setting LSP Categories: $R5 **"
+ GoTo +2
+ ${LogMsg} "Permitted LSP Categories: $R9"
+!endif
+
+ ClearErrors
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro SetAppLSPCategoriesCall _LSP_CATEGORIES
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_LSP_CATEGORIES}"
+ Call SetAppLSPCategories
+ !verbose pop
+!macroend
+
+!macro un.SetAppLSPCategoriesCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "0x00000000"
+ Call un.SetAppLSPCategories
+ !verbose pop
+!macroend
+
+!macro un.SetAppLSPCategories
+ !ifndef un.SetAppLSPCategories
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro SetAppLSPCategories
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Checks if any pinned TaskBar lnk files point to the executable's path passed
+ * to the macro.
+ *
+ * @param _EXE_PATH
+ * The executable path
+ * @return _RESULT
+ * false if no pinned shotcuts were found for this install location.
+ * true if pinned shotcuts were found for this install location.
+ *
+ * $R5 = stores whether a TaskBar lnk file has been found for the executable
+ * $R6 = long path returned from GetShortCutTarget and GetLongPath
+ * $R7 = file name returned from FindFirst and FindNext
+ * $R8 = find handle for FindFirst and FindNext
+ * $R9 = _EXE_PATH and _RESULT
+ */
+!macro IsPinnedToTaskBar
+
+ !ifndef IsPinnedToTaskBar
+ !insertmacro GetLongPath
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define IsPinnedToTaskBar "!insertmacro IsPinnedToTaskBarCall"
+
+ Function IsPinnedToTaskBar
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ StrCpy $R5 "false"
+
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\TaskBar"
+ FindFirst $R8 $R7 "$QUICKLAUNCH\User Pinned\TaskBar\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\TaskBar\$R7"
+ ShellLink::GetShortCutTarget "$QUICKLAUNCH\User Pinned\TaskBar\$R7"
+ Pop $R6
+ ${GetLongPath} "$R6" $R6
+ ${If} "$R6" == "$R9"
+ StrCpy $R5 "true"
+ ${ExitDo}
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R8 $R7
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R8
+ ${EndIf}
+
+ ClearErrors
+
+ StrCpy $R9 $R5
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro IsPinnedToTaskBarCall _EXE_PATH _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_EXE_PATH}"
+ Call IsPinnedToTaskBar
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+/**
+ * Checks if any pinned Start Menu lnk files point to the executable's path
+ * passed to the macro.
+ *
+ * @param _EXE_PATH
+ * The executable path
+ * @return _RESULT
+ * false if no pinned shotcuts were found for this install location.
+ * true if pinned shotcuts were found for this install location.
+ *
+ * $R5 = stores whether a Start Menu lnk file has been found for the executable
+ * $R6 = long path returned from GetShortCutTarget and GetLongPath
+ * $R7 = file name returned from FindFirst and FindNext
+ * $R8 = find handle for FindFirst and FindNext
+ * $R9 = _EXE_PATH and _RESULT
+ */
+!macro IsPinnedToStartMenu
+
+ !ifndef IsPinnedToStartMenu
+ !insertmacro GetLongPath
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define IsPinnedToStartMenu "!insertmacro IsPinnedToStartMenuCall"
+
+ Function IsPinnedToStartMenu
+ Exch $R9
+ Push $R8
+ Push $R7
+ Push $R6
+ Push $R5
+
+ StrCpy $R5 "false"
+
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\StartMenu"
+ FindFirst $R8 $R7 "$QUICKLAUNCH\User Pinned\StartMenu\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\StartMenu\$R7"
+ ShellLink::GetShortCutTarget "$QUICKLAUNCH\User Pinned\StartMenu\$R7"
+ Pop $R6
+ ${GetLongPath} "$R6" $R6
+ ${If} "$R6" == "$R9"
+ StrCpy $R5 "true"
+ ${ExitDo}
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R8 $R7
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R8
+ ${EndIf}
+
+ ClearErrors
+
+ StrCpy $R9 $R5
+
+ Pop $R5
+ Pop $R6
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro IsPinnedToStartMenuCall _EXE_PATH _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_EXE_PATH}"
+ Call IsPinnedToStartMenu
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+/**
+ * Gets the number of pinned shortcut lnk files pinned to the Task Bar.
+ *
+ * @return _RESULT
+ * number of pinned shortcut lnk files.
+ *
+ * $R7 = file name returned from FindFirst and FindNext
+ * $R8 = find handle for FindFirst and FindNext
+ * $R9 = _RESULT
+ */
+!macro PinnedToTaskBarLnkCount
+
+ !ifndef PinnedToTaskBarLnkCount
+ !insertmacro GetLongPath
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define PinnedToTaskBarLnkCount "!insertmacro PinnedToTaskBarLnkCountCall"
+
+ Function PinnedToTaskBarLnkCount
+ Push $R9
+ Push $R8
+ Push $R7
+
+ StrCpy $R9 0
+
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\TaskBar"
+ FindFirst $R8 $R7 "$QUICKLAUNCH\User Pinned\TaskBar\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\TaskBar\$R7"
+ IntOp $R9 $R9 + 1
+ ${EndIf}
+ ClearErrors
+ FindNext $R8 $R7
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R8
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro PinnedToTaskBarLnkCountCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call PinnedToTaskBarLnkCount
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+/**
+ * Gets the number of pinned shortcut lnk files pinned to the Start Menu.
+ *
+ * @return _RESULT
+ * number of pinned shortcut lnk files.
+ *
+ * $R7 = file name returned from FindFirst and FindNext
+ * $R8 = find handle for FindFirst and FindNext
+ * $R9 = _RESULT
+ */
+!macro PinnedToStartMenuLnkCount
+
+ !ifndef PinnedToStartMenuLnkCount
+ !insertmacro GetLongPath
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define PinnedToStartMenuLnkCount "!insertmacro PinnedToStartMenuLnkCountCall"
+
+ Function PinnedToStartMenuLnkCount
+ Push $R9
+ Push $R8
+ Push $R7
+
+ StrCpy $R9 0
+
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\StartMenu"
+ FindFirst $R8 $R7 "$QUICKLAUNCH\User Pinned\StartMenu\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$QUICKLAUNCH\User Pinned\StartMenu\$R7"
+ IntOp $R9 $R9 + 1
+ ${EndIf}
+ ClearErrors
+ FindNext $R8 $R7
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R8
+ ${EndIf}
+
+ ClearErrors
+
+ Pop $R7
+ Pop $R8
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro PinnedToStartMenuLnkCountCall _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call PinnedToStartMenuLnkCount
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+/**
+ * Update Start Menu / TaskBar lnk files that point to the executable's path
+ * passed to the macro and all other shortcuts installed by the application with
+ * the current application user model ID. Requires ApplicationID.
+ *
+ * NOTE: this does not update Desktop shortcut application user model ID due to
+ * bug 633728.
+ *
+ * @param _EXE_PATH
+ * The main application executable path
+ * @param _APP_ID
+ * The application user model ID for the current install
+ * @return _RESULT
+ * false if no pinned shotcuts were found for this install location.
+ * true if pinned shotcuts were found for this install location.
+ */
+!macro UpdateShortcutAppModelIDs
+
+ !ifndef UpdateShortcutAppModelIDs
+ !insertmacro GetLongPath
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define UpdateShortcutAppModelIDs "!insertmacro UpdateShortcutAppModelIDsCall"
+
+ Function UpdateShortcutAppModelIDs
+ ; stack: path, appid
+ Exch $R9 ; stack: $R9, appid | $R9 = path
+ Exch 1 ; stack: appid, $R9
+ Exch $R8 ; stack: $R8, $R9 | $R8 = appid
+ Push $R7 ; stack: $R7, $R8, $R9
+ Push $R6
+ Push $R5
+ Push $R4
+ Push $R3 ; stack: $R3, $R5, $R6, $R7, $R8, $R9
+ Push $R2
+
+ ; $R9 = main application executable path
+ ; $R8 = appid
+ ; $R7 = path to the application's start menu programs directory
+ ; $R6 = path to the shortcut log ini file
+ ; $R5 = shortcut filename
+ ; $R4 = GetShortCutTarget result
+
+ StrCpy $R3 "false"
+
+ ; installed shortcuts
+ ${${_MOZFUNC_UN}GetLongPath} "$INSTDIR\uninstall\${SHORTCUTS_LOG}" $R6
+ ${If} ${FileExists} "$R6"
+ ; Update the Start Menu shortcuts' App ID for this application
+ StrCpy $R2 -1
+ ${Do}
+ IntOp $R2 $R2 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R5 "$R6" "STARTMENU" "Shortcut$R2"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$SMPROGRAMS\$R5"
+ ShellLink::GetShortCutTarget "$SMPROGRAMS\$$R5"
+ Pop $R4
+ ${GetLongPath} "$R4" $R4
+ ${If} "$R4" == "$R9" ; link path == install path
+ ApplicationID::Set "$SMPROGRAMS\$R5" "$R8" "true"
+ Pop $R4
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+
+ ; Update the Quick Launch shortcuts' App ID for this application
+ StrCpy $R2 -1
+ ${Do}
+ IntOp $R2 $R2 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R5 "$R6" "QUICKLAUNCH" "Shortcut$R2"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$QUICKLAUNCH\$R5"
+ ShellLink::GetShortCutTarget "$QUICKLAUNCH\$R5"
+ Pop $R4
+ ${GetLongPath} "$R4" $R4
+ ${If} "$R4" == "$R9" ; link path == install path
+ ApplicationID::Set "$QUICKLAUNCH\$R5" "$R8" "true"
+ Pop $R4
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+
+ ; Update the Start Menu Programs shortcuts' App ID for this application
+ ClearErrors
+ ReadINIStr $R7 "$R6" "SMPROGRAMS" "RelativePathToDir"
+ ${Unless} ${Errors}
+ ${${_MOZFUNC_UN}GetLongPath} "$SMPROGRAMS\$R7" $R7
+ ${Unless} "$R7" == ""
+ StrCpy $R2 -1
+ ${Do}
+ IntOp $R2 $R2 + 1 ; Increment the counter
+ ClearErrors
+ ReadINIStr $R5 "$R6" "SMPROGRAMS" "Shortcut$R2"
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+
+ ${If} ${FileExists} "$R7\$R5"
+ ShellLink::GetShortCutTarget "$R7\$R5"
+ Pop $R4
+ ${GetLongPath} "$R4" $R4
+ ${If} "$R4" == "$R9" ; link path == install path
+ ApplicationID::Set "$R7\$R5" "$R8" "true"
+ Pop $R4
+ ${EndIf}
+ ${EndIf}
+ ${Loop}
+ ${EndUnless}
+ ${EndUnless}
+ ${EndIf}
+
+ StrCpy $R7 "$QUICKLAUNCH\User Pinned"
+ StrCpy $R3 "false"
+
+ ; $R9 = main application executable path
+ ; $R8 = appid
+ ; $R7 = user pinned path
+ ; $R6 = find handle
+ ; $R5 = found filename
+ ; $R4 = GetShortCutTarget result
+
+ ; TaskBar links
+ FindFirst $R6 $R5 "$R7\TaskBar\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$R7\TaskBar\$R5"
+ ShellLink::GetShortCutTarget "$R7\TaskBar\$R5"
+ Pop $R4
+ ${If} "$R4" == "$R9" ; link path == install path
+ ApplicationID::Set "$R7\TaskBar\$R5" "$R8" "true"
+ Pop $R4 ; pop Set result off the stack
+ StrCpy $R3 "true"
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R6 $R5
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R6
+
+ ; Start menu links
+ FindFirst $R6 $R5 "$R7\StartMenu\*.lnk"
+ ${Do}
+ ${If} ${FileExists} "$R7\StartMenu\$R5"
+ ShellLink::GetShortCutTarget "$R7\StartMenu\$R5"
+ Pop $R4
+ ${If} "$R4" == "$R9" ; link path == install path
+ ApplicationID::Set "$R7\StartMenu\$R5" "$R8" "true"
+ Pop $R4 ; pop Set result off the stack
+ StrCpy $R3 "true"
+ ${EndIf}
+ ${EndIf}
+ ClearErrors
+ FindNext $R6 $R5
+ ${If} ${Errors}
+ ${ExitDo}
+ ${EndIf}
+ ${Loop}
+ FindClose $R6
+
+ ClearErrors
+
+ StrCpy $R9 $R3
+
+ Pop $R2
+ Pop $R3 ; stack: $R4, $R5, $R6, $R7, $R8, $R9
+ Pop $R4 ; stack: $R5, $R6, $R7, $R8, $R9
+ Pop $R5 ; stack: $R6, $R7, $R8, $R9
+ Pop $R6 ; stack: $R7, $R8, $R9
+ Pop $R7 ; stack: $R8, $R9
+ Exch $R8 ; stack: appid, $R9 | $R8 = old $R8
+ Exch 1 ; stack: $R9, appid
+ Exch $R9 ; stack: path, appid | $R9 = old $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro UpdateShortcutAppModelIDsCall _EXE_PATH _APP_ID _RESULT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_APP_ID}"
+ Push "${_EXE_PATH}"
+ Call UpdateShortcutAppModelIDs
+ Pop ${_RESULT}
+ !verbose pop
+!macroend
+
+!macro IsUserAdmin
+ ; Copied from: http://nsis.sourceforge.net/IsUserAdmin
+ Function IsUserAdmin
+ Push $R0
+ Push $R1
+ Push $R2
+
+ ClearErrors
+ UserInfo::GetName
+ IfErrors Win9x
+ Pop $R1
+ UserInfo::GetAccountType
+ Pop $R2
+
+ StrCmp $R2 "Admin" 0 Continue
+ StrCpy $R0 "true"
+ Goto Done
+
+ Continue:
+
+ StrCmp $R2 "" Win9x
+ StrCpy $R0 "false"
+ Goto Done
+
+ Win9x:
+ StrCpy $R0 "true"
+
+ Done:
+ Pop $R2
+ Pop $R1
+ Exch $R0
+ FunctionEnd
+!macroend
+
+/**
+ * Retrieve if present or generate and store a 64 bit hash of an install path
+ * using the City Hash algorithm. On return the resulting id is saved in the
+ * $AppUserModelID variable declared by inserting this macro. InitHashAppModelId
+ * will attempt to load from HKLM/_REG_PATH first, then HKCU/_REG_PATH. If found
+ * in either it will return the hash it finds. If not found it will generate a
+ * new hash and attempt to store the hash in HKLM/_REG_PATH, then HKCU/_REG_PATH.
+ * Subsequent calls will then retreive the stored hash value. On any failure,
+ * $AppUserModelID will be set to an empty string.
+ *
+ * A second string that will be used for Private Browsing mode is also generated by
+ * taking the regular $AppUserModelID and appending ";PrivateBrowsingAUMID" to it.
+ * This is stored in $AppUserModelIDPrivate upon successful completion of this
+ * function.
+ *
+ * Registry format: root/_REG_PATH/"_EXE_PATH" = "hash"
+ *
+ * @param _EXE_PATH
+ * The main application executable path
+ * @param _REG_PATH
+ * The HKLM/HKCU agnostic registry path where the key hash should
+ * be stored. ex: "Software\Mozilla\Firefox\TaskBarIDs"
+ * @result (Var) $AppUserModelID and $AppUserModelIDPrivate contain the
+ * app model id and the private app model id, respectively.
+ */
+!macro InitHashAppModelId
+ !ifndef ${_MOZFUNC_UN}InitHashAppModelId
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}GetLongPath
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !ifndef InitHashAppModelId
+ Var AppUserModelID
+ Var AppUserModelIDPrivate
+ !endif
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}InitHashAppModelId "!insertmacro ${_MOZFUNC_UN}InitHashAppModelIdCall"
+
+ Function ${_MOZFUNC_UN}InitHashAppModelId
+ ; stack: apppath, regpath
+ Exch $R9 ; stack: $R9, regpath | $R9 = apppath
+ Exch 1 ; stack: regpath, $R9
+ Exch $R8 ; stack: $R8, $R9 | $R8 = regpath
+ Push $R7
+
+ ${${_MOZFUNC_UN}GetLongPath} "$R9" $R9
+ ; Always create a new AppUserModelID and overwrite the existing one
+ ; for the current installation path.
+ CityHash::GetCityHash64 "$R9"
+ Pop $AppUserModelID
+ ${If} $AppUserModelID == "error"
+ GoTo end
+ ${EndIf}
+ ClearErrors
+ WriteRegStr HKLM "$R8" "$R9" "$AppUserModelID"
+ ${If} ${Errors}
+ ClearErrors
+ WriteRegStr HKCU "$R8" "$R9" "$AppUserModelID"
+ ${If} ${Errors}
+ StrCpy $AppUserModelID "error"
+ ${EndIf}
+ ${EndIf}
+
+ end:
+ ${If} "$AppUserModelID" == "error"
+ StrCpy $AppUserModelID ""
+ StrCpy $AppUserModelIDPrivate ""
+ ${Else}
+ StrCpy $AppUserModelIDPrivate "$AppUserModelID;PrivateBrowsingAUMID"
+ ${EndIf}
+
+ ClearErrors
+ Pop $R7
+ Exch $R8
+ Exch 1
+ Exch $R9
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro InitHashAppModelIdCall _EXE_PATH _REG_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_REG_PATH}"
+ Push "${_EXE_PATH}"
+ Call InitHashAppModelId
+ !verbose pop
+!macroend
+
+!macro un.InitHashAppModelIdCall _EXE_PATH _REG_PATH
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_REG_PATH}"
+ Push "${_EXE_PATH}"
+ Call un.InitHashAppModelId
+ !verbose pop
+!macroend
+
+!macro un.InitHashAppModelId
+ !ifndef un.InitHashAppModelId
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro InitHashAppModelId
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Try to locate the default profile of this install from AppUserModelID
+ * using installs.ini.
+ * FIXME This could instead use the Install<AUMID> entries in profiles.ini?
+ *
+ * - `SetShellVarContext current` must be called before this macro so
+ * $APPDATA gets the current user's data.
+ * - InitHashAppModelId must have been called before this macro to set
+ * $AppUserModelID
+ *
+ * @result: Path of the profile directory (not checked for existence),
+ * or "" if not found, left on top of the stack.
+ */
+!macro FindInstallSpecificProfileMaybeUn _MOZFUNC_UN
+ Push $R0
+ Push $0
+ Push $1
+ Push $2
+
+ StrCpy $R0 ""
+ ; Look for an install-specific profile, which might be listed as
+ ; either a relative or an absolute path (installs.ini doesn't say which).
+ ${If} ${FileExists} "$APPDATA\Mozilla\Firefox\installs.ini"
+ ClearErrors
+ ReadINIStr $1 "$APPDATA\Mozilla\Firefox\installs.ini" "$AppUserModelID" "Default"
+ ${IfNot} ${Errors}
+ ${${_MOZFUNC_UN}GetLongPath} "$APPDATA\Mozilla\Firefox\$1" $2
+ ${If} ${FileExists} $2
+ StrCpy $R0 $2
+ ${Else}
+ ${${_MOZFUNC_UN}GetLongPath} "$1" $2
+ ${If} ${FileExists} $2
+ StrCpy $R0 $2
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+ ${EndIf}
+
+ Pop $2
+ Pop $1
+ Pop $0
+ Exch $R0
+!macroend
+!define FindInstallSpecificProfile "!insertmacro FindInstallSpecificProfileMaybeUn ''"
+!define un.FindInstallSpecificProfile "!insertmacro FindInstallSpecificProfileMaybeUn un."
+
+/**
+ * Copy the post-signing data, which was left alongside the installer
+ * by the self-extractor stub, into the global location for this data.
+ *
+ * If the post-signing data file doesn't exist, or is empty, "0" is
+ * pushed on the stack, and nothing is copied.
+ * Otherwise the first line of the post-signing data (including newline,
+ * if any) is pushed on the stack.
+ */
+!macro CopyPostSigningData
+ !ifndef CopyPostSigningData
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define CopyPostSigningData "Call CopyPostSigningData"
+
+ Function CopyPostSigningData
+ Push $0 ; Stack: old $0
+ Push $1 ; Stack: $1, old $0
+
+ ${LineRead} "$EXEDIR\postSigningData" "1" $0
+ ${If} ${Errors}
+ ClearErrors
+ StrCpy $0 "0"
+ ${Else}
+ CopyFiles /SILENT "$EXEDIR\postSigningData" "$INSTDIR"
+ ${Endif}
+
+ Pop $1 ; Stack: old $0
+ Exch $0 ; Stack: postSigningData
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+################################################################################
+# Helpers for taskbar progress
+
+!ifndef CLSCTX_INPROC_SERVER
+ !define CLSCTX_INPROC_SERVER 1
+!endif
+
+!define CLSID_ITaskbarList {56fdf344-fd6d-11d0-958a-006097c9a090}
+!define IID_ITaskbarList3 {ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf}
+!define ITaskbarList3->SetProgressValue $ITaskbarList3->9
+!define ITaskbarList3->SetProgressState $ITaskbarList3->10
+
+/**
+ * Creates a single uninitialized object of the ITaskbarList class with a
+ * reference to the ITaskbarList3 interface. This object can be used to set
+ * progress and state on the installer's taskbar icon using the helper macros
+ * in this section.
+ */
+!macro ITBL3Create
+
+ !ifndef ${_MOZFUNC_UN}ITBL3Create
+ Var ITaskbarList3
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}ITBL3Create "!insertmacro ${_MOZFUNC_UN}ITBL3CreateCall"
+
+ Function ${_MOZFUNC_UN}ITBL3Create
+ ; Setting to 0 allows the helper macros to detect when the object was not
+ ; created.
+ StrCpy $ITaskbarList3 0
+ ; Don't create when running silently.
+ ${Unless} ${Silent}
+ System::Call "ole32::CoCreateInstance(g '${CLSID_ITaskbarList}', \
+ i 0, \
+ i ${CLSCTX_INPROC_SERVER}, \
+ g '${IID_ITaskbarList3}', \
+ *i .s)"
+ Pop $ITaskbarList3
+ ${EndUnless}
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro ITBL3CreateCall
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call ITBL3Create
+ !verbose pop
+!macroend
+
+!macro un.ITBL3CreateCall _PATH_TO_IMAGE
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Call un.ITBL3Create
+ !verbose pop
+!macroend
+
+!macro un.ITBL3Create
+ !ifndef un.ITBL3Create
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro ITBL3Create
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Sets the percentage completed on the taskbar process icon progress indicator.
+ *
+ * @param _COMPLETED
+ * The proportion of the operation that has been completed in relation
+ * to _TOTAL.
+ * @param _TOTAL
+ * The value _COMPLETED will have when the operation has completed.
+ *
+ * $R8 = _COMPLETED
+ * $R9 = _TOTAL
+ */
+!macro ITBL3SetProgressValueCall _COMPLETED _TOTAL
+ Push ${_COMPLETED}
+ Push ${_TOTAL}
+ ${CallArtificialFunction} ITBL3SetProgressValue_
+!macroend
+
+!define ITBL3SetProgressValue "!insertmacro ITBL3SetProgressValueCall"
+!define un.ITBL3SetProgressValue "!insertmacro ITBL3SetProgressValueCall"
+
+!macro ITBL3SetProgressValue_
+ Exch $R9
+ Exch 1
+ Exch $R8
+ ${If} $ITaskbarList3 <> 0
+ System::Call "${ITaskbarList3->SetProgressValue}(i$HWNDPARENT, l$R8, l$R9)"
+ ${EndIf}
+ Exch $R8
+ Exch 1
+ Exch $R9
+!macroend
+
+; Normal state / no progress bar
+!define TBPF_NOPROGRESS 0x00000000
+; Marquee style progress bar
+!define TBPF_INDETERMINATE 0x00000001
+; Standard progress bar
+!define TBPF_NORMAL 0x00000002
+; Red taskbar button to indicate an error occurred
+!define TBPF_ERROR 0x00000004
+; Yellow taskbar button to indicate user attention (input) is required to
+; resume progress
+!define TBPF_PAUSED 0x00000008
+
+/**
+ * Sets the state on the taskbar process icon progress indicator.
+ *
+ * @param _STATE
+ * The state to set on the taskbar icon progress indicator. Only one of
+ * the states defined above should be specified.
+ *
+ * $R9 = _STATE
+ */
+!macro ITBL3SetProgressStateCall _STATE
+ Push ${_STATE}
+ ${CallArtificialFunction} ITBL3SetProgressState_
+!macroend
+
+!define ITBL3SetProgressState "!insertmacro ITBL3SetProgressStateCall"
+!define un.ITBL3SetProgressState "!insertmacro ITBL3SetProgressStateCall"
+
+!macro ITBL3SetProgressState_
+ Exch $R9
+ ${If} $ITaskbarList3 <> 0
+ System::Call "${ITaskbarList3->SetProgressState}(i$HWNDPARENT, i$R9)"
+ ${EndIf}
+ Exch $R9
+!macroend
+
+################################################################################
+# Helpers for the new user interface
+
+!ifndef MAXDWORD
+ !define MAXDWORD 0xffffffff
+!endif
+
+!ifndef DT_WORDBREAK
+ !define DT_WORDBREAK 0x0010
+!endif
+!ifndef DT_SINGLELINE
+ !define DT_SINGLELINE 0x0020
+!endif
+!ifndef DT_NOCLIP
+ !define DT_NOCLIP 0x0100
+!endif
+!ifndef DT_CALCRECT
+ !define DT_CALCRECT 0x0400
+!endif
+!ifndef DT_EDITCONTROL
+ !define DT_EDITCONTROL 0x2000
+!endif
+!ifndef DT_RTLREADING
+ !define DT_RTLREADING 0x00020000
+!endif
+!ifndef DT_NOFULLWIDTHCHARBREAK
+ !define DT_NOFULLWIDTHCHARBREAK 0x00080000
+!endif
+
+!define /ifndef GWL_STYLE -16
+!define /ifndef GWL_EXSTYLE -20
+
+!ifndef WS_EX_NOINHERITLAYOUT
+ !define WS_EX_NOINHERITLAYOUT 0x00100000
+!endif
+
+!ifndef PBS_MARQUEE
+ !define PBS_MARQUEE 0x08
+!endif
+
+!ifndef PBM_SETRANGE32
+ !define PBM_SETRANGE32 0x406
+!endif
+!ifndef PBM_GETRANGE
+ !define PBM_GETRANGE 0x407
+!endif
+
+!ifndef SHACF_FILESYSTEM
+ !define SHACF_FILESYSTEM 1
+!endif
+
+!define MOZ_LOADTRANSPARENT ${LR_LOADFROMFILE}|${LR_LOADTRANSPARENT}|${LR_LOADMAP3DCOLORS}
+
+; Extend nsDialogs.nsh to support creating centered labels if it is already
+; included
+!ifmacrodef __NSD_DefineControl
+!insertmacro __NSD_DefineControl LabelCenter
+!define __NSD_LabelCenter_CLASS STATIC
+!define __NSD_LabelCenter_STYLE ${DEFAULT_STYLES}|${SS_NOTIFY}|${SS_CENTER}
+!define __NSD_LabelCenter_EXSTYLE ${WS_EX_TRANSPARENT}
+!endif
+
+/**
+ * Draws an image file (BMP, GIF, or JPG) onto a bitmap control, with scaling.
+ * Adapted from https://stackoverflow.com/a/13405711/1508094
+ *
+ * @param CONTROL bitmap control created by NSD_CreateBitmap
+ * @param IMAGE path to image file to draw to the bitmap
+ * @param HANDLE output bitmap handle which must be freed via NSD_FreeImage
+ * after nsDialogs::Show has been called
+ */
+!macro __SetStretchedImageOLE CONTROL IMAGE HANDLE
+ !ifndef IID_IPicture
+ !define IID_IPicture {7BF80980-BF32-101A-8BBB-00AA00300CAB}
+ !endif
+ !ifndef SRCCOPY
+ !define SRCCOPY 0xCC0020
+ !endif
+ !ifndef HALFTONE
+ !define HALFTONE 4
+ !endif
+ !ifndef IMAGE_BITMAP
+ !define IMAGE_BITMAP 0
+ !endif
+
+ Push $0 ; HANDLE
+ Push $1 ; memory DC
+ Push $2 ; IPicture created from IMAGE
+ Push $3 ; HBITMAP obtained from $2
+ Push $4 ; BITMAPINFO obtained from $3
+ Push $5 ; width of IMAGE
+ Push $6 ; height of IMAGE
+ Push $7 ; width of CONTROL
+ Push $8 ; height of CONTROL
+ Push $R0 ; CONTROL
+
+ StrCpy $R0 ${CONTROL} ; in case ${CONTROL} is $0
+ StrCpy $7 ""
+ StrCpy $8 ""
+
+ System::Call '*(i, i, i, i) i.s'
+ Pop $0
+
+ ${If} $0 <> 0
+ System::Call 'user32::GetClientRect(i R0, i r0)'
+ System::Call '*$0(i, i, i .s, i .s)'
+ System::Free $0
+ Pop $7
+ Pop $8
+ ${EndIf}
+
+ ${If} $7 > 0
+ ${AndIf} $8 > 0
+ System::Call 'oleaut32::OleLoadPicturePath(w"${IMAGE}",i0,i0,i0,g"${IID_IPicture}",*i.r2)i.r1'
+ ${If} $1 = 0
+ System::Call 'user32::GetDC(i0)i.s'
+ System::Call 'gdi32::CreateCompatibleDC(iss)i.r1'
+ System::Call 'gdi32::CreateCompatibleBitmap(iss,ir7,ir8)i.r0'
+ System::Call 'user32::ReleaseDC(i0,is)'
+ System::Call '$2->3(*i.r3)i.r4' ; IPicture->get_Handle
+ ${If} $4 = 0
+ System::Call 'gdi32::SetStretchBltMode(ir1,i${HALFTONE})'
+ System::Call '*(&i40,&i1024)i.r4' ; BITMAP / BITMAPINFO
+ System::Call 'gdi32::GetObject(ir3,i24,ir4)'
+ System::Call 'gdi32::SelectObject(ir1,ir0)i.s'
+ System::Call '*$4(i40,i.r5,i.r6,i0,i,i.s)' ; Grab size and bits-ptr AND init as BITMAPINFOHEADER
+ System::Call 'gdi32::GetDIBits(ir1,ir3,i0,i0,i0,ir4,i0)' ; init BITMAPINFOHEADER
+ System::Call 'gdi32::GetDIBits(ir1,ir3,i0,i0,i0,ir4,i0)' ; init BITMAPINFO
+ System::Call 'gdi32::StretchDIBits(ir1,i0,i0,ir7,ir8,i0,i0,ir5,ir6,is,ir4,i0,i${SRCCOPY})'
+ System::Call 'gdi32::SelectObject(ir1,is)'
+ System::Free $4
+ SendMessage $R0 ${STM_SETIMAGE} ${IMAGE_BITMAP} $0
+ ${EndIf}
+ System::Call 'gdi32::DeleteDC(ir1)'
+ System::Call '$2->2()' ; IPicture->release()
+ ${EndIf}
+ ${EndIf}
+
+ Pop $R0
+ Pop $8
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ Pop $2
+ Pop $1
+ Exch $0
+ Pop ${HANDLE}
+!macroend
+!define SetStretchedImageOLE "!insertmacro __SetStretchedImageOLE"
+
+/**
+ * Display a task dialog box with custom strings and button labels.
+ *
+ * The task dialog is highly customizable. The specific style being used here
+ * is similar to a MessageBox except that the button text is customizable.
+ * MessageBox-style buttons are used instead of command link buttons; this can
+ * be made configurable if command buttons are needed.
+ *
+ * See https://msdn.microsoft.com/en-us/library/windows/desktop/bb760544.aspx
+ * for the TaskDialogIndirect function's documentation, and links to definitions
+ * of the TASKDIALOGCONFIG and TASKDIALOG_BUTTON structures it uses.
+ *
+ * @param INSTRUCTION Dialog header string; use empty string if unneeded
+ * @param CONTENT Secondary message string; use empty string if unneeded
+ * @param BUTTON1 Text for the first button, the one selected by default
+ * @param BUTTON2 Text for the second button
+ *
+ * @return One of the following values will be left on the stack:
+ * 1001 if the first button was clicked
+ * 1002 if the second button was clicked
+ * 2 (IDCANCEL) if the dialog was closed
+ * 0 on error
+ */
+!macro _ShowTaskDialog INSTRUCTION CONTENT BUTTON1 BUTTON2
+ !ifndef SIZEOF_TASKDIALOGCONFIG_32BIT
+ !define SIZEOF_TASKDIALOGCONFIG_32BIT 96
+ !endif
+ !ifndef TDF_ALLOW_DIALOG_CANCELLATION
+ !define TDF_ALLOW_DIALOG_CANCELLATION 0x0008
+ !endif
+ !ifndef TDF_RTL_LAYOUT
+ !define TDF_RTL_LAYOUT 0x02000
+ !endif
+ !ifndef TD_WARNING_ICON
+ !define TD_WARNING_ICON 0x0FFFF
+ !endif
+
+ Push $0 ; return value
+ Push $1 ; TASKDIALOGCONFIG struct
+ Push $2 ; TASKDIALOG_BUTTON array
+ Push $3 ; dwFlags member of the TASKDIALOGCONFIG
+
+ StrCpy $3 ${TDF_ALLOW_DIALOG_CANCELLATION}
+ !ifdef ${AB_CD}_rtl
+ IntOp $3 $3 | ${TDF_RTL_LAYOUT}
+ !endif
+
+ ; Build an array of two TASKDIALOG_BUTTON structs
+ System::Call "*(i 1001, \
+ w '${BUTTON1}', \
+ i 1002, \
+ w '${BUTTON2}' \
+ ) p.r2"
+ ; Build a TASKDIALOGCONFIG struct
+ System::Call "*(i ${SIZEOF_TASKDIALOGCONFIG_32BIT}, \
+ p $HWNDPARENT, \
+ p 0, \
+ i $3, \
+ i 0, \
+ w '$(INSTALLER_WIN_CAPTION)', \
+ p ${TD_WARNING_ICON}, \
+ w '${INSTRUCTION}', \
+ w '${CONTENT}', \
+ i 2, \
+ p r2, \
+ i 1001, \
+ i 0, \
+ p 0, \
+ i 0, \
+ p 0, \
+ p 0, \
+ p 0, \
+ p 0, \
+ p 0, \
+ p 0, \
+ p 0, \
+ p 0, \
+ i 0 \
+ ) p.r1"
+ System::Call "comctl32::TaskDialogIndirect(p r1, *i 0 r0, p 0, p 0)"
+ System::Free $1
+ System::Free $2
+
+ Pop $3
+ Pop $2
+ Pop $1
+ Exch $0
+!macroend
+!define ShowTaskDialog "!insertmacro _ShowTaskDialog"
+
+/**
+ * Removes a single style from a control.
+ *
+ * _HANDLE the handle of the control
+ * _STYLE the style to remove
+ */
+!macro _RemoveStyle _HANDLE _STYLE
+ Push $0
+
+ System::Call 'user32::GetWindowLongW(i ${_HANDLE}, i ${GWL_STYLE}) i .r0'
+ IntOp $0 $0 | ${_STYLE}
+ IntOp $0 $0 - ${_STYLE}
+ System::Call 'user32::SetWindowLongW(i ${_HANDLE}, i ${GWL_STYLE}, i r0)'
+
+ Pop $0
+!macroend
+!define RemoveStyle "!insertmacro _RemoveStyle"
+
+/**
+ * Adds a single extended style to a control.
+ *
+ * _HANDLE the handle of the control
+ * _EXSTYLE the extended style to add
+ */
+!macro _AddExStyle _HANDLE _EXSTYLE
+ Push $0
+
+ System::Call 'user32::GetWindowLongW(i ${_HANDLE}, i ${GWL_EXSTYLE}) i .r0'
+ IntOp $0 $0 | ${_EXSTYLE}
+ System::Call 'user32::SetWindowLongW(i ${_HANDLE}, i ${GWL_EXSTYLE}, i r0)'
+
+ Pop $0
+!macroend
+!define AddExStyle "!insertmacro _AddExStyle"
+
+/**
+ * Removes a single extended style from a control.
+ *
+ * _HANDLE the handle of the control
+ * _EXSTYLE the extended style to remove
+ */
+!macro _RemoveExStyle _HANDLE _EXSTYLE
+ Push $0
+
+ System::Call 'user32::GetWindowLongW(i ${_HANDLE}, i ${GWL_EXSTYLE}) i .r0'
+ IntOp $0 $0 | ${_EXSTYLE}
+ IntOp $0 $0 - ${_EXSTYLE}
+ System::Call 'user32::SetWindowLongW(i ${_HANDLE}, i ${GWL_EXSTYLE}, i r0)'
+
+ Pop $0
+!macroend
+!define RemoveExStyle "!insertmacro _RemoveExStyle"
+
+/**
+ * Set the necessary styles to configure the given window as right-to-left
+ *
+ * _HANDLE the handle of the control to configure
+ */
+!macro _MakeWindowRTL _HANDLE
+ !define /ifndef WS_EX_RIGHT 0x00001000
+ !define /ifndef WS_EX_LEFT 0x00000000
+ !define /ifndef WS_EX_RTLREADING 0x00002000
+ !define /ifndef WS_EX_LTRREADING 0x00000000
+ !define /ifndef WS_EX_LAYOUTRTL 0x00400000
+
+ ${AddExStyle} ${_HANDLE} ${WS_EX_LAYOUTRTL}
+ ${RemoveExStyle} ${_HANDLE} ${WS_EX_RTLREADING}
+ ${RemoveExStyle} ${_HANDLE} ${WS_EX_RIGHT}
+ ${AddExStyle} ${_HANDLE} ${WS_EX_LEFT}|${WS_EX_LTRREADING}
+!macroend
+!define MakeWindowRTL "!insertmacro _MakeWindowRTL"
+
+/**
+ * Gets the extent of the specified text in pixels for sizing a control.
+ *
+ * _TEXT the text to get the text extent for
+ * _FONT the font to use when getting the text extent
+ * _RES_WIDTH return value - control width for the text
+ * _RES_HEIGHT return value - control height for the text
+ */
+!macro GetTextExtentCall _TEXT _FONT _RES_WIDTH _RES_HEIGHT
+ Push "${_TEXT}"
+ Push "${_FONT}"
+ ${CallArtificialFunction} GetTextExtent_
+ Pop ${_RES_WIDTH}
+ Pop ${_RES_HEIGHT}
+!macroend
+
+!define GetTextExtent "!insertmacro GetTextExtentCall"
+!define un.GetTextExtent "!insertmacro GetTextExtentCall"
+
+!macro GetTextExtent_
+ Exch $0 ; font
+ Exch 1
+ Exch $1 ; text
+ Push $2
+ Push $3
+ Push $4
+ Push $5
+ Push $6
+ Push $7
+
+ ; Reuse the existing NSIS control which is used for BrandingText instead of
+ ; creating a new control.
+ GetDlgItem $2 $HWNDPARENT 1028
+
+ System::Call 'user32::GetDC(i r2) i .r3'
+ System::Call 'gdi32::SelectObject(i r3, i r0)'
+
+ StrLen $4 "$1"
+
+ System::Call '*(i, i) i .r5'
+ System::Call 'gdi32::GetTextExtentPoint32W(i r3, t$\"$1$\", i r4, i r5)'
+ System::Call '*$5(i .r6, i .r7)'
+ System::Free $5
+
+ System::Call 'user32::ReleaseDC(i r2, i r3)'
+
+ StrCpy $1 $7
+ StrCpy $0 $6
+
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ Pop $2
+ Exch $1 ; return height
+ Exch 1
+ Exch $0 ; return width
+!macroend
+
+/**
+ * Gets the width and the height of a control in pixels.
+ *
+ * _HANDLE the handle of the control
+ * _RES_WIDTH return value - control width for the text
+ * _RES_HEIGHT return value - control height for the text
+ */
+!macro GetDlgItemWidthHeightCall _HANDLE _RES_WIDTH _RES_HEIGHT
+ Push "${_HANDLE}"
+ ${CallArtificialFunction} GetDlgItemWidthHeight_
+ Pop ${_RES_WIDTH}
+ Pop ${_RES_HEIGHT}
+!macroend
+
+!define GetDlgItemWidthHeight "!insertmacro GetDlgItemWidthHeightCall"
+!define un.GetDlgItemWidthHeight "!insertmacro GetDlgItemWidthHeightCall"
+
+!macro GetDlgItemWidthHeight_
+ Exch $0 ; handle for the control
+ Push $1
+ Push $2
+
+ System::Call '*(i, i, i, i) i .r2'
+ ; The left and top values will always be 0 so the right and bottom values
+ ; will be the width and height.
+ System::Call 'user32::GetClientRect(i r0, i r2)'
+ System::Call '*$2(i, i, i .r0, i .r1)'
+ System::Free $2
+
+ Pop $2
+ Exch $1 ; return height
+ Exch 1
+ Exch $0 ; return width
+!macroend
+
+/**
+ * Gets the number of pixels from the beginning of the dialog to the end of a
+ * control in a RTL friendly manner.
+ *
+ * _HANDLE the handle of the control
+ * _RES_PX return value - pixels from the beginning of the dialog to the end of
+ * the control
+ */
+!macro GetDlgItemEndPXCall _HANDLE _RES_PX
+ Push "${_HANDLE}"
+ ${CallArtificialFunction} GetDlgItemEndPX_
+ Pop ${_RES_PX}
+!macroend
+
+!define GetDlgItemEndPX "!insertmacro GetDlgItemEndPXCall"
+!define un.GetDlgItemEndPX "!insertmacro GetDlgItemEndPXCall"
+
+!macro GetDlgItemEndPX_
+ Exch $0 ; handle of the control
+ Push $1
+ Push $2
+
+ ; #32770 is the dialog class
+ FindWindow $1 "#32770" "" $HWNDPARENT
+ System::Call '*(i, i, i, i) i .r2'
+ System::Call 'user32::GetWindowRect(i r0, i r2)'
+ System::Call 'user32::MapWindowPoints(i 0, i r1,i r2, i 2)'
+ System::Call '*$2(i, i, i .r0, i)'
+ System::Free $2
+
+ Pop $2
+ Pop $1
+ Exch $0 ; pixels from the beginning of the dialog to the end of the control
+!macroend
+
+/**
+ * Gets the number of pixels from the top of a dialog to the bottom of a control
+ *
+ * _CONTROL the handle of the control
+ * _RES_PX return value - pixels from the top of the dialog to the bottom
+ * of the control
+ */
+!macro GetDlgItemBottomPXCall _CONTROL _RES_PX
+ Push "${_CONTROL}"
+ ${CallArtificialFunction} GetDlgItemBottomPX_
+ Pop ${_RES_PX}
+!macroend
+
+!define GetDlgItemBottomPX "!insertmacro GetDlgItemBottomPXCall"
+!define un.GetDlgItemBottomPX "!insertmacro GetDlgItemBottomPXCall"
+
+!macro GetDlgItemBottomPX_
+ Exch $0 ; handle of the control
+ Push $1
+ Push $2
+
+ ; #32770 is the dialog class
+ FindWindow $1 "#32770" "" $HWNDPARENT
+ System::Call '*(i, i, i, i) i .r2'
+ System::Call 'user32::GetWindowRect(i r0, i r2)'
+ System::Call 'user32::MapWindowPoints(i 0, i r1, i r2, i 2)'
+ System::Call '*$2(i, i, i, i .r0)'
+ System::Free $2
+
+ Pop $2
+ Pop $1
+ Exch $0 ; pixels from the top of the dialog to the bottom of the control
+!macroend
+
+/**
+ * Gets the width and height for sizing a control that has the specified text.
+ * The control's height and width will be dynamically determined for the maximum
+ * width specified.
+ *
+ * _TEXT the text
+ * _FONT the font to use when getting the width and height
+ * _MAX_WIDTH the maximum width for the control in pixels
+ * _RES_WIDTH return value - control width for the text in pixels.
+ * This might be larger than _MAX_WIDTH if that constraint couldn't
+ * be satisfied, e.g. a single word that couldn't be broken up is
+ * longer than _MAX_WIDTH by itself.
+ * _RES_HEIGHT return value - control height for the text in pixels
+ */
+!macro GetTextWidthHeight
+
+ !ifndef ${_MOZFUNC_UN}GetTextWidthHeight
+ !define _MOZFUNC_UN_TMP ${_MOZFUNC_UN}
+ !insertmacro ${_MOZFUNC_UN_TMP}WordFind
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN ${_MOZFUNC_UN_TMP}
+ !undef _MOZFUNC_UN_TMP
+
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !define ${_MOZFUNC_UN}GetTextWidthHeight "!insertmacro ${_MOZFUNC_UN}GetTextWidthHeightCall"
+
+ Function ${_MOZFUNC_UN}GetTextWidthHeight
+ ; Stack contents after each instruction (top of the stack on the left):
+ ; _MAX_WIDTH _FONT _TEXT
+ Exch $0 ; $0 _FONT _TEXT
+ Exch 1 ; _FONT $0 _TEXT
+ Exch $1 ; $1 $0 _TEXT
+ Exch 2 ; _TEXT $0 $1
+ Exch $2 ; $2 $0 $1
+ ; That's all the parameters, now save our scratch registers.
+ Push $3 ; handle to a temporary control for drawing the text into
+ Push $4 ; DC handle
+ Push $5 ; string length of the text argument
+ Push $6 ; RECT struct to call DrawText with
+ Push $7 ; width returned from DrawText
+ Push $8 ; height returned from DrawText
+ Push $9 ; flags to pass to DrawText
+
+ StrCpy $9 "${DT_NOCLIP}|${DT_CALCRECT}|${DT_WORDBREAK}"
+ !ifdef ${AB_CD}_rtl
+ StrCpy $9 "$9|${DT_RTLREADING}"
+ !endif
+
+ ; Reuse the existing NSIS control which is used for BrandingText instead
+ ; of creating a new control.
+ GetDlgItem $3 $HWNDPARENT 1028
+
+ System::Call 'user32::GetDC(i r3) i .r4'
+ System::Call 'gdi32::SelectObject(i r4, i r1)'
+
+ StrLen $5 "$2" ; text length
+ System::Call '*(i, i, i r0, i) i .r6'
+
+ System::Call 'user32::DrawTextW(i r4, t $\"$2$\", i r5, i r6, i r9)'
+ System::Call '*$6(i, i, i .r7, i .r8)'
+ System::Free $6
+
+ System::Call 'user32::ReleaseDC(i r3, i r4)'
+
+ StrCpy $1 $8
+ StrCpy $0 $7
+
+ ; Restore the values that were in our scratch registers.
+ Pop $9
+ Pop $8
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ ; Restore our parameter registers and return our results.
+ ; Stack contents after each instruction (top of the stack on the left):
+ ; $2 $0 $1
+ Pop $2 ; $0 $1
+ Exch 1 ; $1 $0
+ Exch $1 ; _RES_HEIGHT $0
+ Exch 1 ; $0 _RES_HEIGHT
+ Exch $0 ; _RES_WIDTH _RES_HEIGHT
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro GetTextWidthHeightCall _TEXT _FONT _MAX_WIDTH _RES_WIDTH _RES_HEIGHT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_TEXT}"
+ Push "${_FONT}"
+ Push "${_MAX_WIDTH}"
+ Call GetTextWidthHeight
+ Pop ${_RES_WIDTH}
+ Pop ${_RES_HEIGHT}
+ !verbose pop
+!macroend
+
+!macro un.GetTextWidthHeightCall _TEXT _FONT _MAX_WIDTH _RES_WIDTH _RES_HEIGHT
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ Push "${_TEXT}"
+ Push "${_FONT}"
+ Push "${_MAX_WIDTH}"
+ Call un.GetTextWidthHeight
+ Pop ${_RES_WIDTH}
+ Pop ${_RES_HEIGHT}
+ !verbose pop
+!macroend
+
+!macro un.GetTextWidthHeight
+ !ifndef un.GetTextWidthHeight
+ !verbose push
+ !verbose ${_MOZFUNC_VERBOSE}
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN "un."
+
+ !insertmacro GetTextWidthHeight
+
+ !undef _MOZFUNC_UN
+ !define _MOZFUNC_UN
+ !verbose pop
+ !endif
+!macroend
+
+/**
+ * Convert a number of dialog units to a number of pixels.
+ *
+ * _DU Number of dialog units to convert
+ * _AXIS Which axis you want to convert a value along, X or Y
+ * _RV Register or variable to return the number of pixels in
+ */
+!macro _DialogUnitsToPixels _DU _AXIS _RV
+ Push $0
+ Push $1
+
+ ; The dialog units value might be a string ending with a 'u',
+ ; so convert it to a number.
+ IntOp $0 "${_DU}" + 0
+
+ !if ${_AXIS} == 'Y'
+ System::Call '*(i 0, i 0, i 0, i r0) i .r1'
+ System::Call 'user32::MapDialogRect(p $HWNDPARENT, p r1)'
+ System::Call '*$1(i, i, i, i . r0)'
+ !else if ${_AXIS} == 'X'
+ System::Call '*(i 0, i 0, i r0, i 0) i .r1'
+ System::Call 'user32::MapDialogRect(p $HWNDPARENT, p r1)'
+ System::Call '*$1(i, i, i . r0, i)'
+ !else
+ !error "Invalid axis ${_AXIS} passed to DialogUnitsToPixels; please use X or Y"
+ !endif
+ System::Free $1
+
+ Pop $1
+ Exch $0
+ Pop ${_RV}
+!macroend
+!define DialogUnitsToPixels "!insertmacro _DialogUnitsToPixels"
+
+/**
+ * Convert a given left coordinate for a dialog control to flip the control to
+ * the other side of the dialog if we're using an RTL locale.
+ *
+ * _LEFT_DU Number of dialog units to convert
+ * _WIDTH Width of the control in either pixels or DU's
+ * If the string has a 'u' on the end, it will be interpreted as
+ * dialog units, otherwise it will be interpreted as pixels.
+ * _RV Register or variable to return converted coordinate, in pixels
+ */
+!macro _ConvertLeftCoordForRTLCall _LEFT_DU _WIDTH _RV
+ Push "${_LEFT_DU}"
+ Push "${_WIDTH}"
+ ${CallArtificialFunction} _ConvertLeftCoordForRTL
+ Pop ${_RV}
+!macroend
+
+!define ConvertLeftCoordForRTL "!insertmacro _ConvertLeftCoordForRTLCall"
+!define un.ConvertLeftCoordForRTL "!insertmacro _ConvertLeftCoordForRTLCall"
+
+!macro _ConvertLeftCoordForRTL
+ ; Stack contents after each instruction (top of the stack on the left):
+ ; _WIDTH _LEFT_DU
+ Exch $0 ; $0 _LEFT_DU
+ Exch 1 ; _LEFT_DU $0
+ Exch $1 ; $1 $0
+ ; That's all the parameters, now save our scratch registers.
+ Push $2 ; width of the entire dialog, in pixels
+ Push $3 ; _LEFT_DU converted to pixels
+ Push $4 ; temp string search result
+
+ !ifndef ${AB_CD}_rtl
+ StrCpy $0 "$1"
+ !else
+ ${GetDlgItemWidthHeight} $HWNDPARENT $2 $3
+ ${DialogUnitsToPixels} $1 X $3
+
+ ClearErrors
+ ${${_MOZFUNC_UN}WordFind} "$0" "u" "E+1{" $4
+ ${IfNot} ${Errors}
+ ${DialogUnitsToPixels} $4 X $0
+ ${EndIf}
+
+ IntOp $1 $2 - $3
+ IntOp $1 $1 - $0
+ StrCpy $0 $1
+ !endif
+
+ ; Restore the values that were in our scratch registers.
+ Pop $4
+ Pop $3
+ Pop $2
+ ; Restore our parameter registers and return our result.
+ ; Stack contents after each instruction (top of the stack on the left):
+ ; $1 $0
+ Pop $1 ; $0
+ Exch $0 ; _RV
+!macroend
+
+/**
+ * Gets the elapsed time in seconds between two values in milliseconds stored as
+ * an int64. The caller will typically get the millisecond values using
+ * GetTickCount with a long return value as follows.
+ * System::Call "kernel32::GetTickCount()l .s"
+ * Pop $varname
+ *
+ * _START_TICK_COUNT
+ * _FINISH_TICK_COUNT
+ * _RES_ELAPSED_SECONDS return value - elapsed time between _START_TICK_COUNT
+ * and _FINISH_TICK_COUNT in seconds.
+ */
+!macro GetSecondsElapsedCall _START_TICK_COUNT _FINISH_TICK_COUNT _RES_ELAPSED_SECONDS
+ Push "${_START_TICK_COUNT}"
+ Push "${_FINISH_TICK_COUNT}"
+ ${CallArtificialFunction} GetSecondsElapsed_
+ Pop ${_RES_ELAPSED_SECONDS}
+!macroend
+
+!define GetSecondsElapsed "!insertmacro GetSecondsElapsedCall"
+!define un.GetSecondsElapsed "!insertmacro GetSecondsElapsedCall"
+
+!macro GetSecondsElapsed_
+ Exch $0 ; finish tick count
+ Exch 1
+ Exch $1 ; start tick count
+
+ System::Int64Op $0 - $1
+ Pop $0
+ ; Discard the top bits of the int64 by bitmasking with MAXDWORD
+ System::Int64Op $0 & ${MAXDWORD}
+ Pop $0
+
+ ; Convert from milliseconds to seconds
+ System::Int64Op $0 / 1000
+ Pop $0
+
+ Pop $1
+ Exch $0 ; return elapsed seconds
+!macroend
+
+/**
+ * Create a process to execute a command line. If it is successfully created,
+ * wait on it with WaitForInputIdle, to avoid exiting the current process too
+ * early (exiting early can cause the created process's windows to be opened in
+ * the background).
+ *
+ * CMDLINE Is the command line to execute, like the argument to Exec
+ */
+!define ExecAndWaitForInputIdle "!insertmacro ExecAndWaitForInputIdle_"
+!define CREATE_DEFAULT_ERROR_MODE 0x04000000
+!macro ExecAndWaitForInputIdle_ CMDLINE
+ ; derived from https://stackoverflow.com/a/13960786/3444805 by Anders Kjersem
+ Push $0
+ Push $1
+ Push $2
+
+ ; Command line
+ StrCpy $0 "${CMDLINE}"
+
+ ; STARTUPINFO
+ System::Alloc 68
+ Pop $1
+ ; fill in STARTUPINFO.cb (first field) with sizeof(STARTUPINFO)
+ System::Call "*$1(i 68)"
+
+ ; PROCESS_INFORMATION
+ System::Call "*(i, i, i, i) i . r2"
+
+ ; CREATE_DEFAULT_ERROR_MODE follows NSIS myCreateProcess used in Exec
+ System::Call "kernel32::CreateProcessW(i 0, t r0, i 0, i 0, i 0, i ${CREATE_DEFAULT_ERROR_MODE}, i 0, i 0, i r1, i r2) i . r0"
+
+ System::Free $1
+ ${If} $0 <> 0
+ System::Call "*$2(i . r0, i . r1)"
+ ; $0: hProcess, $1: hThread
+ System::Call "user32::WaitForInputIdle(i $0, i 10000)"
+ System::Call "kernel32::CloseHandle(i $0)"
+ System::Call "kernel32::CloseHandle(i $1)"
+ ${EndIf}
+ System::Free $2
+
+ Pop $2
+ Pop $1
+ Pop $0
+!macroend
+
+Function WriteRegQWORD
+ ; Stack contents:
+ ; VALUE, VALUE_NAME, SUBKEY, ROOTKEY
+ Exch $3 ; $3, VALUE_NAME, SUBKEY, ROOTKEY
+ Exch 1 ; VALUE_NAME, $3, SUBKEY, ROOTKEY
+ Exch $2 ; $2, $3, SUBKEY, ROOTKEY
+ Exch 2 ; SUBKEY, $3, $2, ROOTKEY
+ Exch $1 ; $1, $3, $2, ROOTKEY
+ Exch 3 ; ROOTKEY, $3, $2, $1
+ Exch $0 ; $0, $3, $2, $1
+ System::Call "advapi32::RegSetKeyValueW(p r0, w r1, w r2, i 11, *l r3, i 8) i.r0"
+ ${IfNot} $0 = 0
+ SetErrors
+ ${EndIf}
+ Pop $0
+ Pop $3
+ Pop $2
+ Pop $1
+FunctionEnd
+!macro WriteRegQWORD ROOTKEY SUBKEY VALUE_NAME VALUE
+ ${If} "${ROOTKEY}" == "HKCR"
+ Push 0x80000000
+ ${ElseIf} "${ROOTKEY}" == "HKCU"
+ Push 0x80000001
+ ${ElseIf} "${ROOTKEY}" == "HKLM"
+ Push 0x80000002
+ ${Endif}
+ Push "${SUBKEY}"
+ Push "${VALUE_NAME}"
+ System::Int64Op ${VALUE} + 0 ; The result is pushed on the stack
+ Call WriteRegQWORD
+!macroend
+!define WriteRegQWORD "!insertmacro WriteRegQWORD"
+
+Function ReadRegQWORD
+ ; Stack contents:
+ ; VALUE_NAME, SUBKEY, ROOTKEY
+ Exch $2 ; $2, SUBKEY, ROOTKEY
+ Exch 1 ; SUBKEY, $2, ROOTKEY
+ Exch $1 ; $1, $2, ROOTKEY
+ Exch 2 ; ROOTKEY, $2, $1
+ Exch $0 ; $0, $2, $1
+ System::Call "advapi32::RegGetValueW(p r0, w r1, w r2, i 0x48, p 0, *l s, *i 8) i.r0"
+ ${IfNot} $0 = 0
+ SetErrors
+ ${EndIf}
+ ; VALUE, $0, $2, $1
+ Exch 3 ; $1, $0, $2, VALUE
+ Pop $1 ; $0, $2, VALUE
+ Pop $0 ; $2, VALUE
+ Pop $2 ; VALUE
+FunctionEnd
+!macro ReadRegQWORD DEST ROOTKEY SUBKEY VALUE_NAME
+ ${If} "${ROOTKEY}" == "HKCR"
+ Push 0x80000000
+ ${ElseIf} "${ROOTKEY}" == "HKCU"
+ Push 0x80000001
+ ${ElseIf} "${ROOTKEY}" == "HKLM"
+ Push 0x80000002
+ ${Endif}
+ Push "${SUBKEY}"
+ Push "${VALUE_NAME}"
+ Call ReadRegQWORD
+ Pop ${DEST}
+!macroend
+!define ReadRegQWORD "!insertmacro ReadRegQWORD"
+
diff --git a/toolkit/mozapps/installer/windows/nsis/locale-fonts.nsh b/toolkit/mozapps/installer/windows/nsis/locale-fonts.nsh
new file mode 100644
index 0000000000..0738a0ff1e
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/locale-fonts.nsh
@@ -0,0 +1,675 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+; Acholi
+!if "${AB_CD}" == "ach"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Afrikaans
+!if "${AB_CD}" == "af"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Akan
+!if "${AB_CD}" == "ak"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Aragonese
+!if "${AB_CD}" == "an"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Arabic
+!if "${AB_CD}" == "ar"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Assamese
+!if "${AB_CD}" == "as"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Asturian
+!if "${AB_CD}" == "ast"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Azerbaijani
+!if "${AB_CD}" == "az"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Belarusian
+!if "${AB_CD}" == "be"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Bulgarian
+!if "${AB_CD}" == "bg"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Bengali
+!if "${AB_CD}" == "bn"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Breton
+!if "${AB_CD}" == "br"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Bosnian
+!if "${AB_CD}" == "bs"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Catalan
+!if "${AB_CD}" == "ca"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Czech
+!if "${AB_CD}" == "cs"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Kashubian
+!if "${AB_CD}" == "csb"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Welsh
+!if "${AB_CD}" == "cy"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Danish
+!if "${AB_CD}" == "da"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; German
+!if "${AB_CD}" == "de"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Greek
+!if "${AB_CD}" == "el"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; English - Great Britain
+!if "${AB_CD}" == "en-GB"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; English - United States
+!if "${AB_CD}" == "en-US"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; English - South Africa
+!if "${AB_CD}" == "en-ZA"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Esperanto
+!if "${AB_CD}" == "eo"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Spanish - Argentina
+!if "${AB_CD}" == "es-AR"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Spanish - Chile
+!if "${AB_CD}" == "es-CL"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Spanish
+!if "${AB_CD}" == "es-ES"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Spanish - Mexico
+!if "${AB_CD}" == "es-MX"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Estonian
+!if "${AB_CD}" == "et"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Basque
+!if "${AB_CD}" == "eu"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Persian
+!if "${AB_CD}" == "fa"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Fulah
+!if "${AB_CD}" == "ff"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Finnish
+!if "${AB_CD}" == "fi"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; French
+!if "${AB_CD}" == "fr"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Frisian
+!if "${AB_CD}" == "fy-NL"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Irish
+!if "${AB_CD}" == "ga-IE"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Scottish Gaelic
+!if "${AB_CD}" == "gd"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Galician
+!if "${AB_CD}" == "gl"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Gujarati
+!if "${AB_CD}" == "gu-IN"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Hawaiian
+!if "${AB_CD}" == "haw"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Hebrew
+!if "${AB_CD}" == "he"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; hindi
+!if "${AB_CD}" == "hi-IN"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Croatian
+!if "${AB_CD}" == "hr"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Upper Sorbian
+!if "${AB_CD}" == "hsb"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Hungarian
+!if "${AB_CD}" == "hu"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Armenian
+!if "${AB_CD}" == "hy-AM"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Indonesian
+!if "${AB_CD}" == "id"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Iloko
+!if "${AB_CD}" == "ilo"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Icelandic
+!if "${AB_CD}" == "is"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Italian
+!if "${AB_CD}" == "it"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Japanese
+!if "${AB_CD}" == "ja"
+!define FONT_NAME1 "Meiryo UI"
+!define FONT_FILE1 "meiryo.ttc"
+!endif
+
+; Georgian
+!if "${AB_CD}" == "ka"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Kazakh
+!if "${AB_CD}" == "kk"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Khmer
+!if "${AB_CD}" == "km"
+!define FONT_NAME1 "Leelawadee UI"
+!define FONT_FILE1 "LeelawUI.ttf"
+!endif
+
+; Kannada
+!if "${AB_CD}" == "kn"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Korean
+!if "${AB_CD}" == "ko"
+!define FONT_NAME1 "Malgun Gothic"
+!define FONT_FILE1 "malgun.ttf"
+!endif
+
+; Kurdish
+!if "${AB_CD}" == "ku"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Luganda
+!if "${AB_CD}" == "lg"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Ligurian
+!if "${AB_CD}" == "lij"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Lithuanian
+!if "${AB_CD}" == "lt"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Latvian
+!if "${AB_CD}" == "lv"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Maithili
+!if "${AB_CD}" == "mai"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Macedonian
+!if "${AB_CD}" == "mk"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Malayalam
+!if "${AB_CD}" == "ml"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Mongolian
+!if "${AB_CD}" == "mn"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Marathi
+!if "${AB_CD}" == "mr"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Malay
+!if "${AB_CD}" == "ms"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Burmese
+!if "${AB_CD}" == "my"
+!define FONT_NAME1 "Myanmar Text"
+!define FONT_FILE1 "mmrtext.ttf"
+!endif
+
+; Norwegian Bokmål
+!if "${AB_CD}" == "nb-NO"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Nepali Nepal
+!if "${AB_CD}" == "ne-NP"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Dutch
+!if "${AB_CD}" == "nl"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Norwegian Nynorsk
+!if "${AB_CD}" == "nn-NO"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Southern Ndebele
+!if "${AB_CD}" == "nr"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Northern Sotho
+!if "${AB_CD}" == "nso"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Occitan
+!if "${AB_CD}" == "oc"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Odia
+!if "${AB_CD}" == "or"
+!define FONT_NAME1 "Kalinga"
+!define FONT_FILE1 "kalinga.ttf"
+!endif
+
+; Punjabi
+!if "${AB_CD}" == "pa-IN"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Polish
+!if "${AB_CD}" == "pl"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Portugese - Brazil
+!if "${AB_CD}" == "pt-BR"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Portugese
+!if "${AB_CD}" == "pt-PT"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Romansh
+!if "${AB_CD}" == "rm"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Romanian
+!if "${AB_CD}" == "ro"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Russian
+!if "${AB_CD}" == "ru"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Kinyarwanda
+!if "${AB_CD}" == "rw"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Sakha
+!if "${AB_CD}" == "sah"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Sinhala
+!if "${AB_CD}" == "si"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Slovak
+!if "${AB_CD}" == "sk"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Slovene
+!if "${AB_CD}" == "sl"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Songhay
+!if "${AB_CD}" == "son"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Albanian
+!if "${AB_CD}" == "sq"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Serbian
+!if "${AB_CD}" == "sr"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Swazi
+!if "${AB_CD}" == "ss"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Southern Sotho
+!if "${AB_CD}" == "st"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Swedish
+!if "${AB_CD}" == "sv-Se"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Swahili
+!if "${AB_CD}" == "sw"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Tamil
+!if "${AB_CD}" == "ta"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Tamil - Sri Lanka
+!if "${AB_CD}" == "ta-LK"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Telugu
+!if "${AB_CD}" == "te"
+!define FONT_NAME1 "Nirmala UI"
+!define FONT_FILE1 "Nirmala.ttf"
+!endif
+
+; Thai
+!if "${AB_CD}" == "th"
+!define FONT_NAME1 "Leelawadee UI"
+!define FONT_FILE1 "LeelawUI.ttf"
+!endif
+
+; Tswana
+!if "${AB_CD}" == "tn"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Turkish
+!if "${AB_CD}" == "tr"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Tsonga
+!if "${AB_CD}" == "ts"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Uyghur
+!if "${AB_CD}" == "ug"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Ukrainian
+!if "${AB_CD}" == "uk"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Urdu
+!if "${AB_CD}" == "ur"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Venda
+!if "${AB_CD}" == "ve"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Vietnamese
+!if "${AB_CD}" == "vi"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Wolof
+!if "${AB_CD}" == "wo"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Xhosa
+!if "${AB_CD}" == "xh"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
+
+; Chinese (Simplified)
+!if "${AB_CD}" == "zh-CN"
+!define FONT_NAME1 "Microsoft YaHei UI"
+!define FONT_FILE1 "msyh.ttc"
+!endif
+
+; Chinese (Traditional)
+!if "${AB_CD}" == "zh-TW"
+!define FONT_NAME1 "Microsoft JhengHei UI"
+!define FONT_FILE1 "msjh.ttc"
+!endif
+
+; Zulu
+!if "${AB_CD}" == "zu"
+!define FONT_NAME1 "Segoe UI"
+!define FONT_FILE1 "segoeui.ttf"
+!endif
diff --git a/toolkit/mozapps/installer/windows/nsis/locale-rtl.nlf b/toolkit/mozapps/installer/windows/nsis/locale-rtl.nlf
new file mode 100644
index 0000000000..a4ea9ae6e1
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/locale-rtl.nlf
@@ -0,0 +1,12 @@
+# Header, don't edit
+NLF v6
+# Start editing here
+# Language ID
+0
+# Font and size - dash (-) means default
+-
+-
+# Codepage - dash (-) means ANSI code page
+-
+# RTL - anything else than RTL means LTR
+RTL
diff --git a/toolkit/mozapps/installer/windows/nsis/locale.nlf b/toolkit/mozapps/installer/windows/nsis/locale.nlf
new file mode 100644
index 0000000000..0995026a0f
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/locale.nlf
@@ -0,0 +1,12 @@
+# Header, don't edit
+NLF v6
+# Start editing here
+# Language ID
+0
+# Font and size - dash (-) means default
+-
+-
+# Codepage - dash (-) means ANSI code page
+-
+# RTL - anything else than RTL means LTR
+-
diff --git a/toolkit/mozapps/installer/windows/nsis/locales.nsi b/toolkit/mozapps/installer/windows/nsis/locales.nsi
new file mode 100755
index 0000000000..938aef47f6
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/locales.nsi
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+/**
+ * "One off" locale configuration settings for RTL (e.g. locale text is read
+ * right to left).
+ */
+
+; Arabic
+!define ar_rtl
+
+; Hebrew
+!define he_rtl
+
+; Persian
+!define fa_rtl
+
+; Uyghur
+!define ug_rtl
+
+; Urdu
+!define ur_rtl
diff --git a/toolkit/mozapps/installer/windows/nsis/makensis.mk b/toolkit/mozapps/installer/windows/nsis/makensis.mk
new file mode 100755
index 0000000000..97667f4b00
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/makensis.mk
@@ -0,0 +1,133 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 CONFIG_DIR
+$(error CONFIG_DIR must be set before including makensis.mk)
+endif
+
+ABS_CONFIG_DIR := $(abspath $(CONFIG_DIR))
+
+SFX_MODULE ?= $(error SFX_MODULE is not defined)
+
+ifeq ($(TARGET_CPU), aarch64)
+USE_UPX :=
+else
+ifneq (,$(UPX)$(MOZ_AUTOMATION))
+USE_UPX := --use-upx
+endif
+endif
+
+TOOLKIT_NSIS_FILES = \
+ common.nsh \
+ locale.nlf \
+ locale-fonts.nsh \
+ locale-rtl.nlf \
+ locales.nsi \
+ overrides.nsh \
+ setup.ico \
+ $(NULL)
+
+CUSTOM_NSIS_PLUGINS = \
+ AccessControl.dll \
+ AppAssocReg.dll \
+ ApplicationID.dll \
+ BitsUtils.dll \
+ CertCheck.dll \
+ CityHash.dll \
+ ExecInExplorer.dll \
+ HttpPostFile.dll \
+ InetBgDL.dll \
+ InvokeShellVerb.dll \
+ liteFirewallW.dll \
+ nsJSON.dll \
+ PinToTaskbar.dll \
+ ServicesHelper.dll \
+ ShellLink.dll \
+ UAC.dll \
+ WebBrowser.dll \
+ $(NULL)
+
+CUSTOM_UI = \
+ nsisui.exe \
+ $(NULL)
+
+$(CONFIG_DIR)/setup.exe::
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/toolkit/mozapps/installer/windows/nsis/,$(TOOLKIT_NSIS_FILES)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/other-licenses/nsis/Plugins/,$(CUSTOM_NSIS_PLUGINS)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/other-licenses/nsis/,$(CUSTOM_UI)) $(CONFIG_DIR)
+ cd $(CONFIG_DIR) && $(MAKENSISU) $(MAKENSISU_FLAGS) installer.nsi
+ifdef MOZ_STUB_INSTALLER
+ cd $(CONFIG_DIR) && $(MAKENSISU) $(MAKENSISU_FLAGS) stub.nsi
+endif
+
+ifdef ZIP_IN
+installer:: $(CONFIG_DIR)/setup.exe $(ZIP_IN)
+ @echo 'Packaging $(WIN32_INSTALLER_OUT).'
+ $(NSINSTALL) -D '$(ABS_DIST)/$(PKG_INST_PATH)'
+ $(PYTHON3) $(MOZILLA_DIR)/mach repackage installer \
+ -o '$(ABS_DIST)/$(PKG_INST_PATH)$(PKG_INST_BASENAME).exe' \
+ --package-name '$(MOZ_PKG_DIR)' \
+ --package '$(ZIP_IN)' \
+ --tag $(topsrcdir)/$(MOZ_BUILD_APP)/installer/windows/app.tag \
+ --setupexe $(CONFIG_DIR)/setup.exe \
+ --sfx-stub $(SFX_MODULE) \
+ $(USE_UPX)
+ifdef MOZ_STUB_INSTALLER
+ $(PYTHON3) $(MOZILLA_DIR)/mach repackage installer \
+ -o '$(ABS_DIST)/$(PKG_INST_PATH)$(PKG_STUB_BASENAME).exe' \
+ --tag $(topsrcdir)/browser/installer/windows/stub.tag \
+ --setupexe $(CONFIG_DIR)/setup-stub.exe \
+ --sfx-stub $(SFX_MODULE) \
+ $(USE_UPX)
+endif
+else
+installer::
+ $(error ZIP_IN must be set when building installer)
+endif
+
+HELPER_DEPS = $(GLOBAL_DEPS) \
+ $(addprefix $(srcdir)/,$(INSTALLER_FILES)) \
+ $(addprefix $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/,$(BRANDING_FILES)) \
+ $(srcdir)/nsis/defines.nsi.in \
+ $(topsrcdir)/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py \
+ $(addprefix $(MOZILLA_DIR)/toolkit/mozapps/installer/windows/nsis/,$(TOOLKIT_NSIS_FILES)) \
+ $(addprefix $(MOZILLA_DIR)/other-licenses/nsis/Plugins/,$(CUSTOM_NSIS_PLUGINS))
+
+# For building the uninstaller during the application build so it can be
+# included for mar file generation.
+$(CONFIG_DIR)/helper.exe: $(HELPER_DEPS)
+ $(RM) -r $(CONFIG_DIR)
+ $(MKDIR) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(srcdir)/,$(INSTALLER_FILES)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/,$(BRANDING_FILES)) $(CONFIG_DIR)
+ $(call py_action,preprocessor defines.nsi,-Fsubstitution $(DEFINES) $(ACDEFINES) \
+ $(srcdir)/nsis/defines.nsi.in -o $(CONFIG_DIR)/defines.nsi)
+ $(PYTHON3) $(topsrcdir)/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py \
+ --preprocess-locale $(topsrcdir) \
+ $(PPL_LOCALE_ARGS) $(AB_CD) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/toolkit/mozapps/installer/windows/nsis/,$(TOOLKIT_NSIS_FILES)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/other-licenses/nsis/Plugins/,$(CUSTOM_NSIS_PLUGINS)) $(CONFIG_DIR)
+ cd $(CONFIG_DIR) && $(MAKENSISU) $(MAKENSISU_FLAGS) uninstaller.nsi
+
+uninstaller:: $(CONFIG_DIR)/helper.exe
+ $(NSINSTALL) -D $(DIST)/bin/uninstall
+ cp $(CONFIG_DIR)/helper.exe $(DIST)/bin/uninstall
+
+ifdef MOZ_MAINTENANCE_SERVICE
+maintenanceservice_installer::
+ $(RM) -r $(CONFIG_DIR)
+ $(MKDIR) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(srcdir)/,$(INSTALLER_FILES)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(topsrcdir)/$(MOZ_BRANDING_DIRECTORY)/,$(BRANDING_FILES)) $(CONFIG_DIR)
+ $(call py_action,preprocessor defines.nsi,-Fsubstitution $(DEFINES) $(ACDEFINES) \
+ $(srcdir)/nsis/defines.nsi.in -o $(CONFIG_DIR)/defines.nsi)
+ $(PYTHON3) $(topsrcdir)/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py \
+ --preprocess-locale $(topsrcdir) \
+ $(PPL_LOCALE_ARGS) $(AB_CD) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/toolkit/mozapps/installer/windows/nsis/,$(TOOLKIT_NSIS_FILES)) $(CONFIG_DIR)
+ $(INSTALL) $(addprefix $(MOZILLA_DIR)/other-licenses/nsis/Plugins/,$(CUSTOM_NSIS_PLUGINS)) $(CONFIG_DIR)
+ cd $(CONFIG_DIR) && $(MAKENSISU) $(MAKENSISU_FLAGS) maintenanceservice_installer.nsi
+ $(NSINSTALL) -D $(DIST)/bin/
+ cp $(CONFIG_DIR)/maintenanceservice_installer.exe $(DIST)/bin
+endif
diff --git a/toolkit/mozapps/installer/windows/nsis/overrides.nsh b/toolkit/mozapps/installer/windows/nsis/overrides.nsh
new file mode 100755
index 0000000000..a8f05622da
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/overrides.nsh
@@ -0,0 +1,610 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+################################################################################
+# Modified versions of macros provided by NSIS
+
+!ifndef OVERRIDES_INCLUDED
+!define OVERRIDES_INCLUDED
+
+!ifndef ___X64__NSH___
+!include x64.nsh
+!endif
+
+; When including a file check if its verbose macro is defined to prevent
+; loading the file a second time.
+!ifmacrondef TEXTFUNC_VERBOSE
+!include TextFunc.nsh
+!endif
+
+!ifmacrondef FILEFUNC_VERBOSE
+!include FileFunc.nsh
+!endif
+
+; This was added to NSIS 3.0.4 and is needed for Windows ARM64 support
+!ifmacrondef GetNativeMachineArchitecture
+!define GetNativeMachineArchitecture "!insertmacro GetNativeMachineArchitecture "
+!macro GetNativeMachineArchitecture outvar
+ !define GetNativeMachineArchitecture_lbl lbl_GNMA_${__COUNTER__}
+ System::Call kernel32::GetCurrentProcess()p.s
+ System::Call kernel32::IsWow64Process2(ps,*i,*i0s)
+ Pop ${outvar}
+ IntCmp ${outvar} 0 "" ${GetNativeMachineArchitecture_lbl}_done ${GetNativeMachineArchitecture_lbl}_done
+ !if "${NSIS_PTR_SIZE}" <= 4
+ !if "${NSIS_CHAR_SIZE}" <= 1
+ System::Call 'USER32::CharNextW(w"")p.s'
+ Pop ${outvar}
+ IntPtrCmpU ${outvar} 0 "" ${GetNativeMachineArchitecture_lbl}_oldnt ${GetNativeMachineArchitecture_lbl}_oldnt
+ StrCpy ${outvar} 332 ; Always IMAGE_FILE_MACHINE_I386 on Win9x
+ Goto ${GetNativeMachineArchitecture_lbl}_done
+ ${GetNativeMachineArchitecture_lbl}_oldnt:
+ !endif
+ !endif
+ System::Call '*0x7FFE002E(&i2.s)'
+ Pop ${outvar}
+ ${GetNativeMachineArchitecture_lbl}_done:
+ !undef GetNativeMachineArchitecture_lbl
+!macroend
+
+!macro _IsNativeMachineArchitecture _ignore _arc _t _f
+ !insertmacro _LOGICLIB_TEMP
+ ${GetNativeMachineArchitecture} $_LOGICLIB_TEMP
+ !insertmacro _= $_LOGICLIB_TEMP ${_arc} `${_t}` `${_f}`
+!macroend
+
+!define IsNativeMachineArchitecture `"" IsNativeMachineArchitecture `
+!define IsNativeIA32 '${IsNativeMachineArchitecture} 332' ; Intel x86
+!define IsNativeAMD64 '${IsNativeMachineArchitecture} 34404' ; x86-64/x64
+!define IsNativeARM64 '${IsNativeMachineArchitecture} 43620'
+!endif
+
+!verbose push
+!verbose 3
+!ifndef _OVERRIDE_VERBOSE
+ !define _OVERRIDE_VERBOSE 3
+!endif
+!verbose ${_OVERRIDE_VERBOSE}
+!define OVERRIDE_VERBOSE `!insertmacro OVERRIDE_VERBOSE`
+!define _OVERRIDE_UN
+!define _OVERRIDE_S
+!verbose pop
+
+!macro OVERRIDE_VERBOSE _VERBOSE
+ !verbose push
+ !verbose 3
+ !undef _OVERRIDE_VERBOSE
+ !define _OVERRIDE_VERBOSE ${_VERBOSE}
+ !verbose pop
+!macroend
+
+; Modified version of Locate from the NSIS File Functions Header v3.4 (it has
+; the same version in earlier versions of NSIS even though it has changed) that
+; is distributed with NSIS v2.46-Unicode. This version has the calls to
+; SetDetailsPrint commented out.
+; See <NSIS v2.46-Unicode App Dir>/include/FileFunc.nsh for more information.
+!macro LocateNoDetailsCall _PATH _OPTIONS _FUNC
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+ Push $0
+ Push `${_PATH}`
+ Push `${_OPTIONS}`
+ GetFunctionAddress $0 `${_FUNC}`
+ Push `$0`
+ Call LocateNoDetails
+ Pop $0
+ !verbose pop
+!macroend
+
+!macro LocateNoDetails
+ !ifndef ${_OVERRIDE_UN}LocateNoDetails
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+ !define ${_OVERRIDE_UN}LocateNoDetails `!insertmacro ${_OVERRIDE_UN}LocateNoDetailsCall`
+
+ Function ${_OVERRIDE_UN}LocateNoDetails
+ Exch $2
+ Exch
+ Exch $1
+ Exch
+ Exch 2
+ Exch $0
+ Exch 2
+ Push $3
+ Push $4
+ Push $5
+ Push $6
+ Push $7
+ Push $8
+ Push $9
+ Push $R6
+ Push $R7
+ Push $R8
+ Push $R9
+ ClearErrors
+
+ StrCpy $3 ''
+ StrCpy $4 ''
+ StrCpy $5 ''
+ StrCpy $6 ''
+ StrCpy $7 ''
+ StrCpy $8 0
+ StrCpy $R7 ''
+
+ StrCpy $R9 $0 1 -1
+ StrCmp $R9 '\' 0 +3
+ StrCpy $0 $0 -1
+ goto -3
+ IfFileExists '$0\*.*' 0 error
+
+ option:
+ StrCpy $R9 $1 1
+ StrCpy $1 $1 '' 1
+ StrCmp $R9 ' ' -2
+ StrCmp $R9 '' sizeset
+ StrCmp $R9 '/' 0 -4
+ StrCpy $9 -1
+ IntOp $9 $9 + 1
+ StrCpy $R9 $1 1 $9
+ StrCmp $R9 '' +2
+ StrCmp $R9 '/' 0 -3
+ StrCpy $R8 $1 $9
+ StrCpy $R8 $R8 '' 2
+ StrCpy $R9 $R8 '' -1
+ StrCmp $R9 ' ' 0 +3
+ StrCpy $R8 $R8 -1
+ goto -3
+ StrCpy $R9 $1 2
+ StrCpy $1 $1 '' $9
+
+ StrCmp $R9 'L=' 0 mask
+ StrCpy $3 $R8
+ StrCmp $3 '' +6
+ StrCmp $3 'FD' +5
+ StrCmp $3 'F' +4
+ StrCmp $3 'D' +3
+ StrCmp $3 'DE' +2
+ StrCmp $3 'FDE' 0 error
+ goto option
+
+ mask:
+ StrCmp $R9 'M=' 0 size
+ StrCpy $4 $R8
+ goto option
+
+ size:
+ StrCmp $R9 'S=' 0 gotosubdir
+ StrCpy $6 $R8
+ goto option
+
+ gotosubdir:
+ StrCmp $R9 'G=' 0 banner
+ StrCpy $7 $R8
+ StrCmp $7 '' +3
+ StrCmp $7 '1' +2
+ StrCmp $7 '0' 0 error
+ goto option
+
+ banner:
+ StrCmp $R9 'B=' 0 error
+ StrCpy $R7 $R8
+ StrCmp $R7 '' +3
+ StrCmp $R7 '1' +2
+ StrCmp $R7 '0' 0 error
+ goto option
+
+ sizeset:
+ StrCmp $6 '' default
+ StrCpy $9 0
+ StrCpy $R9 $6 1 $9
+ StrCmp $R9 '' +4
+ StrCmp $R9 ':' +3
+ IntOp $9 $9 + 1
+ goto -4
+ StrCpy $5 $6 $9
+ IntOp $9 $9 + 1
+ StrCpy $1 $6 1 -1
+ StrCpy $6 $6 -1 $9
+ StrCmp $5 '' +2
+ IntOp $5 $5 + 0
+ StrCmp $6 '' +2
+ IntOp $6 $6 + 0
+
+ StrCmp $1 'B' 0 +3
+ StrCpy $1 1
+ goto default
+ StrCmp $1 'K' 0 +3
+ StrCpy $1 1024
+ goto default
+ StrCmp $1 'M' 0 +3
+ StrCpy $1 1048576
+ goto default
+ StrCmp $1 'G' 0 error
+ StrCpy $1 1073741824
+
+ default:
+ StrCmp $3 '' 0 +2
+ StrCpy $3 'FD'
+ StrCmp $4 '' 0 +2
+ StrCpy $4 '*.*'
+ StrCmp $7 '' 0 +2
+ StrCpy $7 '1'
+ StrCmp $R7 '' 0 +2
+ StrCpy $R7 '0'
+ StrCpy $7 'G$7B$R7'
+
+ StrCpy $8 1
+ Push $0
+; SetDetailsPrint textonly
+
+ nextdir:
+ IntOp $8 $8 - 1
+ Pop $R8
+
+ StrCpy $9 $7 2 2
+ StrCmp $9 'B0' +3
+ GetLabelAddress $9 findfirst
+ goto call
+; DetailPrint 'Search in: $R8'
+
+ findfirst:
+ FindFirst $0 $R7 '$R8\$4'
+ IfErrors subdir
+ StrCmp $R7 '.' 0 dir
+ FindNext $0 $R7
+ StrCmp $R7 '..' 0 dir
+ FindNext $0 $R7
+ IfErrors 0 dir
+ FindClose $0
+ goto subdir
+
+ dir:
+ IfFileExists '$R8\$R7\*.*' 0 file
+ StrCpy $R6 ''
+ StrCmp $3 'DE' +4
+ StrCmp $3 'FDE' +3
+ StrCmp $3 'FD' precall
+ StrCmp $3 'F' findnext precall
+ FindFirst $9 $R9 '$R8\$R7\*.*'
+ StrCmp $R9 '.' 0 +4
+ FindNext $9 $R9
+ StrCmp $R9 '..' 0 +2
+ FindNext $9 $R9
+ FindClose $9
+ IfErrors precall findnext
+
+ file:
+ StrCmp $3 'FDE' +3
+ StrCmp $3 'FD' +2
+ StrCmp $3 'F' 0 findnext
+ StrCpy $R6 0
+ StrCmp $5$6 '' precall
+ FileOpen $9 '$R8\$R7' r
+ IfErrors +3
+ FileSeek $9 0 END $R6
+ FileClose $9
+ System::Int64Op $R6 / $1
+ Pop $R6
+ StrCmp $5 '' +2
+ IntCmp $R6 $5 0 findnext
+ StrCmp $6 '' +2
+ IntCmp $R6 $6 0 0 findnext
+
+ precall:
+ StrCpy $9 0
+ StrCpy $R9 '$R8\$R7'
+
+ call:
+ Push $0
+ Push $1
+ Push $2
+ Push $3
+ Push $4
+ Push $5
+ Push $6
+ Push $7
+ Push $8
+ Push $9
+ Push $R7
+ Push $R8
+ StrCmp $9 0 +4
+ StrCpy $R6 ''
+ StrCpy $R7 ''
+ StrCpy $R9 ''
+ Call $2
+ Pop $R9
+ Pop $R8
+ Pop $R7
+ Pop $9
+ Pop $8
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ Pop $2
+ Pop $1
+ Pop $0
+
+ IfErrors 0 +3
+ FindClose $0
+ goto error
+ StrCmp $R9 'StopLocateNoDetails' 0 +3
+ FindClose $0
+ goto clearstack
+ goto $9
+
+ findnext:
+ FindNext $0 $R7
+ IfErrors 0 dir
+ FindClose $0
+
+ subdir:
+ StrCpy $9 $7 2
+ StrCmp $9 'G0' end
+ FindFirst $0 $R7 '$R8\*.*'
+ StrCmp $R7 '.' 0 pushdir
+ FindNext $0 $R7
+ StrCmp $R7 '..' 0 pushdir
+ FindNext $0 $R7
+ IfErrors 0 pushdir
+ FindClose $0
+ StrCmp $8 0 end nextdir
+
+ pushdir:
+ IfFileExists '$R8\$R7\*.*' 0 +3
+ Push '$R8\$R7'
+ IntOp $8 $8 + 1
+ FindNext $0 $R7
+ IfErrors 0 pushdir
+ FindClose $0
+ StrCmp $8 0 end nextdir
+
+ error:
+ SetErrors
+
+ clearstack:
+ StrCmp $8 0 end
+ IntOp $8 $8 - 1
+ Pop $R8
+ goto clearstack
+
+ end:
+; SetDetailsPrint both
+ Pop $R9
+ Pop $R8
+ Pop $R7
+ Pop $R6
+ Pop $9
+ Pop $8
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ Pop $2
+ Pop $1
+ Pop $0
+ FunctionEnd
+
+ !verbose pop
+ !endif
+!macroend
+
+!macro un.LocateNoDetailsCall _PATH _OPTIONS _FUNC
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+ Push $0
+ Push `${_PATH}`
+ Push `${_OPTIONS}`
+ GetFunctionAddress $0 `${_FUNC}`
+ Push `$0`
+ Call un.LocateNoDetails
+ Pop $0
+ !verbose pop
+!macroend
+
+!macro un.LocateNoDetails
+ !ifndef un.LocateNoDetails
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+ !undef _OVERRIDE_UN
+ !define _OVERRIDE_UN `un.`
+
+ !insertmacro LocateNoDetails
+
+ !undef _OVERRIDE_UN
+ !define _OVERRIDE_UN
+ !verbose pop
+ !endif
+!macroend
+
+; Modified version of TextCompare from the NSIS Text Functions Header v2.4 (it
+; has the same version in earlier versions of NSIS even though it has changed)
+; that is distributed with NSIS v2.46-Unicode. This version has the calls to
+; SetDetailsPrint commented out.
+; See <NSIS v2.46-Unicode App Dir>/include/TextFunc.nsh for more information.
+!macro TextCompareNoDetailsCall _FILE1 _FILE2 _OPTION _FUNC
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+ Push $0
+ Push `${_FILE1}`
+ Push `${_FILE2}`
+ Push `${_OPTION}`
+ GetFunctionAddress $0 `${_FUNC}`
+ Push `$0`
+ ${CallArtificialFunction} TextCompareNoDetails_
+ Pop $0
+ !verbose pop
+!macroend
+
+!macro TextCompareNoDetailsSCall _FILE1 _FILE2 _OPTION _FUNC
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+ Push $0
+ Push `${_FILE1}`
+ Push `${_FILE2}`
+ Push `${_OPTION}`
+ GetFunctionAddress $0 `${_FUNC}`
+ Push `$0`
+ ${CallArtificialFunction} TextCompareNoDetailsS_
+ Pop $0
+ !verbose pop
+!macroend
+
+
+!macro TextCompareNoDetailsBody _OVERRIDE_S
+ Exch $3
+ Exch
+ Exch $2
+ Exch
+ Exch 2
+ Exch $1
+ Exch 2
+ Exch 3
+ Exch $0
+ Exch 3
+ Push $4
+ Push $5
+ Push $6
+ Push $7
+ Push $8
+ Push $9
+ ClearErrors
+
+ IfFileExists $0 0 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_error
+ IfFileExists $1 0 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_error
+ StrCmp $2 'FastDiff' +5
+ StrCmp $2 'FastEqual' +4
+ StrCmp $2 'SlowDiff' +3
+ StrCmp $2 'SlowEqual' +2
+ goto TextFunc_TextCompareNoDetails${_OVERRIDE_S}_error
+
+ FileOpen $4 $0 r
+ IfErrors TextFunc_TextCompareNoDetails${_OVERRIDE_S}_error
+ FileOpen $5 $1 r
+ IfErrors TextFunc_TextCompareNoDetails${_OVERRIDE_S}_error
+; SetDetailsPrint textonly
+
+ StrCpy $6 0
+ StrCpy $8 0
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_nextline:
+ StrCmp${_OVERRIDE_S} $4 '' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_fast
+ IntOp $8 $8 + 1
+ FileRead $4 $9
+ IfErrors 0 +4
+ FileClose $4
+ StrCpy $4 ''
+ StrCmp${_OVERRIDE_S} $5 '' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_end
+ StrCmp $2 'FastDiff' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_fast
+ StrCmp $2 'FastEqual' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_fast TextFunc_TextCompareNoDetails${_OVERRIDE_S}_slow
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_fast:
+ StrCmp${_OVERRIDE_S} $5 '' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_call
+ IntOp $6 $6 + 1
+ FileRead $5 $7
+ IfErrors 0 +5
+ FileClose $5
+ StrCpy $5 ''
+ StrCmp${_OVERRIDE_S} $4 '' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_end
+ StrCmp $2 'FastDiff' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_call TextFunc_TextCompareNoDetails${_OVERRIDE_S}_close
+ StrCmp $2 'FastDiff' 0 +2
+ StrCmp${_OVERRIDE_S} $7 $9 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_nextline TextFunc_TextCompareNoDetails${_OVERRIDE_S}_call
+ StrCmp${_OVERRIDE_S} $7 $9 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_call TextFunc_TextCompareNoDetails${_OVERRIDE_S}_nextline
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_slow:
+ StrCmp${_OVERRIDE_S} $4 '' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_close
+ StrCpy $6 ''
+; DetailPrint '$8. $9'
+ FileSeek $5 0
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_slownext:
+ FileRead $5 $7
+ IfErrors 0 +2
+ StrCmp $2 'SlowDiff' TextFunc_TextCompareNoDetails${_OVERRIDE_S}_call TextFunc_TextCompareNoDetails${_OVERRIDE_S}_nextline
+ StrCmp $2 'SlowDiff' 0 +2
+ StrCmp${_OVERRIDE_S} $7 $9 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_nextline TextFunc_TextCompareNoDetails${_OVERRIDE_S}_slownext
+ IntOp $6 $6 + 1
+ StrCmp${_OVERRIDE_S} $7 $9 0 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_slownext
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_call:
+ Push $2
+ Push $3
+ Push $4
+ Push $5
+ Push $6
+ Push $7
+ Push $8
+ Push $9
+ Call $3
+ Pop $0
+ Pop $9
+ Pop $8
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ Pop $2
+ StrCmp $0 'StopTextCompareNoDetails' 0 TextFunc_TextCompareNoDetails${_OVERRIDE_S}_nextline
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_close:
+ FileClose $4
+ FileClose $5
+ goto TextFunc_TextCompareNoDetails${_OVERRIDE_S}_end
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_error:
+ SetErrors
+
+ TextFunc_TextCompareNoDetails${_OVERRIDE_S}_end:
+; SetDetailsPrint both
+ Pop $9
+ Pop $8
+ Pop $7
+ Pop $6
+ Pop $5
+ Pop $4
+ Pop $3
+ Pop $2
+ Pop $1
+ Pop $0
+!macroend
+
+!define TextCompareNoDetails `!insertmacro TextCompareNoDetailsCall`
+!define un.TextCompareNoDetails `!insertmacro TextCompareNoDetailsCall`
+
+!macro TextCompareNoDetails
+!macroend
+
+!macro un.TextCompareNoDetails
+!macroend
+
+!macro TextCompareNoDetails_
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+
+ !insertmacro TextCompareNoDetailsBody ''
+
+ !verbose pop
+!macroend
+
+!define TextCompareNoDetailsS `!insertmacro TextCompareNoDetailsSCall`
+!define un.TextCompareNoDetailsS `!insertmacro TextCompareNoDetailsSCall`
+
+!macro TextCompareNoDetailsS
+!macroend
+
+!macro un.TextCompareNoDetailsS
+!macroend
+
+!macro TextCompareNoDetailsS_
+ !verbose push
+ !verbose ${_OVERRIDE_VERBOSE}
+
+ !insertmacro TextCompareNoDetailsBody 'S'
+
+ !verbose pop
+!macroend
+
+!endif
diff --git a/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py b/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py
new file mode 100644
index 0000000000..280ab3d085
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/preprocess-locale.py
@@ -0,0 +1,379 @@
+# preprocess-locale.py
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+# preprocess-locale.py provides two functions depending on the arguments passed
+# to it when invoked.
+#
+# Preprocesses installer locale properties files and creates a basic NSIS nlf
+# file when invoked with --preprocess-locale.
+#
+# Converts a UTF-8 file to a new UTF-16LE file when invoked with
+# --convert-utf8-utf16le.
+
+from codecs import BOM_UTF16_LE
+import io
+from os.path import join, isfile
+import sys
+from optparse import OptionParser
+
+
+def open_utf16le_file(path):
+ """
+ Returns an opened file object with a a UTF-16LE byte order mark.
+ """
+ fp = io.open(path, "w+b")
+ fp.write(BOM_UTF16_LE)
+ return fp
+
+
+def get_locale_strings(path, prefix, middle, add_cr):
+ """
+ Returns a string created by converting an installer locale properties file
+ into the format required by NSIS locale files.
+
+ Parameters:
+ path - the path to the installer locale properties file to preprocess
+ prefix - a string to prefix each line with
+ middle - a string to insert between the name and value for each line
+ add_cr - boolean for whether to add an NSIS carriage return before NSIS
+ linefeeds when there isn't one already
+ """
+ output = ""
+ fp = io.open(path, "r", encoding="utf-8")
+ for line in fp:
+ line = line.strip()
+ if line == "" or line[0] == "#":
+ continue
+
+ name, value = line.split("=", 1)
+ value = value.strip() # trim whitespace from the start and end
+ if value and value[-1] == '"' and value[0] == '"':
+ value = value[1:-1] # remove " from the start and end
+
+ if add_cr:
+ value = value.replace("\\n", "\\r\\n") # prefix $\n with $\r
+ value = value.replace("\\r\\r", "\\r") # replace $\r$\r with $\r
+
+ value = value.replace('"', '$\\"') # prefix " with $\
+ value = value.replace("\\r", "$\\r") # prefix \r with $
+ value = value.replace("\\n", "$\\n") # prefix \n with $
+ value = value.replace("\\t", "$\\t") # prefix \t with $
+
+ output += prefix + name.strip() + middle + ' "' + value + '"\n'
+ fp.close()
+ return output
+
+
+def lookup(path, l10ndirs):
+ for d in l10ndirs:
+ if isfile(join(d, path)):
+ return join(d, path)
+ return join(l10ndirs[-1], path)
+
+
+def preprocess_locale_files(config_dir, l10ndirs):
+ """
+ Preprocesses the installer localized properties files into the format
+ required by NSIS and creates a basic NSIS nlf file.
+
+ Parameters:
+ config_dir - the path to the destination directory
+ l10ndirs - list of paths to search for installer locale files
+ """
+
+ # Create the main NSIS language file
+ fp = open_utf16le_file(join(config_dir, "overrideLocale.nsh"))
+ locale_strings = get_locale_strings(
+ lookup("override.properties", l10ndirs), "LangString ^", " 0 ", False
+ )
+ fp.write(locale_strings.encode("utf-16-le"))
+ fp.close()
+
+ # Create the Modern User Interface language file
+ fp = open_utf16le_file(join(config_dir, "baseLocale.nsh"))
+ fp.write(
+ (
+ """;NSIS Modern User Interface - Language File
+;Compatible with Modern UI 1.68
+;Language: baseLocale (0)
+!insertmacro MOZ_MUI_LANGUAGEFILE_BEGIN \"baseLocale\"
+!define MUI_LANGNAME \"baseLocale\"
+"""
+ ).encode("utf-16-le")
+ )
+ locale_strings = get_locale_strings(
+ lookup("mui.properties", l10ndirs), "!define ", " ", True
+ )
+ fp.write(locale_strings.encode("utf-16-le"))
+ fp.write("!insertmacro MOZ_MUI_LANGUAGEFILE_END\n".encode("utf-16-le"))
+ fp.close()
+
+ # Create the custom language file for our custom strings
+ fp = open_utf16le_file(join(config_dir, "customLocale.nsh"))
+ locale_strings = get_locale_strings(
+ lookup("custom.properties", l10ndirs), "LangString ", " 0 ", True
+ )
+ fp.write(locale_strings.encode("utf-16-le"))
+ fp.close()
+
+
+def create_nlf_file(moz_dir, ab_cd, config_dir):
+ """
+ Create a basic NSIS nlf file.
+
+ Parameters:
+ moz_dir - the path to top source directory for the toolkit source
+ ab_cd - the locale code
+ config_dir - the path to the destination directory
+ """
+ rtl = "-"
+
+ # Check whether the locale is right to left from locales.nsi.
+ fp = io.open(
+ join(moz_dir, "toolkit/mozapps/installer/windows/nsis/locales.nsi"),
+ "r",
+ encoding="utf-8",
+ )
+ for line in fp:
+ line = line.strip()
+ if line == "!define " + ab_cd + "_rtl":
+ rtl = "RTL"
+ break
+
+ fp.close()
+
+ # Create the main NSIS language file with RTL for right to left locales
+ # along with the default codepage, font name, and font size represented
+ # by the '-' character.
+ fp = open_utf16le_file(join(config_dir, "baseLocale.nlf"))
+ fp.write(
+ (
+ """# Header, don't edit
+NLF v6
+# Start editing here
+# Language ID
+0
+# Font and size - dash (-) means default
+-
+-
+# Codepage - dash (-) means ANSI code page
+-
+# RTL - anything else than RTL means LTR
+%s
+"""
+ % rtl
+ ).encode("utf-16-le")
+ )
+ fp.close()
+
+
+def preprocess_locale_file(config_dir, l10ndirs, properties_filename, output_filename):
+ """
+ Preprocesses a single localized properties file into the format
+ required by NSIS and creates a basic NSIS nlf file.
+
+ Parameters:
+ config_dir - the path to the destination directory
+ l10ndirs - list of paths to search for installer locale files
+ properties_filename - the name of the properties file to search for
+ output_filename - the output filename to write
+ """
+
+ # Create the custom language file for our custom strings
+ fp = open_utf16le_file(join(config_dir, output_filename))
+ locale_strings = get_locale_strings(
+ lookup(properties_filename, l10ndirs), "LangString ", " 0 ", True
+ )
+ fp.write(locale_strings.encode("utf-16-le"))
+ fp.close()
+
+
+def convert_utf8_utf16le(in_file_path, out_file_path):
+ """
+ Converts a UTF-8 file to a new UTF-16LE file
+
+ Arguments:
+ in_file_path - the path to the UTF-8 source file to convert
+ out_file_path - the path to the UTF-16LE destination file to create
+ """
+ in_fp = open(in_file_path, "r", encoding="utf-8")
+ out_fp = open_utf16le_file(out_file_path)
+ out_fp.write(in_fp.read().encode("utf-16-le"))
+ in_fp.close()
+ out_fp.close()
+
+
+if __name__ == "__main__":
+ usage = """usage: %prog command <args>
+
+Commands:
+ --convert-utf8-utf16le - Preprocesses installer locale properties files
+ --preprocess-locale - Preprocesses the installer localized properties
+ files into the format required by NSIS and
+ creates a basic NSIS nlf file.
+ --preprocess-single-file - Preprocesses a single properties file into the
+ format required by NSIS
+ --create-nlf-file - Creates a basic NSIS nlf file
+
+preprocess-locale.py --preprocess-locale <src> <locale> <code> <dest>
+
+Arguments:
+ <src> \tthe path to top source directory for the toolkit source
+ <locale>\tthe path to the installer's locale files
+ <code> \tthe locale code
+ <dest> \tthe path to the destination directory
+
+
+preprocess-locale.py --preprocess-single-file <src>
+ <locale>
+ <dest>
+ <infile>
+ <outfile>
+
+Arguments:
+ <src> \tthe path to top source directory for the toolkit source
+ <locale> \tthe path to the installer's locale files
+ <dest> \tthe path to the destination directory
+ <infile> \tthe properties file to process
+ <outfile>\tthe nsh file to write
+
+
+preprocess-locale.py --create-nlf-file <src>
+ <code>
+ <dest>
+
+Arguments:
+ <src> \tthe path to top source directory for the toolkit source
+ <code> \tthe locale code
+ <dest> \tthe path to the destination directory
+
+
+preprocess-locale.py --convert-utf8-utf16le <src> <dest>
+
+Arguments:
+ <src> \tthe path to the UTF-8 source file to convert
+ <dest>\tthe path to the UTF-16LE destination file to create
+"""
+
+ preprocess_locale_args_help_string = """\
+Arguments to --preprocess-locale should be:
+ <src> <locale> <code> <dest>
+or
+ <src> <code> <dest> --l10n-dir <dir> [--l10n-dir <dir> ...]"""
+
+ preprocess_single_file_args_help_string = """\
+Arguments to --preprocess-single_file should be:
+ <src> <locale> <code> <dest> <infile> <outfile>
+or
+ <src> <locale> <code> <dest> <infile> <outfile>
+ --l10n-dir <dir> [--l10n-dir <dir>...]"""
+
+ create_nlf_args_help_string = """\
+Arguments to --create-nlf-file should be:
+ <src> <code> <dest>"""
+
+ p = OptionParser(usage=usage)
+ p.add_option(
+ "--preprocess-locale", action="store_true", default=False, dest="preprocess"
+ )
+ p.add_option(
+ "--preprocess-single-file",
+ action="store_true",
+ default=False,
+ dest="preprocessSingle",
+ )
+ p.add_option(
+ "--create-nlf-file", action="store_true", default=False, dest="createNlf"
+ )
+ p.add_option(
+ "--l10n-dir",
+ action="append",
+ default=[],
+ dest="l10n_dirs",
+ help="Add directory to lookup for locale files",
+ )
+ p.add_option(
+ "--convert-utf8-utf16le", action="store_true", default=False, dest="convert"
+ )
+
+ options, args = p.parse_args()
+
+ foundOne = False
+ if options.preprocess:
+ foundOne = True
+ if options.convert:
+ if foundOne:
+ p.error("More than one command specified")
+ else:
+ foundOne = True
+ if options.preprocessSingle:
+ if foundOne:
+ p.error("More than one command specified")
+ else:
+ foundOne = True
+ if options.createNlf:
+ if foundOne:
+ p.error("More than one command specified")
+ else:
+ foundOne = True
+
+ if not foundOne:
+ p.error("No command specified")
+
+ if options.preprocess:
+ if len(args) not in (3, 4):
+ p.error(preprocess_locale_args_help_string)
+
+ # Parse args
+ pargs = args[:]
+ moz_dir = pargs[0]
+ if len(pargs) == 4:
+ l10n_dirs = [pargs[1]]
+ del pargs[1]
+ else:
+ if not options.l10n_dirs:
+ p.error(preprocess_locale_args_help_string)
+ l10n_dirs = options.l10n_dirs
+ ab_cd = pargs[1]
+ config_dir = pargs[2]
+
+ # Create the output files
+ create_nlf_file(moz_dir, ab_cd, config_dir)
+ preprocess_locale_files(config_dir, l10n_dirs)
+ elif options.preprocessSingle:
+ if len(args) not in (4, 5):
+ p.error(preprocess_single_file_args_help_string)
+
+ # Parse args
+ pargs = args[:]
+ moz_dir = pargs[0]
+ if len(pargs) == 5:
+ l10n_dirs = [pargs[1]]
+ del pargs[1]
+ else:
+ if not options.l10n_dirs:
+ p.error(preprocess_single_file_args_help_string)
+ l10n_dirs = options.l10n_dirs
+ config_dir = pargs[1]
+ in_file = pargs[2]
+ out_file = pargs[3]
+
+ # Create the output files
+ preprocess_locale_file(config_dir, l10n_dirs, in_file, out_file)
+ elif options.createNlf:
+ if len(args) != 3:
+ p.error(create_nlf_args_help_string)
+
+ # Parse args
+ pargs = args[:]
+ moz_dir = pargs[0]
+ ab_cd = pargs[1]
+ config_dir = pargs[2]
+
+ # Create the output files
+ create_nlf_file(moz_dir, ab_cd, config_dir)
+ elif options.convert:
+ if len(args) != 2:
+ p.error("--convert-utf8-utf16le needs both of <src> <dest>")
+ convert_utf8_utf16le(*args)
diff --git a/toolkit/mozapps/installer/windows/nsis/setup.ico b/toolkit/mozapps/installer/windows/nsis/setup.ico
new file mode 100644
index 0000000000..9801fed54f
--- /dev/null
+++ b/toolkit/mozapps/installer/windows/nsis/setup.ico
Binary files differ
diff --git a/toolkit/mozapps/notificationserver/NotificationCallback.cpp b/toolkit/mozapps/notificationserver/NotificationCallback.cpp
new file mode 100644
index 0000000000..94b991538d
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/NotificationCallback.cpp
@@ -0,0 +1,276 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=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 "NotificationCallback.h"
+
+#include <sstream>
+#include <string>
+#include <vector>
+
+#include "mozilla/CmdLineAndEnvUtils.h"
+#include "mozilla/ToastNotificationHeaderOnlyUtils.h"
+
+using namespace mozilla::widget::toastnotification;
+
+HRESULT STDMETHODCALLTYPE
+NotificationCallback::QueryInterface(REFIID riid, void** ppvObject) {
+ if (!ppvObject) {
+ return E_POINTER;
+ }
+
+ *ppvObject = nullptr;
+
+ if (!(riid == guid || riid == __uuidof(INotificationActivationCallback) ||
+ riid == __uuidof(IUnknown))) {
+ return E_NOINTERFACE;
+ }
+
+ AddRef();
+ *ppvObject = reinterpret_cast<void*>(this);
+
+ return S_OK;
+}
+
+HRESULT STDMETHODCALLTYPE NotificationCallback::Activate(
+ LPCWSTR appUserModelId, LPCWSTR invokedArgs,
+ const NOTIFICATION_USER_INPUT_DATA* data, ULONG dataCount) {
+ HandleActivation(invokedArgs);
+
+ // Windows 8 style callbacks are not called and notifications are not removed
+ // from the Action Center unless we return `S_OK`, so always do so even if
+ // we're unable to handle the notification properly.
+ return S_OK;
+}
+
+void NotificationCallback::HandleActivation(LPCWSTR invokedArgs) {
+ auto maybeArgs = ParseToastArguments(invokedArgs);
+ if (maybeArgs) {
+ NOTIFY_LOG(mozilla::LogLevel::Info,
+ (L"Invoked with arguments: '%s'", invokedArgs));
+ } else {
+ NOTIFY_LOG(mozilla::LogLevel::Info, (L"COM server disabled for toast"));
+ return;
+ }
+ const auto& args = maybeArgs.value();
+ auto [programPath, cmdLine] = BuildRunCommand(args);
+
+ // This pipe object will let Firefox notify us when it has handled the
+ // notification. Create this before interacting with the application so the
+ // application can rely on it existing.
+ auto maybePipe = CreatePipe(args.windowsTag);
+
+ // Run the application.
+
+ STARTUPINFOW si = {};
+ si.cb = sizeof(STARTUPINFOW);
+ PROCESS_INFORMATION pi = {};
+
+ // Runs `{program path} [--profile {profile path}] [--notification-windowsTag
+ // {tag}]`.
+ CreateProcessW(programPath.c_str(), cmdLine.get(), nullptr, nullptr, false,
+ DETACHED_PROCESS | NORMAL_PRIORITY_CLASS, nullptr, nullptr,
+ &si, &pi);
+
+ NOTIFY_LOG(mozilla::LogLevel::Info, (L"Invoked %s", cmdLine.get()));
+
+ // Transfer `SetForegroundWindow` permission to the launched application.
+
+ maybePipe.apply([](const auto& pipe) {
+ if (ConnectPipeWithTimeout(pipe)) {
+ HandlePipeMessages(pipe);
+ }
+ });
+}
+
+mozilla::Maybe<ToastArgs> NotificationCallback::ParseToastArguments(
+ LPCWSTR invokedArgs) {
+ ToastArgs parsedArgs;
+ std::wistringstream args(invokedArgs);
+ bool serverDisabled = true;
+
+ for (std::wstring key, value;
+ std::getline(args, key) && std::getline(args, value);) {
+ if (key == kLaunchArgProgram) {
+ serverDisabled = false;
+ } else if (key == kLaunchArgProfile) {
+ parsedArgs.profile = value;
+ } else if (key == kLaunchArgTag) {
+ parsedArgs.windowsTag = value;
+ } else if (key == kLaunchArgLogging) {
+ gVerbose = value == L"verbose";
+ } else if (key == kLaunchArgAction) {
+ parsedArgs.action = value;
+ }
+ }
+
+ if (serverDisabled) {
+ return mozilla::Nothing();
+ }
+
+ return mozilla::Some(parsedArgs);
+}
+
+std::tuple<path, mozilla::UniquePtr<wchar_t[]>>
+NotificationCallback::BuildRunCommand(const ToastArgs& args) {
+ path programPath = installDir / L"" MOZ_APP_NAME;
+ programPath += L".exe";
+
+ std::vector<const wchar_t*> childArgv;
+ childArgv.push_back(programPath.c_str());
+
+ if (!args.profile.empty()) {
+ childArgv.push_back(L"--profile");
+ childArgv.push_back(args.profile.c_str());
+ } else {
+ NOTIFY_LOG(mozilla::LogLevel::Warning,
+ (L"No profile; invocation will choose default profile"));
+ }
+
+ if (!args.windowsTag.empty()) {
+ childArgv.push_back(L"--notification-windowsTag");
+ childArgv.push_back(args.windowsTag.c_str());
+ } else {
+ NOTIFY_LOG(mozilla::LogLevel::Warning, (L"No windowsTag; invoking anyway"));
+ }
+
+ if (!args.action.empty()) {
+ childArgv.push_back(L"--notification-windowsAction");
+ childArgv.push_back(args.action.c_str());
+ } else {
+ NOTIFY_LOG(mozilla::LogLevel::Warning, (L"No action; invoking anyway"));
+ }
+
+ return {programPath,
+ mozilla::MakeCommandLine(childArgv.size(), childArgv.data())};
+}
+
+mozilla::Maybe<nsAutoHandle> NotificationCallback::CreatePipe(
+ const std::wstring& tag) {
+ if (tag.empty()) {
+ return mozilla::Nothing();
+ }
+
+ // Prefix required by pipe API.
+ std::wstring pipeName = GetNotificationPipeName(tag.c_str());
+
+ nsAutoHandle pipe(CreateNamedPipeW(
+ pipeName.c_str(), PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,
+ PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT |
+ PIPE_REJECT_REMOTE_CLIENTS,
+ 1, sizeof(ToastNotificationPermissionMessage),
+ sizeof(ToastNotificationPidMessage), 0, nullptr));
+ if (pipe.get() == INVALID_HANDLE_VALUE) {
+ NOTIFY_LOG(mozilla::LogLevel::Error, (L"Error creating pipe %s, error %lu",
+ pipeName.c_str(), GetLastError()));
+ return mozilla::Nothing();
+ }
+
+ return mozilla::Some(pipe.out());
+}
+
+bool NotificationCallback::ConnectPipeWithTimeout(const nsAutoHandle& pipe) {
+ nsAutoHandle overlappedEvent(CreateEventW(nullptr, TRUE, FALSE, nullptr));
+ if (!overlappedEvent) {
+ NOTIFY_LOG(
+ mozilla::LogLevel::Error,
+ (L"Error creating pipe connect event, error %lu", GetLastError()));
+ return false;
+ }
+
+ OVERLAPPED overlappedConnect{};
+ overlappedConnect.hEvent = overlappedEvent.get();
+
+ BOOL result = ConnectNamedPipe(pipe.get(), &overlappedConnect);
+ DWORD lastError = GetLastError();
+ if (lastError == ERROR_IO_PENDING) {
+ NOTIFY_LOG(mozilla::LogLevel::Info, (L"Waiting on pipe connection"));
+
+ if (!WaitEventWithTimeout(overlappedEvent)) {
+ NOTIFY_LOG(mozilla::LogLevel::Warning,
+ (L"Pipe connect wait failed, cancelling (connection may still "
+ L"succeed)"));
+
+ CancelIo(pipe.get());
+ DWORD undefined;
+ BOOL overlappedResult =
+ GetOverlappedResult(pipe.get(), &overlappedConnect, &undefined, TRUE);
+ if (!overlappedResult || GetLastError() != ERROR_PIPE_CONNECTED) {
+ NOTIFY_LOG(mozilla::LogLevel::Error,
+ (L"Pipe connect failed, error %lu", GetLastError()));
+ return false;
+ }
+
+ // Pipe connected before cancellation, fall through.
+ }
+ } else if (result) {
+ // Overlapped `ConnectNamedPipe` should return 0.
+ NOTIFY_LOG(mozilla::LogLevel::Error,
+ (L"Error connecting pipe, error %lu", lastError));
+ return false;
+ } else if (lastError != ERROR_PIPE_CONNECTED) {
+ NOTIFY_LOG(mozilla::LogLevel::Error,
+ (L"Error connecting pipe, error %lu", lastError));
+ return false;
+ }
+
+ NOTIFY_LOG(mozilla::LogLevel::Info, (L"Pipe connected!"));
+ return true;
+}
+
+void NotificationCallback::HandlePipeMessages(const nsAutoHandle& pipe) {
+ ToastNotificationPidMessage in{};
+ auto read = [&](OVERLAPPED& overlapped) {
+ return ReadFile(pipe.get(), &in, sizeof(in), nullptr, &overlapped);
+ };
+ if (!SyncDoOverlappedIOWithTimeout(pipe, sizeof(in), read)) {
+ NOTIFY_LOG(mozilla::LogLevel::Error, (L"Pipe read failed"));
+ return;
+ }
+
+ ToastNotificationPermissionMessage out{};
+ out.setForegroundPermissionGranted = TransferForegroundPermission(in.pid);
+ auto write = [&](OVERLAPPED& overlapped) {
+ return WriteFile(pipe.get(), &out, sizeof(out), nullptr, &overlapped);
+ };
+ if (!SyncDoOverlappedIOWithTimeout(pipe, sizeof(out), write)) {
+ NOTIFY_LOG(mozilla::LogLevel::Error, (L"Pipe write failed"));
+ return;
+ }
+
+ NOTIFY_LOG(mozilla::LogLevel::Info, (L"Pipe write succeeded!"));
+}
+
+DWORD NotificationCallback::TransferForegroundPermission(DWORD pid) {
+ // When the instance of Firefox is still running we need to grant it
+ // foreground permission to bring itself to the foreground. We're able to do
+ // this even though the COM server is not the foreground process likely due to
+ // Windows granting permission to the COM object via
+ // `CoAllowSetForegroundWindow`.
+ //
+ // Note that issues surrounding `SetForegroundWindow` permissions are obscured
+ // when builds are run with a debugger, whereupon Windows grants
+ // `SetForegroundWindow` permission in all instances.
+ //
+ // We can not rely on granting this permission to the process created above
+ // because remote server clients do not meet the criteria to receive
+ // `SetForegroundWindow` permissions without unsupported hacks.
+ if (!pid) {
+ NOTIFY_LOG(mozilla::LogLevel::Warning,
+ (L"`pid` received from pipe was 0, no process to grant "
+ L"`SetForegroundWindow` permission to"));
+ return FALSE;
+ }
+ // When this call succeeds, the COM process loses the `SetForegroundWindow`
+ // permission.
+ if (!AllowSetForegroundWindow(pid)) {
+ NOTIFY_LOG(mozilla::LogLevel::Error,
+ (L"Failed to grant `SetForegroundWindow` permission, error %lu",
+ GetLastError()));
+ return FALSE;
+ }
+
+ return TRUE;
+}
diff --git a/toolkit/mozapps/notificationserver/NotificationCallback.h b/toolkit/mozapps/notificationserver/NotificationCallback.h
new file mode 100644
index 0000000000..0611186e0f
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/NotificationCallback.h
@@ -0,0 +1,73 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=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 NotificationCallback_h__
+#define NotificationCallback_h__
+
+#include <filesystem>
+#include <tuple>
+#include <unknwn.h>
+#include <wrl.h>
+
+#include "mozilla/Maybe.h"
+#include "nsWindowsHelpers.h"
+
+using namespace Microsoft::WRL;
+using namespace std::filesystem;
+
+// Windows 10+ declarations.
+// TODO remove declarations and add `#include
+// <notificationactivationcallback.h>` when Windows 10 is the minimum supported.
+typedef struct NOTIFICATION_USER_INPUT_DATA {
+ LPCWSTR Key;
+ LPCWSTR Value;
+} NOTIFICATION_USER_INPUT_DATA;
+
+MIDL_INTERFACE("53E31837-6600-4A81-9395-75CFFE746F94")
+INotificationActivationCallback : public IUnknown {
+ public:
+ virtual HRESULT STDMETHODCALLTYPE Activate(
+ LPCWSTR appUserModelId, LPCWSTR invokedArgs,
+ const NOTIFICATION_USER_INPUT_DATA* data, ULONG count) = 0;
+};
+
+struct ToastArgs {
+ std::wstring profile;
+ std::wstring windowsTag;
+ std::wstring action;
+};
+
+class NotificationCallback final
+ : public RuntimeClass<RuntimeClassFlags<ClassicCom>,
+ INotificationActivationCallback> {
+ public:
+ HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) final;
+
+ HRESULT STDMETHODCALLTYPE Activate(LPCWSTR appUserModelId,
+ LPCWSTR invokedArgs,
+ const NOTIFICATION_USER_INPUT_DATA* data,
+ ULONG dataCount) final;
+
+ explicit NotificationCallback(const GUID& runtimeGuid,
+ const path& dllInstallDir)
+ : guid(runtimeGuid), installDir(dllInstallDir) {}
+
+ private:
+ const GUID guid = {};
+ const path installDir = {};
+
+ void HandleActivation(LPCWSTR invokedArgs);
+ mozilla::Maybe<ToastArgs> ParseToastArguments(LPCWSTR invokedArgs);
+ std::tuple<path, mozilla::UniquePtr<wchar_t[]>> BuildRunCommand(
+ const ToastArgs& args);
+
+ static mozilla::Maybe<nsAutoHandle> CreatePipe(const std::wstring& tag);
+ static bool ConnectPipeWithTimeout(const nsAutoHandle& pipe);
+ static void HandlePipeMessages(const nsAutoHandle& pipe);
+ static DWORD TransferForegroundPermission(const DWORD pid);
+};
+
+#endif
diff --git a/toolkit/mozapps/notificationserver/NotificationComServer.cpp b/toolkit/mozapps/notificationserver/NotificationComServer.cpp
new file mode 100644
index 0000000000..184b55be44
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/NotificationComServer.cpp
@@ -0,0 +1,132 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=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 <filesystem>
+#include <string>
+
+#include "mozilla/WinHeaderOnlyUtils.h"
+
+#include "NotificationFactory.h"
+
+using namespace std::filesystem;
+
+static path processDllPath = {};
+
+// Populate the path to this DLL.
+bool PopulateDllPath(HINSTANCE dllInstance) {
+ std::vector<wchar_t> path(MAX_PATH, 0);
+ DWORD charsWritten =
+ GetModuleFileNameW(dllInstance, path.data(), path.size());
+
+ // GetModuleFileNameW returns the count of characters written including null
+ // when truncated, excluding null otherwise. Therefore the count will always
+ // be less than the buffer size when not truncated.
+ while (charsWritten == path.size()) {
+ path.resize(path.size() * 2, 0);
+ charsWritten = GetModuleFileNameW(dllInstance, path.data(), path.size());
+ }
+
+ if (charsWritten == 0) {
+ return false;
+ }
+
+ processDllPath = path.data();
+ return true;
+}
+
+// Our activator's CLSID is generated once either during install or at runtime
+// by the application generating the notification so that notifications work
+// with parallel installs and portable/development builds. When a COM object is
+// requested we verify the CLSID's InprocServer registry entry matches this
+// DLL's path.
+bool CheckRuntimeClsid(REFCLSID rclsid) {
+ // MSIX Notification COM Server registration is isolated to the package and is
+ // identical across installs/channels.
+ if (mozilla::HasPackageIdentity()) {
+ // Keep synchronized with `python\mozbuild\mozbuild\repackaging\msix.py`.
+ constexpr CLSID MOZ_INOTIFICATIONACTIVATION_CLSID = {
+ 0x916f9b5d,
+ 0xb5b2,
+ 0x4d36,
+ {0xb0, 0x47, 0x03, 0xc7, 0xa5, 0x2f, 0x81, 0xc8}};
+
+ return IsEqualCLSID(rclsid, MOZ_INOTIFICATIONACTIVATION_CLSID);
+ }
+
+ std::wstring clsid_str;
+ {
+ wchar_t* raw_clsid_str;
+ if (SUCCEEDED(StringFromCLSID(rclsid, &raw_clsid_str))) {
+ clsid_str += raw_clsid_str;
+ CoTaskMemFree(raw_clsid_str);
+ } else {
+ return false;
+ }
+ }
+
+ std::wstring key = L"CLSID\\";
+ key += clsid_str;
+ key += L"\\InprocServer32";
+
+ DWORD bufferLen = 0;
+ LSTATUS status = RegGetValueW(HKEY_CLASSES_ROOT, key.c_str(), L"",
+ RRF_RT_REG_SZ, nullptr, nullptr, &bufferLen);
+ if (status != ERROR_SUCCESS) {
+ return false;
+ }
+
+ std::vector<wchar_t> clsidDllPathBuffer(bufferLen / sizeof(wchar_t));
+ // Sanity assignment in case the buffer length found was not an integer
+ // multiple of `sizeof(wchar_t)`.
+ bufferLen = clsidDllPathBuffer.size() * sizeof(wchar_t);
+
+ status = RegGetValueW(HKEY_CLASSES_ROOT, key.c_str(), L"", RRF_RT_REG_SZ,
+ nullptr, clsidDllPathBuffer.data(), &bufferLen);
+ if (status != ERROR_SUCCESS) {
+ return false;
+ }
+
+ path clsidDllPath = clsidDllPathBuffer.data();
+ return equivalent(processDllPath, clsidDllPath);
+}
+
+extern "C" {
+HRESULT STDMETHODCALLTYPE DllGetClassObject(REFCLSID rclsid, REFIID riid,
+ LPVOID* ppv) {
+ if (!ppv) {
+ return E_INVALIDARG;
+ }
+ *ppv = nullptr;
+
+ if (!CheckRuntimeClsid(rclsid)) {
+ return CLASS_E_CLASSNOTAVAILABLE;
+ }
+
+ using namespace Microsoft::WRL;
+ ComPtr<NotificationFactory> factory =
+ Make<NotificationFactory, const GUID&, const path&>(
+ rclsid, processDllPath.parent_path());
+
+ switch (factory->QueryInterface(riid, ppv)) {
+ case S_OK:
+ return S_OK;
+ case E_NOINTERFACE:
+ return CLASS_E_CLASSNOTAVAILABLE;
+ default:
+ return E_UNEXPECTED;
+ }
+}
+
+BOOL STDMETHODCALLTYPE DllMain(HINSTANCE hinstDLL, DWORD fdwReason,
+ LPVOID lpReserved) {
+ if (fdwReason == DLL_PROCESS_ATTACH) {
+ if (!PopulateDllPath(hinstDLL)) {
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+}
diff --git a/toolkit/mozapps/notificationserver/NotificationFactory.cpp b/toolkit/mozapps/notificationserver/NotificationFactory.cpp
new file mode 100644
index 0000000000..a1043f17b9
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/NotificationFactory.cpp
@@ -0,0 +1,33 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=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 "NotificationFactory.h"
+
+HRESULT STDMETHODCALLTYPE NotificationFactory::CreateInstance(
+ IUnknown* pUnkOuter, REFIID riid, void** ppvObject) {
+ if (pUnkOuter != nullptr) {
+ return CLASS_E_NOAGGREGATION;
+ }
+
+ if (!ppvObject) {
+ return E_INVALIDARG;
+ }
+ *ppvObject = nullptr;
+
+ using namespace Microsoft::WRL;
+ ComPtr<NotificationCallback> callback =
+ Make<NotificationCallback, const GUID&, const path&>(notificationGuid,
+ installDir);
+
+ switch (callback->QueryInterface(riid, ppvObject)) {
+ case S_OK:
+ return S_OK;
+ case E_NOINTERFACE:
+ return E_NOINTERFACE;
+ default:
+ return E_UNEXPECTED;
+ }
+}
diff --git a/toolkit/mozapps/notificationserver/NotificationFactory.h b/toolkit/mozapps/notificationserver/NotificationFactory.h
new file mode 100644
index 0000000000..0936b1ffeb
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/NotificationFactory.h
@@ -0,0 +1,31 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=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 NotificationFactory_h__
+#define NotificationFactory_h__
+
+#include <filesystem>
+
+#include "NotificationCallback.h"
+
+using namespace std::filesystem;
+using namespace Microsoft::WRL;
+
+class NotificationFactory final : public ClassFactory<> {
+ public:
+ HRESULT STDMETHODCALLTYPE CreateInstance(IUnknown* pUnkOuter, REFIID riid,
+ void** ppvObject) final;
+
+ explicit NotificationFactory(const GUID& runtimeGuid,
+ const path& dllInstallDir)
+ : notificationGuid(runtimeGuid), installDir(dllInstallDir) {}
+
+ private:
+ const GUID notificationGuid = {};
+ const path installDir = {};
+};
+
+#endif
diff --git a/toolkit/mozapps/notificationserver/moz.build b/toolkit/mozapps/notificationserver/moz.build
new file mode 100644
index 0000000000..f3a943da8b
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/moz.build
@@ -0,0 +1,34 @@
+# -*- 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 = ("Toolkit", "Alerts Service")
+
+SharedLibrary("notificationserver")
+
+UNIFIED_SOURCES = [
+ "/mfbt/Poison.cpp", # Necessary for global poison definitions.
+ "NotificationCallback.cpp",
+ "NotificationComServer.cpp",
+ "NotificationFactory.cpp",
+]
+
+DEFFILE = "notificationserver.def"
+
+DEFINES["MOZ_APP_NAME"] = '"%s"' % CONFIG["MOZ_APP_NAME"]
+DEFINES["MOZ_APP_DISPLAYNAME"] = '"%s"' % CONFIG["MOZ_APP_DISPLAYNAME"]
+
+DEFINES["IMPL_MFBT"] = True
+
+OS_LIBS += [
+ "advapi32",
+ "kernel32",
+ "runtimeobject",
+ "user32",
+]
+
+LIBRARY_DEFINES["MOZ_NO_MOZALLOC"] = True
+DisableStlWrapping()
diff --git a/toolkit/mozapps/notificationserver/notificationserver.def b/toolkit/mozapps/notificationserver/notificationserver.def
new file mode 100644
index 0000000000..694d5609e3
--- /dev/null
+++ b/toolkit/mozapps/notificationserver/notificationserver.def
@@ -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/.
+
+LIBRARY notificationserver.dll
+EXPORTS DllGetClassObject PRIVATE
diff --git a/toolkit/mozapps/preferences/changemp.js b/toolkit/mozapps/preferences/changemp.js
new file mode 100644
index 0000000000..2062d497df
--- /dev/null
+++ b/toolkit/mozapps/preferences/changemp.js
@@ -0,0 +1,220 @@
+// -*- tab-width: 2; indent-tabs-mode: nil; js-indent-level: 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/. */
+
+const nsPK11TokenDB = "@mozilla.org/security/pk11tokendb;1";
+const nsIPK11TokenDB = Ci.nsIPK11TokenDB;
+const nsIDialogParamBlock = Ci.nsIDialogParamBlock;
+const nsPKCS11ModuleDB = "@mozilla.org/security/pkcs11moduledb;1";
+const nsIPKCS11ModuleDB = Ci.nsIPKCS11ModuleDB;
+const nsIPKCS11Slot = Ci.nsIPKCS11Slot;
+const nsIPK11Token = Ci.nsIPK11Token;
+
+var params;
+var pw1;
+
+function init() {
+ pw1 = document.getElementById("pw1");
+
+ process();
+ document.addEventListener("dialogaccept", setPassword);
+}
+
+function process() {
+ // If the token is unitialized, don't use the old password box.
+ // Otherwise, do.
+
+ let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService(
+ Ci.nsIPK11TokenDB
+ );
+ let token = tokenDB.getInternalKeyToken();
+ if (token) {
+ let oldpwbox = document.getElementById("oldpw");
+ let msgBox = document.getElementById("message");
+ if ((token.needsLogin() && token.needsUserInit) || !token.needsLogin()) {
+ oldpwbox.hidden = true;
+ msgBox.hidden = false;
+
+ if (!token.needsLogin()) {
+ oldpwbox.setAttribute("inited", "empty");
+ } else {
+ oldpwbox.setAttribute("inited", "true");
+ }
+
+ // Select first password field
+ document.getElementById("pw1").focus();
+ } else {
+ // Select old password field
+ oldpwbox.hidden = false;
+ msgBox.hidden = true;
+ oldpwbox.setAttribute("inited", "false");
+ oldpwbox.focus();
+ }
+ }
+
+ if (
+ !token.hasPassword &&
+ !Services.policies.isAllowed("removeMasterPassword")
+ ) {
+ document.getElementById("admin").hidden = false;
+ }
+
+ if (params) {
+ // Return value 0 means "canceled"
+ params.SetInt(1, 0);
+ }
+
+ checkPasswords();
+}
+
+async function createAlert(titleL10nId, messageL10nId) {
+ const [title, message] = await document.l10n.formatValues([
+ { id: titleL10nId },
+ { id: messageL10nId },
+ ]);
+ Services.prompt.alert(window, title, message);
+}
+
+function setPassword() {
+ var pk11db = Cc[nsPK11TokenDB].getService(nsIPK11TokenDB);
+ var token = pk11db.getInternalKeyToken();
+
+ var oldpwbox = document.getElementById("oldpw");
+ var initpw = oldpwbox.getAttribute("inited");
+
+ if (initpw == "false" || initpw == "empty") {
+ try {
+ var oldpw = "";
+ var passok = 0;
+
+ if (initpw == "empty") {
+ passok = 1;
+ } else {
+ oldpw = oldpwbox.value;
+ passok = token.checkPassword(oldpw);
+ }
+
+ if (passok) {
+ if (initpw == "empty" && pw1.value == "") {
+ // This makes no sense that we arrive here,
+ // we reached a case that should have been prevented by checkPasswords.
+ } else {
+ if (pw1.value == "") {
+ var secmoddb = Cc[nsPKCS11ModuleDB].getService(nsIPKCS11ModuleDB);
+ if (secmoddb.isFIPSEnabled) {
+ // empty passwords are not allowed in FIPS mode
+ createAlert(
+ "pw-change-failed-title",
+ "pp-change2empty-in-fips-mode"
+ );
+ passok = 0;
+ }
+ }
+ if (passok) {
+ token.changePassword(oldpw, pw1.value);
+ if (pw1.value == "") {
+ createAlert("pw-change-success-title", "settings-pp-erased-ok");
+ } else {
+ createAlert("pw-change-success-title", "pp-change-ok");
+ }
+ }
+ }
+ } else {
+ oldpwbox.focus();
+ oldpwbox.setAttribute("value", "");
+ createAlert("pw-change-failed-title", "incorrect-pp");
+ }
+ } catch (e) {
+ console.error(e);
+ createAlert("pw-change-failed-title", "failed-pp-change");
+ }
+ } else {
+ token.initPassword(pw1.value);
+ if (pw1.value == "") {
+ createAlert("pw-change-success-title", "settings-pp-not-wanted");
+ }
+ }
+}
+
+function setPasswordStrength() {
+ // Here is how we weigh the quality of the password
+ // number of characters
+ // numbers
+ // non-alpha-numeric chars
+ // upper and lower case characters
+
+ var pw = document.getElementById("pw1").value;
+
+ // length of the password
+ var pwlength = pw.length;
+ if (pwlength > 5) {
+ pwlength = 5;
+ }
+
+ // use of numbers in the password
+ var numnumeric = pw.replace(/[0-9]/g, "");
+ var numeric = pw.length - numnumeric.length;
+ if (numeric > 3) {
+ numeric = 3;
+ }
+
+ // use of symbols in the password
+ var symbols = pw.replace(/\W/g, "");
+ var numsymbols = pw.length - symbols.length;
+ if (numsymbols > 3) {
+ numsymbols = 3;
+ }
+
+ // use of uppercase in the password
+ var numupper = pw.replace(/[A-Z]/g, "");
+ var upper = pw.length - numupper.length;
+ if (upper > 3) {
+ upper = 3;
+ }
+
+ var pwstrength =
+ pwlength * 10 - 20 + numeric * 10 + numsymbols * 15 + upper * 10;
+
+ // make sure we're give a value between 0 and 100
+ if (pwstrength < 0) {
+ pwstrength = 0;
+ }
+
+ if (pwstrength > 100) {
+ pwstrength = 100;
+ }
+
+ var mymeter = document.getElementById("pwmeter");
+ mymeter.value = pwstrength;
+}
+
+function checkPasswords() {
+ var pw1 = document.getElementById("pw1").value;
+ var pw2 = document.getElementById("pw2").value;
+ var ok = document.getElementById("changemp").getButton("accept");
+
+ var oldpwbox = document.getElementById("oldpw");
+ if (oldpwbox) {
+ var initpw = oldpwbox.getAttribute("inited");
+
+ if (initpw == "empty" && pw1 == "") {
+ // The token has already been initialized, therefore this dialog
+ // was called with the intention to change the password.
+ // The token currently uses an empty password.
+ // We will not allow changing the password from empty to empty.
+ ok.setAttribute("disabled", "true");
+ return;
+ }
+ }
+
+ if (
+ pw1 == pw2 &&
+ (pw1 != "" || Services.policies.isAllowed("removeMasterPassword"))
+ ) {
+ ok.setAttribute("disabled", "false");
+ } else {
+ ok.setAttribute("disabled", "true");
+ }
+}
diff --git a/toolkit/mozapps/preferences/changemp.xhtml b/toolkit/mozapps/preferences/changemp.xhtml
new file mode 100644
index 0000000000..c2c6d75677
--- /dev/null
+++ b/toolkit/mozapps/preferences/changemp.xhtml
@@ -0,0 +1,96 @@
+<?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
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-width: 40em"
+ onload="init()"
+ data-l10n-id="primary-password-dialog"
+>
+ <dialog id="changemp">
+ <script src="chrome://mozapps/content/preferences/changemp.js" />
+
+ <linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="toolkit/preferences/preferences.ftl"
+ />
+ </linkset>
+
+ <description
+ id="admin"
+ class="header"
+ data-l10n-id="primary-password-required-by-policy"
+ hidden="true"
+ ></description>
+ <description
+ control="pw1"
+ data-l10n-id="primary-password-description"
+ ></description>
+
+ <vbox>
+ <hbox>
+ <label
+ flex="1"
+ control="oldpw"
+ data-l10n-id="set-password-old-password"
+ ></label>
+ <html:input id="oldpw" type="password" />
+ <!-- This textbox is inserted as a workaround to the fact that making the 'type'
+ & 'disabled' property of the 'oldpw' textbox toggle between ['password' &
+ 'false'] and ['text' & 'true'] - as would be necessary if the menu has more
+ than one tokens, some initialized and some not - does not work properly. So,
+ either the textbox 'oldpw' or the textbox 'message' would be displayed,
+ depending on the state of the token selected
+ -->
+ <html:input
+ type="text"
+ data-l10n-attrs="value"
+ data-l10n-id="password-not-set"
+ id="message"
+ disabled="true"
+ />
+ </hbox>
+ <hbox>
+ <label
+ flex="1"
+ control="pw1"
+ data-l10n-id="set-password-new-password"
+ ></label>
+ <html:input
+ id="pw1"
+ type="password"
+ oninput="setPasswordStrength(); checkPasswords();"
+ />
+ </hbox>
+ <hbox>
+ <label
+ flex="1"
+ control="pw2"
+ data-l10n-id="set-password-reenter-password"
+ ></label>
+ <html:input id="pw2" type="password" oninput="checkPasswords();" />
+ </hbox>
+ </vbox>
+
+ <html:label
+ for="pwmeter"
+ style="display: flex"
+ data-l10n-id="set-password-meter"
+ ></html:label>
+ <html:progress id="pwmeter" value="0" max="100" />
+
+ <description
+ control="pw2"
+ class="header"
+ data-l10n-id="primary-password-warning"
+ ></description>
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/preferences/fontbuilder.js b/toolkit/mozapps/preferences/fontbuilder.js
new file mode 100644
index 0000000000..5e264bfa53
--- /dev/null
+++ b/toolkit/mozapps/preferences/fontbuilder.js
@@ -0,0 +1,120 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/* import-globals-from ../../content/preferencesBindings.js */
+
+var FontBuilder = {
+ _enumerator: null,
+ get enumerator() {
+ if (!this._enumerator) {
+ this._enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].createInstance(
+ Ci.nsIFontEnumerator
+ );
+ }
+ return this._enumerator;
+ },
+
+ _allFonts: null,
+ _langGroupSupported: false,
+ async buildFontList(aLanguage, aFontType, aMenuList) {
+ // Remove the original <menupopup>
+ if (aMenuList.menupopup) {
+ aMenuList.menupopup.remove();
+ }
+
+ let defaultFont = null;
+ // Load Font Lists
+ let fonts = await this.enumerator.EnumerateFontsAsync(aLanguage, aFontType);
+ if (fonts.length) {
+ defaultFont = this.enumerator.getDefaultFont(aLanguage, aFontType);
+ } else {
+ fonts = await this.enumerator.EnumerateFontsAsync(aLanguage, "");
+ if (fonts.length) {
+ defaultFont = this.enumerator.getDefaultFont(aLanguage, "");
+ }
+ }
+
+ if (!this._allFonts) {
+ this._allFonts = await this.enumerator.EnumerateAllFontsAsync({});
+ }
+
+ // Build the UI for the Default Font and Fonts for this CSS type.
+ const popup = document.createXULElement("menupopup");
+ let separator;
+ if (fonts.length) {
+ let menuitem = document.createXULElement("menuitem");
+ if (defaultFont) {
+ document.l10n.setAttributes(menuitem, "fonts-label-default", {
+ name: defaultFont,
+ });
+ } else {
+ document.l10n.setAttributes(menuitem, "fonts-label-default-unnamed");
+ }
+ menuitem.setAttribute("value", ""); // Default Font has a blank value
+ popup.appendChild(menuitem);
+
+ separator = document.createXULElement("menuseparator");
+ popup.appendChild(separator);
+
+ for (let font of fonts) {
+ menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("value", font);
+ menuitem.setAttribute("label", font);
+ popup.appendChild(menuitem);
+ }
+ }
+
+ // Build the UI for the remaining fonts.
+ if (this._allFonts.length > fonts.length) {
+ this._langGroupSupported = true;
+ // Both lists are sorted, and the Fonts-By-Type list is a subset of the
+ // All-Fonts list, so walk both lists side-by-side, skipping values we've
+ // already created menu items for.
+ let builtItem = separator ? separator.nextSibling : popup.firstChild;
+ let builtItemValue = builtItem ? builtItem.getAttribute("value") : null;
+
+ separator = document.createXULElement("menuseparator");
+ popup.appendChild(separator);
+
+ for (let font of this._allFonts) {
+ if (font != builtItemValue) {
+ const menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("value", font);
+ menuitem.setAttribute("label", font);
+ popup.appendChild(menuitem);
+ } else {
+ builtItem = builtItem.nextSibling;
+ builtItemValue = builtItem ? builtItem.getAttribute("value") : null;
+ }
+ }
+ }
+ aMenuList.appendChild(popup);
+ },
+
+ readFontSelection(aElement) {
+ // Determine the appropriate value to select, for the following cases:
+ // - there is no setting
+ // - the font selected by the user is no longer present (e.g. deleted from
+ // fonts folder)
+ const preference = Preferences.get(aElement.getAttribute("preference"));
+ if (preference.value) {
+ const fontItems = aElement.getElementsByAttribute(
+ "value",
+ preference.value
+ );
+
+ // There is a setting that actually is in the list. Respect it.
+ if (fontItems.length) {
+ return undefined;
+ }
+ }
+
+ // Otherwise, use "default" font of current system which is computed
+ // with "font.name-list.*". If "font.name.*" is empty string, it means
+ // "default". So, return empty string in this case.
+ return "";
+ },
+};
diff --git a/toolkit/mozapps/preferences/jar.mn b/toolkit/mozapps/preferences/jar.mn
new file mode 100644
index 0000000000..0c41ea0c79
--- /dev/null
+++ b/toolkit/mozapps/preferences/jar.mn
@@ -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/.
+
+toolkit.jar:
+% content mozapps %content/mozapps/
+ content/mozapps/preferences/fontbuilder.js
+ content/mozapps/preferences/changemp.js
+ content/mozapps/preferences/changemp.xhtml
+ content/mozapps/preferences/removemp.js
+ content/mozapps/preferences/removemp.xhtml
diff --git a/toolkit/mozapps/preferences/moz.build b/toolkit/mozapps/preferences/moz.build
new file mode 100644
index 0000000000..199926b764
--- /dev/null
+++ b/toolkit/mozapps/preferences/moz.build
@@ -0,0 +1,10 @@
+# -*- 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 = ("Toolkit", "Preferences")
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/mozapps/preferences/removemp.js b/toolkit/mozapps/preferences/removemp.js
new file mode 100644
index 0000000000..a5cf729459
--- /dev/null
+++ b/toolkit/mozapps/preferences/removemp.js
@@ -0,0 +1,52 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+var gRemovePasswordDialog = {
+ _token: null,
+ _okButton: null,
+ _password: null,
+ init() {
+ this._okButton = document.getElementById("removemp").getButton("accept");
+ document.l10n.setAttributes(this._okButton, "pw-remove-button");
+
+ this._password = document.getElementById("password");
+
+ var pk11db = Cc["@mozilla.org/security/pk11tokendb;1"].getService(
+ Ci.nsIPK11TokenDB
+ );
+ this._token = pk11db.getInternalKeyToken();
+
+ // Initialize the enabled state of the Remove button by checking the
+ // initial value of the password ("" should be incorrect).
+ this.validateInput();
+ document.addEventListener("dialogaccept", function () {
+ gRemovePasswordDialog.removePassword();
+ });
+ },
+
+ validateInput() {
+ this._okButton.disabled = !this._token.checkPassword(this._password.value);
+ },
+
+ async createAlert(titleL10nId, messageL10nId) {
+ const [title, message] = await document.l10n.formatValues([
+ { id: titleL10nId },
+ { id: messageL10nId },
+ ]);
+ Services.prompt.alert(window, title, message);
+ },
+
+ removePassword() {
+ if (this._token.checkPassword(this._password.value)) {
+ this._token.changePassword(this._password.value, "");
+ this.createAlert("pw-change-success-title", "settings-pp-erased-ok");
+ } else {
+ this._password.value = "";
+ this._password.focus();
+ this.createAlert("pw-change-failed-title", "incorrect-pp");
+ }
+ },
+};
diff --git a/toolkit/mozapps/preferences/removemp.xhtml b/toolkit/mozapps/preferences/removemp.xhtml
new file mode 100644
index 0000000000..6ecceff31b
--- /dev/null
+++ b/toolkit/mozapps/preferences/removemp.xhtml
@@ -0,0 +1,54 @@
+<?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
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-width: 35em"
+ onload="gRemovePasswordDialog.init()"
+ data-l10n-id="remove-primary-password"
+>
+ <dialog id="removemp">
+ <script src="chrome://mozapps/content/preferences/removemp.js" />
+
+ <linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link
+ rel="localization"
+ href="toolkit/preferences/preferences.ftl"
+ />
+ </linkset>
+
+ <vbox id="warnings">
+ <description
+ data-l10n-id="remove-primary-password-warning1"
+ ></description>
+ <description
+ class="header"
+ data-l10n-id="remove-primary-password-warning2"
+ ></description>
+ </vbox>
+
+ <separator class="thin" />
+
+ <groupbox>
+ <label data-l10n-id="remove-info" />
+
+ <hbox align="center">
+ <label control="password" data-l10n-id="remove-password-old-password" />
+ <html:input
+ id="password"
+ type="password"
+ oninput="gRemovePasswordDialog.validateInput();"
+ aria-describedby="warnings"
+ />
+ </hbox>
+ </groupbox>
+
+ <separator />
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/update/AppUpdater.sys.mjs b/toolkit/mozapps/update/AppUpdater.sys.mjs
new file mode 100644
index 0000000000..6133fb613b
--- /dev/null
+++ b/toolkit/mozapps/update/AppUpdater.sys.mjs
@@ -0,0 +1,880 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ UpdateLog: "resource://gre/modules/UpdateLog.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+const PREF_APP_UPDATE_CANCELATIONS_OSX = "app.update.cancelations.osx";
+const PREF_APP_UPDATE_ELEVATE_NEVER = "app.update.elevate.never";
+
+class AbortError extends Error {
+ constructor(...params) {
+ super(...params);
+ this.name = this.constructor.name;
+ }
+}
+
+/**
+ * `AbortablePromise`s automatically add themselves to this set on construction
+ * and remove themselves when they settle.
+ */
+var gPendingAbortablePromises = new Set();
+
+/**
+ * Creates a Promise that can be resolved immediately with an abort method.
+ *
+ * Note that the underlying Promise will probably still run to completion since
+ * there isn't any general way to abort Promises. So if it is possible to abort
+ * the operation instead or in addition to using this class, that is preferable.
+ */
+class AbortablePromise {
+ #abortFn;
+ #promise;
+ #hasCompleted = false;
+
+ constructor(promise) {
+ let abortPromise = new Promise((resolve, reject) => {
+ this.#abortFn = () => reject(new AbortError());
+ });
+ this.#promise = Promise.race([promise, abortPromise]);
+ this.#promise = this.#promise.finally(() => {
+ this.#hasCompleted = true;
+ gPendingAbortablePromises.delete(this);
+ });
+ gPendingAbortablePromises.add(this);
+ }
+
+ abort() {
+ if (this.#hasCompleted) {
+ return;
+ }
+ this.#abortFn();
+ }
+
+ /**
+ * This can be `await`ed on to get the result of the `AbortablePromise`. It
+ * will resolve with the value that the Promise provided to the constructor
+ * resolves with.
+ */
+ get promise() {
+ return this.#promise;
+ }
+
+ /**
+ * Will be `true` if the Promise provided to the constructor has resolved or
+ * `abort()` has been called. Otherwise `false`.
+ */
+ get hasCompleted() {
+ return this.#hasCompleted;
+ }
+}
+
+function makeAbortable(promise) {
+ let abortable = new AbortablePromise(promise);
+ return abortable.promise;
+}
+
+function abortAllPromises() {
+ for (const promise of gPendingAbortablePromises) {
+ promise.abort();
+ }
+}
+
+/**
+ * This class checks for app updates in the foreground. It has several public
+ * methods for checking for updates, downloading updates, stopping the current
+ * update, and getting the current update status. It can also register
+ * listeners that will be called back as different stages of updates occur.
+ */
+export class AppUpdater {
+ #listeners = new Set();
+ #status = AppUpdater.STATUS.NEVER_CHECKED;
+ // This will basically be set to `true` when `AppUpdater.check` is called and
+ // back to `false` right before it returns.
+ // It is also set to `true` during an update swap and back to `false` when the
+ // swap completes.
+ #updateBusy = false;
+ // When settings require that the user be asked for permission to download
+ // updates and we have an update to download, we will assign a function.
+ // Calling this function allows the download to proceed.
+ #permissionToDownloadGivenFn = null;
+ #_update = null;
+ // Keeps track of if we have an `update-swap` listener connected. We only
+ // connect it when the status is `READY_TO_RESTART`, but we can't use that to
+ // tell if its connected because we might be in the middle of an update swap
+ // in which case the status will have temporarily changed.
+ #swapListenerConnected = false;
+
+ constructor() {
+ try {
+ this.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]);
+ } catch (e) {
+ this.#onException(e);
+ }
+ }
+
+ #onException(exception) {
+ try {
+ this.#update = null;
+
+ if (this.#swapListenerConnected) {
+ LOG("AppUpdater:#onException - Removing update-swap listener");
+ Services.obs.removeObserver(this, "update-swap");
+ this.#swapListenerConnected = false;
+ }
+
+ if (exception instanceof AbortError) {
+ // This should be where we end up if `AppUpdater.stop()` is called while
+ // `AppUpdater.check` is running or during an update swap.
+ LOG(
+ "AppUpdater:#onException - Caught AbortError. Setting status " +
+ "NEVER_CHECKED"
+ );
+ this.#setStatus(AppUpdater.STATUS.NEVER_CHECKED);
+ } else {
+ LOG(
+ "AppUpdater:#onException - Exception caught. Setting status " +
+ "INTERNAL_ERROR"
+ );
+ console.error(exception);
+ this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
+ }
+ } catch (e) {
+ LOG(
+ "AppUpdater:#onException - Caught additional exception while " +
+ "handling previous exception"
+ );
+ console.error(e);
+ }
+ }
+
+ /**
+ * This can be accessed by consumers to inspect the update that is being
+ * prepared for installation. It will always be null if `AppUpdater.check`
+ * hasn't been called yet. `AppUpdater.check` will set it to an instance of
+ * nsIUpdate once there is one available. This may be immediate, if an update
+ * is already downloading or has been downloaded. It may be delayed if an
+ * update check needs to be performed first. It also may remain null if the
+ * browser is up to date or if the update check fails.
+ *
+ * Regarding the difference between `AppUpdater.update`, `AppUpdater.#update`,
+ * and `AppUpdater.#_update`:
+ * - `AppUpdater.update` and `AppUpdater.#update` are effectively identical
+ * except that `AppUpdater.update` is readonly since it should not be
+ * changed from outside this class.
+ * - `AppUpdater.#_update` should only ever be modified by the setter for
+ * `AppUpdater.#update` in order to ensure that the "foregroundDownload"
+ * property is set on assignment.
+ * The quick and easy rule for using these is to always use `#update`
+ * internally and (of course) always use `update` externally.
+ */
+ get update() {
+ return this.#update;
+ }
+
+ get #update() {
+ return this.#_update;
+ }
+
+ set #update(update) {
+ this.#_update = update;
+ if (this.#_update) {
+ this.#_update.QueryInterface(Ci.nsIWritablePropertyBag);
+ this.#_update.setProperty("foregroundDownload", "true");
+ }
+ }
+
+ /**
+ * The main entry point for checking for updates. As different stages of the
+ * check and possible subsequent update occur, the updater's status is set and
+ * listeners are called.
+ *
+ * Note that this is the correct entry point, regardless of the current state
+ * of the updater. Although the function name suggests that this function will
+ * start an update check, it will only do that if we aren't already in the
+ * update process. Otherwise, it will simply monitor the update process,
+ * update its own status, and call listeners.
+ *
+ * This function is async and will resolve when the update is ready to
+ * install, or a failure state is reached.
+ * However, most callers probably don't actually want to track its progress by
+ * awaiting on this function. More likely, it is desired to kick this function
+ * off without awaiting and add a listener via addListener. This allows the
+ * caller to see when the updater is checking for an update, downloading it,
+ * etc rather than just knowing "now it's running" and "now it's done".
+ *
+ * Note that calling this function while this instance is already performing
+ * or monitoring an update check/download will have no effect. In other words,
+ * it is only really necessary/useful to call this function when the status is
+ * `NEVER_CHECKED` or `NO_UPDATES_FOUND`.
+ */
+ async check() {
+ try {
+ // We don't want to end up with multiple instances of the same `async`
+ // functions waiting on the same events, so if we are already busy going
+ // through the update state, don't enter this function. This must not
+ // be in the try/catch that sets #updateBusy to false in its finally
+ // block.
+ if (this.#updateBusy) {
+ return;
+ }
+ } catch (e) {
+ this.#onException(e);
+ }
+
+ try {
+ this.#updateBusy = true;
+ this.#update = null;
+
+ if (this.#swapListenerConnected) {
+ LOG("AppUpdater:check - Removing update-swap listener");
+ Services.obs.removeObserver(this, "update-swap");
+ this.#swapListenerConnected = false;
+ }
+
+ if (!AppConstants.MOZ_UPDATER || this.#updateDisabledByPackage) {
+ LOG(
+ "AppUpdater:check -" +
+ "AppConstants.MOZ_UPDATER=" +
+ AppConstants.MOZ_UPDATER +
+ "this.#updateDisabledByPackage: " +
+ this.#updateDisabledByPackage
+ );
+ this.#setStatus(AppUpdater.STATUS.NO_UPDATER);
+ return;
+ }
+
+ if (this.aus.disabled) {
+ LOG("AppUpdater:check - AUS disabled");
+ this.#setStatus(AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY);
+ return;
+ }
+
+ let updateState = this.aus.currentState;
+ let stateName = this.aus.getStateName(updateState);
+ LOG(`AppUpdater:check - currentState=${stateName}`);
+
+ if (updateState == Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ LOG("AppUpdater:check - ready for restart");
+ this.#onReadyToRestart();
+ return;
+ }
+
+ if (this.aus.isOtherInstanceHandlingUpdates) {
+ LOG("AppUpdater:check - this.aus.isOtherInstanceHandlingUpdates");
+ this.#setStatus(AppUpdater.STATUS.OTHER_INSTANCE_HANDLING_UPDATES);
+ return;
+ }
+
+ if (updateState == Ci.nsIApplicationUpdateService.STATE_DOWNLOADING) {
+ LOG("AppUpdater:check - downloading");
+ this.#update = this.um.downloadingUpdate;
+ await this.#downloadUpdate();
+ return;
+ }
+
+ if (updateState == Ci.nsIApplicationUpdateService.STATE_STAGING) {
+ LOG("AppUpdater:check - staging");
+ this.#update = this.um.readyUpdate;
+ await this.#awaitStagingComplete();
+ return;
+ }
+
+ // Clear prefs that could prevent a user from discovering available updates.
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX);
+ }
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_NEVER)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_NEVER);
+ }
+ this.#setStatus(AppUpdater.STATUS.CHECKING);
+ LOG("AppUpdater:check - starting update check");
+ let check = this.checker.checkForUpdates(this.checker.FOREGROUND_CHECK);
+ let result;
+ try {
+ result = await makeAbortable(check.result);
+ } catch (e) {
+ // If we are aborting, stop the update check on our way out.
+ if (e instanceof AbortError) {
+ this.checker.stopCheck(check.id);
+ }
+ throw e;
+ }
+
+ if (!result.checksAllowed) {
+ // This shouldn't happen. The cases where this can happen should be
+ // handled specifically, above.
+ LOG("AppUpdater:check - !checksAllowed; INTERNAL_ERROR");
+ this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
+ return;
+ }
+
+ if (!result.succeeded) {
+ LOG("AppUpdater:check - Update check failed; CHECKING_FAILED");
+ this.#setStatus(AppUpdater.STATUS.CHECKING_FAILED);
+ return;
+ }
+
+ LOG("AppUpdater:check - Update check succeeded");
+ this.#update = this.aus.selectUpdate(result.updates);
+ if (!this.#update) {
+ LOG("AppUpdater:check - result: NO_UPDATES_FOUND");
+ this.#setStatus(AppUpdater.STATUS.NO_UPDATES_FOUND);
+ return;
+ }
+
+ if (this.#update.unsupported) {
+ LOG("AppUpdater:check - result: UNSUPPORTED SYSTEM");
+ this.#setStatus(AppUpdater.STATUS.UNSUPPORTED_SYSTEM);
+ return;
+ }
+
+ if (!this.aus.canApplyUpdates) {
+ LOG("AppUpdater:check - result: MANUAL_UPDATE");
+ this.#setStatus(AppUpdater.STATUS.MANUAL_UPDATE);
+ return;
+ }
+
+ let updateAuto = await makeAbortable(
+ lazy.UpdateUtils.getAppUpdateAutoEnabled()
+ );
+ if (!updateAuto || this.aus.manualUpdateOnly) {
+ LOG(
+ "AppUpdater:check - Need to wait for user approval to start the " +
+ "download."
+ );
+
+ let downloadPermissionPromise = new Promise(resolve => {
+ this.#permissionToDownloadGivenFn = resolve;
+ });
+ // There are other interfaces through which the user can start the
+ // download, so we want to listen both for permission, and for the
+ // download to independently start.
+ let downloadStartPromise = Promise.race([
+ downloadPermissionPromise,
+ this.aus.stateTransition,
+ ]);
+
+ this.#setStatus(AppUpdater.STATUS.DOWNLOAD_AND_INSTALL);
+
+ await makeAbortable(downloadStartPromise);
+ LOG("AppUpdater:check - Got user approval. Proceeding with download");
+ // If we resolved because of `aus.stateTransition`, we may actually be
+ // downloading a different update now.
+ if (this.um.downloadingUpdate) {
+ this.#update = this.um.downloadingUpdate;
+ }
+ } else {
+ LOG(
+ "AppUpdater:check - updateAuto is active and " +
+ "manualUpdateOnlydateOnly is inactive. Start the download."
+ );
+ }
+ await this.#downloadUpdate();
+ } catch (e) {
+ this.#onException(e);
+ } finally {
+ this.#updateBusy = false;
+ }
+ }
+
+ /**
+ * This only has an effect if the status is `DOWNLOAD_AND_INSTALL`.This
+ * indicates that the user has configured Firefox not to download updates
+ * without permission, and we are waiting the user's permission.
+ * This function should be called if and only if the user's permission was
+ * given as it will allow the update download to proceed.
+ */
+ allowUpdateDownload() {
+ if (this.#permissionToDownloadGivenFn) {
+ this.#permissionToDownloadGivenFn();
+ }
+ }
+
+ // true if updating is disabled because we're running in an app package.
+ // This is distinct from aus.disabled because we need to avoid
+ // messages being shown to the user about an "administrator" handling
+ // updates; packaged apps may be getting updated by an administrator or they
+ // may not be, and we don't have a good way to tell the difference from here,
+ // so we err to the side of less confusion for unmanaged users.
+ get #updateDisabledByPackage() {
+ return Services.sysinfo.getProperty("isPackagedApp");
+ }
+
+ // true when updating in background is enabled.
+ get #updateStagingEnabled() {
+ LOG(
+ "AppUpdater:#updateStagingEnabled" +
+ "canStageUpdates: " +
+ this.aus.canStageUpdates
+ );
+ return (
+ !this.aus.disabled &&
+ !this.#updateDisabledByPackage &&
+ this.aus.canStageUpdates
+ );
+ }
+
+ /**
+ * Downloads an update mar or connects to an in-progress download.
+ * Doesn't resolve until the update is ready to install, or a failure state
+ * is reached.
+ */
+ async #downloadUpdate() {
+ this.#setStatus(AppUpdater.STATUS.DOWNLOADING);
+
+ let success = await this.aus.downloadUpdate(this.#update, false);
+ if (!success) {
+ LOG("AppUpdater:#downloadUpdate - downloadUpdate failed.");
+ this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
+ return;
+ }
+
+ await this.#awaitDownloadComplete();
+ }
+
+ /**
+ * Listens for a download to complete.
+ * Doesn't resolve until the update is ready to install, or a failure state
+ * is reached.
+ */
+ async #awaitDownloadComplete() {
+ let updateState = this.aus.currentState;
+ if (
+ updateState != Ci.nsIApplicationUpdateService.STATE_DOWNLOADING &&
+ updateState != Ci.nsIApplicationUpdateService.STATE_SWAP
+ ) {
+ throw new Error(
+ "AppUpdater:#awaitDownloadComplete invoked in unexpected state: " +
+ this.aus.getStateName(updateState)
+ );
+ }
+
+ // We may already be in the `DOWNLOADING` state, depending on how we entered
+ // this function. But we actually want to alert the listeners again even if
+ // we are because `this.update.selectedPatch` is null early in the
+ // downloading state, but it should be set by now and listeners may want to
+ // update UI based on that.
+ this.#setStatus(AppUpdater.STATUS.DOWNLOADING);
+
+ const updateDownloadProgress = (progress, progressMax) => {
+ this.#setStatus(AppUpdater.STATUS.DOWNLOADING, progress, progressMax);
+ };
+
+ const progressObserver = {
+ onStartRequest(aRequest) {
+ LOG(
+ `AppUpdater:#awaitDownloadComplete.observer.onStartRequest - ` +
+ `aRequest: ${aRequest}`
+ );
+ },
+
+ onStatus(aRequest, aStatus, aStatusArg) {
+ LOG(
+ `AppUpdater:#awaitDownloadComplete.observer.onStatus ` +
+ `- aRequest: ${aRequest}, aStatus: ${aStatus}, ` +
+ `aStatusArg: ${aStatusArg}`
+ );
+ },
+
+ onProgress(aRequest, aProgress, aProgressMax) {
+ LOG(
+ `AppUpdater:#awaitDownloadComplete.observer.onProgress ` +
+ `- aRequest: ${aRequest}, aProgress: ${aProgress}, ` +
+ `aProgressMax: ${aProgressMax}`
+ );
+ updateDownloadProgress(aProgress, aProgressMax);
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ LOG(
+ `AppUpdater:#awaitDownloadComplete.observer.onStopRequest ` +
+ `- aRequest: ${aRequest}, aStatusCode: ${aStatusCode}`
+ );
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIProgressEventSink",
+ "nsIRequestObserver",
+ ]),
+ };
+
+ let listenForProgress =
+ updateState == Ci.nsIApplicationUpdateService.STATE_DOWNLOADING;
+
+ if (listenForProgress) {
+ this.aus.addDownloadListener(progressObserver);
+ LOG("AppUpdater:#awaitDownloadComplete - Registered download listener");
+ }
+
+ LOG("AppUpdater:#awaitDownloadComplete - Waiting for state transition.");
+ try {
+ await makeAbortable(this.aus.stateTransition);
+ } finally {
+ if (listenForProgress) {
+ this.aus.removeDownloadListener(progressObserver);
+ LOG("AppUpdater:#awaitDownloadComplete - Download listener removed");
+ }
+ }
+
+ updateState = this.aus.currentState;
+ LOG(
+ "AppUpdater:#awaitDownloadComplete - State transition seen. New state: " +
+ this.aus.getStateName(updateState)
+ );
+
+ switch (updateState) {
+ case Ci.nsIApplicationUpdateService.STATE_IDLE:
+ LOG(
+ "AppUpdater:#awaitDownloadComplete - Setting status DOWNLOAD_FAILED."
+ );
+ this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
+ break;
+ case Ci.nsIApplicationUpdateService.STATE_STAGING:
+ LOG("AppUpdater:#awaitDownloadComplete - awaiting staging completion.");
+ await this.#awaitStagingComplete();
+ break;
+ case Ci.nsIApplicationUpdateService.STATE_PENDING:
+ LOG("AppUpdater:#awaitDownloadComplete - ready to restart.");
+ this.#onReadyToRestart();
+ break;
+ default:
+ LOG(
+ "AppUpdater:#awaitDownloadComplete - Setting status INTERNAL_ERROR."
+ );
+ this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
+ break;
+ }
+ }
+
+ /**
+ * This function registers an observer that watches for the staging process
+ * to complete. Once it does, it sets the status to either request that the
+ * user restarts to install the update on success, request that the user
+ * manually download and install the newer version, or automatically download
+ * a complete update if applicable.
+ * Doesn't resolve until the update is ready to install, or a failure state
+ * is reached.
+ */
+ async #awaitStagingComplete() {
+ let updateState = this.aus.currentState;
+ if (updateState != Ci.nsIApplicationUpdateService.STATE_STAGING) {
+ throw new Error(
+ "AppUpdater:#awaitStagingComplete invoked in unexpected state: " +
+ this.aus.getStateName(updateState)
+ );
+ }
+
+ LOG("AppUpdater:#awaitStagingComplete - Setting status STAGING.");
+ this.#setStatus(AppUpdater.STATUS.STAGING);
+
+ LOG("AppUpdater:#awaitStagingComplete - Waiting for state transition.");
+ await makeAbortable(this.aus.stateTransition);
+
+ updateState = this.aus.currentState;
+ LOG(
+ "AppUpdater:#awaitStagingComplete - State transition seen. New state: " +
+ this.aus.getStateName(updateState)
+ );
+
+ switch (updateState) {
+ case Ci.nsIApplicationUpdateService.STATE_PENDING:
+ LOG("AppUpdater:#awaitStagingComplete - ready for restart");
+ this.#onReadyToRestart();
+ break;
+ case Ci.nsIApplicationUpdateService.STATE_IDLE:
+ LOG("AppUpdater:#awaitStagingComplete - DOWNLOAD_FAILED");
+ this.#setStatus(AppUpdater.STATUS.DOWNLOAD_FAILED);
+ break;
+ case Ci.nsIApplicationUpdateService.STATE_DOWNLOADING:
+ // We've fallen back to downloading the complete update because the
+ // partial update failed to be staged. Return to the downloading stage.
+ LOG(
+ "AppUpdater:#awaitStagingComplete - Partial update must have " +
+ "failed to stage. Downloading complete update."
+ );
+ await this.#awaitDownloadComplete();
+ break;
+ default:
+ LOG(
+ "AppUpdater:#awaitStagingComplete - Setting status INTERNAL_ERROR."
+ );
+ this.#setStatus(AppUpdater.STATUS.INTERNAL_ERROR);
+ break;
+ }
+ }
+
+ #onReadyToRestart() {
+ let updateState = this.aus.currentState;
+ if (updateState != Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ throw new Error(
+ "AppUpdater:#onReadyToRestart invoked in unexpected state: " +
+ this.aus.getStateName(updateState)
+ );
+ }
+
+ LOG("AppUpdater:#onReadyToRestart - Setting status READY_FOR_RESTART.");
+ if (this.#swapListenerConnected) {
+ LOG(
+ "AppUpdater:#onReadyToRestart - update-swap listener already attached"
+ );
+ } else {
+ this.#swapListenerConnected = true;
+ LOG("AppUpdater:#onReadyToRestart - Attaching update-swap listener");
+ Services.obs.addObserver(this, "update-swap", /* ownsWeak */ true);
+ }
+ this.#setStatus(AppUpdater.STATUS.READY_FOR_RESTART);
+ }
+
+ /**
+ * Stops the current check for updates and any ongoing download.
+ *
+ * If this is called before `AppUpdater.check()` is called or after it
+ * resolves, this should have no effect. If this is called while `check()` is
+ * still running, `AppUpdater` will return to the NEVER_CHECKED state. We
+ * don't really want to leave it in any of the intermediary states after we
+ * have disconnected all the listeners that would allow those states to ever
+ * change.
+ */
+ stop() {
+ LOG("AppUpdater:stop called");
+ if (this.#swapListenerConnected) {
+ LOG("AppUpdater:stop - Removing update-swap listener");
+ Services.obs.removeObserver(this, "update-swap");
+ this.#swapListenerConnected = false;
+ }
+ abortAllPromises();
+ }
+
+ /**
+ * {AppUpdater.STATUS} The status of the current check or update.
+ *
+ * Note that until AppUpdater.check has been called, this will always be set
+ * to NEVER_CHECKED.
+ */
+ get status() {
+ return this.#status;
+ }
+
+ /**
+ * Adds a listener function that will be called back on status changes as
+ * different stages of updates occur. The function will be called without
+ * arguments for most status changes; see the comments around the STATUS value
+ * definitions below. This is safe to call multiple times with the same
+ * function. It will be added only once.
+ *
+ * @param {function} listener
+ * The listener function to add.
+ */
+ addListener(listener) {
+ this.#listeners.add(listener);
+ }
+
+ /**
+ * Removes a listener. This is safe to call multiple times with the same
+ * function, or with a function that was never added.
+ *
+ * @param {function} listener
+ * The listener function to remove.
+ */
+ removeListener(listener) {
+ this.#listeners.delete(listener);
+ }
+
+ /**
+ * Sets the updater's current status and calls listeners.
+ *
+ * @param {AppUpdater.STATUS} status
+ * The new updater status.
+ * @param {*} listenerArgs
+ * Arguments to pass to listeners.
+ */
+ #setStatus(status, ...listenerArgs) {
+ this.#status = status;
+ for (let listener of this.#listeners) {
+ listener(status, ...listenerArgs);
+ }
+ return status;
+ }
+
+ observe(subject, topic, status) {
+ LOG(
+ "AppUpdater:observe " +
+ "- subject: " +
+ subject +
+ ", topic: " +
+ topic +
+ ", status: " +
+ status
+ );
+ switch (topic) {
+ case "update-swap":
+ // This is asynchronous, but we don't really want to wait for it in this
+ // observer.
+ this.#handleUpdateSwap();
+ break;
+ }
+ }
+
+ async #handleUpdateSwap() {
+ try {
+ // This must not be in the try/catch that sets #updateBusy to `false` in
+ // its finally block.
+ // There really shouldn't be any way to enter this function when
+ // `#updateBusy` is `true`. But let's just be safe because we don't want
+ // to ever end up with two things running at once.
+ if (this.#updateBusy) {
+ return;
+ }
+ } catch (e) {
+ this.#onException(e);
+ }
+
+ try {
+ this.#updateBusy = true;
+
+ // During an update swap, the new update will initially be stored in
+ // `downloadingUpdate`. Part way through, it will be moved into
+ // `readyUpdate` and `downloadingUpdate` will be set to `null`.
+ this.#update = this.um.downloadingUpdate;
+ if (!this.#update) {
+ this.#update = this.um.readyUpdate;
+ }
+
+ await this.#awaitDownloadComplete();
+ } catch (e) {
+ this.#onException(e);
+ } finally {
+ this.#updateBusy = false;
+ }
+ }
+}
+
+XPCOMUtils.defineLazyServiceGetter(
+ AppUpdater.prototype,
+ "aus",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ AppUpdater.prototype,
+ "checker",
+ "@mozilla.org/updates/update-checker;1",
+ "nsIUpdateChecker"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ AppUpdater.prototype,
+ "um",
+ "@mozilla.org/updates/update-manager;1",
+ "nsIUpdateManager"
+);
+
+AppUpdater.STATUS = {
+ // Updates are allowed and there's no downloaded or staged update, but the
+ // AppUpdater hasn't checked for updates yet, so it doesn't know more than
+ // that.
+ NEVER_CHECKED: 0,
+
+ // The updater isn't available (AppConstants.MOZ_UPDATER is falsey).
+ NO_UPDATER: 1,
+
+ // "appUpdate" is not allowed by policy.
+ UPDATE_DISABLED_BY_POLICY: 2,
+
+ // Another app instance is handling updates.
+ OTHER_INSTANCE_HANDLING_UPDATES: 3,
+
+ // There's an update, but it's not supported on this system.
+ UNSUPPORTED_SYSTEM: 4,
+
+ // The user must apply updates manually.
+ MANUAL_UPDATE: 5,
+
+ // The AppUpdater is checking for updates.
+ CHECKING: 6,
+
+ // The AppUpdater checked for updates and none were found.
+ NO_UPDATES_FOUND: 7,
+
+ // The AppUpdater is downloading an update. Listeners are notified of this
+ // status as a download starts. They are also notified on download progress,
+ // and in that case they are passed two arguments: the current download
+ // progress and the total download size.
+ DOWNLOADING: 8,
+
+ // The AppUpdater tried to download an update but it failed.
+ DOWNLOAD_FAILED: 9,
+
+ // There's an update available, but the user wants us to ask them to download
+ // and install it.
+ DOWNLOAD_AND_INSTALL: 10,
+
+ // An update is staging.
+ STAGING: 11,
+
+ // An update is downloaded and staged and will be applied on restart.
+ READY_FOR_RESTART: 12,
+
+ // Essential components of the updater are failing and preventing us from
+ // updating.
+ INTERNAL_ERROR: 13,
+
+ // Failed to check for updates, network timeout, dns errors could cause this
+ CHECKING_FAILED: 14,
+
+ /**
+ * Is the given `status` a terminal state in the update state machine?
+ *
+ * A terminal state means that the `check()` method has completed.
+ *
+ * N.b.: `DOWNLOAD_AND_INSTALL` is not considered terminal because the normal
+ * flow is that Firefox will show UI prompting the user to install, and when
+ * the user interacts, the `check()` method will continue through the update
+ * state machine.
+ *
+ * @returns {boolean} `true` if `status` is terminal.
+ */
+ isTerminalStatus(status) {
+ return ![
+ AppUpdater.STATUS.CHECKING,
+ AppUpdater.STATUS.DOWNLOAD_AND_INSTALL,
+ AppUpdater.STATUS.DOWNLOADING,
+ AppUpdater.STATUS.NEVER_CHECKED,
+ AppUpdater.STATUS.STAGING,
+ ].includes(status);
+ },
+
+ /**
+ * Turn the given `status` into a string for debugging.
+ *
+ * @returns {?string} representation of given numerical `status`.
+ */
+ debugStringFor(status) {
+ for (let [k, v] of Object.entries(AppUpdater.STATUS)) {
+ if (v == status) {
+ return k;
+ }
+ }
+ return null;
+ },
+};
+
+/**
+ * Logs a string to the error console. If enabled, also logs to the update
+ * messages file.
+ * @param string
+ * The string to write to the error console.
+ */
+function LOG(string) {
+ lazy.UpdateLog.logPrefixedString("AUS:AUM", string);
+}
diff --git a/toolkit/mozapps/update/BackgroundTask_backgroundupdate.sys.mjs b/toolkit/mozapps/update/BackgroundTask_backgroundupdate.sys.mjs
new file mode 100644
index 0000000000..d5cf96cbaa
--- /dev/null
+++ b/toolkit/mozapps/update/BackgroundTask_backgroundupdate.sys.mjs
@@ -0,0 +1,470 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+import { BackgroundUpdate } from "resource://gre/modules/BackgroundUpdate.sys.mjs";
+import { DevToolsSocketStatus } from "resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs";
+
+const { EXIT_CODE } = BackgroundUpdate;
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs",
+ ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "UpdateService",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: "app.update.background.loglevel",
+ prefix: "BackgroundUpdate",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+export const backgroundTaskTimeoutSec = Services.prefs.getIntPref(
+ "app.update.background.timeoutSec",
+ 10 * 60
+);
+
+/**
+ * Verify that pre-conditions to update this installation (both persistent and
+ * transient) are fulfilled, and if they are all fulfilled, pump the update
+ * loop.
+ *
+ * This means checking for, downloading, and potentially applying updates.
+ *
+ * @returns {any} - Returns AppUpdater status upon update loop exit.
+ */
+async function _attemptBackgroundUpdate() {
+ let SLUG = "_attemptBackgroundUpdate";
+
+ // Here's where we do `post-update-processing`. Creating the stub invokes the
+ // `UpdateServiceStub()` constructor, which handles various migrations (which should not be
+ // necessary, but we want to run for consistency and any migrations added in the future) and then
+ // dispatches `post-update-processing` (if appropriate). We want to do this very early, so that
+ // the real update service is in its fully initialized state before any usage.
+ lazy.log.debug(
+ `${SLUG}: creating UpdateServiceStub() for "post-update-processing"`
+ );
+ Cc["@mozilla.org/updates/update-service-stub;1"].createInstance(
+ Ci.nsISupports
+ );
+
+ lazy.log.debug(
+ `${SLUG}: checking for preconditions necessary to update this installation`
+ );
+ let reasons = await BackgroundUpdate._reasonsToNotUpdateInstallation();
+
+ if (BackgroundUpdate._force()) {
+ // We want to allow developers and testers to monkey with the system.
+ lazy.log.debug(
+ `${SLUG}: app.update.background.force=true, ignoring reasons: ${JSON.stringify(
+ reasons
+ )}`
+ );
+ reasons = [];
+ }
+
+ reasons.sort();
+ for (let reason of reasons) {
+ Glean.backgroundUpdate.reasons.add(reason);
+ }
+
+ let enabled = !reasons.length;
+ if (!enabled) {
+ lazy.log.info(
+ `${SLUG}: not running background update task: '${JSON.stringify(
+ reasons
+ )}'`
+ );
+
+ return lazy.AppUpdater.STATUS.NEVER_CHECKED;
+ }
+
+ let result = new Promise(resolve => {
+ let appUpdater = new lazy.AppUpdater();
+
+ let _appUpdaterListener = (status, progress, progressMax) => {
+ let stringStatus = lazy.AppUpdater.STATUS.debugStringFor(status);
+ Glean.backgroundUpdate.states.add(stringStatus);
+ Glean.backgroundUpdate.finalState.set(stringStatus);
+
+ if (lazy.AppUpdater.STATUS.isTerminalStatus(status)) {
+ lazy.log.debug(
+ `${SLUG}: background update transitioned to terminal status ${status}: ${stringStatus}`
+ );
+ appUpdater.removeListener(_appUpdaterListener);
+ appUpdater.stop();
+ resolve(status);
+ } else if (status == lazy.AppUpdater.STATUS.CHECKING) {
+ // The usual initial flow for the Background Update Task is to kick off
+ // the update download and immediately exit. For consistency, we are
+ // going to enforce this flow. So if we are just now checking for
+ // updates, we will limit the updater such that it cannot start staging,
+ // even if we immediately download the entire update.
+ lazy.log.debug(
+ `${SLUG}: This session will be limited to downloading updates only.`
+ );
+ lazy.UpdateService.onlyDownloadUpdatesThisSession = true;
+ } else if (
+ status == lazy.AppUpdater.STATUS.DOWNLOADING &&
+ (lazy.UpdateService.onlyDownloadUpdatesThisSession ||
+ (progress !== undefined && progressMax !== undefined))
+ ) {
+ // We get a DOWNLOADING callback with no progress or progressMax values
+ // when we initially switch to the DOWNLOADING state. But when we get
+ // onProgress notifications, progress and progressMax will be defined.
+ // Remember to keep in mind that progressMax is a required value that
+ // we can count on being meaningful, but it will be set to -1 for BITS
+ // transfers that haven't begun yet.
+ if (
+ lazy.UpdateService.onlyDownloadUpdatesThisSession ||
+ progressMax < 0 ||
+ progress != progressMax
+ ) {
+ lazy.log.debug(
+ `${SLUG}: Download in progress. Exiting task while download ` +
+ `transfers`
+ );
+ // If the download is still in progress, we don't want the Background
+ // Update Task to hang around waiting for it to complete.
+ lazy.UpdateService.onlyDownloadUpdatesThisSession = true;
+
+ appUpdater.removeListener(_appUpdaterListener);
+ appUpdater.stop();
+ resolve(status);
+ } else {
+ lazy.log.debug(`${SLUG}: Download has completed!`);
+ }
+ } else {
+ lazy.log.debug(
+ `${SLUG}: background update transitioned to status ${status}: ${stringStatus}`
+ );
+ }
+ };
+ appUpdater.addListener(_appUpdaterListener);
+
+ appUpdater.check();
+ });
+
+ return result;
+}
+
+/**
+ * Maybe submit a "background-update" custom Glean ping.
+ *
+ * If data reporting upload in general is enabled Glean will submit a ping. To determine if
+ * telemetry is enabled, Glean will look at the relevant pref, which was mirrored from the default
+ * profile. Note that the Firefox policy mechanism will manage this pref, locking it to particular
+ * values as appropriate.
+ */
+export async function maybeSubmitBackgroundUpdatePing() {
+ let SLUG = "maybeSubmitBackgroundUpdatePing";
+
+ // It should be possible to turn AUSTLMY data into Glean data, but mapping histograms isn't
+ // trivial, so we don't do it at this time. Bug 1703313.
+
+ // Including a reason allows to differentiate pings sent as part of the task
+ // and pings queued and sent by Glean on a different schedule.
+ GleanPings.backgroundUpdate.submit("backgroundupdate_task");
+
+ lazy.log.info(`${SLUG}: submitted "background-update" ping`);
+}
+
+export async function runBackgroundTask(commandLine) {
+ let SLUG = "runBackgroundTask";
+ lazy.log.error(`${SLUG}: backgroundupdate`);
+ let automaticRestartFound =
+ -1 != commandLine.findFlag("automatic-restart", false);
+
+ // Modify Glean metrics for a successful automatic restart.
+ if (automaticRestartFound) {
+ Glean.backgroundUpdate.automaticRestartSuccess.set(true);
+ lazy.log.debug(`${SLUG}: application automatic restart completed`);
+ }
+
+ // Help debugging. This is a pared down version of
+ // `dataProviders.application` in `Troubleshoot.sys.mjs`. When adding to this
+ // debugging data, try to follow the form from that module.
+ let data = {
+ name: Services.appinfo.name,
+ osVersion:
+ Services.sysinfo.getProperty("name") +
+ " " +
+ Services.sysinfo.getProperty("version") +
+ " " +
+ Services.sysinfo.getProperty("build"),
+ version: AppConstants.MOZ_APP_VERSION_DISPLAY,
+ buildID: Services.appinfo.appBuildID,
+ distributionID: Services.prefs
+ .getDefaultBranch("")
+ .getCharPref("distribution.id", ""),
+ updateChannel: lazy.UpdateUtils.UpdateChannel,
+ UpdRootD: Services.dirsvc.get("UpdRootD", Ci.nsIFile).path,
+ };
+ lazy.log.debug(`${SLUG}: current configuration`, data);
+
+ // Other instances running are a transient precondition (during this invocation). We'd prefer to
+ // check this later, as a reason for not updating, but Glean is not tested in multi-process
+ // environments and while its storage (backed by rkv) can in theory support multiple processes, it
+ // is not clear that it in fact does support multiple processes. So we are conservative here.
+ // There is a potential time-of-check/time-of-use race condition here, but if process B starts
+ // after we pass this test, that process should exit after it gets to this check, avoiding
+ // multiple processes using the same Glean storage. If and when more and longer-running
+ // background tasks become common, we may need to be more fine-grained and share just the Glean
+ // storage resource.
+ lazy.log.debug(`${SLUG}: checking if other instance is running`);
+ let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ if (DevToolsSocketStatus.hasSocketOpened()) {
+ lazy.log.warn(
+ `${SLUG}: Ignoring the 'multiple instances' check because a DevTools server is listening.`
+ );
+ } else if (syncManager.isOtherInstanceRunning()) {
+ lazy.log.error(`${SLUG}: another instance is running`);
+ return EXIT_CODE.OTHER_INSTANCE;
+ }
+
+ // Here we mirror specific prefs from the default profile into our temporary profile. We want to
+ // do this early because some of the prefs may impact internals such as log levels. Generally,
+ // however, we want prefs from the default profile to not impact the mechanics of checking for,
+ // downloading, and applying updates, since such prefs should be be per-installation prefs, using
+ // the mechanisms of Bug 1691486. Sadly using this mechanism for many relevant prefs (namely
+ // `app.update.BITS.enabled` and `app.update.service.enabled`) is difficult: see Bug 1657533.
+ //
+ // We also read any Nimbus targeting snapshot from the default profile.
+ let defaultProfileTargetingSnapshot = {};
+ try {
+ let defaultProfilePrefs;
+ await lazy.BackgroundTasksUtils.withProfileLock(async lock => {
+ let predicate = name => {
+ return (
+ name.startsWith("app.update.") || // For obvious reasons.
+ name.startsWith("datareporting.") || // For Glean.
+ name.startsWith("logging.") || // For Glean.
+ name.startsWith("telemetry.fog.") || // For Glean.
+ name.startsWith("app.partner.") || // For our metrics.
+ name === "app.shield.optoutstudies.enabled" || // For Nimbus.
+ name === "services.settings.server" || // For Remote Settings via Nimbus.
+ name === "services.settings.preview_enabled" || // For Remote Settings via Nimbus.
+ name === "messaging-system.rsexperimentloader.collection_id" // For Firefox Messaging System.
+ );
+ };
+
+ defaultProfilePrefs = await lazy.BackgroundTasksUtils.readPreferences(
+ predicate,
+ lock
+ );
+ let telemetryClientID =
+ await lazy.BackgroundTasksUtils.readTelemetryClientID(lock);
+ Glean.backgroundUpdate.clientId.set(telemetryClientID);
+
+ // Read targeting snapshot, collect background update specific telemetry. Never throws.
+ defaultProfileTargetingSnapshot =
+ await BackgroundUpdate.readFirefoxMessagingSystemTargetingSnapshot(
+ lock
+ );
+ });
+
+ for (let [name, value] of Object.entries(defaultProfilePrefs)) {
+ switch (typeof value) {
+ case "boolean":
+ Services.prefs.setBoolPref(name, value);
+ break;
+ case "number":
+ Services.prefs.setIntPref(name, value);
+ break;
+ case "string":
+ Services.prefs.setCharPref(name, value);
+ break;
+ default:
+ throw new Error(
+ `Pref from default profile with name "${name}" has unrecognized type`
+ );
+ }
+ }
+ } catch (e) {
+ if (!lazy.BackgroundTasksUtils.hasDefaultProfile()) {
+ lazy.log.error(`${SLUG}: caught exception; no default profile exists`, e);
+ return EXIT_CODE.DEFAULT_PROFILE_DOES_NOT_EXIST;
+ }
+
+ if (e.name == "CannotLockProfileError") {
+ lazy.log.error(
+ `${SLUG}: caught exception; could not lock default profile`,
+ e
+ );
+ return EXIT_CODE.DEFAULT_PROFILE_CANNOT_BE_LOCKED;
+ }
+
+ lazy.log.error(
+ `${SLUG}: caught exception reading preferences and telemetry client ID from default profile`,
+ e
+ );
+ return EXIT_CODE.DEFAULT_PROFILE_CANNOT_BE_READ;
+ }
+
+ // Now that we have prefs from the default profile, we can configure Firefox-on-Glean.
+
+ // Glean has a preinit queue for metric operations that happen before init, so
+ // this is safe. We want to have these metrics set before the first possible
+ // time we might send (built-in) pings.
+ await BackgroundUpdate.recordUpdateEnvironment();
+
+ // To help debugging, use the `GLEAN_LOG_PINGS` and `GLEAN_DEBUG_VIEW_TAG`
+ // environment variables: see
+ // https://mozilla.github.io/glean/book/user/debugging/index.html.
+ let gleanRoot = await IOUtils.getDirectory(
+ Services.dirsvc.get("UpdRootD", Ci.nsIFile).path,
+ "backgroundupdate",
+ "datareporting",
+ "glean"
+ );
+ Services.fog.initializeFOG(
+ gleanRoot.path,
+ "firefox.desktop.background.update"
+ );
+
+ // For convenience, mirror our loglevel.
+ let logLevel = Services.prefs.getCharPref(
+ "app.update.background.loglevel",
+ "error"
+ );
+ const logLevelPrefs = [
+ "browser.newtabpage.activity-stream.asrouter.debugLogLevel",
+ "messaging-system.log",
+ "services.settings.loglevel",
+ "toolkit.backgroundtasks.loglevel",
+ ];
+ for (let logLevelPref of logLevelPrefs) {
+ lazy.log.info(`${SLUG}: setting ${logLevelPref}=${logLevel}`);
+ Services.prefs.setCharPref(logLevelPref, logLevel);
+ }
+
+ // The langpack updating mechanism expects the addons manager, but in background task mode, the
+ // addons manager is not present. Since we can't update langpacks from the background task
+ // temporary profile, we disable the langpack updating mechanism entirely. This relies on the
+ // default profile being the only profile that schedules the OS-level background task and ensuring
+ // the task is not scheduled when langpacks are present. Non-default profiles that have langpacks
+ // installed may experience the issues that motivated Bug 1647443. If this turns out to be a
+ // significant problem in the wild, we could store more information about profiles and their
+ // active langpacks to disable background updates in more cases, maybe in per-installation prefs.
+ Services.prefs.setBoolPref("app.update.langpack.enabled", false);
+
+ let result = EXIT_CODE.SUCCESS;
+
+ let stringStatus = lazy.AppUpdater.STATUS.debugStringFor(
+ lazy.AppUpdater.STATUS.NEVER_CHECKED
+ );
+ Glean.backgroundUpdate.states.add(stringStatus);
+ Glean.backgroundUpdate.finalState.set(stringStatus);
+
+ let updateStatus = lazy.AppUpdater.STATUS.NEVER_CHECKED;
+ try {
+ // Return AppUpdater status from _attemptBackgroundUpdate() to
+ // check if the status is STATUS.READY_FOR_RESTART.
+ updateStatus = await _attemptBackgroundUpdate();
+
+ lazy.log.info(`${SLUG}: attempted background update`);
+ Glean.backgroundUpdate.exitCodeSuccess.set(true);
+
+ try {
+ // Now that we've pumped the update loop, we can start Nimbus and the Firefox Messaging System
+ // and see if we should message the user. This minimizes the risk of messaging impacting the
+ // function of the background update system.
+ await lazy.BackgroundTasksUtils.enableNimbus(
+ commandLine,
+ defaultProfileTargetingSnapshot.environment
+ );
+
+ await lazy.BackgroundTasksUtils.enableFirefoxMessagingSystem(
+ defaultProfileTargetingSnapshot.environment
+ );
+ } catch (f) {
+ // Try to make it easy to witness errors in this system. We can pass through any exception
+ // without disrupting (future) background updates.
+ //
+ // Most meaningful issues with the Nimbus/experiments system will be reported via Glean
+ // events.
+ lazy.log.warn(
+ `${SLUG}: exception raised from Nimbus/Firefox Messaging System`,
+ f
+ );
+ throw f;
+ }
+ } catch (e) {
+ // TODO: in the future, we might want to classify failures into transient and persistent and
+ // backoff the update task in the face of continuous persistent errors.
+ lazy.log.error(`${SLUG}: caught exception attempting background update`, e);
+
+ result = EXIT_CODE.EXCEPTION;
+ Glean.backgroundUpdate.exitCodeException.set(true);
+ } finally {
+ // This is the point to report telemetry, assuming that the default profile's data reporting
+ // configuration allows it.
+ await maybeSubmitBackgroundUpdatePing();
+ }
+
+ // TODO: ensure the update service has persisted its state before we exit. Bug 1700846.
+ // TODO: ensure that Glean's upload mechanism is aware of Gecko shutdown. Bug 1703572.
+ await lazy.ExtensionUtils.promiseTimeout(500);
+
+ // If we're in a staged background update, we need to restart Firefox to complete the update.
+ lazy.log.debug(
+ `${SLUG}: Checking if staged background update is ready for restart`
+ );
+ // If a restart loop is occurring then automaticRestartFound will be true.
+ if (
+ lazy.NimbusFeatures.backgroundUpdateAutomaticRestart.getVariable(
+ "enabled"
+ ) &&
+ updateStatus === lazy.AppUpdater.STATUS.READY_FOR_RESTART &&
+ !automaticRestartFound
+ ) {
+ lazy.log.debug(
+ `${SLUG}: Starting Firefox restart after staged background update`
+ );
+
+ // We need to restart Firefox with the same arguments to ensure
+ // the background update continues from where it was before the restart.
+ try {
+ Cc["@mozilla.org/updates/update-processor;1"]
+ .createInstance(Ci.nsIUpdateProcessor)
+ .attemptAutomaticApplicationRestartWithLaunchArgs([
+ "-automatic-restart",
+ ]);
+ // Report an attempted automatic restart.
+ Glean.backgroundUpdate.automaticRestartAttempted.set(true);
+ lazy.log.debug(`${SLUG}: automatic application restart queued`);
+ } catch (e) {
+ lazy.log.error(
+ `${SLUG}: caught exception; failed to queue automatic application restart`,
+ e
+ );
+ }
+ }
+
+ return result;
+}
diff --git a/toolkit/mozapps/update/BackgroundUpdate.sys.mjs b/toolkit/mozapps/update/BackgroundUpdate.sys.mjs
new file mode 100644
index 0000000000..28d0fc8538
--- /dev/null
+++ b/toolkit/mozapps/update/BackgroundUpdate.sys.mjs
@@ -0,0 +1,1045 @@
+/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { EXIT_CODE } from "resource://gre/modules/BackgroundTasksManager.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ASRouterTargeting:
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs",
+ ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ TaskScheduler: "resource://gre/modules/TaskScheduler.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
+ // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
+ maxLogLevel: "error",
+ maxLogLevelPref: "app.update.background.loglevel",
+ prefix: "BackgroundUpdate",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+ChromeUtils.defineLazyGetter(lazy, "localization", () => {
+ return new Localization(
+ ["branding/brand.ftl", "toolkit/updates/backgroundupdate.ftl"],
+ true
+ );
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ idleService: ["@mozilla.org/widget/useridleservice;1", "nsIUserIdleService"],
+ UpdateService: [
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService",
+ ],
+});
+
+// We may want to change the definition of the task over time. When we do this,
+// we need to remove and re-register the task. We will make sure this happens
+// by storing the installed version number of the task to a pref and comparing
+// that version number to the current version. If they aren't equal, we know
+// that we have to re-register the task.
+const TASK_DEF_CURRENT_VERSION = 4;
+const TASK_INSTALLED_VERSION_PREF =
+ "app.update.background.lastInstalledTaskVersion";
+
+// This returns the version of the task naming scheme being used which
+// is different from the task version used for the task definition.
+function taskNameVersion(taskVersion) {
+ if (AppConstants.platform != "win" || taskVersion < 4) {
+ return 1;
+ }
+ return 2;
+}
+
+async function deleteTasksInRange(installedVersion, currentVersion) {
+ for (
+ let taskVersion = installedVersion;
+ taskVersion <= currentVersion;
+ taskVersion++
+ ) {
+ await lazy.TaskScheduler.deleteTask(this.taskId, {
+ nameVersion: taskNameVersion(taskVersion),
+ });
+ }
+}
+
+export var BackgroundUpdate = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsINamed",
+ "nsIObserver",
+ "nsITimerCallback",
+ ]),
+ name: "BackgroundUpdate",
+
+ _initialized: false,
+
+ get taskId() {
+ let taskId = "backgroundupdate";
+ if (AppConstants.platform == "win") {
+ // In the future, we might lift this to TaskScheduler Win impl, so that
+ // all tasks associated with this installation look consistent in the
+ // Windows Task Scheduler UI.
+ taskId = `${AppConstants.MOZ_APP_DISPLAYNAME_DO_NOT_USE} Background Update`;
+ }
+ return taskId;
+ },
+
+ /**
+ * Whether this installation has an App and a GRE omnijar.
+ *
+ * Installations without an omnijar are generally developer builds and should not be updated.
+ *
+ * @returns {boolean} - true if this installation has an App and a GRE omnijar.
+ */
+ async _hasOmnijar() {
+ const appOmniJar = PathUtils.join(
+ Services.dirsvc.get("XCurProcD", Ci.nsIFile).path,
+ AppConstants.OMNIJAR_NAME
+ );
+ const greOmniJar = PathUtils.join(
+ Services.dirsvc.get("GreD", Ci.nsIFile).path,
+ AppConstants.OMNIJAR_NAME
+ );
+
+ let bothExist =
+ (await IOUtils.exists(appOmniJar)) && (await IOUtils.exists(greOmniJar));
+
+ return bothExist;
+ },
+
+ _force() {
+ // We want to allow developers and testers to monkey with the system.
+ return Services.prefs.getBoolPref("app.update.background.force", false);
+ },
+
+ /**
+ * Check eligibility criteria determining if this installation should be updated using the
+ * background updater.
+ *
+ * These reasons should not factor in transient reasons, for example if there are currently multiple
+ * Firefox instances running.
+ *
+ * Both the browser proper and the backgroundupdate background task invoke this function, so avoid
+ * using profile specifics here. Profile specifics that the background task specifically sources
+ * from the default profile are available here.
+ *
+ * @returns [string] - descriptions of failed criteria; empty if all criteria were met.
+ */
+ async _reasonsToNotUpdateInstallation() {
+ let SLUG = "_reasonsToNotUpdateInstallation";
+ let reasons = [];
+
+ lazy.log.debug(`${SLUG}: checking app.update.auto`);
+ let updateAuto = await lazy.UpdateUtils.getAppUpdateAutoEnabled();
+ if (!updateAuto) {
+ reasons.push(this.REASON.NO_APP_UPDATE_AUTO);
+ }
+
+ lazy.log.debug(`${SLUG}: checking app.update.background.enabled`);
+ let updateBackground = await lazy.UpdateUtils.readUpdateConfigSetting(
+ "app.update.background.enabled"
+ );
+ if (!updateBackground) {
+ reasons.push(this.REASON.NO_APP_UPDATE_BACKGROUND_ENABLED);
+ }
+
+ const bts =
+ "@mozilla.org/backgroundtasks;1" in Cc &&
+ Cc["@mozilla.org/backgroundtasks;1"].getService(Ci.nsIBackgroundTasks);
+
+ lazy.log.debug(`${SLUG}: checking for MOZ_BACKGROUNDTASKS`);
+ if (!AppConstants.MOZ_BACKGROUNDTASKS || !bts) {
+ reasons.push(this.REASON.NO_MOZ_BACKGROUNDTASKS);
+ }
+
+ // The methods exposed by the update service named like `canX` answer the
+ // question "can I do action X RIGHT NOW", where-as we want the variants
+ // named like `canUsuallyX` to answer the question "can I usually do X, now
+ // and in the future".
+ let updateService = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+ );
+
+ lazy.log.debug(
+ `${SLUG}: checking that updates are not disabled by policy, testing ` +
+ `configuration, or abnormal runtime environment`
+ );
+ if (!updateService.canUsuallyCheckForUpdates) {
+ reasons.push(this.REASON.CANNOT_USUALLY_CHECK);
+ }
+
+ lazy.log.debug(
+ `${SLUG}: checking that we can make progress: updates can stage and/or apply`
+ );
+ if (
+ !updateService.canUsuallyStageUpdates &&
+ !updateService.canUsuallyApplyUpdates
+ ) {
+ reasons.push(this.REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY);
+ }
+
+ lazy.log.debug(
+ `${SLUG}: checking that we are on a supported OS (currently only Windows)`
+ );
+ if (AppConstants.platform != "win") {
+ reasons.push(this.REASON.UNSUPPORTED_OS);
+ }
+
+ if (AppConstants.platform == "win") {
+ lazy.log.debug(`${SLUG}: checking that we can usually use Windows BITS`);
+ if (!updateService.canUsuallyUseBits) {
+ // There's no technical reason to require BITS, but the experience
+ // without BITS will be worse because the background tasks will run
+ // while downloading, consuming valuable resources.
+ reasons.push(this.REASON.WINDOWS_CANNOT_USUALLY_USE_BITS);
+ }
+
+ // Historically the background update process assumed the Mozilla
+ // Maintenance Service was available and could update this installation.
+ // We want to handle unelevated installations where this is not the case,
+ // and for flexibility we are rolling this out behind a Nimbus feature.
+ lazy.log.debug(
+ `${SLUG}: checking that the Mozilla Maintenance Service Registry key exists, ` +
+ `or that the unelevated installs are permitted`
+ );
+ let serviceRegKeyExists = false;
+ try {
+ serviceRegKeyExists = Cc["@mozilla.org/updates/update-processor;1"]
+ .createInstance(Ci.nsIUpdateProcessor)
+ .getServiceRegKeyExists();
+ } catch (ex) {
+ lazy.log.error(
+ `${SLUG}: Failed to check for Maintenance Service Registry Key: ${ex}`
+ );
+ }
+
+ if (!serviceRegKeyExists) {
+ // A Nimbus rollout sets this preference and allows users with
+ // unelevated installations to update in the background. For that to
+ // work we use the setPref function to toggle a preference, because the
+ // value for Nimbus is currently not readable in a backgroundtask. The
+ // preference serves in that case as our communication channel.
+ let allowUnelevated = await Services.prefs.getBoolPref(
+ "app.update.background.allowUpdatesForUnelevatedInstallations"
+ );
+
+ if (!allowUnelevated) {
+ // With the nimbus feature disabled and without the registry key we
+ // do not want to attempt an update for unelevated installations.
+ reasons.push(this.REASON.SERVICE_REGISTRY_KEY_MISSING);
+ } else {
+ // We record in telemetry, that the service registry key is missing
+ // and the experiment is enabled. This is the first time that the
+ // Nimbus feature could impact Firefox behaviour.
+ lazy.NimbusFeatures.backgroundUpdate.recordExposureEvent();
+ lazy.log.debug(
+ `${SLUG}: ` +
+ "expermiment active: trying to update unelevated installations."
+ );
+
+ // Now check if the path is writable. If not we are dealing with an
+ // elevated installation and cannot update it without the service for
+ // which the registry key is missing at this point.
+ if (!updateService.isAppBaseDirWritable) {
+ reasons.push(this.REASON.SERVICE_REGISTRY_KEY_MISSING);
+ reasons.push(this.REASON.APPBASEDIR_NOT_WRITABLE);
+ }
+ }
+ }
+ }
+
+ lazy.log.debug(`${SLUG}: checking that this installation has an omnijar`);
+ if (!(await this._hasOmnijar())) {
+ reasons.push(this.REASON.NO_OMNIJAR);
+ }
+
+ if (updateService.manualUpdateOnly) {
+ reasons.push(this.REASON.MANUAL_UPDATE_ONLY);
+ }
+
+ this._recordGleanMetrics(reasons);
+
+ return reasons;
+ },
+
+ /**
+ * Check if this particular profile should schedule tasks to update this installation using the
+ * background updater.
+ *
+ * Only the browser proper should invoke this function, not background tasks, so this is the place
+ * to use profile specifics.
+ *
+ * @returns [string] - descriptions of failed criteria; empty if all criteria were met.
+ */
+ async _reasonsToNotScheduleUpdates() {
+ let SLUG = "_reasonsToNotScheduleUpdates";
+ let reasons = [];
+
+ const bts =
+ "@mozilla.org/backgroundtasks;1" in Cc &&
+ Cc["@mozilla.org/backgroundtasks;1"].getService(Ci.nsIBackgroundTasks);
+
+ if (bts && bts.isBackgroundTaskMode) {
+ throw new Components.Exception(
+ `Not available in --backgroundtask mode`,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+
+ // No default profile happens under xpcshell but also when running local
+ // builds. It's unexpected in the wild so we track it separately.
+ if (!lazy.BackgroundTasksUtils.hasDefaultProfile()) {
+ reasons.push(this.REASON.NO_DEFAULT_PROFILE_EXISTS);
+ }
+
+ if (!lazy.BackgroundTasksUtils.currentProfileIsDefaultProfile()) {
+ reasons.push(this.REASON.NOT_DEFAULT_PROFILE);
+ }
+
+ lazy.log.debug(`${SLUG}: checking app.update.langpack.enabled`);
+ let updateLangpack = Services.prefs.getBoolPref(
+ "app.update.langpack.enabled",
+ true
+ );
+ if (updateLangpack) {
+ lazy.log.debug(
+ `${SLUG}: app.update.langpack.enabled=true, checking that no langpacks are installed`
+ );
+
+ let langpacks = await lazy.AddonManager.getAddonsByTypes(["locale"]);
+ lazy.log.debug(`${langpacks.length} langpacks installed`);
+ if (langpacks.length) {
+ reasons.push(this.REASON.LANGPACK_INSTALLED);
+ }
+ }
+
+ this._recordGleanMetrics(reasons);
+
+ return reasons;
+ },
+
+ /**
+ * Register a background update task.
+ *
+ * @param {string} [taskId]
+ * The task identifier; defaults to the platform-specific background update task ID.
+ * @return {object} non-null if the background task was registered.
+ */
+ async _registerBackgroundUpdateTask(taskId = this.taskId) {
+ let binary = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+ let args = [
+ "--MOZ_LOG",
+ // Note: `maxsize:1` means 1Mb total size, trimmed to 512kb on overflow.
+ "sync,prependheader,timestamp,append,maxsize:1,Dump:5",
+ "--MOZ_LOG_FILE",
+ // The full path might hit command line length limits, but also makes it
+ // much easier to find the relevant log file when starting from the
+ // Windows Task Scheduler UI.
+ PathUtils.join(
+ Services.dirsvc.get("UpdRootD", Ci.nsIFile).path,
+ "backgroundupdate.moz_log"
+ ),
+ "--backgroundtask",
+ "backgroundupdate",
+ ];
+
+ let workingDirectory = Services.dirsvc.get("UpdRootD", Ci.nsIFile).path;
+ await IOUtils.makeDirectory(workingDirectory, { ignoreExisting: true });
+
+ let description = await lazy.localization.formatValue(
+ "backgroundupdate-task-description"
+ );
+
+ // Let the task run for a maximum of 20 minutes before the task scheduler
+ // stops it.
+ let executionTimeoutSec = 20 * 60;
+
+ let result = await lazy.TaskScheduler.registerTask(
+ taskId,
+ binary.path,
+ // Keep this default in sync with the preference in firefox.js.
+ Services.prefs.getIntPref("app.update.background.interval", 60 * 60 * 7),
+ {
+ workingDirectory,
+ args,
+ description,
+ executionTimeoutSec,
+ }
+ );
+
+ Services.prefs.setIntPref(
+ TASK_INSTALLED_VERSION_PREF,
+ TASK_DEF_CURRENT_VERSION
+ );
+
+ return result;
+ },
+
+ /**
+ * Background Update is controlled by the per-installation pref
+ * "app.update.background.enabled". When Background Update was still in the
+ * experimental phase, the default value of this pref may have been changed.
+ * Now that the feature has been rolled out, we need to make sure that the
+ * desired default value is restored.
+ */
+ async ensureExperimentToRolloutTransitionPerformed() {
+ if (!lazy.UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED) {
+ return;
+ }
+ const transitionPerformedPref = "app.update.background.rolledout";
+ if (Services.prefs.getBoolPref(transitionPerformedPref, false)) {
+ // writeUpdateConfigSetting serializes access to the config file. Because
+ // of this, we can safely return here without worrying about another call
+ // to this function that might still be in progress.
+ return;
+ }
+ Services.prefs.setBoolPref(transitionPerformedPref, true);
+
+ const defaultValue =
+ lazy.UpdateUtils.PER_INSTALLATION_PREFS["app.update.background.enabled"]
+ .defaultValue;
+ await lazy.UpdateUtils.writeUpdateConfigSetting(
+ "app.update.background.enabled",
+ defaultValue,
+ { setDefaultOnly: true }
+ );
+
+ // To be thorough, remove any traces of the pref that used to control the
+ // default value that we just set. We don't want any users to have the
+ // impression that that pref is still useful.
+ Services.prefs.clearUserPref("app.update.background.scheduling.enabled");
+ },
+
+ observe(subject, topic, data) {
+ let whatChanged;
+ switch (topic) {
+ case "idle-daily":
+ this._snapshot.saveSoon();
+ return;
+
+ case "user-interaction-active":
+ this._startTargetingSnapshottingTimer();
+ Services.obs.removeObserver(this, "idle-daily");
+ Services.obs.removeObserver(this, "user-interaction-active");
+ lazy.log.debug(
+ `observe: ${topic}; started targeting snapshotting timer`
+ );
+ return;
+
+ case "nsPref:changed":
+ whatChanged = `per-profile pref ${data}`;
+ break;
+
+ case "auto-update-config-change":
+ whatChanged = `per-installation pref app.update.auto`;
+ break;
+
+ case "background-update-config-change":
+ whatChanged = `per-installation pref app.update.background.enabled`;
+ break;
+
+ case "nimbus-update":
+ whatChanged = `Nimbus ${data}`;
+ break;
+ }
+
+ lazy.log.debug(
+ `observe: ${whatChanged} may have changed; invoking maybeScheduleBackgroundUpdateTask`
+ );
+ this.maybeScheduleBackgroundUpdateTask();
+ },
+
+ /**
+ * Maybe schedule (or unschedule) background tasks using OS-level task scheduling mechanisms.
+ *
+ * @return {boolean} true if a task is now scheduled, false otherwise.
+ */
+ async maybeScheduleBackgroundUpdateTask() {
+ let SLUG = "maybeScheduleBackgroundUpdateTask";
+
+ await this.ensureExperimentToRolloutTransitionPerformed();
+
+ lazy.log.info(
+ `${SLUG}: checking eligibility before scheduling background update task`
+ );
+
+ // datetime with an empty parameter records 'now'
+ Glean.backgroundUpdate.timeLastUpdateScheduled.set();
+
+ let previousEnabled;
+ let successfullyReadPrevious;
+ try {
+ previousEnabled = await lazy.TaskScheduler.taskExists(this.taskId);
+ successfullyReadPrevious = true;
+ } catch (ex) {
+ successfullyReadPrevious = false;
+ }
+
+ const previousReasons = Services.prefs.getCharPref(
+ "app.update.background.previous.reasons",
+ null
+ );
+
+ if (!this._initialized) {
+ Services.obs.addObserver(this, "auto-update-config-change");
+ Services.obs.addObserver(this, "background-update-config-change");
+
+ // Witness when our own prefs change.
+ Services.prefs.addObserver("app.update.background.force", this);
+ Services.prefs.addObserver("app.update.background.interval", this);
+ lazy.NimbusFeatures.backgroundUpdate.onUpdate((event, reason) => {
+ this.observe(null, "nimbus-update", reason);
+ });
+
+ // Witness when the langpack updating feature is changed.
+ Services.prefs.addObserver("app.update.langpack.enabled", this);
+
+ // Witness when langpacks come and go.
+ const onAddonEvent = async addon => {
+ if (addon.type != "locale") {
+ return;
+ }
+ lazy.log.debug(
+ `${SLUG}: langpacks may have changed; invoking maybeScheduleBackgroundUpdateTask`
+ );
+ // No need to await this promise.
+ this.maybeScheduleBackgroundUpdateTask();
+ };
+ const addonsListener = {
+ onEnabled: onAddonEvent,
+ onDisabled: onAddonEvent,
+ onInstalled: onAddonEvent,
+ onUninstalled: onAddonEvent,
+ };
+ lazy.AddonManager.addAddonListener(addonsListener);
+
+ this._initialized = true;
+ }
+
+ lazy.log.debug(
+ `${SLUG}: checking for reasons to not update this installation`
+ );
+ let reasons = await this._reasonsToNotUpdateInstallation();
+
+ lazy.log.debug(
+ `${SLUG}: checking for reasons to not schedule background updates with this profile`
+ );
+ let moreReasons = await this._reasonsToNotScheduleUpdates();
+ reasons.push(...moreReasons);
+
+ let enabled = !reasons.length;
+
+ if (this._force()) {
+ // We want to allow developers and testers to monkey with the system.
+ lazy.log.debug(
+ `${SLUG}: app.update.background.force=true, ignoring reasons: ${JSON.stringify(
+ reasons
+ )}`
+ );
+ reasons = [];
+ enabled = true;
+ }
+
+ let updatePreviousPrefs = () => {
+ if (reasons.length) {
+ Services.prefs.setCharPref(
+ "app.update.background.previous.reasons",
+ JSON.stringify(reasons)
+ );
+ } else {
+ Services.prefs.clearUserPref("app.update.background.previous.reasons");
+ }
+ };
+
+ try {
+ // Interacting with `TaskScheduler.jsm` can throw, so we'll catch.
+ if (!enabled) {
+ lazy.log.info(
+ `${SLUG}: not scheduling background update: '${JSON.stringify(
+ reasons
+ )}'`
+ );
+
+ if (!successfullyReadPrevious || previousEnabled) {
+ let installedVersion = Services.prefs.getIntPref(
+ TASK_INSTALLED_VERSION_PREF,
+ TASK_DEF_CURRENT_VERSION
+ );
+ await deleteTasksInRange(installedVersion, TASK_DEF_CURRENT_VERSION);
+ lazy.log.debug(
+ `${SLUG}: witnessed falling (enabled -> disabled) edge; deleted task ${this.taskId}.`
+ );
+ }
+
+ updatePreviousPrefs();
+
+ return false;
+ }
+
+ if (successfullyReadPrevious && previousEnabled) {
+ let taskInstalledVersion = Services.prefs.getIntPref(
+ TASK_INSTALLED_VERSION_PREF,
+ 1
+ );
+ if (taskInstalledVersion == TASK_DEF_CURRENT_VERSION) {
+ lazy.log.info(
+ `${SLUG}: background update was previously enabled; not registering task.`
+ );
+
+ return true;
+ }
+ lazy.log.info(
+ `${SLUG}: Detected task version change from ` +
+ `${taskInstalledVersion} to ${TASK_DEF_CURRENT_VERSION}. ` +
+ `Removing task so the new version can be registered`
+ );
+ try {
+ let installedVersion = Services.prefs.getIntPref(
+ TASK_INSTALLED_VERSION_PREF,
+ TASK_DEF_CURRENT_VERSION
+ );
+ await deleteTasksInRange(installedVersion, TASK_DEF_CURRENT_VERSION);
+ } catch (e) {
+ lazy.log.error(`${SLUG}: Error removing old task: ${e}`);
+ }
+ try {
+ // When the update directory was moved, we migrated the old contents
+ // to the new location. This can potentially happen in a background
+ // task. However, we also need to re-register the background task
+ // with the task scheduler in order to update the MOZ_LOG_FILE value
+ // to point to the new location. If the task runs before Firefox has
+ // a chance to re-register the task, the log file may be recreated in
+ // the old location. In practice, this would be unusual, because
+ // MOZ_LOG_FILE will not create the parent directories necessary to
+ // put a log file in the specified location. But just to be safe,
+ // we'll do some cleanup when we re-register the task to make sure
+ // that no log file is hanging around in the old location.
+ let oldUpdateDir = Services.dirsvc.get(
+ "OldUpdRootD",
+ Ci.nsIFile
+ ).path;
+ let oldLog = PathUtils.join(oldUpdateDir, "backgroundupdate.moz_log");
+
+ if (await IOUtils.exists(oldLog)) {
+ try {
+ await IOUtils.remove(oldLog);
+ // We may have created some directories in order to put this log
+ // file in this location. Clean them up if they are empty.
+ //
+ // Potentially removes "C:\ProgramData\Mozilla\updates\<hash>"
+ await IOUtils.remove(oldUpdateDir);
+ // Potentially removes "C:\ProgramData\Mozilla\updates"
+ await IOUtils.remove(PathUtils.parent(oldUpdateDir));
+ // Potentially removes "C:\ProgramData\Mozilla"
+ await IOUtils.remove(PathUtils.parent(oldUpdateDir, 2));
+ } catch (e) {
+ if (
+ !(
+ DOMException.isInstance(e) &&
+ e.name === "OperationError" &&
+ e.message.includes(
+ "Could not remove the non-empty directory at"
+ )
+ )
+ ) {
+ throw e;
+ }
+ }
+ }
+ } catch (ex) {
+ lazy.log.warn(
+ `${SLUG}: Ignoring error encountered attempting to remove stale log file: ${ex}`
+ );
+ }
+ }
+
+ lazy.log.info(
+ `${SLUG}: background update was previously disabled for reasons: '${previousReasons}'`
+ );
+
+ await this._registerBackgroundUpdateTask(this.taskId);
+ lazy.log.info(
+ `${SLUG}: witnessed rising (disabled -> enabled) edge; registered task ${this.taskId}`
+ );
+
+ updatePreviousPrefs();
+
+ return true;
+ } catch (e) {
+ lazy.log.error(
+ `${SLUG}: exiting after uncaught exception in maybeScheduleBackgroundUpdateTask!`,
+ e
+ );
+
+ return false;
+ }
+ },
+
+ /**
+ * Record parts of the update environment for our custom Glean ping.
+ *
+ * This is just like the Telemetry Environment, but pared down to what we're
+ * likely to use in background update-specific analyses.
+ *
+ * Right now this is only for use in the background update task, but after Bug
+ * 1703313 (migrating AUS telemetry to be Glean-aware) we might use it more
+ * generally.
+ */
+ async recordUpdateEnvironment() {
+ try {
+ Glean.update.serviceEnabled.set(
+ Services.prefs.getBoolPref("app.update.service.enabled", false)
+ );
+ } catch (e) {
+ // It's fine if some or all of these are missing.
+ }
+
+ // In the background update task, this should always be enabled, but let's
+ // find out if there's an error in the system.
+ Glean.update.autoDownload.set(
+ await lazy.UpdateUtils.getAppUpdateAutoEnabled()
+ );
+ Glean.update.backgroundUpdate.set(
+ await lazy.UpdateUtils.readUpdateConfigSetting(
+ "app.update.background.enabled"
+ )
+ );
+
+ Glean.update.channel.set(lazy.UpdateUtils.UpdateChannel);
+ Glean.update.enabled.set(
+ !Services.policies || Services.policies.isAllowed("appUpdate")
+ );
+
+ Glean.update.canUsuallyApplyUpdates.set(
+ lazy.UpdateService.canUsuallyApplyUpdates
+ );
+ Glean.update.canUsuallyCheckForUpdates.set(
+ lazy.UpdateService.canUsuallyCheckForUpdates
+ );
+ Glean.update.canUsuallyStageUpdates.set(
+ lazy.UpdateService.canUsuallyStageUpdates
+ );
+ Glean.update.canUsuallyUseBits.set(lazy.UpdateService.canUsuallyUseBits);
+ },
+
+ /**
+ * Schedule periodic snapshotting of the Firefox Messaging System
+ * targeting configuration.
+ *
+ * The background update task will target messages based on the
+ * latest snapshot of the default profile's targeting configuration.
+ */
+ async scheduleFirefoxMessagingSystemTargetingSnapshotting() {
+ let SLUG = "scheduleFirefoxMessagingSystemTargetingSnapshotting";
+ let path = PathUtils.join(PathUtils.profileDir, "targeting.snapshot.json");
+
+ let snapshot = new lazy.JSONFile({
+ beforeSave: async () => {
+ if (Services.startup.shuttingDown) {
+ // Collecting targeting information can be slow and cause shutdown
+ // crashes. Just write what we have in that case. During shutdown,
+ // the regular log apparatus is not available, so use `dump`.
+ if (lazy.log.shouldLog("debug")) {
+ dump(
+ `${SLUG}: shutting down, so not updating Firefox Messaging System targeting information from beforeSave\n`
+ );
+ }
+ return;
+ }
+
+ lazy.log.debug(
+ `${SLUG}: preparing to write Firefox Messaging System targeting information to ${path}`
+ );
+
+ // Merge latest data into existing data. This data may be partial, due
+ // to runtime errors and abbreviated collections, especially when
+ // shutting down. We accept the risk of incomplete or even internally
+ // inconsistent data: it's generally better to have stale data (and
+ // potentially target a user as they appeared in the past) than to block
+ // shutdown for more accurate results. An alternate approach would be
+ // to restrict the targeting data collected, but it's hard to
+ // distinguish expensive collection operations and the system loses
+ // flexibility when restrictions of this type are added.
+ let latestData = await lazy.ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [
+ lazy.ExperimentManager.createTargetingContext(),
+ lazy.ASRouterTargeting.Environment,
+ ],
+ });
+ // We expect to always have data, but: belt-and-braces.
+ if (snapshot?.data?.environment) {
+ Object.assign(snapshot.data.environment, latestData.environment);
+ } else {
+ snapshot.data = latestData;
+ }
+ },
+ path,
+ });
+
+ // We don't `load`, since we don't care about reading existing (now stale)
+ // data.
+ snapshot.data = await lazy.ASRouterTargeting.getEnvironmentSnapshot(
+ lazy.ASRouterTargeting.Environment,
+ lazy.ExperimentManager.createTargetingContext()
+ );
+
+ // Persist.
+ snapshot.saveSoon();
+
+ this._snapshot = snapshot;
+
+ // Continue persisting periodically. `JSONFile.sys.mjs` will also persist one
+ // last time before shutdown.
+ // Hold a reference to prevent GC.
+ this._targetingSnapshottingTimer = Cc[
+ "@mozilla.org/timer;1"
+ ].createInstance(Ci.nsITimer);
+ // By default, snapshot Firefox Messaging System targeting for use by the
+ // background update task every 60 minutes.
+ this._targetingSnapshottingTimerIntervalSec = Services.prefs.getIntPref(
+ "app.update.background.messaging.targeting.snapshot.intervalSec",
+ 3600
+ );
+ this._startTargetingSnapshottingTimer();
+ },
+
+ // nsITimerCallback
+ notify() {
+ const SLUG = "_targetingSnapshottingTimerCallback";
+
+ if (Services.startup.shuttingDown) {
+ // Collecting targeting information can be slow and cause shutdown
+ // crashes, so if we're shutting down, don't try to collect. During
+ // shutdown, the regular log apparatus is not available, so use `dump`.
+ if (lazy.log.shouldLog("debug")) {
+ dump(
+ `${SLUG}: shutting down, so not updating Firefox Messaging System targeting information from timer\n`
+ );
+ }
+ return;
+ }
+
+ this._snapshot.saveSoon();
+
+ if (
+ lazy.idleService.idleTime >
+ this._targetingSnapshottingTimerIntervalSec * 1000
+ ) {
+ lazy.log.debug(
+ `${SLUG}: idle time longer than interval, adding observers`
+ );
+ Services.obs.addObserver(this, "idle-daily");
+ Services.obs.addObserver(this, "user-interaction-active");
+ } else {
+ lazy.log.debug(`${SLUG}: restarting timer`);
+ this._startTargetingSnapshottingTimer();
+ }
+ },
+
+ _startTargetingSnapshottingTimer() {
+ this._targetingSnapshottingTimer.initWithCallback(
+ this,
+ this._targetingSnapshottingTimerIntervalSec * 1000,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * Reads the snapshotted Firefox Messaging System targeting out of a profile.
+ * Collects background update specific telemetry. Never throws.
+ *
+ * If no `lock` is given, the default profile is locked and the preferences
+ * read from it. If `lock` is given, read from the given lock's directory.
+ *
+ * @param {nsIProfileLock} [lock] optional lock to use
+ * @returns {object} possibly empty targeting snapshot.
+ */
+ async readFirefoxMessagingSystemTargetingSnapshot(lock = null) {
+ let SLUG = "readFirefoxMessagingSystemTargetingSnapshot";
+
+ let defaultProfileTargetingSnapshot = {};
+
+ Glean.backgroundUpdate.targetingExists.set(false);
+ Glean.backgroundUpdate.targetingException.set(true);
+ try {
+ defaultProfileTargetingSnapshot =
+ await lazy.BackgroundTasksUtils.readFirefoxMessagingSystemTargetingSnapshot(
+ lock
+ );
+ Glean.backgroundUpdate.targetingExists.set(true);
+ Glean.backgroundUpdate.targetingException.set(false);
+
+ if (defaultProfileTargetingSnapshot?.version) {
+ Glean.backgroundUpdate.targetingVersion.set(
+ defaultProfileTargetingSnapshot.version
+ );
+ }
+
+ let environment = defaultProfileTargetingSnapshot?.environment;
+ if (environment) {
+ if (environment.firefoxVersion) {
+ Glean.backgroundUpdate.targetingEnvFirefoxVersion.set(
+ environment.firefoxVersion
+ );
+ }
+ if (environment.currentDate) {
+ Glean.backgroundUpdate.targetingEnvCurrentDate.set(
+ // Glean date times are provided in nanoseconds, `getTime()` yields
+ // milliseconds (after the Unix epoch).
+ new Date(environment.currentDate).getTime() * 1000
+ );
+ }
+ if (environment.profileAgeCreated) {
+ Glean.backgroundUpdate.targetingEnvProfileAge.set(
+ // Glean date times are provided in nanoseconds, `profileAgeCreated`
+ // is in milliseconds (after the Unix epoch).
+ environment.profileAgeCreated * 1000
+ );
+ }
+
+ // Experiment details.
+ let activeExperiments = (
+ environment.activeExperiments || []
+ ).toSorted();
+ let activeRollouts = (environment.activeRollouts || []).toSorted();
+ let previousExperiments = (
+ environment.previousExperiments || []
+ ).toSorted();
+ let previousRollouts = (environment.previousRollouts || []).toSorted();
+
+ // Add default profile experiments to background task profile Glean experiments.
+ for (let slug of Object.keys(environment.enrollmentsMap || [])) {
+ let branch = environment.enrollmentsMap[slug];
+ let source = "defaultProfile";
+
+ // Experiments have type "nimbus-nimbus", rollouts type "nimbus-rollout".
+ let type;
+ if (
+ activeExperiments.includes(slug) ||
+ previousExperiments.includes(slug)
+ ) {
+ type = "nimbus-nimbus";
+ } else if (
+ activeRollouts.includes(slug) ||
+ previousRollouts.includes(slug)
+ ) {
+ type = "nimbus-rollout";
+ } else {
+ // This shouldn't happen, but it's not worth failing.
+ lazy.log.warn(
+ `${SLUG}: enrollment not recognized as experiment or rollout: '${slug}'`
+ );
+ type = "nimbus-unexpected";
+ }
+
+ let extras = { type, source };
+ Services.fog.setExperimentActive(slug, branch, extras);
+
+ if (
+ previousExperiments.includes(slug) ||
+ previousRollouts.includes(slug)
+ ) {
+ Services.fog.setExperimentInactive(slug, branch, extras);
+ }
+ }
+ }
+ } catch (f) {
+ if (DOMException.isInstance(f) && f.name === "NotFoundError") {
+ Glean.backgroundUpdate.targetingException.set(false);
+ lazy.log.info(`${SLUG}: no default profile targeting snapshot exists`);
+ } else {
+ lazy.log.warn(
+ `${SLUG}: ignoring exception reading default profile targeting snapshot`,
+ f
+ );
+ }
+ }
+
+ return defaultProfileTargetingSnapshot;
+ },
+
+ /**
+ * Local helper function to record all reasons why the background updater is
+ * not used with Glean. This function will only track the first 20 reasons.
+ * It is also fault tolerant and will only display debug messages if the
+ * metric cannot be recorded for any reason.
+ *
+ * @param {array of strings} [reasons]
+ * a list of BackgroundUpdate.REASON values (=> string)
+ */
+ async _recordGleanMetrics(reasons) {
+ // Record Glean metrics with all the reasons why the update was impossible.
+ for (const [key, value] of Object.entries(this.REASON)) {
+ if (reasons.includes(value)) {
+ try {
+ // `testGetValue` throws a `DataError` in case
+ // of `InvalidOverflow` and other outstanding errors.
+ Glean.backgroundUpdate.reasonsToNotUpdate.testGetValue();
+ Glean.backgroundUpdate.reasonsToNotUpdate.add(key);
+ } catch (e) {
+ // Debug print an error message and break the loop to avoid Glean
+ // messages on the console would otherwise be caused by the add().
+ lazy.log.debug("Error recording reasonsToNotUpdate");
+ console.log("Error recording reasonsToNotUpdate");
+ break;
+ }
+ }
+ }
+ },
+};
+
+BackgroundUpdate.REASON = {
+ CANNOT_USUALLY_CHECK:
+ "cannot usually check for updates due to policy, testing configuration, or runtime environment",
+ CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY:
+ "updates cannot usually stage and cannot usually apply",
+ LANGPACK_INSTALLED:
+ "app.update.langpack.enabled=true and at least one langpack is installed",
+ MANUAL_UPDATE_ONLY: "the ManualAppUpdateOnly policy is enabled",
+ NO_DEFAULT_PROFILE_EXISTS: "no default profile exists",
+ NOT_DEFAULT_PROFILE: "not default profile",
+ NO_APP_UPDATE_AUTO: "app.update.auto=false",
+ NO_APP_UPDATE_BACKGROUND_ENABLED: "app.update.background.enabled=false",
+ NO_MOZ_BACKGROUNDTASKS: "MOZ_BACKGROUNDTASKS=0",
+ NO_OMNIJAR: "no omnijar",
+ SERVICE_REGISTRY_KEY_MISSING:
+ "the maintenance service registry key is not present",
+ UNSUPPORTED_OS: "unsupported OS",
+ WINDOWS_CANNOT_USUALLY_USE_BITS: "on Windows but cannot usually use BITS",
+ APPBASEDIR_NOT_WRITABLE: "the base directory is not writable",
+};
+
+/**
+ * Specific exit codes for `--backgroundtask backgroundupdate`.
+ *
+ * These help distinguish common failure cases. In particular, they distinguish
+ * "default profile does not exist" from "default profile cannot be locked" from
+ * more general errors reading from the default profile.
+ */
+BackgroundUpdate.EXIT_CODE = {
+ ...EXIT_CODE,
+ // We clone the other exit codes simply so we can use one object for all the codes.
+ DEFAULT_PROFILE_DOES_NOT_EXIST: 11,
+ DEFAULT_PROFILE_CANNOT_BE_LOCKED: 12,
+ DEFAULT_PROFILE_CANNOT_BE_READ: 13,
+ // Another instance is running.
+ OTHER_INSTANCE: 21,
+};
diff --git a/toolkit/mozapps/update/UpdateListener.sys.mjs b/toolkit/mozapps/update/UpdateListener.sys.mjs
new file mode 100644
index 0000000000..355402ce49
--- /dev/null
+++ b/toolkit/mozapps/update/UpdateListener.sys.mjs
@@ -0,0 +1,524 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "AppUpdateService",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "UpdateManager",
+ "@mozilla.org/updates/update-manager;1",
+ "nsIUpdateManager"
+);
+
+const PREF_APP_UPDATE_UNSUPPORTED_URL = "app.update.unsupported.url";
+const PREF_APP_UPDATE_SUPPRESS_PROMPTS = "app.update.suppressPrompts";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "SUPPRESS_PROMPTS",
+ PREF_APP_UPDATE_SUPPRESS_PROMPTS,
+ false
+);
+
+// Setup the hamburger button badges for updates.
+export var UpdateListener = {
+ timeouts: [],
+
+ restartDoorhangerShown: false,
+
+ // Once a restart badge/doorhanger is scheduled, these store the time that
+ // they were scheduled at (as milliseconds elapsed since the UNIX epoch). This
+ // allows us to resume the badge/doorhanger timers rather than restarting
+ // them from the beginning when a new update comes along.
+ updateFirstReadyTime: null,
+
+ // If PREF_APP_UPDATE_SUPPRESS_PROMPTS is true, we'll dispatch a notification
+ // prompt 14 days from the last build time, or 7 days from the last update
+ // time; whichever is sooner. It's hardcoded here to make sure update prompts
+ // can't be suppressed permanently without knowledge of the consequences.
+ promptDelayMsFromBuild: 14 * 24 * 60 * 60 * 1000, // 14 days
+
+ promptDelayMsFromUpdate: 7 * 24 * 60 * 60 * 1000, // 7 days
+
+ // If the last update time or current build time is more than 1 day in the
+ // future, it has probably been manipulated and should be distrusted.
+ promptMaxFutureVariation: 24 * 60 * 60 * 1000, // 1 day
+
+ latestUpdate: null,
+
+ availablePromptScheduled: false,
+
+ get badgeWaitTime() {
+ return Services.prefs.getIntPref("app.update.badgeWaitTime", 4 * 24 * 3600); // 4 days
+ },
+
+ get suppressedPromptDelay() {
+ // Return the time (in milliseconds) after which a suppressed prompt should
+ // be shown. Either 14 days from the last build time, or 7 days from the
+ // last update time; whichever comes sooner. If build time is not available
+ // and valid, schedule according to update time instead. If neither is
+ // available and valid, schedule the prompt for right now. Times are checked
+ // against the current time, since if the OS time is correct and nothing has
+ // been manipulated, the build time and update time will always be in the
+ // past. If the build time or update time is an hour in the future, it could
+ // just be a timezone issue. But if it is more than 24 hours in the future,
+ // it's probably due to attempted manipulation.
+ let now = Date.now();
+ let buildId = AppConstants.MOZ_BUILDID;
+ let buildTime =
+ new Date(
+ buildId.slice(0, 4),
+ buildId.slice(4, 6) - 1,
+ buildId.slice(6, 8),
+ buildId.slice(8, 10),
+ buildId.slice(10, 12),
+ buildId.slice(12, 14)
+ ).getTime() ?? 0;
+ let updateTime = lazy.UpdateManager.getUpdateAt(0)?.installDate ?? 0;
+ // Check that update/build times are at most 24 hours after now.
+ if (buildTime - now > this.promptMaxFutureVariation) {
+ buildTime = 0;
+ }
+ if (updateTime - now > this.promptMaxFutureVariation) {
+ updateTime = 0;
+ }
+ let promptTime = now;
+ // If both times are available, choose the sooner.
+ if (updateTime && buildTime) {
+ promptTime = Math.min(
+ buildTime + this.promptDelayMsFromBuild,
+ updateTime + this.promptDelayMsFromUpdate
+ );
+ } else if (updateTime || buildTime) {
+ // When the update time is missing, this installation was probably just
+ // installed and hasn't been updated yet. Ideally, we would instead set
+ // promptTime to installTime + this.promptDelayMsFromUpdate. But it's
+ // easier to get the build time than the install time. And on Nightly, the
+ // times ought to be fairly close together anyways.
+ promptTime = (updateTime || buildTime) + this.promptDelayMsFromUpdate;
+ }
+ return promptTime - now;
+ },
+
+ maybeShowUnsupportedNotification() {
+ // Persist the unsupported notification across sessions. If at some point an
+ // update is found this pref is cleared and the notification won't be shown.
+ let url = Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null);
+ if (url) {
+ this.showUpdateNotification(
+ "unsupported",
+ win => this.openUnsupportedUpdateUrl(win, url),
+ true,
+ { dismissed: true }
+ );
+ }
+ },
+
+ reset() {
+ this.clearPendingAndActiveNotifications();
+ this.restartDoorhangerShown = false;
+ this.updateFirstReadyTime = null;
+ },
+
+ clearPendingAndActiveNotifications() {
+ lazy.AppMenuNotifications.removeNotification(/^update-/);
+ this.clearCallbacks();
+ },
+
+ clearCallbacks() {
+ this.timeouts.forEach(t => clearTimeout(t));
+ this.timeouts = [];
+ this.availablePromptScheduled = false;
+ },
+
+ addTimeout(time, callback) {
+ this.timeouts.push(
+ setTimeout(() => {
+ this.clearCallbacks();
+ callback();
+ }, time)
+ );
+ },
+
+ requestRestart() {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (!cancelQuit.data) {
+ Services.startup.quit(
+ Services.startup.eAttemptQuit | Services.startup.eRestart
+ );
+ }
+ },
+
+ openManualUpdateUrl(win) {
+ let manualUpdateUrl = Services.urlFormatter.formatURLPref(
+ "app.update.url.manual"
+ );
+ win.openURL(manualUpdateUrl);
+ },
+
+ openUnsupportedUpdateUrl(win, detailsURL) {
+ win.openURL(detailsURL);
+ },
+
+ getReleaseNotesUrl(update) {
+ try {
+ // Release notes are enabled by default for EN locales only, but can be
+ // enabled for other locales within an experiment.
+ if (
+ !Services.locale.appLocaleAsBCP47.startsWith("en") &&
+ !lazy.NimbusFeatures.updatePrompt.getVariable("showReleaseNotesLink")
+ ) {
+ return null;
+ }
+ // The release notes URL is set in the pref app.releaseNotesURL.prompt,
+ // but can also be overridden by an experiment.
+ let url = lazy.NimbusFeatures.updatePrompt.getVariable("releaseNotesURL");
+ if (url) {
+ let versionString = update.appVersion;
+ switch (update.channel) {
+ case "aurora":
+ case "beta":
+ versionString += "beta";
+ break;
+ }
+ url = Services.urlFormatter.formatURL(
+ url.replace("%VERSION%", versionString)
+ );
+ }
+ return url || null;
+ } catch (error) {
+ return null;
+ }
+ },
+
+ showUpdateNotification(type, mainAction, mainActionDismiss, options = {}) {
+ const addTelemetry = id => {
+ // No telemetry for the "downloading" state.
+ if (type !== "downloading") {
+ // Histogram category labels can't have dashes in them.
+ let telemetryType = type.replaceAll("-", "");
+ Services.telemetry.getHistogramById(id).add(telemetryType);
+ }
+ };
+ let action = {
+ callback(win, fromDoorhanger) {
+ if (fromDoorhanger) {
+ addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_DOORHANGER");
+ } else {
+ addTelemetry("UPDATE_NOTIFICATION_MAIN_ACTION_MENU");
+ }
+ mainAction(win);
+ },
+ dismiss: mainActionDismiss,
+ };
+
+ let secondaryAction = {
+ callback() {
+ addTelemetry("UPDATE_NOTIFICATION_DISMISSED");
+ },
+ dismiss: true,
+ };
+ lazy.AppMenuNotifications.showNotification(
+ "update-" + type,
+ action,
+ secondaryAction,
+ options
+ );
+ if (options.dismissed) {
+ addTelemetry("UPDATE_NOTIFICATION_BADGE_SHOWN");
+ } else {
+ addTelemetry("UPDATE_NOTIFICATION_SHOWN");
+ }
+ },
+
+ showRestartNotification(update, dismissed) {
+ let notification = lazy.AppUpdateService.isOtherInstanceHandlingUpdates
+ ? "other-instance"
+ : "restart";
+ if (!dismissed) {
+ this.restartDoorhangerShown = true;
+ }
+ this.showUpdateNotification(
+ notification,
+ () => this.requestRestart(),
+ true,
+ { dismissed }
+ );
+ },
+
+ showUpdateAvailableNotification(update, dismissed) {
+ let learnMoreURL = this.getReleaseNotesUrl(update);
+ this.showUpdateNotification(
+ "available",
+ // This is asynchronous, but we are just going to kick it off.
+ () => lazy.AppUpdateService.downloadUpdate(update, true),
+ false,
+ { dismissed, learnMoreURL }
+ );
+ lazy.NimbusFeatures.updatePrompt.recordExposureEvent({ once: true });
+ },
+
+ showManualUpdateNotification(update, dismissed) {
+ let learnMoreURL = this.getReleaseNotesUrl(update);
+ this.showUpdateNotification(
+ "manual",
+ win => this.openManualUpdateUrl(win),
+ false,
+ { dismissed, learnMoreURL }
+ );
+ lazy.NimbusFeatures.updatePrompt.recordExposureEvent({ once: true });
+ },
+
+ showUnsupportedUpdateNotification(update, dismissed) {
+ if (!update || !update.detailsURL) {
+ console.error(
+ "The update for an unsupported notification must have a " +
+ "detailsURL attribute."
+ );
+ return;
+ }
+ let url = update.detailsURL;
+ if (
+ url != Services.prefs.getCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, null)
+ ) {
+ Services.prefs.setCharPref(PREF_APP_UPDATE_UNSUPPORTED_URL, url);
+ this.showUpdateNotification(
+ "unsupported",
+ win => this.openUnsupportedUpdateUrl(win, url),
+ true,
+ { dismissed }
+ );
+ }
+ },
+
+ showUpdateDownloadingNotification() {
+ this.showUpdateNotification(
+ "downloading",
+ // The user clicked on the "Downloading update" app menu item.
+ // Code in browser/components/customizableui/content/panelUI.js
+ // receives the following notification and opens the about dialog.
+ () => Services.obs.notifyObservers(null, "show-update-progress"),
+ true,
+ { dismissed: true }
+ );
+ },
+
+ scheduleUpdateAvailableNotification(update) {
+ // Show a badge/banner-only notification immediately.
+ this.showUpdateAvailableNotification(update, true);
+ // Track the latest update, since we will almost certainly have a new update
+ // 7 days from now. In a common scenario, update 1 triggers the timer.
+ // Updates 2, 3, 4, and 5 come without opening a prompt, since one is
+ // already scheduled. Then, the timer ends and the prompt that was triggered
+ // by update 1 is opened. But rather than downloading update 1, of course,
+ // it will download update 5, the latest update.
+ this.latestUpdate = update;
+ // Only schedule one doorhanger at a time. If we don't, then a new
+ // doorhanger would be scheduled at least once per day. If the user
+ // downloads the first update, we don't want to keep alerting them.
+ if (!this.availablePromptScheduled) {
+ this.addTimeout(Math.max(0, this.suppressedPromptDelay), () => {
+ // If we downloaded or installed an update via the badge or banner
+ // while the timer was running, bail out of showing the doorhanger.
+ if (
+ lazy.UpdateManager.downloadingUpdate ||
+ lazy.UpdateManager.readyUpdate
+ ) {
+ return;
+ }
+ this.showUpdateAvailableNotification(this.latestUpdate, false);
+ });
+ this.availablePromptScheduled = true;
+ }
+ },
+
+ handleUpdateError(update, status) {
+ switch (status) {
+ case "download-attempt-failed":
+ this.clearCallbacks();
+ this.showUpdateAvailableNotification(update, false);
+ break;
+ case "download-attempts-exceeded":
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ case "elevation-attempt-failed":
+ this.clearCallbacks();
+ this.showRestartNotification(false);
+ break;
+ case "elevation-attempts-exceeded":
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ case "check-attempts-exceeded":
+ case "unknown":
+ case "bad-perms":
+ // Background update has failed, let's show the UI responsible for
+ // prompting the user to update manually.
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ }
+ },
+
+ handleUpdateStagedOrDownloaded(update, status) {
+ switch (status) {
+ case "applied":
+ case "pending":
+ case "applied-service":
+ case "pending-service":
+ case "pending-elevate":
+ case "success":
+ this.clearCallbacks();
+
+ let initialBadgeWaitTimeMs = this.badgeWaitTime * 1000;
+ let initialDoorhangerWaitTimeMs = update.promptWaitTime * 1000;
+ let now = Date.now();
+
+ if (!this.updateFirstReadyTime) {
+ this.updateFirstReadyTime = now;
+ }
+
+ let badgeWaitTimeMs = Math.max(
+ 0,
+ this.updateFirstReadyTime + initialBadgeWaitTimeMs - now
+ );
+ let doorhangerWaitTimeMs = Math.max(
+ 0,
+ this.updateFirstReadyTime + initialDoorhangerWaitTimeMs - now
+ );
+
+ // On Nightly only, permit disabling doorhangers for update restart
+ // notifications by setting PREF_APP_UPDATE_SUPPRESS_PROMPTS
+ if (AppConstants.NIGHTLY_BUILD && lazy.SUPPRESS_PROMPTS) {
+ this.showRestartNotification(update, true);
+ } else if (badgeWaitTimeMs < doorhangerWaitTimeMs) {
+ this.addTimeout(badgeWaitTimeMs, () => {
+ // Skip the badge if we're waiting for another instance.
+ if (!lazy.AppUpdateService.isOtherInstanceHandlingUpdates) {
+ this.showRestartNotification(update, true);
+ }
+
+ if (!this.restartDoorhangerShown) {
+ // doorhangerWaitTimeMs is relative to when we initially received
+ // the event. Since we've already waited badgeWaitTimeMs, subtract
+ // that from doorhangerWaitTimeMs.
+ let remainingTime = doorhangerWaitTimeMs - badgeWaitTimeMs;
+ this.addTimeout(remainingTime, () => {
+ this.showRestartNotification(update, false);
+ });
+ }
+ });
+ } else {
+ this.addTimeout(doorhangerWaitTimeMs, () => {
+ this.showRestartNotification(update, this.restartDoorhangerShown);
+ });
+ }
+ break;
+ }
+ },
+
+ handleUpdateAvailable(update, status) {
+ switch (status) {
+ case "show-prompt":
+ // If an update is available, show an update available doorhanger unless
+ // PREF_APP_UPDATE_SUPPRESS_PROMPTS is true (only on Nightly).
+ if (AppConstants.NIGHTLY_BUILD && lazy.SUPPRESS_PROMPTS) {
+ this.scheduleUpdateAvailableNotification(update);
+ } else {
+ this.showUpdateAvailableNotification(update, false);
+ }
+ break;
+ case "cant-apply":
+ this.clearCallbacks();
+ this.showManualUpdateNotification(update, false);
+ break;
+ case "unsupported":
+ this.clearCallbacks();
+ this.showUnsupportedUpdateNotification(update, false);
+ break;
+ }
+ },
+
+ handleUpdateDownloading(status) {
+ switch (status) {
+ case "downloading":
+ this.showUpdateDownloadingNotification();
+ break;
+ case "idle":
+ this.clearPendingAndActiveNotifications();
+ break;
+ }
+ },
+
+ handleUpdateSwap() {
+ // This function is called because we just finished downloading an update
+ // (possibly) when another update was already ready.
+ // At some point, we may want to have some sort of intermediate
+ // notification to display here so that the badge doesn't just disappear.
+ // Currently, this function just hides update notifications and clears
+ // the callback timers so that notifications will not be shown. We want to
+ // clear the restart notification so the user doesn't try to restart to
+ // update during staging. We want to clear any other notifications too,
+ // since none of them make sense to display now.
+ // Our observer will fire again when the update is either ready to install
+ // or an error has been encountered.
+ this.clearPendingAndActiveNotifications();
+ },
+
+ observe(subject, topic, status) {
+ let update = subject && subject.QueryInterface(Ci.nsIUpdate);
+
+ switch (topic) {
+ case "update-available":
+ if (status != "unsupported") {
+ // An update check has found an update so clear the unsupported pref
+ // in case it is set.
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_UNSUPPORTED_URL);
+ }
+ this.handleUpdateAvailable(update, status);
+ break;
+ case "update-downloading":
+ this.handleUpdateDownloading(status);
+ break;
+ case "update-staged":
+ case "update-downloaded":
+ // An update check has found an update and downloaded / staged the
+ // update so clear the unsupported pref in case it is set.
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_UNSUPPORTED_URL);
+ this.handleUpdateStagedOrDownloaded(update, status);
+ break;
+ case "update-error":
+ this.handleUpdateError(update, status);
+ break;
+ case "update-swap":
+ this.handleUpdateSwap();
+ break;
+ }
+ },
+};
diff --git a/toolkit/mozapps/update/UpdateLog.sys.mjs b/toolkit/mozapps/update/UpdateLog.sys.mjs
new file mode 100644
index 0000000000..d9b13aac66
--- /dev/null
+++ b/toolkit/mozapps/update/UpdateLog.sys.mjs
@@ -0,0 +1,206 @@
+/* -*- Mode: javascript; 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/. */
+
+/**
+ * Update logging lives in its own module for several reasons. First, it avoids
+ * having some very similar logging code duplicated in several places. And
+ * second, we only want to open the update messages file once. Opening it
+ * multiple times from multiple files does not result in all those messages
+ * ending up in the same log file.
+ *
+ * Note that simply importing this module can cause the value of the
+ * `app.update.log.file` pref to change. This may seem a bit weird, but we are
+ * going to consider it to be acceptable because any other module that wants to
+ * interact with that pref really ought to be doing it by interacting with this
+ * module instead, making the pref more of an internal implementation detail.
+ * This option was chosen over doing this work more lazily (say, on a log
+ * message) because this initialization step has user-visible consequences
+ * (the log file moves) and we'd prefer that those happen close to startup so
+ * that the user doesn't observe it happening at a seemingly random time.
+ */
+
+const PREF_APP_UPDATE_LOG = "app.update.log";
+const PREF_APP_UPDATE_LOG_FILE = "app.update.log.file";
+
+const FILE_UPDATE_MESSAGES = "update_messages.log";
+const FILE_BACKUP_MESSAGES = "update_messages_old.log";
+
+const KEY_PROFILE_DIR = "ProfD";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "BinaryOutputStream",
+ function ul_GetBinaryStream() {
+ return Components.Constructor(
+ "@mozilla.org/binaryoutputstream;1",
+ "nsIBinaryOutputStream",
+ "setOutputStream"
+ );
+ }
+);
+
+let gLogEnabled;
+let gLogFileEnabled;
+let gLogFileOutputStream;
+let gLogFileBinaryStream;
+
+let gChangeListeners = [];
+
+function updateLogEnabledVars() {
+ let prevLogEnabled = gLogEnabled;
+ let prefLogFileEnabled = gLogFileEnabled;
+
+ gLogFileEnabled = Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG_FILE, false);
+ if (gLogFileEnabled) {
+ gLogEnabled = true;
+ } else {
+ gLogEnabled = Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG, false);
+ }
+
+ if (gLogEnabled != prevLogEnabled || gLogFileEnabled != prefLogFileEnabled) {
+ // Since we use this function during initialization, technically any present
+ // listeners will be fired at that point. But it's not really possible for
+ // any listeners to have been added yet.
+ for (const listener of gChangeListeners) {
+ try {
+ listener();
+ } catch (ex) {
+ LOG(`Listener error: ${ex}`);
+ }
+ }
+ }
+}
+updateLogEnabledVars();
+
+function shutdown() {
+ Services.prefs.removeObserver(PREF_APP_UPDATE_LOG, updateLogEnabledVars);
+ Services.obs.removeObserver(shutdown, "quit-application");
+
+ // Release references to listeners.
+ gChangeListeners = [];
+
+ if (gLogFileOutputStream) {
+ gLogFileOutputStream.QueryInterface(Ci.nsISafeOutputStream);
+ gLogFileOutputStream.finish();
+ }
+}
+
+Services.obs.addObserver(shutdown, "quit-application");
+// This one call observes PREF_APP_UPDATE_LOG and PREF_APP_UPDATE_LOG_FILE
+Services.prefs.addObserver(PREF_APP_UPDATE_LOG, updateLogEnabledVars);
+
+function logPrefixedString(prefix, message) {
+ message = message.toString();
+
+ if (gLogEnabled) {
+ dump("*** " + prefix + " " + message + "\n");
+ if (!Cu.isInAutomation) {
+ Services.console.logStringMessage(prefix + " " + message);
+ }
+
+ if (gLogFileEnabled) {
+ if (!gLogFileOutputStream) {
+ let logfile = Services.dirsvc.get(KEY_PROFILE_DIR, Ci.nsIFile);
+ logfile.append(FILE_UPDATE_MESSAGES);
+ gLogFileOutputStream =
+ lazy.FileUtils.openAtomicFileOutputStream(logfile);
+ }
+ if (!gLogFileBinaryStream) {
+ gLogFileBinaryStream = new lazy.BinaryOutputStream(
+ gLogFileOutputStream
+ );
+ }
+
+ try {
+ let encoded = new TextEncoder().encode(prefix + " " + message + "\n");
+ gLogFileBinaryStream.writeByteArray(encoded);
+ gLogFileOutputStream.flush();
+ } catch (e) {
+ dump(
+ "*** " + prefix + " Unable to write to messages file: " + e + "\n"
+ );
+ Services.console.logStringMessage(
+ prefix + " Unable to write to messages file: " + e
+ );
+ }
+ }
+ }
+}
+
+// Prevent file logging from persisting for more than a session by disabling
+// it on startup.
+function deactivateUpdateLogFile() {
+ if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_LOG_FILE, false)) {
+ return;
+ }
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_LOG_FILE, false);
+ LOG("Application update file logging being automatically turned off");
+ let logFile = Services.dirsvc.get(KEY_PROFILE_DIR, Ci.nsIFile);
+ logFile.append(FILE_UPDATE_MESSAGES);
+
+ try {
+ logFile.moveTo(null, FILE_BACKUP_MESSAGES);
+ } catch (e) {
+ LOG(
+ "Failed to backup update messages log (" +
+ e +
+ "). Attempting to " +
+ "remove it."
+ );
+ try {
+ logFile.remove(false);
+ } catch (e) {
+ LOG("Also failed to remove the update messages log: " + e);
+ }
+ }
+}
+// This can potentially cause I/O at startup, which isn't great. But it's pretty
+// rare to have this option turned on, especially since we automatically turn it
+// off.
+deactivateUpdateLogFile();
+
+/**
+ * Logs a string to the error console.
+ * @param string
+ * The string to write to the error console.
+ */
+function LOG(string) {
+ logPrefixedString("AUS:LOG", string);
+}
+
+export const UpdateLog = {
+ logPrefixedString,
+ get enabled() {
+ return gLogEnabled;
+ },
+ get logFileEnabled() {
+ return gLogFileEnabled;
+ },
+
+ /**
+ * Adds a callback function to be called when `UpdateLog.enabled` or
+ * `UpdateLog.logFileEnabled` change values.
+ *
+ * Adding listeners here is preferable to adding pref listeners to the
+ * underlying prefs both because it keeps callers out of the implementation
+ * details and because this file also uses those listeners. Since it's hard to
+ * guarantee what order the listeners run in, the actual logging behavior may
+ * not have changed yet when another pref listener is invoked.
+ *
+ * @param listener
+ * The callback function that will be called when the configuration
+ * changes. It will be called with no arguments.
+ */
+ addConfigChangeListener(listener) {
+ gChangeListeners.push(listener);
+ },
+};
diff --git a/toolkit/mozapps/update/UpdateService.sys.mjs b/toolkit/mozapps/update/UpdateService.sys.mjs
new file mode 100644
index 0000000000..16ebf64ef3
--- /dev/null
+++ b/toolkit/mozapps/update/UpdateService.sys.mjs
@@ -0,0 +1,7241 @@
+/* -*- Mode: javascript; 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { AUSTLMY } from "resource://gre/modules/UpdateTelemetry.sys.mjs";
+
+import {
+ Bits,
+ BitsRequest,
+ BitsUnknownError,
+ BitsVerificationError,
+} from "resource://gre/modules/Bits.sys.mjs";
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ UpdateLog: "resource://gre/modules/UpdateLog.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+ WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs",
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "AUS",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "UM",
+ "@mozilla.org/updates/update-manager;1",
+ "nsIUpdateManager"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "CheckSvc",
+ "@mozilla.org/updates/update-checker;1",
+ "nsIUpdateChecker"
+);
+
+if (AppConstants.ENABLE_WEBDRIVER) {
+ XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "Marionette",
+ "@mozilla.org/remote/marionette;1",
+ "nsIMarionette"
+ );
+
+ XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "RemoteAgent",
+ "@mozilla.org/remote/agent;1",
+ "nsIRemoteAgent"
+ );
+} else {
+ lazy.Marionette = { running: false };
+ lazy.RemoteAgent = { running: false };
+}
+
+const UPDATESERVICE_CID = Components.ID(
+ "{B3C290A6-3943-4B89-8BBE-C01EB7B3B311}"
+);
+
+const PREF_APP_UPDATE_ALTUPDATEDIRPATH = "app.update.altUpdateDirPath";
+const PREF_APP_UPDATE_BACKGROUNDERRORS = "app.update.backgroundErrors";
+const PREF_APP_UPDATE_BACKGROUNDMAXERRORS = "app.update.backgroundMaxErrors";
+const PREF_APP_UPDATE_BITS_ENABLED = "app.update.BITS.enabled";
+const PREF_APP_UPDATE_CANCELATIONS = "app.update.cancelations";
+const PREF_APP_UPDATE_CANCELATIONS_OSX = "app.update.cancelations.osx";
+const PREF_APP_UPDATE_CANCELATIONS_OSX_MAX = "app.update.cancelations.osx.max";
+const PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_ENABLED =
+ "app.update.checkOnlyInstance.enabled";
+const PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_INTERVAL =
+ "app.update.checkOnlyInstance.interval";
+const PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_TIMEOUT =
+ "app.update.checkOnlyInstance.timeout";
+const PREF_APP_UPDATE_DISABLEDFORTESTING = "app.update.disabledForTesting";
+const PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS = "app.update.download.attempts";
+const PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS = "app.update.download.maxAttempts";
+const PREF_APP_UPDATE_ELEVATE_NEVER = "app.update.elevate.never";
+const PREF_APP_UPDATE_ELEVATE_VERSION = "app.update.elevate.version";
+const PREF_APP_UPDATE_ELEVATE_ATTEMPTS = "app.update.elevate.attempts";
+const PREF_APP_UPDATE_ELEVATE_MAXATTEMPTS = "app.update.elevate.maxAttempts";
+const PREF_APP_UPDATE_LANGPACK_ENABLED = "app.update.langpack.enabled";
+const PREF_APP_UPDATE_LANGPACK_TIMEOUT = "app.update.langpack.timeout";
+const PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD = "app.update.notifyDuringDownload";
+const PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED =
+ "app.update.noWindowAutoRestart.enabled";
+const PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_DELAY_MS =
+ "app.update.noWindowAutoRestart.delayMs";
+const PREF_APP_UPDATE_PROMPTWAITTIME = "app.update.promptWaitTime";
+const PREF_APP_UPDATE_SERVICE_ENABLED = "app.update.service.enabled";
+const PREF_APP_UPDATE_SERVICE_ERRORS = "app.update.service.errors";
+const PREF_APP_UPDATE_SERVICE_MAXERRORS = "app.update.service.maxErrors";
+const PREF_APP_UPDATE_SOCKET_MAXERRORS = "app.update.socket.maxErrors";
+const PREF_APP_UPDATE_SOCKET_RETRYTIMEOUT = "app.update.socket.retryTimeout";
+const PREF_APP_UPDATE_STAGING_ENABLED = "app.update.staging.enabled";
+const PREF_APP_UPDATE_URL_DETAILS = "app.update.url.details";
+const PREF_NETWORK_PROXY_TYPE = "network.proxy.type";
+
+const URI_BRAND_PROPERTIES = "chrome://branding/locale/brand.properties";
+const URI_UPDATE_NS = "http://www.mozilla.org/2005/app-update";
+const URI_UPDATES_PROPERTIES =
+ "chrome://mozapps/locale/update/updates.properties";
+
+const KEY_EXECUTABLE = "XREExeF";
+const KEY_UPDROOT = "UpdRootD";
+const KEY_OLD_UPDROOT = "OldUpdRootD";
+
+const DIR_UPDATES = "updates";
+const DIR_UPDATE_READY = "0";
+const DIR_UPDATE_DOWNLOADING = "downloading";
+
+const FILE_ACTIVE_UPDATE_XML = "active-update.xml";
+const FILE_BACKUP_UPDATE_LOG = "backup-update.log";
+const FILE_BACKUP_UPDATE_ELEVATED_LOG = "backup-update-elevated.log";
+const FILE_BT_RESULT = "bt.result";
+const FILE_LAST_UPDATE_LOG = "last-update.log";
+const FILE_LAST_UPDATE_ELEVATED_LOG = "last-update-elevated.log";
+const FILE_UPDATES_XML = "updates.xml";
+const FILE_UPDATE_LOG = "update.log";
+const FILE_UPDATE_ELEVATED_LOG = "update-elevated.log";
+const FILE_UPDATE_MAR = "update.mar";
+const FILE_UPDATE_STATUS = "update.status";
+const FILE_UPDATE_TEST = "update.test";
+const FILE_UPDATE_VERSION = "update.version";
+
+const STATE_NONE = "null";
+const STATE_DOWNLOADING = "downloading";
+const STATE_PENDING = "pending";
+const STATE_PENDING_SERVICE = "pending-service";
+const STATE_PENDING_ELEVATE = "pending-elevate";
+const STATE_APPLYING = "applying";
+const STATE_APPLIED = "applied";
+const STATE_APPLIED_SERVICE = "applied-service";
+const STATE_SUCCEEDED = "succeeded";
+const STATE_DOWNLOAD_FAILED = "download-failed";
+const STATE_FAILED = "failed";
+
+// BITS will keep retrying a download after transient errors, unless this much
+// time has passed since there has been download progress.
+// Similarly to ...POLL_RATE_MS below, we are much more aggressive when the user
+// is watching the download progress.
+const BITS_IDLE_NO_PROGRESS_TIMEOUT_SECS = 3600; // 1 hour
+const BITS_ACTIVE_NO_PROGRESS_TIMEOUT_SECS = 5;
+
+// These value control how frequently we get updates from the BITS client on
+// the progress made downloading. The difference between the two is that the
+// active interval is the one used when the user is watching. The idle interval
+// is the one used when no one is watching.
+const BITS_IDLE_POLL_RATE_MS = 1000;
+const BITS_ACTIVE_POLL_RATE_MS = 200;
+
+// The values below used by this code are from common/updatererrors.h
+const WRITE_ERROR = 7;
+const ELEVATION_CANCELED = 9;
+const SERVICE_UPDATER_COULD_NOT_BE_STARTED = 24;
+const SERVICE_NOT_ENOUGH_COMMAND_LINE_ARGS = 25;
+const SERVICE_UPDATER_SIGN_ERROR = 26;
+const SERVICE_UPDATER_COMPARE_ERROR = 27;
+const SERVICE_UPDATER_IDENTITY_ERROR = 28;
+const SERVICE_STILL_APPLYING_ON_SUCCESS = 29;
+const SERVICE_STILL_APPLYING_ON_FAILURE = 30;
+const SERVICE_UPDATER_NOT_FIXED_DRIVE = 31;
+const SERVICE_COULD_NOT_LOCK_UPDATER = 32;
+const SERVICE_INSTALLDIR_ERROR = 33;
+const WRITE_ERROR_ACCESS_DENIED = 35;
+const WRITE_ERROR_CALLBACK_APP = 37;
+const UNEXPECTED_STAGING_ERROR = 43;
+const DELETE_ERROR_STAGING_LOCK_FILE = 44;
+const SERVICE_COULD_NOT_COPY_UPDATER = 49;
+const SERVICE_STILL_APPLYING_TERMINATED = 50;
+const SERVICE_STILL_APPLYING_NO_EXIT_CODE = 51;
+const SERVICE_COULD_NOT_IMPERSONATE = 58;
+const WRITE_ERROR_FILE_COPY = 61;
+const WRITE_ERROR_DELETE_FILE = 62;
+const WRITE_ERROR_OPEN_PATCH_FILE = 63;
+const WRITE_ERROR_PATCH_FILE = 64;
+const WRITE_ERROR_APPLY_DIR_PATH = 65;
+const WRITE_ERROR_CALLBACK_PATH = 66;
+const WRITE_ERROR_FILE_ACCESS_DENIED = 67;
+const WRITE_ERROR_DIR_ACCESS_DENIED = 68;
+const WRITE_ERROR_DELETE_BACKUP = 69;
+const WRITE_ERROR_EXTRACT = 70;
+
+// Error codes 80 through 99 are reserved for UpdateService.jsm and are not
+// defined in common/updatererrors.h
+const ERR_UPDATER_CRASHED = 89;
+const ERR_OLDER_VERSION_OR_SAME_BUILD = 90;
+const ERR_UPDATE_STATE_NONE = 91;
+const ERR_CHANNEL_CHANGE = 92;
+const INVALID_UPDATER_STATE_CODE = 98;
+const INVALID_UPDATER_STATUS_CODE = 99;
+
+const SILENT_UPDATE_NEEDED_ELEVATION_ERROR = 105;
+const WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION = 106;
+
+// Array of write errors to simplify checks for write errors
+const WRITE_ERRORS = [
+ WRITE_ERROR,
+ WRITE_ERROR_ACCESS_DENIED,
+ WRITE_ERROR_CALLBACK_APP,
+ WRITE_ERROR_FILE_COPY,
+ WRITE_ERROR_DELETE_FILE,
+ WRITE_ERROR_OPEN_PATCH_FILE,
+ WRITE_ERROR_PATCH_FILE,
+ WRITE_ERROR_APPLY_DIR_PATH,
+ WRITE_ERROR_CALLBACK_PATH,
+ WRITE_ERROR_FILE_ACCESS_DENIED,
+ WRITE_ERROR_DIR_ACCESS_DENIED,
+ WRITE_ERROR_DELETE_BACKUP,
+ WRITE_ERROR_EXTRACT,
+ WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION,
+];
+
+// Array of write errors to simplify checks for service errors
+const SERVICE_ERRORS = [
+ SERVICE_UPDATER_COULD_NOT_BE_STARTED,
+ SERVICE_NOT_ENOUGH_COMMAND_LINE_ARGS,
+ SERVICE_UPDATER_SIGN_ERROR,
+ SERVICE_UPDATER_COMPARE_ERROR,
+ SERVICE_UPDATER_IDENTITY_ERROR,
+ SERVICE_STILL_APPLYING_ON_SUCCESS,
+ SERVICE_STILL_APPLYING_ON_FAILURE,
+ SERVICE_UPDATER_NOT_FIXED_DRIVE,
+ SERVICE_COULD_NOT_LOCK_UPDATER,
+ SERVICE_INSTALLDIR_ERROR,
+ SERVICE_COULD_NOT_COPY_UPDATER,
+ SERVICE_STILL_APPLYING_TERMINATED,
+ SERVICE_STILL_APPLYING_NO_EXIT_CODE,
+ SERVICE_COULD_NOT_IMPERSONATE,
+];
+
+// Custom update error codes
+const BACKGROUNDCHECK_MULTIPLE_FAILURES = 110;
+const NETWORK_ERROR_OFFLINE = 111;
+
+// Error codes should be < 1000. Errors above 1000 represent http status codes
+const HTTP_ERROR_OFFSET = 1000;
+
+// The is an HRESULT error that may be returned from the BITS interface
+// indicating that access was denied. Often, this error code is returned when
+// attempting to access a job created by a different user.
+const HRESULT_E_ACCESSDENIED = -2147024891;
+
+const DOWNLOAD_CHUNK_SIZE = 300000; // bytes
+
+// The number of consecutive failures when updating using the service before
+// setting the app.update.service.enabled preference to false.
+const DEFAULT_SERVICE_MAX_ERRORS = 10;
+
+// The number of consecutive socket errors to allow before falling back to
+// downloading a different MAR file or failing if already downloading the full.
+const DEFAULT_SOCKET_MAX_ERRORS = 10;
+
+// The number of milliseconds to wait before retrying a connection error.
+const DEFAULT_SOCKET_RETRYTIMEOUT = 2000;
+
+// Default maximum number of elevation cancelations per update version before
+// giving up.
+const DEFAULT_CANCELATIONS_OSX_MAX = 3;
+
+// This maps app IDs to their respective notification topic which signals when
+// the application's user interface has been displayed.
+const APPID_TO_TOPIC = {
+ // Firefox
+ "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "sessionstore-windows-restored",
+ // SeaMonkey
+ "{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}": "sessionstore-windows-restored",
+ // Thunderbird
+ "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "mail-startup-done",
+};
+
+// The interval for the update xml write deferred task.
+const XML_SAVER_INTERVAL_MS = 200;
+
+// How long after a patch has downloaded should we wait for language packs to
+// update before proceeding anyway.
+const LANGPACK_UPDATE_DEFAULT_TIMEOUT = 300000;
+
+// Interval between rechecks for other instances after the initial check finds
+// at least one other instance.
+const ONLY_INSTANCE_CHECK_DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
+
+// Wait this long after detecting that another instance is running (having been
+// polling that entire time) before giving up and applying the update anyway.
+const ONLY_INSTANCE_CHECK_DEFAULT_TIMEOUT_MS = 6 * 60 * 60 * 1000; // 6 hours
+
+// The other instance check timeout can be overridden via a pref, but we limit
+// that value to this so that the pref can't effectively disable the feature.
+const ONLY_INSTANCE_CHECK_MAX_TIMEOUT_MS = 2 * 24 * 60 * 60 * 1000; // 2 days
+
+// Values to use when polling for staging. See `pollForStagingEnd` for more
+// details.
+const STAGING_POLLING_MIN_INTERVAL_MS = 15 * 1000; // 15 seconds
+const STAGING_POLLING_MAX_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
+const STAGING_POLLING_ATTEMPTS_PER_INTERVAL = 5;
+const STAGING_POLLING_MAX_DURATION_MS = 1 * 60 * 60 * 1000; // 1 hour
+
+var gUpdateMutexHandle = null;
+// This value will be set to true if it appears that BITS is being used by
+// another user to download updates. We don't really want two users using BITS
+// at once. Computers with many users (ex: a school computer), should not end
+// up with dozens of BITS jobs.
+var gBITSInUseByAnotherUser = false;
+// The update service can be invoked as part of a standalone headless background
+// task. In this context, when the background task kicks off an update
+// download, we don't want it to move on to staging. As soon as the download has
+// kicked off, the task begins shutting down and, even if the the download
+// completes incredibly quickly, we don't want staging to begin while we are
+// shutting down. That isn't a well tested scenario and it's possible that it
+// could leave us in a bad state.
+let gOnlyDownloadUpdatesThisSession = false;
+// This will be the backing for `nsIApplicationUpdateService.currentState`
+var gUpdateState = Ci.nsIApplicationUpdateService.STATE_IDLE;
+
+/**
+ * Simple container and constructor for a Promise and its resolve function.
+ */
+class SelfContainedPromise {
+ constructor() {
+ this.promise = new Promise(resolve => {
+ this.resolve = resolve;
+ });
+ }
+}
+
+// This will contain a `SelfContainedPromise` that will be used to back
+// `nsIApplicationUpdateService.stateTransition`.
+var gStateTransitionPromise = new SelfContainedPromise();
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "gUpdateBundle",
+ function aus_gUpdateBundle() {
+ return Services.strings.createBundle(URI_UPDATES_PROPERTIES);
+ }
+);
+
+/**
+ * gIsBackgroundTaskMode will be true if Firefox is currently running as a
+ * background task. Otherwise it will be false.
+ */
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "gIsBackgroundTaskMode",
+ function aus_gCurrentlyRunningAsBackgroundTask() {
+ if (!("@mozilla.org/backgroundtasks;1" in Cc)) {
+ return false;
+ }
+ const bts = Cc["@mozilla.org/backgroundtasks;1"].getService(
+ Ci.nsIBackgroundTasks
+ );
+ if (!bts) {
+ return false;
+ }
+ return bts.isBackgroundTaskMode;
+ }
+);
+
+/**
+ * Changes `nsIApplicationUpdateService.currentState` and causes
+ * `nsIApplicationUpdateService.stateTransition` to resolve.
+ */
+function transitionState(newState) {
+ if (newState == gUpdateState) {
+ LOG("transitionState - Not transitioning state because it isn't changing.");
+ return;
+ }
+ LOG(
+ `transitionState - "${lazy.AUS.getStateName(gUpdateState)}" -> ` +
+ `"${lazy.AUS.getStateName(newState)}".`
+ );
+ gUpdateState = newState;
+ // Assign the new Promise before we resolve the old one just to make sure that
+ // anything that runs as a result of `resolve` doesn't end up waiting on the
+ // Promise that already resolved.
+ let oldStateTransitionPromise = gStateTransitionPromise;
+ gStateTransitionPromise = new SelfContainedPromise();
+ oldStateTransitionPromise.resolve();
+}
+
+/**
+ * When a plain JS object is passed through xpconnect the other side sees a
+ * wrapped version of the object instead of the real object. Since these two
+ * objects are different they act as different keys for Map and WeakMap. However
+ * xpconnect gives us a way to get the underlying JS object from the wrapper so
+ * this function returns the JS object regardless of whether passed the JS
+ * object or its wrapper for use in places where it is unclear which one you
+ * have.
+ */
+function unwrap(obj) {
+ return obj.wrappedJSObject ?? obj;
+}
+
+/**
+ * When an update starts to download (and if the feature is enabled) the add-ons
+ * manager starts downloading updated language packs for the new application
+ * version. A promise is used to track whether those updates are complete so the
+ * front-end is only notified that an application update is ready once the
+ * language pack updates have been staged.
+ *
+ * In order to be able to access that promise from various places in the update
+ * service they are cached in this map using the nsIUpdate object as a weak
+ * owner. Note that the key should always be the result of calling the above
+ * unwrap function on the nsIUpdate to ensure a consistent object is used as the
+ * key.
+ *
+ * When the language packs finish staging the nsIUpdate entriy is removed from
+ * this map so if the entry is still there then language pack updates are in
+ * progress.
+ */
+const LangPackUpdates = new WeakMap();
+
+/**
+ * When we're polling to see if other running instances of the application have
+ * exited, there's no need to ever start polling again in parallel. To prevent
+ * doing that, we keep track of the promise that resolves when polling completes
+ * and return that if a second simultaneous poll is requested, so that the
+ * multiple callers end up waiting for the same promise to resolve.
+ */
+let gOtherInstancePollPromise;
+
+/**
+ * Query the update sync manager to see if another instance of this same
+ * installation of this application is currently running, under the context of
+ * any operating system user (not just the current one).
+ * This function immediately returns the current, instantaneous status of any
+ * other instances.
+ *
+ * @return true if at least one other instance is running, false if not
+ */
+function isOtherInstanceRunning(callback) {
+ const checkEnabled = Services.prefs.getBoolPref(
+ PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_ENABLED,
+ true
+ );
+ if (!checkEnabled) {
+ LOG("isOtherInstanceRunning - disabled by pref, skipping check");
+ return false;
+ }
+
+ try {
+ let syncManager = Cc[
+ "@mozilla.org/updates/update-sync-manager;1"
+ ].getService(Ci.nsIUpdateSyncManager);
+ return syncManager.isOtherInstanceRunning();
+ } catch (ex) {
+ LOG(`isOtherInstanceRunning - sync manager failed with exception: ${ex}`);
+ return false;
+ }
+}
+
+/**
+ * Query the update sync manager to see if another instance of this same
+ * installation of this application is currently running, under the context of
+ * any operating system user (not just the one running this instance).
+ * This function polls for the status of other instances continually
+ * (asynchronously) until either none exist or a timeout expires.
+ *
+ * @return a Promise that resolves with false if at any point during polling no
+ * other instances can be found, or resolves with true if the timeout
+ * expires when other instances are still running
+ */
+function waitForOtherInstances() {
+ // If we're already in the middle of a poll, reuse it rather than start again.
+ if (gOtherInstancePollPromise) {
+ return gOtherInstancePollPromise;
+ }
+
+ let timeout = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_TIMEOUT,
+ ONLY_INSTANCE_CHECK_DEFAULT_TIMEOUT_MS
+ );
+ // Don't allow the pref to set a super high timeout and break this feature.
+ if (timeout > ONLY_INSTANCE_CHECK_MAX_TIMEOUT_MS) {
+ timeout = ONLY_INSTANCE_CHECK_MAX_TIMEOUT_MS;
+ }
+
+ let interval = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CHECK_ONLY_INSTANCE_INTERVAL,
+ ONLY_INSTANCE_CHECK_DEFAULT_POLL_INTERVAL_MS
+ );
+ // Don't allow an interval longer than the timeout.
+ interval = Math.min(interval, timeout);
+
+ let iterations = 0;
+ const maxIterations = Math.ceil(timeout / interval);
+
+ gOtherInstancePollPromise = new Promise(function (resolve, reject) {
+ let poll = function () {
+ iterations++;
+ if (!isOtherInstanceRunning()) {
+ LOG("waitForOtherInstances - no other instances found, exiting");
+ resolve(false);
+ gOtherInstancePollPromise = undefined;
+ } else if (iterations >= maxIterations) {
+ LOG(
+ "waitForOtherInstances - timeout expired while other instances " +
+ "are still running"
+ );
+ resolve(true);
+ gOtherInstancePollPromise = undefined;
+ } else if (iterations + 1 == maxIterations && timeout % interval != 0) {
+ // In case timeout isn't a multiple of interval, set the next timeout
+ // for the remainder of the time rather than for the usual interval.
+ lazy.setTimeout(poll, timeout % interval);
+ } else {
+ lazy.setTimeout(poll, interval);
+ }
+ };
+
+ LOG("waitForOtherInstances - beginning polling");
+ poll();
+ });
+
+ return gOtherInstancePollPromise;
+}
+
+/**
+ * Tests to make sure that we can write to a given directory.
+ *
+ * @param updateTestFile a test file in the directory that needs to be tested.
+ * @param createDirectory whether a test directory should be created.
+ * @throws if we don't have right access to the directory.
+ */
+function testWriteAccess(updateTestFile, createDirectory) {
+ const NORMAL_FILE_TYPE = Ci.nsIFile.NORMAL_FILE_TYPE;
+ const DIRECTORY_TYPE = Ci.nsIFile.DIRECTORY_TYPE;
+ if (updateTestFile.exists()) {
+ updateTestFile.remove(false);
+ }
+ updateTestFile.create(
+ createDirectory ? DIRECTORY_TYPE : NORMAL_FILE_TYPE,
+ createDirectory ? FileUtils.PERMS_DIRECTORY : FileUtils.PERMS_FILE
+ );
+ updateTestFile.remove(false);
+}
+
+/**
+ * Windows only function that closes a Win32 handle.
+ *
+ * @param handle The handle to close
+ */
+function closeHandle(handle) {
+ if (handle) {
+ let lib = lazy.ctypes.open("kernel32.dll");
+ let CloseHandle = lib.declare(
+ "CloseHandle",
+ lazy.ctypes.winapi_abi,
+ lazy.ctypes.int32_t /* success */,
+ lazy.ctypes.void_t.ptr
+ ); /* handle */
+ CloseHandle(handle);
+ lib.close();
+ }
+}
+
+/**
+ * Windows only function that creates a mutex.
+ *
+ * @param aName
+ * The name for the mutex.
+ * @param aAllowExisting
+ * If false the function will close the handle and return null.
+ * @return The Win32 handle to the mutex.
+ */
+function createMutex(aName, aAllowExisting = true) {
+ if (AppConstants.platform != "win") {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ const INITIAL_OWN = 1;
+ const ERROR_ALREADY_EXISTS = 0xb7;
+ let lib = lazy.ctypes.open("kernel32.dll");
+ let CreateMutexW = lib.declare(
+ "CreateMutexW",
+ lazy.ctypes.winapi_abi,
+ lazy.ctypes.void_t.ptr /* return handle */,
+ lazy.ctypes.void_t.ptr /* security attributes */,
+ lazy.ctypes.int32_t /* initial owner */,
+ lazy.ctypes.char16_t.ptr
+ ); /* name */
+
+ let handle = CreateMutexW(null, INITIAL_OWN, aName);
+ let alreadyExists = lazy.ctypes.winLastError == ERROR_ALREADY_EXISTS;
+ if (handle && !handle.isNull() && !aAllowExisting && alreadyExists) {
+ closeHandle(handle);
+ handle = null;
+ }
+ lib.close();
+
+ if (handle && handle.isNull()) {
+ handle = null;
+ }
+
+ return handle;
+}
+
+/**
+ * Windows only function that determines a unique mutex name for the
+ * installation.
+ *
+ * @param aGlobal
+ * true if the function should return a global mutex. A global mutex is
+ * valid across different sessions.
+ * @return Global mutex path
+ */
+function getPerInstallationMutexName(aGlobal = true) {
+ if (AppConstants.platform != "win") {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(hasher.SHA1);
+
+ let exeFile = Services.dirsvc.get(KEY_EXECUTABLE, Ci.nsIFile);
+
+ var data = new TextEncoder().encode(exeFile.path.toLowerCase());
+
+ hasher.update(data, data.length);
+ return (
+ (aGlobal ? "Global\\" : "") + "MozillaUpdateMutex-" + hasher.finish(true)
+ );
+}
+
+/**
+ * Whether or not the current instance has the update mutex. The update mutex
+ * gives protection against 2 applications from the same installation updating:
+ * 1) Running multiple profiles from the same installation path
+ * 2) Two applications running in 2 different user sessions from the same path
+ *
+ * @return true if this instance holds the update mutex
+ */
+function hasUpdateMutex() {
+ if (AppConstants.platform != "win") {
+ return true;
+ }
+ if (!gUpdateMutexHandle) {
+ gUpdateMutexHandle = createMutex(getPerInstallationMutexName(true), false);
+ }
+ return !!gUpdateMutexHandle;
+}
+
+/**
+ * Determines whether or not all descendants of a directory are writeable.
+ * Note: Does not check the root directory itself for writeability.
+ *
+ * @return true if all descendants are writeable, false otherwise
+ */
+function areDirectoryEntriesWriteable(aDir) {
+ let items = aDir.directoryEntries;
+ while (items.hasMoreElements()) {
+ let item = items.nextFile;
+ if (!item.isWritable()) {
+ LOG("areDirectoryEntriesWriteable - unable to write to " + item.path);
+ return false;
+ }
+ if (item.isDirectory() && !areDirectoryEntriesWriteable(item)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/**
+ * OSX only function to determine if the user requires elevation to be able to
+ * write to the application bundle.
+ *
+ * @return true if elevation is required, false otherwise
+ */
+function getElevationRequired() {
+ if (AppConstants.platform != "macosx") {
+ return false;
+ }
+
+ try {
+ // Recursively check that the application bundle (and its descendants) can
+ // be written to.
+ LOG(
+ "getElevationRequired - recursively testing write access on " +
+ getInstallDirRoot().path
+ );
+ if (
+ !getInstallDirRoot().isWritable() ||
+ !areDirectoryEntriesWriteable(getInstallDirRoot())
+ ) {
+ LOG(
+ "getElevationRequired - unable to write to application bundle, " +
+ "elevation required"
+ );
+ return true;
+ }
+ } catch (ex) {
+ LOG(
+ "getElevationRequired - unable to write to application bundle, " +
+ "elevation required. Exception: " +
+ ex
+ );
+ return true;
+ }
+ LOG(
+ "getElevationRequired - able to write to application bundle, elevation " +
+ "not required"
+ );
+ return false;
+}
+
+/**
+ * A promise that resolves when language packs are downloading or if no language
+ * packs are being downloaded.
+ */
+function promiseLangPacksUpdated(update) {
+ let promise = LangPackUpdates.get(unwrap(update));
+ if (promise) {
+ LOG(
+ "promiseLangPacksUpdated - waiting for language pack updates to stage."
+ );
+ return promise;
+ }
+
+ // In case callers rely on a promise just return an already resolved promise.
+ return Promise.resolve();
+}
+
+/*
+ * See nsIUpdateService.idl
+ */
+function isAppBaseDirWritable() {
+ let appDirTestFile = "";
+
+ try {
+ appDirTestFile = getAppBaseDir();
+ appDirTestFile.append(FILE_UPDATE_TEST);
+ } catch (e) {
+ LOG(
+ "isAppBaseDirWritable - Base directory or test path could not be " +
+ `determined: ${e}`
+ );
+ return false;
+ }
+
+ try {
+ LOG(
+ `isAppBaseDirWritable - testing write access for ${appDirTestFile.path}`
+ );
+
+ if (appDirTestFile.exists()) {
+ appDirTestFile.remove(false);
+ }
+ // if we're unable to create the test file this will throw an exception:
+ appDirTestFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+ appDirTestFile.remove(false);
+ LOG(`isAppBaseDirWritable - Path is writable: ${appDirTestFile.path}`);
+ return true;
+ } catch (e) {
+ LOG(
+ `isAppBaseDirWritable - Path '${appDirTestFile.path}' ` +
+ `is not writable: ${e}`
+ );
+ }
+ // No write access to the installation directory
+ return false;
+}
+
+/**
+ * Determines whether or not an update can be applied. This is always true on
+ * Windows when the service is used. On Mac OS X and Linux, if the user has
+ * write access to the update directory this will return true because on OSX we
+ * offer users the option to perform an elevated update when necessary and on
+ * Linux the update directory is located in the application directory.
+ *
+ * @return true if an update can be applied, false otherwise
+ */
+function getCanApplyUpdates() {
+ try {
+ // Check if it is possible to write to the update directory so clients won't
+ // repeatedly try to apply an update without the ability to complete the
+ // update process which requires write access to the update directory.
+ let updateTestFile = getUpdateFile([FILE_UPDATE_TEST]);
+ LOG("getCanApplyUpdates - testing write access " + updateTestFile.path);
+ testWriteAccess(updateTestFile, false);
+ } catch (e) {
+ LOG(
+ "getCanApplyUpdates - unable to apply updates without write " +
+ "access to the update directory. Exception: " +
+ e
+ );
+ return false;
+ }
+
+ if (AppConstants.platform == "macosx") {
+ LOG(
+ "getCanApplyUpdates - bypass the write since elevation can be used " +
+ "on Mac OS X"
+ );
+ return true;
+ }
+
+ if (shouldUseService()) {
+ LOG(
+ "getCanApplyUpdates - bypass the write checks because the Windows " +
+ "Maintenance Service can be used"
+ );
+ return true;
+ }
+
+ try {
+ if (AppConstants.platform == "win") {
+ // On Windows when the maintenance service isn't used updates can still be
+ // performed in a location requiring admin privileges by the client
+ // accepting a UAC prompt from an elevation request made by the updater.
+ // Whether the client can elevate (e.g. has a split token) is determined
+ // in nsXULAppInfo::GetUserCanElevate which is located in nsAppRunner.cpp.
+ let userCanElevate = Services.appinfo.QueryInterface(
+ Ci.nsIWinAppHelper
+ ).userCanElevate;
+ if (lazy.gIsBackgroundTaskMode) {
+ LOG(
+ "getCanApplyUpdates - in background task mode, assuming user can't elevate"
+ );
+ userCanElevate = false;
+ }
+ if (!userCanElevate && !isAppBaseDirWritable) {
+ LOG(
+ "getCanApplyUpdates - unable to apply updates, because the base " +
+ "directory is not writable."
+ );
+ }
+ }
+ } catch (e) {
+ LOG("getCanApplyUpdates - unable to apply updates. Exception: " + e);
+ // No write access to the installation directory
+ return false;
+ }
+
+ LOG("getCanApplyUpdates - able to apply updates");
+ return true;
+}
+
+/**
+ * Whether or not the application can stage an update for the current session.
+ * These checks are only performed once per session due to using a lazy getter.
+ *
+ * @return true if updates can be staged for this session.
+ */
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "gCanStageUpdatesSession",
+ function aus_gCSUS() {
+ if (getElevationRequired()) {
+ LOG(
+ "gCanStageUpdatesSession - unable to stage updates because elevation " +
+ "is required."
+ );
+ return false;
+ }
+
+ try {
+ let updateTestFile;
+ if (AppConstants.platform == "macosx") {
+ updateTestFile = getUpdateFile([FILE_UPDATE_TEST]);
+ } else {
+ updateTestFile = getInstallDirRoot();
+ updateTestFile.append(FILE_UPDATE_TEST);
+ }
+ LOG(
+ "gCanStageUpdatesSession - testing write access " + updateTestFile.path
+ );
+ testWriteAccess(updateTestFile, true);
+ if (AppConstants.platform != "macosx") {
+ // On all platforms except Mac, we need to test the parent directory as
+ // well, as we need to be able to move files in that directory during the
+ // replacing step.
+ updateTestFile = getInstallDirRoot().parent;
+ updateTestFile.append(FILE_UPDATE_TEST);
+ LOG(
+ "gCanStageUpdatesSession - testing write access " +
+ updateTestFile.path
+ );
+ updateTestFile.createUnique(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ updateTestFile.remove(false);
+ }
+ } catch (e) {
+ LOG("gCanStageUpdatesSession - unable to stage updates. Exception: " + e);
+ // No write privileges
+ return false;
+ }
+
+ LOG("gCanStageUpdatesSession - able to stage updates");
+ return true;
+ }
+);
+
+/**
+ * Whether or not the application can stage an update.
+ *
+ * @param {boolean} [transient] Whether transient factors such as the update
+ * mutex should be considered.
+ * @return true if updates can be staged.
+ */
+function getCanStageUpdates(transient = true) {
+ // If staging updates are disabled, then just bail out!
+ if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false)) {
+ LOG(
+ "getCanStageUpdates - staging updates is disabled by preference " +
+ PREF_APP_UPDATE_STAGING_ENABLED
+ );
+ return false;
+ }
+
+ if (AppConstants.platform == "win" && shouldUseService()) {
+ // No need to perform directory write checks, the maintenance service will
+ // be able to write to all directories.
+ LOG("getCanStageUpdates - able to stage updates using the service");
+ return true;
+ }
+
+ if (transient && !hasUpdateMutex()) {
+ LOG(
+ "getCanStageUpdates - unable to apply updates because another " +
+ "instance of the application is already handling updates for this " +
+ "installation."
+ );
+ return false;
+ }
+
+ return lazy.gCanStageUpdatesSession;
+}
+
+/*
+ * Whether or not the application can use BITS to download updates.
+ *
+ * @param {boolean} [transient] Whether transient factors such as the update
+ * mutex should be considered.
+ * @return A string with one of these values:
+ * CanUseBits
+ * NoBits_NotWindows
+ * NoBits_FeatureOff
+ * NoBits_Pref
+ * NoBits_Proxy
+ * NoBits_OtherUser
+ * These strings are directly compatible with the categories for
+ * UPDATE_CAN_USE_BITS_EXTERNAL and UPDATE_CAN_USE_BITS_NOTIFY telemetry
+ * probes. If this function is made to return other values, they should
+ * also be added to the labels lists for those probes in Histograms.json
+ */
+function getCanUseBits(transient = true) {
+ if (AppConstants.platform != "win") {
+ LOG("getCanUseBits - Not using BITS because this is not Windows");
+ return "NoBits_NotWindows";
+ }
+ if (!AppConstants.MOZ_BITS_DOWNLOAD) {
+ LOG("getCanUseBits - Not using BITS because the feature is disabled");
+ return "NoBits_FeatureOff";
+ }
+
+ if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED, true)) {
+ LOG("getCanUseBits - Not using BITS. Disabled by pref.");
+ return "NoBits_Pref";
+ }
+ // Firefox support for passing proxies to BITS is still rudimentary.
+ // For now, disable BITS support on configurations that are not using the
+ // standard system proxy.
+ let defaultProxy = Ci.nsIProtocolProxyService.PROXYCONFIG_SYSTEM;
+ if (
+ Services.prefs.getIntPref(PREF_NETWORK_PROXY_TYPE, defaultProxy) !=
+ defaultProxy &&
+ !Cu.isInAutomation
+ ) {
+ LOG("getCanUseBits - Not using BITS because of proxy usage");
+ return "NoBits_Proxy";
+ }
+ if (transient && gBITSInUseByAnotherUser) {
+ LOG("getCanUseBits - Not using BITS. Already in use by another user");
+ return "NoBits_OtherUser";
+ }
+ LOG("getCanUseBits - BITS can be used to download updates");
+ return "CanUseBits";
+}
+
+/**
+ * Logs a string to the error console. If enabled, also logs to the update
+ * messages file.
+ * @param string
+ * The string to write to the error console.
+ */
+function LOG(string) {
+ lazy.UpdateLog.logPrefixedString("AUS:SVC", string);
+}
+
+/**
+ * Gets the specified directory at the specified hierarchy under the
+ * update root directory and creates it if it doesn't exist.
+ * @param pathArray
+ * An array of path components to locate beneath the directory
+ * specified by |key|
+ * @return nsIFile object for the location specified.
+ */
+function getUpdateDirCreate(pathArray) {
+ if (Cu.isInAutomation) {
+ // This allows tests to use an alternate updates directory so they can test
+ // startup behavior.
+ const MAGIC_TEST_ROOT_PREFIX = "<test-root>";
+ const PREF_TEST_ROOT = "mochitest.testRoot";
+ let alternatePath = Services.prefs.getCharPref(
+ PREF_APP_UPDATE_ALTUPDATEDIRPATH,
+ null
+ );
+ if (alternatePath && alternatePath.startsWith(MAGIC_TEST_ROOT_PREFIX)) {
+ let testRoot = Services.prefs.getCharPref(PREF_TEST_ROOT);
+ let relativePath = alternatePath.substring(MAGIC_TEST_ROOT_PREFIX.length);
+ if (AppConstants.platform == "win") {
+ relativePath = relativePath.replace(/\//g, "\\");
+ }
+ alternatePath = testRoot + relativePath;
+ let updateDir = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ updateDir.initWithPath(alternatePath);
+ for (let i = 0; i < pathArray.length; ++i) {
+ updateDir.append(pathArray[i]);
+ }
+ return updateDir;
+ }
+ }
+
+ let dir = FileUtils.getDir(KEY_UPDROOT, pathArray);
+ try {
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw ex;
+ }
+ // Ignore the exception due to a directory that already exists.
+ }
+ return dir;
+}
+
+/**
+ * Gets the application base directory.
+ *
+ * @return nsIFile object for the application base directory.
+ */
+function getAppBaseDir() {
+ return Services.dirsvc.get(KEY_EXECUTABLE, Ci.nsIFile).parent;
+}
+
+/**
+ * Gets the root of the installation directory which is the application
+ * bundle directory on Mac OS X and the location of the application binary
+ * on all other platforms.
+ *
+ * @return nsIFile object for the directory
+ */
+function getInstallDirRoot() {
+ let dir = getAppBaseDir();
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the executable is stored under Contents/MacOS.
+ dir = dir.parent.parent;
+ }
+ return dir;
+}
+
+/**
+ * Gets the file at the specified hierarchy under the update root directory.
+ * @param pathArray
+ * An array of path components to locate beneath the directory
+ * specified by |key|. The last item in this array must be the
+ * leaf name of a file.
+ * @return nsIFile object for the file specified. The file is NOT created
+ * if it does not exist, however all required directories along
+ * the way are.
+ */
+function getUpdateFile(pathArray) {
+ let file = getUpdateDirCreate(pathArray.slice(0, -1));
+ file.append(pathArray[pathArray.length - 1]);
+ return file;
+}
+
+/**
+ * This function is designed to let us slightly clean up the mapping between
+ * strings and error codes. So that instead of having:
+ * check_error-2147500036=Connection aborted
+ * check_error-2152398850=Connection aborted
+ * We can have:
+ * check_error-connection_aborted=Connection aborted
+ * And map both of those error codes to it.
+ */
+function maybeMapErrorCode(code) {
+ switch (code) {
+ case Cr.NS_BINDING_ABORTED:
+ case Cr.NS_ERROR_ABORT:
+ return "connection_aborted";
+ }
+ return code;
+}
+
+/**
+ * Returns human readable status text from the updates.properties bundle
+ * based on an error code
+ * @param code
+ * The error code to look up human readable status text for
+ * @param defaultCode
+ * The default code to look up should human readable status text
+ * not exist for |code|
+ * @return A human readable status text string
+ */
+function getStatusTextFromCode(code, defaultCode) {
+ code = maybeMapErrorCode(code);
+
+ let reason;
+ try {
+ reason = lazy.gUpdateBundle.GetStringFromName("check_error-" + code);
+ LOG(
+ "getStatusTextFromCode - transfer error: " + reason + ", code: " + code
+ );
+ } catch (e) {
+ defaultCode = maybeMapErrorCode(defaultCode);
+
+ // Use the default reason
+ reason = lazy.gUpdateBundle.GetStringFromName("check_error-" + defaultCode);
+ LOG(
+ "getStatusTextFromCode - transfer error: " +
+ reason +
+ ", default code: " +
+ defaultCode
+ );
+ }
+ return reason;
+}
+
+/**
+ * Get the Ready Update directory. This is the directory that an update
+ * should reside in after download has completed but before it has been
+ * installed and cleaned up.
+ * @return The ready updates directory, as a nsIFile object
+ */
+function getReadyUpdateDir() {
+ return getUpdateDirCreate([DIR_UPDATES, DIR_UPDATE_READY]);
+}
+
+/**
+ * Get the Downloading Update directory. This is the directory that an update
+ * should reside in during download. Once download is completed, it will be
+ * moved to the Ready Update directory.
+ * @return The downloading update directory, as a nsIFile object
+ */
+function getDownloadingUpdateDir() {
+ return getUpdateDirCreate([DIR_UPDATES, DIR_UPDATE_DOWNLOADING]);
+}
+
+/**
+ * Reads the update state from the update.status file in the specified
+ * directory.
+ * @param dir
+ * The dir to look for an update.status file in
+ * @return The status value of the update.
+ */
+function readStatusFile(dir) {
+ let statusFile = dir.clone();
+ statusFile.append(FILE_UPDATE_STATUS);
+ let status = readStringFromFile(statusFile) || STATE_NONE;
+ LOG("readStatusFile - status: " + status + ", path: " + statusFile.path);
+ return status;
+}
+
+/**
+ * Reads the binary transparency result file from the given directory.
+ * Removes the file if it is present (so don't call this twice and expect a
+ * result the second time).
+ * @param dir
+ * The dir to look for an update.bt file in
+ * @return A error code from verifying binary transparency information or null
+ * if the file was not present (indicating there was no error).
+ */
+function readBinaryTransparencyResult(dir) {
+ let binaryTransparencyResultFile = dir.clone();
+ binaryTransparencyResultFile.append(FILE_BT_RESULT);
+ let result = readStringFromFile(binaryTransparencyResultFile);
+ LOG(
+ "readBinaryTransparencyResult - result: " +
+ result +
+ ", path: " +
+ binaryTransparencyResultFile.path
+ );
+ // If result is non-null, the file exists. We should remove it to avoid
+ // double-reporting this result.
+ if (result) {
+ binaryTransparencyResultFile.remove(false);
+ }
+ return result;
+}
+
+/**
+ * Writes the current update operation/state to a file in the patch
+ * directory, indicating to the patching system that operations need
+ * to be performed.
+ * @param dir
+ * The patch directory where the update.status file should be
+ * written.
+ * @param state
+ * The state value to write.
+ */
+function writeStatusFile(dir, state) {
+ let statusFile = dir.clone();
+ statusFile.append(FILE_UPDATE_STATUS);
+ writeStringToFile(statusFile, state);
+}
+
+/**
+ * Writes the update's application version to a file in the patch directory. If
+ * the update doesn't provide application version information via the
+ * appVersion attribute the string "null" will be written to the file.
+ * This value is compared during startup (in nsUpdateDriver.cpp) to determine if
+ * the update should be applied. Note that this won't provide protection from
+ * downgrade of the application for the nightly user case where the application
+ * version doesn't change.
+ * @param dir
+ * The patch directory where the update.version file should be
+ * written.
+ * @param version
+ * The version value to write. Will be the string "null" when the
+ * update doesn't provide the appVersion attribute in the update xml.
+ */
+function writeVersionFile(dir, version) {
+ let versionFile = dir.clone();
+ versionFile.append(FILE_UPDATE_VERSION);
+ writeStringToFile(versionFile, version);
+}
+
+/**
+ * Determines if the service should be used to attempt an update
+ * or not.
+ *
+ * @return true if the service should be used for updates.
+ */
+function shouldUseService() {
+ // This function will return true if the mantenance service should be used if
+ // all of the following conditions are met:
+ // 1) This build was done with the maintenance service enabled
+ // 2) The maintenance service is installed
+ // 3) The pref for using the service is enabled
+ if (
+ !AppConstants.MOZ_MAINTENANCE_SERVICE ||
+ !isServiceInstalled() ||
+ !Services.prefs.getBoolPref(PREF_APP_UPDATE_SERVICE_ENABLED, false)
+ ) {
+ LOG("shouldUseService - returning false");
+ return false;
+ }
+
+ LOG("shouldUseService - returning true");
+ return true;
+}
+
+/**
+ * Determines if the service is is installed.
+ *
+ * @return true if the service is installed.
+ */
+function isServiceInstalled() {
+ if (!AppConstants.MOZ_MAINTENANCE_SERVICE || AppConstants.platform != "win") {
+ LOG("isServiceInstalled - returning false");
+ return false;
+ }
+
+ let installed = 0;
+ try {
+ let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ wrk.open(
+ wrk.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\MaintenanceService",
+ wrk.ACCESS_READ | wrk.WOW64_64
+ );
+ installed = wrk.readIntValue("Installed");
+ wrk.close();
+ } catch (e) {}
+ installed = installed == 1; // convert to bool
+ LOG("isServiceInstalled - returning " + installed);
+ return installed;
+}
+
+/**
+ * Gets the appropriate pending update state. Returns STATE_PENDING_SERVICE,
+ * STATE_PENDING_ELEVATE, or STATE_PENDING.
+ */
+function getBestPendingState() {
+ if (shouldUseService()) {
+ return STATE_PENDING_SERVICE;
+ } else if (getElevationRequired()) {
+ return STATE_PENDING_ELEVATE;
+ }
+ return STATE_PENDING;
+}
+
+/**
+ * Removes the contents of the ready update directory and rotates the update
+ * logs when present. If the update.log exists in the patch directory this will
+ * move the last-update.log if it exists to backup-update.log in the parent
+ * directory of the patch directory and then move the update.log in the patch
+ * directory to last-update.log in the parent directory of the patch directory.
+ *
+ * @param aRemovePatchFiles (optional, defaults to true)
+ * When true the update's patch directory contents are removed.
+ */
+function cleanUpReadyUpdateDir(aRemovePatchFiles = true) {
+ let updateDir;
+ try {
+ updateDir = getReadyUpdateDir();
+ } catch (e) {
+ LOG(
+ "cleanUpReadyUpdateDir - unable to get the updates patch directory. " +
+ "Exception: " +
+ e
+ );
+ return;
+ }
+
+ // Preserve the last update log files for debugging purposes.
+ // Make sure to keep the pairs of logs (ex "last-update.log" and
+ // "last-update-elevated.log") together. We don't want to skip moving
+ // "last-update-elevated.log" just because there isn't an
+ // "update-elevated.log" to take its place.
+ let updateLogFile = updateDir.clone();
+ updateLogFile.append(FILE_UPDATE_LOG);
+ let updateElevatedLogFile = updateDir.clone();
+ updateElevatedLogFile.append(FILE_UPDATE_ELEVATED_LOG);
+ if (updateLogFile.exists() || updateElevatedLogFile.exists()) {
+ const overwriteOrRemoveBackupLog = (log, shouldOverwrite, backupName) => {
+ if (shouldOverwrite) {
+ try {
+ log.moveTo(dir, backupName);
+ } catch (e) {
+ LOG(
+ `cleanUpReadyUpdateDir - failed to rename file '${log.path}' to ` +
+ `'${backupName}': ${e.result}`
+ );
+ }
+ } else {
+ // If we don't have a file to overwrite this one, make sure we remove
+ // it anyways to prevent log pairs from getting mismatched.
+ let backupLogFile = dir.clone();
+ backupLogFile.append(backupName);
+ try {
+ backupLogFile.remove(false);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
+ LOG(
+ `cleanUpReadyUpdateDir - failed to remove file ` +
+ `'${backupLogFile.path}': ${e.result}`
+ );
+ }
+ }
+ }
+ };
+
+ let dir = updateDir.parent;
+ let logFile = dir.clone();
+ logFile.append(FILE_LAST_UPDATE_LOG);
+ const logFileExists = logFile.exists();
+ let elevatedLogFile = dir.clone();
+ elevatedLogFile.append(FILE_LAST_UPDATE_ELEVATED_LOG);
+ const elevatedLogFileExists = elevatedLogFile.exists();
+ if (logFileExists || elevatedLogFileExists) {
+ overwriteOrRemoveBackupLog(
+ logFile,
+ logFileExists,
+ FILE_BACKUP_UPDATE_LOG
+ );
+ overwriteOrRemoveBackupLog(
+ elevatedLogFile,
+ elevatedLogFileExists,
+ FILE_BACKUP_UPDATE_ELEVATED_LOG
+ );
+ }
+
+ overwriteOrRemoveBackupLog(updateLogFile, true, FILE_LAST_UPDATE_LOG);
+ overwriteOrRemoveBackupLog(
+ updateElevatedLogFile,
+ true,
+ FILE_LAST_UPDATE_ELEVATED_LOG
+ );
+ }
+
+ if (aRemovePatchFiles) {
+ let dirEntries = updateDir.directoryEntries;
+ while (dirEntries.hasMoreElements()) {
+ let file = dirEntries.nextFile;
+ // Now, recursively remove this file. The recursive removal is needed for
+ // Mac OSX because this directory will contain a copy of updater.app,
+ // which is itself a directory and the MozUpdater directory on platforms
+ // other than Windows.
+ try {
+ file.remove(true);
+ } catch (e) {
+ LOG("cleanUpReadyUpdateDir - failed to remove file " + file.path);
+ }
+ }
+ }
+}
+
+/**
+ * Removes the contents of the update download directory.
+ *
+ */
+function cleanUpDownloadingUpdateDir() {
+ let updateDir;
+ try {
+ updateDir = getDownloadingUpdateDir();
+ } catch (e) {
+ LOG(
+ "cleanUpDownloadUpdatesDir - unable to get the updates patch " +
+ "directory. Exception: " +
+ e
+ );
+ return;
+ }
+
+ let dirEntries = updateDir.directoryEntries;
+ while (dirEntries.hasMoreElements()) {
+ let file = dirEntries.nextFile;
+ // Now, recursively remove this file.
+ try {
+ file.remove(true);
+ } catch (e) {
+ LOG("cleanUpDownloadUpdatesDir - failed to remove file " + file.path);
+ }
+ }
+}
+
+/**
+ * Clean up the updates list and the directory that contains the update that
+ * is ready to be installed.
+ *
+ * Note - This function causes a state transition to either STATE_DOWNLOADING
+ * or STATE_NONE, depending on whether an update download is in progress.
+ */
+function cleanupReadyUpdate() {
+ // Move the update from the Active Update list into the Past Updates list.
+ if (lazy.UM.readyUpdate) {
+ LOG("cleanupReadyUpdate - Clearing readyUpdate");
+ lazy.UM.addUpdateToHistory(lazy.UM.readyUpdate);
+ lazy.UM.readyUpdate = null;
+ }
+ lazy.UM.saveUpdates();
+
+ let readyUpdateDir = getReadyUpdateDir();
+ let shouldSetDownloadingStatus =
+ lazy.UM.downloadingUpdate ||
+ readStatusFile(readyUpdateDir) == STATE_DOWNLOADING;
+
+ // Now trash the ready update directory, since we're done with it
+ cleanUpReadyUpdateDir();
+
+ // We need to handle two similar cases here.
+ // The first is where we clean up the ready updates directory while we are in
+ // the downloading state. In this case, we remove the update.status file that
+ // says we are downloading, even though we should remain in that state.
+ // The second case is when we clean up a ready update, but there is also a
+ // downloading update (in which case the update status file's state will
+ // reflect the state of the ready update, not the downloading one). In that
+ // case, instead of reverting to STATE_NONE (which is what we do by removing
+ // the status file), we should set our state to downloading.
+ if (shouldSetDownloadingStatus) {
+ LOG("cleanupReadyUpdate - Transitioning back to downloading state.");
+ transitionState(Ci.nsIApplicationUpdateService.STATE_DOWNLOADING);
+ writeStatusFile(readyUpdateDir, STATE_DOWNLOADING);
+ }
+}
+
+/**
+ * Clean up updates list and the directory that the currently downloading update
+ * is downloaded to.
+ *
+ * Note - This function may cause a state transition. If the current state is
+ * STATE_DOWNLOADING, this will cause it to change to STATE_NONE.
+ */
+function cleanupDownloadingUpdate() {
+ // Move the update from the Active Update list into the Past Updates list.
+ if (lazy.UM.downloadingUpdate) {
+ LOG("cleanupDownloadingUpdate - Clearing downloadingUpdate.");
+ lazy.UM.addUpdateToHistory(lazy.UM.downloadingUpdate);
+ lazy.UM.downloadingUpdate = null;
+ }
+ lazy.UM.saveUpdates();
+
+ // Now trash the update download directory, since we're done with it
+ cleanUpDownloadingUpdateDir();
+
+ // If the update status file says we are downloading, we should remove that
+ // too, since we aren't doing that anymore.
+ let readyUpdateDir = getReadyUpdateDir();
+ let status = readStatusFile(readyUpdateDir);
+ if (status == STATE_DOWNLOADING) {
+ let statusFile = readyUpdateDir.clone();
+ statusFile.append(FILE_UPDATE_STATUS);
+ statusFile.remove();
+ }
+}
+
+/**
+ * Clean up updates list, the ready update directory, and the downloading update
+ * directory.
+ *
+ * This is more efficient than calling
+ * cleanupReadyUpdate();
+ * cleanupDownloadingUpdate();
+ * because those need some special handling of the update status file to make
+ * sure that, for example, cleaning up a ready update doesn't make us forget
+ * that we are downloading an update. When we cleanup both updates, we don't
+ * need to worry about things like that.
+ *
+ * Note - This function causes a state transition to STATE_NONE.
+ */
+function cleanupActiveUpdates() {
+ // Move the update from the Active Update list into the Past Updates list.
+ if (lazy.UM.readyUpdate) {
+ LOG("cleanupActiveUpdates - Clearing readyUpdate");
+ lazy.UM.addUpdateToHistory(lazy.UM.readyUpdate);
+ lazy.UM.readyUpdate = null;
+ }
+ if (lazy.UM.downloadingUpdate) {
+ LOG("cleanupActiveUpdates - Clearing downloadingUpdate.");
+ lazy.UM.addUpdateToHistory(lazy.UM.downloadingUpdate);
+ lazy.UM.downloadingUpdate = null;
+ }
+ lazy.UM.saveUpdates();
+
+ // Now trash both active update directories, since we're done with them
+ cleanUpReadyUpdateDir();
+ cleanUpDownloadingUpdateDir();
+}
+
+/**
+ * Writes a string of text to a file. A newline will be appended to the data
+ * written to the file. This function only works with ASCII text.
+ * @param file An nsIFile indicating what file to write to.
+ * @param text A string containing the text to write to the file.
+ * @return true on success, false on failure.
+ */
+function writeStringToFile(file, text) {
+ try {
+ let fos = FileUtils.openSafeFileOutputStream(file);
+ text += "\n";
+ fos.write(text, text.length);
+ FileUtils.closeSafeFileOutputStream(fos);
+ } catch (e) {
+ LOG(`writeStringToFile - Failed to write to file: "${file}". Error: ${e}"`);
+ return false;
+ }
+ return true;
+}
+
+function readStringFromInputStream(inputStream) {
+ var sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sis.init(inputStream);
+ var text = sis.read(sis.available());
+ sis.close();
+ if (text && text[text.length - 1] == "\n") {
+ text = text.slice(0, -1);
+ }
+ return text;
+}
+
+/**
+ * Reads a string of text from a file. A trailing newline will be removed
+ * before the result is returned. This function only works with ASCII text.
+ */
+function readStringFromFile(file) {
+ if (!file.exists()) {
+ LOG("readStringFromFile - file doesn't exist: " + file.path);
+ return null;
+ }
+ var fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fis.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0);
+ return readStringFromInputStream(fis);
+}
+
+/**
+ * Attempts to recover from an update error. If successful, `true` will be
+ * returned and AUS.currentState will be transitioned.
+ */
+function handleUpdateFailure(update) {
+ if (WRITE_ERRORS.includes(update.errorCode)) {
+ LOG(
+ "handleUpdateFailure - Failure is a write error. Setting state to pending"
+ );
+ writeStatusFile(getReadyUpdateDir(), (update.state = STATE_PENDING));
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ return true;
+ }
+
+ if (update.errorCode == SILENT_UPDATE_NEEDED_ELEVATION_ERROR) {
+ // There's no need to count attempts and escalate: it's expected that the
+ // background update task will try to update and fail due to required
+ // elevation repeatedly if, for example, the maintenance service is not
+ // available (or not functioning) and the installation requires privileges
+ // to update.
+
+ let bestState = getBestPendingState();
+ LOG(
+ "handleUpdateFailure - witnessed SILENT_UPDATE_NEEDED_ELEVATION_ERROR, " +
+ "returning to " +
+ bestState
+ );
+ writeStatusFile(getReadyUpdateDir(), (update.state = bestState));
+
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ // Return true to indicate a recoverable error.
+ return true;
+ }
+
+ if (update.errorCode == ELEVATION_CANCELED) {
+ let elevationAttempts = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_ELEVATE_ATTEMPTS,
+ 0
+ );
+ elevationAttempts++;
+ Services.prefs.setIntPref(
+ PREF_APP_UPDATE_ELEVATE_ATTEMPTS,
+ elevationAttempts
+ );
+ let maxAttempts = Math.min(
+ Services.prefs.getIntPref(PREF_APP_UPDATE_ELEVATE_MAXATTEMPTS, 2),
+ 10
+ );
+
+ if (elevationAttempts > maxAttempts) {
+ LOG(
+ "handleUpdateFailure - notifying observers of error. " +
+ "topic: update-error, status: elevation-attempts-exceeded"
+ );
+ Services.obs.notifyObservers(
+ update,
+ "update-error",
+ "elevation-attempts-exceeded"
+ );
+ } else {
+ LOG(
+ "handleUpdateFailure - notifying observers of error. " +
+ "topic: update-error, status: elevation-attempt-failed"
+ );
+ Services.obs.notifyObservers(
+ update,
+ "update-error",
+ "elevation-attempt-failed"
+ );
+ }
+
+ let cancelations = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CANCELATIONS,
+ 0
+ );
+ cancelations++;
+ Services.prefs.setIntPref(PREF_APP_UPDATE_CANCELATIONS, cancelations);
+ if (AppConstants.platform == "macosx") {
+ let osxCancelations = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CANCELATIONS_OSX,
+ 0
+ );
+ osxCancelations++;
+ Services.prefs.setIntPref(
+ PREF_APP_UPDATE_CANCELATIONS_OSX,
+ osxCancelations
+ );
+ let maxCancels = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CANCELATIONS_OSX_MAX,
+ DEFAULT_CANCELATIONS_OSX_MAX
+ );
+ // Prevent the preference from setting a value greater than 5.
+ maxCancels = Math.min(maxCancels, 5);
+ if (osxCancelations >= maxCancels) {
+ LOG(
+ "handleUpdateFailure - Too many OSX cancellations. Cleaning up " +
+ "ready update."
+ );
+ cleanupReadyUpdate();
+ return false;
+ }
+ LOG(
+ `handleUpdateFailure - OSX cancellation. Trying again by setting ` +
+ `status to "${STATE_PENDING_ELEVATE}".`
+ );
+ writeStatusFile(
+ getReadyUpdateDir(),
+ (update.state = STATE_PENDING_ELEVATE)
+ );
+ update.statusText =
+ lazy.gUpdateBundle.GetStringFromName("elevationFailure");
+ } else {
+ LOG(
+ "handleUpdateFailure - Failure because elevation was cancelled. " +
+ "again by setting status to pending."
+ );
+ writeStatusFile(getReadyUpdateDir(), (update.state = STATE_PENDING));
+ }
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ return true;
+ }
+
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS);
+ }
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX);
+ }
+
+ if (SERVICE_ERRORS.includes(update.errorCode)) {
+ var failCount = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_SERVICE_ERRORS,
+ 0
+ );
+ var maxFail = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_SERVICE_MAXERRORS,
+ DEFAULT_SERVICE_MAX_ERRORS
+ );
+ // Prevent the preference from setting a value greater than 10.
+ maxFail = Math.min(maxFail, 10);
+ // As a safety, when the service reaches maximum failures, it will
+ // disable itself and fallback to using the normal update mechanism
+ // without the service.
+ if (failCount >= maxFail) {
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_SERVICE_ENABLED, false);
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_SERVICE_ERRORS);
+ } else {
+ failCount++;
+ Services.prefs.setIntPref(PREF_APP_UPDATE_SERVICE_ERRORS, failCount);
+ }
+
+ LOG(
+ "handleUpdateFailure - Got a service error. Try to update without the " +
+ "service by setting the state to pending."
+ );
+ writeStatusFile(getReadyUpdateDir(), (update.state = STATE_PENDING));
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ return true;
+ }
+
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_SERVICE_ERRORS)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_SERVICE_ERRORS);
+ }
+
+ return false;
+}
+
+/**
+ * Return the first UpdatePatch with the given type.
+ * @param update
+ * A nsIUpdate object to search through for a patch of the desired
+ * type.
+ * @param patch_type
+ * The type of the patch ("complete" or "partial")
+ * @return A nsIUpdatePatch object matching the type specified
+ */
+function getPatchOfType(update, patch_type) {
+ for (var i = 0; i < update.patchCount; ++i) {
+ var patch = update.getPatchAt(i);
+ if (patch && patch.type == patch_type) {
+ return patch;
+ }
+ }
+ return null;
+}
+
+/**
+ * Fall back to downloading a complete update in case an update has failed.
+ *
+ * This will transition `AUS.currentState` to `STATE_DOWNLOADING` if there is
+ * another patch to download, or `STATE_IDLE` if there is not.
+ */
+async function handleFallbackToCompleteUpdate() {
+ // If we failed to install an update, we need to fall back to a complete
+ // update. If the install directory has been modified, more partial updates
+ // will fail for the same reason. Since we only download partial updates
+ // while there is already an update downloaded, we don't have to check the
+ // downloading update, we can be confident that we are not downloading the
+ // right thing at the moment.
+
+ // The downloading update will be newer than the ready update, so use that
+ // update, if it exists.
+ let update = lazy.UM.downloadingUpdate || lazy.UM.readyUpdate;
+ if (!update) {
+ LOG(
+ "handleFallbackToCompleteUpdate - Unable to find an update to fall " +
+ "back to."
+ );
+ return;
+ }
+
+ LOG(
+ "handleFallbackToCompleteUpdate - Cleaning up active updates in " +
+ "preparation of falling back to complete update."
+ );
+ await lazy.AUS.stopDownload();
+ cleanupActiveUpdates();
+
+ if (!update.selectedPatch) {
+ // If we don't have a partial patch selected but a partial is available,
+ // _selectPatch() will download that instead of the complete patch.
+ let patch = getPatchOfType(update, "partial");
+ if (patch) {
+ patch.selected = true;
+ }
+ }
+
+ update.statusText = lazy.gUpdateBundle.GetStringFromName("patchApplyFailure");
+ var oldType = update.selectedPatch ? update.selectedPatch.type : "complete";
+ if (update.selectedPatch && oldType == "partial" && update.patchCount == 2) {
+ // Partial patch application failed, try downloading the complete
+ // update in the background instead.
+ LOG(
+ "handleFallbackToCompleteUpdate - install of partial patch " +
+ "failed, downloading complete patch"
+ );
+ var success = await lazy.AUS.downloadUpdate(update);
+ if (!success) {
+ LOG(
+ "handleFallbackToCompleteUpdate - Starting complete patch download " +
+ "failed. Cleaning up downloading patch."
+ );
+ cleanupDownloadingUpdate();
+ }
+ } else {
+ LOG(
+ "handleFallbackToCompleteUpdate - install of complete or " +
+ "only one patch offered failed. Notifying observers. topic: " +
+ "update-error, status: unknown, " +
+ "update.patchCount: " +
+ update.patchCount +
+ ", " +
+ "oldType: " +
+ oldType
+ );
+ transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE);
+ Services.obs.notifyObservers(update, "update-error", "unknown");
+ }
+}
+
+function pingStateAndStatusCodes(aUpdate, aStartup, aStatus) {
+ let patchType = AUSTLMY.PATCH_UNKNOWN;
+ if (aUpdate && aUpdate.selectedPatch && aUpdate.selectedPatch.type) {
+ if (aUpdate.selectedPatch.type == "complete") {
+ patchType = AUSTLMY.PATCH_COMPLETE;
+ } else if (aUpdate.selectedPatch.type == "partial") {
+ patchType = AUSTLMY.PATCH_PARTIAL;
+ }
+ }
+
+ let suffix = patchType + "_" + (aStartup ? AUSTLMY.STARTUP : AUSTLMY.STAGE);
+ let stateCode = 0;
+ let parts = aStatus.split(":");
+ if (parts.length) {
+ switch (parts[0]) {
+ case STATE_NONE:
+ stateCode = 2;
+ break;
+ case STATE_DOWNLOADING:
+ stateCode = 3;
+ break;
+ case STATE_PENDING:
+ stateCode = 4;
+ break;
+ case STATE_PENDING_SERVICE:
+ stateCode = 5;
+ break;
+ case STATE_APPLYING:
+ stateCode = 6;
+ break;
+ case STATE_APPLIED:
+ stateCode = 7;
+ break;
+ case STATE_APPLIED_SERVICE:
+ stateCode = 9;
+ break;
+ case STATE_SUCCEEDED:
+ stateCode = 10;
+ break;
+ case STATE_DOWNLOAD_FAILED:
+ stateCode = 11;
+ break;
+ case STATE_FAILED:
+ stateCode = 12;
+ break;
+ case STATE_PENDING_ELEVATE:
+ stateCode = 13;
+ break;
+ // Note: Do not use stateCode 14 here. It is defined in
+ // UpdateTelemetry.jsm
+ default:
+ stateCode = 1;
+ }
+
+ if (parts.length > 1) {
+ let statusErrorCode = INVALID_UPDATER_STATE_CODE;
+ if (parts[0] == STATE_FAILED) {
+ statusErrorCode = parseInt(parts[1]) || INVALID_UPDATER_STATUS_CODE;
+ }
+ AUSTLMY.pingStatusErrorCode(suffix, statusErrorCode);
+ }
+ }
+ let binaryTransparencyResult = readBinaryTransparencyResult(
+ getReadyUpdateDir()
+ );
+ if (binaryTransparencyResult) {
+ AUSTLMY.pingBinaryTransparencyResult(
+ suffix,
+ parseInt(binaryTransparencyResult)
+ );
+ }
+ AUSTLMY.pingStateCode(suffix, stateCode);
+}
+
+/**
+ * This returns true if the passed update is the same version or older than the
+ * version and build ID values passed. Otherwise it returns false.
+ */
+function updateIsAtLeastAsOldAs(update, version, buildID) {
+ if (!update || !update.appVersion || !update.buildID) {
+ return false;
+ }
+ let versionComparison = Services.vc.compare(update.appVersion, version);
+ return (
+ versionComparison < 0 ||
+ (versionComparison == 0 && update.buildID == buildID)
+ );
+}
+
+/**
+ * This returns true if the passed update is the same version or older than
+ * currently installed Firefox version.
+ */
+function updateIsAtLeastAsOldAsCurrentVersion(update) {
+ return updateIsAtLeastAsOldAs(
+ update,
+ Services.appinfo.version,
+ Services.appinfo.appBuildID
+ );
+}
+
+/**
+ * This returns true if the passed update is the same version or older than
+ * the update that we have already downloaded (UpdateManager.readyUpdate).
+ * Returns false if no update has already been downloaded.
+ */
+function updateIsAtLeastAsOldAsReadyUpdate(update) {
+ if (
+ !lazy.UM.readyUpdate ||
+ !lazy.UM.readyUpdate.appVersion ||
+ !lazy.UM.readyUpdate.buildID
+ ) {
+ return false;
+ }
+ return updateIsAtLeastAsOldAs(
+ update,
+ lazy.UM.readyUpdate.appVersion,
+ lazy.UM.readyUpdate.buildID
+ );
+}
+
+/**
+ * This function determines whether the error represented by the passed error
+ * code could potentially be recovered from or bypassed by updating without
+ * using the Maintenance Service (i.e. by showing a UAC prompt).
+ * We don't really want to show a UAC prompt, but it's preferable over the
+ * manual update doorhanger. So this function effectively distinguishes between
+ * which of those we should do if update staging failed. (The updater
+ * automatically falls back if the Maintenance Services fails, so this function
+ * doesn't handle that case)
+ *
+ * @param An integer error code from the update.status file. Should be one of
+ * the codes enumerated in updatererrors.h.
+ * @returns true if the code represents a Maintenance Service specific error.
+ * Otherwise, false.
+ */
+function isServiceSpecificErrorCode(errorCode) {
+ return (
+ (errorCode >= 24 && errorCode <= 33) || (errorCode >= 49 && errorCode <= 58)
+ );
+}
+
+/**
+ * This function determines whether the error represented by the passed error
+ * code is the result of the updater failing to allocate memory. This is
+ * relevant when staging because, since Firefox is also running, we may not be
+ * able to allocate much memory. Thus, if we fail to stage an update, we may
+ * succeed at updating without staging.
+ *
+ * @param An integer error code from the update.status file. Should be one of
+ * the codes enumerated in updatererrors.h.
+ * @returns true if the code represents a memory allocation error.
+ * Otherwise, false.
+ */
+function isMemoryAllocationErrorCode(errorCode) {
+ return errorCode >= 10 && errorCode <= 14;
+}
+
+/**
+ * Normally when staging, `nsUpdateProcessor::WaitForProcess` waits for the
+ * staging process to complete by watching for its PID to terminate.
+ * However, there are less ideal situations. Notably, we might start the browser
+ * and find that update staging appears to already be in-progress. If that
+ * happens, we really want to pick up the update process from STATE_STAGING,
+ * but we don't really have any way of keeping an eye on the staging process
+ * other than to just poll the status file.
+ *
+ * Like `nsUpdateProcessor`, this calls `nsIUpdateManager.refreshUpdateStatus`
+ * after polling completes (regardless of result).
+ *
+ * It is also important to keep in mind that the updater might have crashed
+ * during staging, meaning that the status file will never change, no matter how
+ * long we keep polling. So we need to set an upper bound on how long we are
+ * willing to poll for.
+ *
+ * There are three situations that we want to avoid.
+ * (1) We don't want to set the poll interval too long. A user might be watching
+ * the user interface and waiting to restart to install the update. A long poll
+ * interval will cause them to have to wait longer than necessary. Especially
+ * since the expected total staging time is not that long.
+ * (2) We don't want to give up polling too early and give up on an update that
+ * will ultimately succeed.
+ * (3) We don't want to use a rapid polling interval over a long duration.
+ *
+ * To avoid these situations, we will start with a short polling interval, but
+ * will increase it the longer that we have to wait. Then if we hit the upper
+ * bound of polling, we will give up.
+ */
+function pollForStagingEnd() {
+ let pollingIntervalMs = STAGING_POLLING_MIN_INTERVAL_MS;
+ // Number of times to poll before increasing the polling interval.
+ let pollAttemptsAtIntervalRemaining = STAGING_POLLING_ATTEMPTS_PER_INTERVAL;
+ let timeElapsedMs = 0;
+
+ let pollingFn = () => {
+ pollAttemptsAtIntervalRemaining -= 1;
+ // This isn't a perfectly accurate way of keeping time, but it does nicely
+ // sidestep dealing with issues of (non)monotonic time.
+ timeElapsedMs += pollingIntervalMs;
+
+ if (timeElapsedMs >= STAGING_POLLING_MAX_DURATION_MS) {
+ lazy.UM.refreshUpdateStatus();
+ return;
+ }
+
+ if (readStatusFile(getReadyUpdateDir()) != STATE_APPLYING) {
+ lazy.UM.refreshUpdateStatus();
+ return;
+ }
+
+ if (pollAttemptsAtIntervalRemaining <= 0) {
+ pollingIntervalMs = Math.min(
+ pollingIntervalMs * 2,
+ STAGING_POLLING_MAX_INTERVAL_MS
+ );
+ pollAttemptsAtIntervalRemaining = STAGING_POLLING_ATTEMPTS_PER_INTERVAL;
+ }
+
+ lazy.setTimeout(pollingFn, pollingIntervalMs);
+ };
+
+ lazy.setTimeout(pollingFn, pollingIntervalMs);
+}
+
+/**
+ * Update Patch
+ * @param patch
+ * A <patch> element to initialize this object with
+ * @throws if patch has a size of 0
+ * @constructor
+ */
+function UpdatePatch(patch) {
+ this._properties = {};
+ this.errorCode = 0;
+ this.finalURL = null;
+ this.state = STATE_NONE;
+
+ for (let i = 0; i < patch.attributes.length; ++i) {
+ var attr = patch.attributes.item(i);
+ // If an undefined value is saved to the xml file it will be a string when
+ // it is read from the xml file.
+ if (attr.value == "undefined") {
+ continue;
+ }
+ switch (attr.name) {
+ case "xmlns":
+ // Don't save the XML namespace.
+ break;
+ case "selected":
+ this.selected = attr.value == "true";
+ break;
+ case "size":
+ if (0 == parseInt(attr.value)) {
+ LOG("UpdatePatch:init - 0-sized patch!");
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+ this[attr.name] = attr.value;
+ break;
+ case "errorCode":
+ if (attr.value) {
+ let val = parseInt(attr.value);
+ // This will evaluate to false if the value is 0 but that's ok since
+ // this.errorCode is set to the default of 0 above.
+ if (val) {
+ this.errorCode = val;
+ }
+ }
+ break;
+ case "finalURL":
+ case "state":
+ case "type":
+ case "URL":
+ this[attr.name] = attr.value;
+ break;
+ default:
+ if (!this._attrNames.includes(attr.name)) {
+ // Set nsIPropertyBag properties that were read from the xml file.
+ this.setProperty(attr.name, attr.value);
+ }
+ break;
+ }
+ }
+}
+UpdatePatch.prototype = {
+ // nsIUpdatePatch attribute names used to prevent nsIWritablePropertyBag from
+ // over writing nsIUpdatePatch attributes.
+ _attrNames: [
+ "errorCode",
+ "finalURL",
+ "selected",
+ "size",
+ "state",
+ "type",
+ "URL",
+ ],
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ serialize: function UpdatePatch_serialize(updates) {
+ var patch = updates.createElementNS(URI_UPDATE_NS, "patch");
+ patch.setAttribute("size", this.size);
+ patch.setAttribute("type", this.type);
+ patch.setAttribute("URL", this.URL);
+ // Don't write an errorCode if it evaluates to false since 0 is the same as
+ // no error code.
+ if (this.errorCode) {
+ patch.setAttribute("errorCode", this.errorCode);
+ }
+ // finalURL is not available until after the download has started
+ if (this.finalURL) {
+ patch.setAttribute("finalURL", this.finalURL);
+ }
+ // The selected patch is the only patch that should have this attribute.
+ if (this.selected) {
+ patch.setAttribute("selected", this.selected);
+ }
+ if (this.state != STATE_NONE) {
+ patch.setAttribute("state", this.state);
+ }
+
+ for (let [name, value] of Object.entries(this._properties)) {
+ if (value.present && !this._attrNames.includes(name)) {
+ patch.setAttribute(name, value.data);
+ }
+ }
+ return patch;
+ },
+
+ /**
+ * See nsIWritablePropertyBag.idl
+ */
+ setProperty: function UpdatePatch_setProperty(name, value) {
+ if (this._attrNames.includes(name)) {
+ throw Components.Exception(
+ "Illegal value '" +
+ name +
+ "' (attribute exists on nsIUpdatePatch) " +
+ "when calling method: [nsIWritablePropertyBag::setProperty]",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ this._properties[name] = { data: value, present: true };
+ },
+
+ /**
+ * See nsIWritablePropertyBag.idl
+ */
+ deleteProperty: function UpdatePatch_deleteProperty(name) {
+ if (this._attrNames.includes(name)) {
+ throw Components.Exception(
+ "Illegal value '" +
+ name +
+ "' (attribute exists on nsIUpdatePatch) " +
+ "when calling method: [nsIWritablePropertyBag::deleteProperty]",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ if (name in this._properties) {
+ this._properties[name].present = false;
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ },
+
+ /**
+ * See nsIPropertyBag.idl
+ *
+ * Note: this only contains the nsIPropertyBag name / value pairs and not the
+ * nsIUpdatePatch name / value pairs.
+ */
+ get enumerator() {
+ return this.enumerate();
+ },
+
+ *enumerate() {
+ // An nsISupportsInterfacePointer is used so creating an array using
+ // Array.from will retain the QueryInterface for nsIProperty.
+ let ip = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance(
+ Ci.nsISupportsInterfacePointer
+ );
+ let qi = ChromeUtils.generateQI(["nsIProperty"]);
+ for (let [name, value] of Object.entries(this._properties)) {
+ if (value.present && !this._attrNames.includes(name)) {
+ // The nsIPropertyBag enumerator returns a nsISimpleEnumerator whose
+ // elements are nsIProperty objects. Calling QueryInterface for
+ // nsIProperty on the object doesn't return to the caller an object that
+ // is already queried to nsIProperty but do it just in case it is fixed
+ // at some point.
+ ip.data = { name, value: value.data, QueryInterface: qi };
+ yield ip.data.QueryInterface(Ci.nsIProperty);
+ }
+ }
+ },
+
+ /**
+ * See nsIPropertyBag.idl
+ *
+ * Note: returns null instead of throwing when the property doesn't exist to
+ * simplify code and to silence warnings in debug builds.
+ */
+ getProperty: function UpdatePatch_getProperty(name) {
+ if (this._attrNames.includes(name)) {
+ throw Components.Exception(
+ "Illegal value '" +
+ name +
+ "' (attribute exists on nsIUpdatePatch) " +
+ "when calling method: [nsIWritablePropertyBag::getProperty]",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ if (name in this._properties && this._properties[name].present) {
+ return this._properties[name].data;
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIUpdatePatch",
+ "nsIPropertyBag",
+ "nsIWritablePropertyBag",
+ ]),
+};
+
+/**
+ * Update
+ * Implements nsIUpdate
+ * @param update
+ * An <update> element to initialize this object with
+ * @throws if the update contains no patches
+ * @constructor
+ */
+function Update(update) {
+ this._patches = [];
+ this._properties = {};
+ this.isCompleteUpdate = false;
+ this.channel = "default";
+ this.promptWaitTime = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_PROMPTWAITTIME,
+ 43200
+ );
+ this.unsupported = false;
+
+ // Null <update>, assume this is a message container and do no
+ // further initialization
+ if (!update) {
+ return;
+ }
+
+ for (let i = 0; i < update.childNodes.length; ++i) {
+ let patchElement = update.childNodes.item(i);
+ if (
+ patchElement.nodeType != patchElement.ELEMENT_NODE ||
+ patchElement.localName != "patch"
+ ) {
+ continue;
+ }
+
+ let patch;
+ try {
+ patch = new UpdatePatch(patchElement);
+ } catch (e) {
+ continue;
+ }
+ this._patches.push(patch);
+ }
+
+ if (!this._patches.length && !update.hasAttribute("unsupported")) {
+ throw Components.Exception("", Cr.NS_ERROR_ILLEGAL_VALUE);
+ }
+
+ // Set the installDate value with the current time. If the update has an
+ // installDate attribute this will be replaced with that value if it doesn't
+ // equal 0.
+ this.installDate = new Date().getTime();
+ this.patchCount = this._patches.length;
+
+ for (let i = 0; i < update.attributes.length; ++i) {
+ let attr = update.attributes.item(i);
+ if (attr.name == "xmlns" || attr.value == "undefined") {
+ // Don't save the XML namespace or undefined values.
+ // If an undefined value is saved to the xml file it will be a string when
+ // it is read from the xml file.
+ continue;
+ } else if (attr.name == "detailsURL") {
+ this.detailsURL = attr.value;
+ } else if (attr.name == "installDate" && attr.value) {
+ let val = parseInt(attr.value);
+ if (val) {
+ this.installDate = val;
+ }
+ } else if (attr.name == "errorCode" && attr.value) {
+ let val = parseInt(attr.value);
+ if (val) {
+ // Set the value of |_errorCode| instead of |errorCode| since
+ // selectedPatch won't be available at this point and normally the
+ // nsIUpdatePatch will provide the errorCode.
+ this._errorCode = val;
+ }
+ } else if (attr.name == "isCompleteUpdate") {
+ this.isCompleteUpdate = attr.value == "true";
+ } else if (attr.name == "promptWaitTime") {
+ if (!isNaN(attr.value)) {
+ this.promptWaitTime = parseInt(attr.value);
+ }
+ } else if (attr.name == "unsupported") {
+ this.unsupported = attr.value == "true";
+ } else {
+ switch (attr.name) {
+ case "appVersion":
+ case "buildID":
+ case "channel":
+ case "displayVersion":
+ case "elevationFailure":
+ case "name":
+ case "previousAppVersion":
+ case "serviceURL":
+ case "statusText":
+ case "type":
+ this[attr.name] = attr.value;
+ break;
+ default:
+ if (!this._attrNames.includes(attr.name)) {
+ // Set nsIPropertyBag properties that were read from the xml file.
+ this.setProperty(attr.name, attr.value);
+ }
+ break;
+ }
+ }
+ }
+
+ if (!this.previousAppVersion) {
+ this.previousAppVersion = Services.appinfo.version;
+ }
+
+ if (!this.elevationFailure) {
+ this.elevationFailure = false;
+ }
+
+ if (!this.detailsURL) {
+ try {
+ // Try using a default details URL supplied by the distribution
+ // if the update XML does not supply one.
+ this.detailsURL = Services.urlFormatter.formatURLPref(
+ PREF_APP_UPDATE_URL_DETAILS
+ );
+ } catch (e) {
+ this.detailsURL = "";
+ }
+ }
+
+ if (!this.displayVersion) {
+ this.displayVersion = this.appVersion;
+ }
+
+ if (!this.name) {
+ // When the update doesn't provide a name fallback to using
+ // "<App Name> <Update App Version>"
+ let brandBundle = Services.strings.createBundle(URI_BRAND_PROPERTIES);
+ let appName = brandBundle.GetStringFromName("brandShortName");
+ this.name = lazy.gUpdateBundle.formatStringFromName("updateName", [
+ appName,
+ this.displayVersion,
+ ]);
+ }
+}
+Update.prototype = {
+ // nsIUpdate attribute names used to prevent nsIWritablePropertyBag from over
+ // writing nsIUpdate attributes.
+ _attrNames: [
+ "appVersion",
+ "buildID",
+ "channel",
+ "detailsURL",
+ "displayVersion",
+ "elevationFailure",
+ "errorCode",
+ "installDate",
+ "isCompleteUpdate",
+ "name",
+ "previousAppVersion",
+ "promptWaitTime",
+ "serviceURL",
+ "state",
+ "statusText",
+ "type",
+ "unsupported",
+ ],
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ getPatchAt: function Update_getPatchAt(index) {
+ return this._patches[index];
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ *
+ * We use a copy of the state cached on this object in |_state| only when
+ * there is no selected patch, i.e. in the case when we could not load
+ * active updates from the update manager for some reason but still have
+ * the update.status file to work with.
+ */
+ _state: "",
+ get state() {
+ if (this.selectedPatch) {
+ return this.selectedPatch.state;
+ }
+ return this._state;
+ },
+ set state(state) {
+ if (this.selectedPatch) {
+ this.selectedPatch.state = state;
+ }
+ this._state = state;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ *
+ * We use a copy of the errorCode cached on this object in |_errorCode| only
+ * when there is no selected patch, i.e. in the case when we could not load
+ * active updates from the update manager for some reason but still have
+ * the update.status file to work with.
+ */
+ _errorCode: 0,
+ get errorCode() {
+ if (this.selectedPatch) {
+ return this.selectedPatch.errorCode;
+ }
+ return this._errorCode;
+ },
+ set errorCode(errorCode) {
+ if (this.selectedPatch) {
+ this.selectedPatch.errorCode = errorCode;
+ }
+ this._errorCode = errorCode;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get selectedPatch() {
+ for (let i = 0; i < this.patchCount; ++i) {
+ if (this._patches[i].selected) {
+ return this._patches[i];
+ }
+ }
+ return null;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ serialize: function Update_serialize(updates) {
+ // If appVersion isn't defined just return null. This happens when cleaning
+ // up invalid updates (e.g. incorrect channel).
+ if (!this.appVersion) {
+ return null;
+ }
+ let update = updates.createElementNS(URI_UPDATE_NS, "update");
+ update.setAttribute("appVersion", this.appVersion);
+ update.setAttribute("buildID", this.buildID);
+ update.setAttribute("channel", this.channel);
+ update.setAttribute("detailsURL", this.detailsURL);
+ update.setAttribute("displayVersion", this.displayVersion);
+ update.setAttribute("installDate", this.installDate);
+ update.setAttribute("isCompleteUpdate", this.isCompleteUpdate);
+ update.setAttribute("name", this.name);
+ update.setAttribute("previousAppVersion", this.previousAppVersion);
+ update.setAttribute("promptWaitTime", this.promptWaitTime);
+ update.setAttribute("serviceURL", this.serviceURL);
+ update.setAttribute("type", this.type);
+
+ if (this.statusText) {
+ update.setAttribute("statusText", this.statusText);
+ }
+ if (this.unsupported) {
+ update.setAttribute("unsupported", this.unsupported);
+ }
+ if (this.elevationFailure) {
+ update.setAttribute("elevationFailure", this.elevationFailure);
+ }
+
+ for (let [name, value] of Object.entries(this._properties)) {
+ if (value.present && !this._attrNames.includes(name)) {
+ update.setAttribute(name, value.data);
+ }
+ }
+
+ for (let i = 0; i < this.patchCount; ++i) {
+ update.appendChild(this.getPatchAt(i).serialize(updates));
+ }
+
+ updates.documentElement.appendChild(update);
+ return update;
+ },
+
+ /**
+ * See nsIWritablePropertyBag.idl
+ */
+ setProperty: function Update_setProperty(name, value) {
+ if (this._attrNames.includes(name)) {
+ throw Components.Exception(
+ "Illegal value '" +
+ name +
+ "' (attribute exists on nsIUpdate) " +
+ "when calling method: [nsIWritablePropertyBag::setProperty]",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ this._properties[name] = { data: value, present: true };
+ },
+
+ /**
+ * See nsIWritablePropertyBag.idl
+ */
+ deleteProperty: function Update_deleteProperty(name) {
+ if (this._attrNames.includes(name)) {
+ throw Components.Exception(
+ "Illegal value '" +
+ name +
+ "' (attribute exists on nsIUpdate) " +
+ "when calling method: [nsIWritablePropertyBag::deleteProperty]",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ if (name in this._properties) {
+ this._properties[name].present = false;
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ },
+
+ /**
+ * See nsIPropertyBag.idl
+ *
+ * Note: this only contains the nsIPropertyBag name value / pairs and not the
+ * nsIUpdate name / value pairs.
+ */
+ get enumerator() {
+ return this.enumerate();
+ },
+
+ *enumerate() {
+ // An nsISupportsInterfacePointer is used so creating an array using
+ // Array.from will retain the QueryInterface for nsIProperty.
+ let ip = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance(
+ Ci.nsISupportsInterfacePointer
+ );
+ let qi = ChromeUtils.generateQI(["nsIProperty"]);
+ for (let [name, value] of Object.entries(this._properties)) {
+ if (value.present && !this._attrNames.includes(name)) {
+ // The nsIPropertyBag enumerator returns a nsISimpleEnumerator whose
+ // elements are nsIProperty objects. Calling QueryInterface for
+ // nsIProperty on the object doesn't return to the caller an object that
+ // is already queried to nsIProperty but do it just in case it is fixed
+ // at some point.
+ ip.data = { name, value: value.data, QueryInterface: qi };
+ yield ip.data.QueryInterface(Ci.nsIProperty);
+ }
+ }
+ },
+
+ /**
+ * See nsIPropertyBag.idl
+ * Note: returns null instead of throwing when the property doesn't exist to
+ * simplify code and to silence warnings in debug builds.
+ */
+ getProperty: function Update_getProperty(name) {
+ if (this._attrNames.includes(name)) {
+ throw Components.Exception(
+ "Illegal value '" +
+ name +
+ "' (attribute exists on nsIUpdate) " +
+ "when calling method: [nsIWritablePropertyBag::getProperty]",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ if (name in this._properties && this._properties[name].present) {
+ return this._properties[name].data;
+ }
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIUpdate",
+ "nsIPropertyBag",
+ "nsIWritablePropertyBag",
+ ]),
+};
+
+/**
+ * UpdateService
+ * A Service for managing the discovery and installation of software updates.
+ * @constructor
+ */
+export function UpdateService() {
+ LOG("Creating UpdateService");
+ // The observor notification to shut down the service must be before
+ // profile-before-change since nsIUpdateManager uses profile-before-change
+ // to shutdown and write the update xml files.
+ Services.obs.addObserver(this, "quit-application");
+ lazy.UpdateLog.addConfigChangeListener(() => {
+ this._logStatus();
+ });
+
+ this._logStatus();
+}
+
+UpdateService.prototype = {
+ /**
+ * The downloader we are using to download updates. There is only ever one of
+ * these.
+ */
+ _downloader: null,
+
+ /**
+ * Whether or not the service registered the "online" observer.
+ */
+ _registeredOnlineObserver: false,
+
+ /**
+ * The current number of consecutive socket errors
+ */
+ _consecutiveSocketErrors: 0,
+
+ /**
+ * A timer used to retry socket errors
+ */
+ _retryTimer: null,
+
+ /**
+ * Whether or not a background update check was initiated by the
+ * application update timer notification.
+ */
+ _isNotify: true,
+
+ /**
+ * Handle Observer Service notifications
+ * @param subject
+ * The subject of the notification
+ * @param topic
+ * The notification name
+ * @param data
+ * Additional data
+ */
+ observe: async function AUS_observe(subject, topic, data) {
+ switch (topic) {
+ case "post-update-processing":
+ // This pref was not cleared out of profiles after it stopped being used
+ // (Bug 1420514), so clear it out on the next update to avoid confusion
+ // regarding its use.
+ Services.prefs.clearUserPref("app.update.enabled");
+ Services.prefs.clearUserPref("app.update.BITS.inTrialGroup");
+
+ // Background tasks do not notify any delayed startup notifications.
+ if (
+ !lazy.gIsBackgroundTaskMode &&
+ Services.appinfo.ID in APPID_TO_TOPIC
+ ) {
+ // Delay post-update processing to ensure that possible update
+ // dialogs are shown in front of the app window, if possible.
+ // See bug 311614.
+ Services.obs.addObserver(this, APPID_TO_TOPIC[Services.appinfo.ID]);
+ break;
+ }
+ // intentional fallthrough
+ case "sessionstore-windows-restored":
+ case "mail-startup-done":
+ // Background tasks do not notify any delayed startup notifications.
+ if (
+ !lazy.gIsBackgroundTaskMode &&
+ Services.appinfo.ID in APPID_TO_TOPIC
+ ) {
+ Services.obs.removeObserver(
+ this,
+ APPID_TO_TOPIC[Services.appinfo.ID]
+ );
+ }
+ // intentional fallthrough
+ case "test-post-update-processing":
+ // Clean up any extant updates
+ await this._postUpdateProcessing();
+ break;
+ case "network:offline-status-changed":
+ await this._offlineStatusChanged(data);
+ break;
+ case "quit-application":
+ Services.obs.removeObserver(this, topic);
+
+ if (AppConstants.platform == "win" && gUpdateMutexHandle) {
+ // If we hold the update mutex, let it go!
+ // The OS would clean this up sometime after shutdown,
+ // but that would have no guarantee on timing.
+ closeHandle(gUpdateMutexHandle);
+ gUpdateMutexHandle = null;
+ }
+ if (this._retryTimer) {
+ this._retryTimer.cancel();
+ }
+
+ // When downloading an update with nsIIncrementalDownload the download
+ // is stopped when the quit-application observer notification is
+ // received and networking hasn't started to shutdown. The download will
+ // be resumed the next time the application starts. Downloads using
+ // Windows BITS are not stopped since they don't require Firefox to be
+ // running to perform the download.
+ if (this._downloader) {
+ if (this._downloader.usingBits) {
+ await this._downloader.cleanup();
+ } else {
+ // stopDownload() calls _downloader.cleanup()
+ await this.stopDownload();
+ }
+ }
+ // Prevent leaking the downloader (bug 454964)
+ this._downloader = null;
+ // In case any update checks are in progress.
+ lazy.CheckSvc.stopAllChecks();
+ break;
+ case "test-close-handle-update-mutex":
+ if (Cu.isInAutomation) {
+ if (AppConstants.platform == "win" && gUpdateMutexHandle) {
+ LOG("UpdateService:observe - closing mutex handle for testing");
+ closeHandle(gUpdateMutexHandle);
+ gUpdateMutexHandle = null;
+ }
+ }
+ break;
+ }
+ },
+
+ /**
+ * The following needs to happen during the post-update-processing
+ * notification from nsUpdateServiceStub.js:
+ * 1. post update processing
+ * 2. resume of a download that was in progress during a previous session
+ * 3. start of a complete update download after the failure to apply a partial
+ * update
+ */
+
+ /**
+ * Perform post-processing on updates lingering in the updates directory
+ * from a previous application session - either report install failures (and
+ * optionally attempt to fetch a different version if appropriate) or
+ * notify the user of install success.
+ */
+ /* eslint-disable-next-line complexity */
+ _postUpdateProcessing: async function AUS__postUpdateProcessing() {
+ if (this.disabled) {
+ // This function is a point when we can potentially enter the update
+ // system, even with update disabled. Make sure that we do not continue
+ // because update code can have side effects that are visible to the user
+ // and give the impression that updates are enabled. For example, if we
+ // can't write to the update directory, we might complain to the user that
+ // update is broken and they should reinstall.
+ return;
+ }
+ if (!this.canCheckForUpdates) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - unable to check for " +
+ "updates... returning early"
+ );
+ return;
+ }
+ let status = readStatusFile(getReadyUpdateDir());
+ LOG(`UpdateService:_postUpdateProcessing - status = "${status}"`);
+
+ if (!this.canApplyUpdates) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - unable to apply " +
+ "updates... returning early"
+ );
+ if (hasUpdateMutex()) {
+ // If the update is present in the update directory somehow,
+ // it would prevent us from notifying the user of further updates.
+ LOG(
+ "UpdateService:_postUpdateProcessing - Cleaning up active updates."
+ );
+ cleanupActiveUpdates();
+ }
+ return;
+ }
+
+ let updates = [];
+ if (lazy.UM.readyUpdate) {
+ updates.push(lazy.UM.readyUpdate);
+ }
+ if (lazy.UM.downloadingUpdate) {
+ updates.push(lazy.UM.downloadingUpdate);
+ }
+
+ if (status == STATE_NONE) {
+ // A status of STATE_NONE in _postUpdateProcessing means that the
+ // update.status file is present but there isn't an update in progress.
+ // This isn't an expected state, so if we find ourselves in it, we want
+ // to just clean things up to go back to a good state.
+ LOG(
+ "UpdateService:_postUpdateProcessing - Cleaning up unexpected state."
+ );
+ if (!updates.length) {
+ updates.push(new Update(null));
+ }
+ for (let update of updates) {
+ update.state = STATE_FAILED;
+ update.errorCode = ERR_UPDATE_STATE_NONE;
+ update.statusText =
+ lazy.gUpdateBundle.GetStringFromName("statusFailed");
+ }
+ let newStatus = STATE_FAILED + ": " + ERR_UPDATE_STATE_NONE;
+ pingStateAndStatusCodes(updates[0], true, newStatus);
+ cleanupActiveUpdates();
+ return;
+ }
+
+ let channelChanged = updates => {
+ for (let update of updates) {
+ if (update.channel != lazy.UpdateUtils.UpdateChannel) {
+ return true;
+ }
+ }
+ return false;
+ };
+ if (channelChanged(updates)) {
+ let channel = lazy.UM.readyUpdate
+ ? lazy.UM.readyUpdate.channel
+ : lazy.UM.downloadingUpdate.channel;
+ LOG(
+ "UpdateService:_postUpdateProcessing - update channel is " +
+ "different than application's channel, removing update. update " +
+ "channel: " +
+ channel +
+ ", expected channel: " +
+ lazy.UpdateUtils.UpdateChannel
+ );
+ // User switched channels, clear out the old active updates and remove
+ // partial downloads
+ for (let update of updates) {
+ update.state = STATE_FAILED;
+ update.errorCode = ERR_CHANNEL_CHANGE;
+ update.statusText =
+ lazy.gUpdateBundle.GetStringFromName("statusFailed");
+ }
+ let newStatus = STATE_FAILED + ": " + ERR_CHANNEL_CHANGE;
+ pingStateAndStatusCodes(updates[0], true, newStatus);
+ cleanupActiveUpdates();
+ return;
+ }
+
+ // Handle the case when the update is the same or older than the current
+ // version and nsUpdateDriver.cpp skipped updating due to the version being
+ // older than the current version. This also handles the general case when
+ // an update is for an older version or the same version and same build ID.
+ if (
+ status == STATE_PENDING ||
+ status == STATE_PENDING_SERVICE ||
+ status == STATE_APPLIED ||
+ status == STATE_APPLIED_SERVICE ||
+ status == STATE_PENDING_ELEVATE ||
+ status == STATE_DOWNLOADING
+ ) {
+ let tooOldUpdate;
+ if (
+ updateIsAtLeastAsOldAs(
+ lazy.UM.readyUpdate,
+ Services.appinfo.version,
+ Services.appinfo.appBuildID
+ )
+ ) {
+ tooOldUpdate = lazy.UM.readyUpdate;
+ } else if (
+ updateIsAtLeastAsOldAs(
+ lazy.UM.downloadingUpdate,
+ Services.appinfo.version,
+ Services.appinfo.appBuildID
+ )
+ ) {
+ tooOldUpdate = lazy.UM.downloadingUpdate;
+ }
+ if (tooOldUpdate) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - removing update for older " +
+ "application version or same application version with same build " +
+ "ID. update application version: " +
+ tooOldUpdate.appVersion +
+ ", " +
+ "application version: " +
+ Services.appinfo.version +
+ ", update " +
+ "build ID: " +
+ tooOldUpdate.buildID +
+ ", application build ID: " +
+ Services.appinfo.appBuildID
+ );
+ tooOldUpdate.state = STATE_FAILED;
+ tooOldUpdate.statusText =
+ lazy.gUpdateBundle.GetStringFromName("statusFailed");
+ tooOldUpdate.errorCode = ERR_OLDER_VERSION_OR_SAME_BUILD;
+ // This could be split out to report telemetry for each case.
+ let newStatus = STATE_FAILED + ": " + ERR_OLDER_VERSION_OR_SAME_BUILD;
+ pingStateAndStatusCodes(tooOldUpdate, true, newStatus);
+ // Cleanup both updates regardless of which one is too old. It's
+ // exceedingly unlikely that a user could end up in a state where one
+ // update is acceptable and the other isn't. And it makes this function
+ // considerably more complex to try to deal with that possibility.
+ cleanupActiveUpdates();
+ return;
+ }
+ }
+
+ pingStateAndStatusCodes(
+ status == STATE_DOWNLOADING
+ ? lazy.UM.downloadingUpdate
+ : lazy.UM.readyUpdate,
+ true,
+ status
+ );
+ if (lazy.UM.downloadingUpdate || status == STATE_DOWNLOADING) {
+ if (status == STATE_SUCCEEDED) {
+ // If we successfully installed an update while we were downloading
+ // another update, the downloading update is now a partial MAR for
+ // a version that is no longer installed. We know that it's a partial
+ // MAR without checking because we currently only download partial MARs
+ // when an update has already been downloaded.
+ LOG(
+ "UpdateService:_postUpdateProcessing - removing downloading patch " +
+ "because we installed a different patch before it finished" +
+ "downloading."
+ );
+ cleanupDownloadingUpdate();
+ } else {
+ // Attempt to resume download
+ if (lazy.UM.downloadingUpdate) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - resuming patch found in " +
+ "downloading state"
+ );
+ let success = await this.downloadUpdate(lazy.UM.downloadingUpdate);
+ if (!success) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - Failed to resume patch. " +
+ "Cleaning up downloading update."
+ );
+ cleanupDownloadingUpdate();
+ }
+ } else {
+ LOG(
+ "UpdateService:_postUpdateProcessing - Warning: found " +
+ "downloading state, but no downloading patch. Cleaning up " +
+ "active updates."
+ );
+ // Put ourselves back in a good state.
+ cleanupActiveUpdates();
+ }
+ if (status == STATE_DOWNLOADING) {
+ // Done dealing with the downloading update, and there is no ready
+ // update, so return early.
+ return;
+ }
+ }
+ }
+
+ let update = lazy.UM.readyUpdate;
+
+ if (status == STATE_APPLYING) {
+ // This indicates that the background updater service is in either of the
+ // following two states:
+ // 1. It is in the process of applying an update in the background, and
+ // we just happen to be racing against that.
+ // 2. It has failed to apply an update for some reason, and we hit this
+ // case because the updater process has set the update status to
+ // applying, but has never finished.
+ // In order to differentiate between these two states, we look at the
+ // state field of the update object. If it's "pending", then we know
+ // that this is the first time we're hitting this case, so we switch
+ // that state to "applying" and we just wait and hope for the best.
+ // If it's "applying", we know that we've already been here once, so
+ // we really want to start from a clean state.
+ if (
+ update &&
+ (update.state == STATE_PENDING || update.state == STATE_PENDING_SERVICE)
+ ) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - patch found in applying " +
+ "state for the first time"
+ );
+ update.state = STATE_APPLYING;
+ lazy.UM.saveUpdates();
+ transitionState(Ci.nsIApplicationUpdateService.STATE_STAGING);
+ pollForStagingEnd();
+ } else {
+ // We get here even if we don't have an update object
+ LOG(
+ "UpdateService:_postUpdateProcessing - patch found in applying " +
+ "state for the second time. Cleaning up ready update."
+ );
+ cleanupReadyUpdate();
+ }
+ return;
+ }
+
+ if (!update) {
+ if (status != STATE_SUCCEEDED) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - previous patch failed " +
+ "and no patch available. Cleaning up ready update."
+ );
+ cleanupReadyUpdate();
+ return;
+ }
+ LOG(
+ "UpdateService:_postUpdateProcessing - Update data missing. Creating " +
+ "an empty update object."
+ );
+ update = new Update(null);
+ }
+
+ let parts = status.split(":");
+ update.state = parts[0];
+ LOG(
+ `UpdateService:_postUpdateProcessing - Setting update's state from ` +
+ `the status file (="${update.state}")`
+ );
+ if (update.state == STATE_FAILED && parts[1]) {
+ update.errorCode = parseInt(parts[1]);
+ LOG(
+ `UpdateService:_postUpdateProcessing - Setting update's errorCode ` +
+ `from the status file (="${update.errorCode}")`
+ );
+ }
+
+ if (status != STATE_SUCCEEDED) {
+ // Rotate the update logs so the update log isn't removed. By passing
+ // false the patch directory won't be removed.
+ cleanUpReadyUpdateDir(false);
+ }
+
+ if (status == STATE_SUCCEEDED) {
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS);
+ }
+ update.statusText =
+ lazy.gUpdateBundle.GetStringFromName("installSuccess");
+
+ // The only time that update is not a reference to readyUpdate is when
+ // readyUpdate is null.
+ if (!lazy.UM.readyUpdate) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - Assigning successful update " +
+ "readyUpdate before cleaning it up."
+ );
+ lazy.UM.readyUpdate = update;
+ }
+
+ // Done with this update. Clean it up.
+ LOG(
+ "UpdateService:_postUpdateProcessing - Cleaning up successful ready " +
+ "update."
+ );
+ cleanupReadyUpdate();
+
+ Services.prefs.setIntPref(PREF_APP_UPDATE_ELEVATE_ATTEMPTS, 0);
+ } else if (status == STATE_PENDING_ELEVATE) {
+ // In case the active-update.xml file is deleted.
+ if (!update) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - status is pending-elevate " +
+ "but there isn't a ready update, removing update"
+ );
+ cleanupReadyUpdate();
+ } else {
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ if (Services.startup.wasSilentlyStarted) {
+ // This check _should_ be unnecessary since we should not silently
+ // restart if state == pending-elevate. But the update elevation
+ // dialog is a way that we could potentially show UI on startup, even
+ // with no windows open. Which we really do not want to do on a silent
+ // restart.
+ // So this is defense in depth.
+ LOG(
+ "UpdateService:_postUpdateProcessing - status is " +
+ "pending-elevate, but this is a silent startup, so the " +
+ "elevation window has been suppressed."
+ );
+ } else {
+ LOG(
+ "UpdateService:_postUpdateProcessing - status is " +
+ "pending-elevate. Showing Update elevation dialog."
+ );
+ let uri = "chrome://mozapps/content/update/updateElevation.xhtml";
+ let features =
+ "chrome,centerscreen,resizable=no,titlebar,toolbar=no,dialog=no";
+ Services.ww.openWindow(null, uri, "Update:Elevation", features, null);
+ }
+ }
+ } else {
+ // If there was an I/O error it is assumed that the patch is not invalid
+ // and it is set to pending so an attempt to apply it again will happen
+ // when the application is restarted.
+ if (update.state == STATE_FAILED && update.errorCode) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - Attempting handleUpdateFailure"
+ );
+ if (handleUpdateFailure(update)) {
+ LOG(
+ "UpdateService:_postUpdateProcessing - handleUpdateFailure success."
+ );
+ return;
+ }
+ }
+
+ LOG(
+ "UpdateService:_postUpdateProcessing - Attempting to fall back to a " +
+ "complete update."
+ );
+ // Something went wrong with the patch application process.
+ await handleFallbackToCompleteUpdate();
+ }
+ },
+
+ /**
+ * Register an observer when the network comes online, so we can short-circuit
+ * the app.update.interval when there isn't connectivity
+ */
+ _registerOnlineObserver: function AUS__registerOnlineObserver() {
+ if (this._registeredOnlineObserver) {
+ LOG(
+ "UpdateService:_registerOnlineObserver - observer already registered"
+ );
+ return;
+ }
+
+ LOG(
+ "UpdateService:_registerOnlineObserver - waiting for the network to " +
+ "be online, then forcing another check"
+ );
+
+ Services.obs.addObserver(this, "network:offline-status-changed");
+ this._registeredOnlineObserver = true;
+ },
+
+ /**
+ * Called from the network:offline-status-changed observer.
+ */
+ _offlineStatusChanged: async function AUS__offlineStatusChanged(status) {
+ if (status !== "online") {
+ return;
+ }
+
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ this._registeredOnlineObserver = false;
+
+ LOG(
+ "UpdateService:_offlineStatusChanged - network is online, forcing " +
+ "another background check"
+ );
+
+ // the background checker is contained in notify
+ await this._attemptResume();
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ onCheckComplete: async function AUS_onCheckComplete(result) {
+ if (result.succeeded) {
+ await this._selectAndInstallUpdate(result.updates);
+ return;
+ }
+
+ if (!result.checksAllowed) {
+ LOG("UpdateService:onCheckComplete - checks not allowed");
+ return;
+ }
+
+ // On failure, result.updates is guaranteed to have exactly one update
+ // containing error information.
+ let update = result.updates[0];
+
+ LOG(
+ "UpdateService:onCheckComplete - error during background update. error " +
+ "code: " +
+ update.errorCode +
+ ", status text: " +
+ update.statusText
+ );
+
+ if (update.errorCode == NETWORK_ERROR_OFFLINE) {
+ // Register an online observer to try again
+ this._registerOnlineObserver();
+ if (this._pingSuffix) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_OFFLINE);
+ }
+ return;
+ }
+
+ // Send the error code to telemetry
+ AUSTLMY.pingCheckExError(this._pingSuffix, update.errorCode);
+ update.errorCode = BACKGROUNDCHECK_MULTIPLE_FAILURES;
+ let errCount = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_BACKGROUNDERRORS,
+ 0
+ );
+
+ // If we already have an update ready, we don't want to worry the user over
+ // update check failures. As far as the user knows, the update status is
+ // the status of the ready update. We don't want to confuse them by saying
+ // that an update check failed.
+ if (lazy.UM.readyUpdate) {
+ LOG(
+ "UpdateService:onCheckComplete - Ignoring error because another " +
+ "update is ready."
+ );
+ return;
+ }
+
+ errCount++;
+ Services.prefs.setIntPref(PREF_APP_UPDATE_BACKGROUNDERRORS, errCount);
+ // Don't allow the preference to set a value greater than 20 for max errors.
+ let maxErrors = Math.min(
+ Services.prefs.getIntPref(PREF_APP_UPDATE_BACKGROUNDMAXERRORS, 10),
+ 20
+ );
+
+ if (errCount >= maxErrors) {
+ LOG(
+ "UpdateService:onCheckComplete - notifying observers of error. " +
+ "topic: update-error, status: check-attempts-exceeded"
+ );
+ Services.obs.notifyObservers(
+ update,
+ "update-error",
+ "check-attempts-exceeded"
+ );
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_GENERAL_ERROR_PROMPT);
+ } else {
+ LOG(
+ "UpdateService:onCheckComplete - notifying observers of error. " +
+ "topic: update-error, status: check-attempt-failed"
+ );
+ Services.obs.notifyObservers(
+ update,
+ "update-error",
+ "check-attempt-failed"
+ );
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_GENERAL_ERROR_SILENT);
+ }
+ },
+
+ /**
+ * Called when a connection should be resumed
+ */
+ _attemptResume: async function AUS_attemptResume() {
+ LOG("UpdateService:_attemptResume");
+ // If a download is in progress and we aren't already downloading it, then
+ // resume it.
+ if (this.isDownloading) {
+ // There is nothing to resume. We are already downloading.
+ LOG("UpdateService:_attemptResume - already downloading.");
+ return;
+ }
+ if (
+ this._downloader &&
+ this._downloader._patch &&
+ this._downloader._patch.state == STATE_DOWNLOADING &&
+ this._downloader._update
+ ) {
+ LOG(
+ "UpdateService:_attemptResume - _patch.state: " +
+ this._downloader._patch.state
+ );
+ let success = await this.downloadUpdate(this._downloader._update);
+ LOG("UpdateService:_attemptResume - downloadUpdate success: " + success);
+ if (!success) {
+ LOG(
+ "UpdateService:_attemptResume - Resuming download failed. Cleaning " +
+ "up downloading update."
+ );
+ cleanupDownloadingUpdate();
+ }
+ return;
+ }
+
+ // Kick off an update check
+ (async () => {
+ let check = lazy.CheckSvc.checkForUpdates(lazy.CheckSvc.BACKGROUND_CHECK);
+ await this.onCheckComplete(await check.result);
+ })();
+ },
+
+ /**
+ * Notified when a timer fires
+ * @param timer
+ * The timer that fired
+ */
+ notify: function AUS_notify(timer) {
+ this._checkForBackgroundUpdates(true);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ checkForBackgroundUpdates: function AUS_checkForBackgroundUpdates() {
+ return this._checkForBackgroundUpdates(false);
+ },
+
+ // The suffix used for background update check telemetry histogram ID's.
+ get _pingSuffix() {
+ if (lazy.UM.readyUpdate) {
+ // Once an update has been downloaded, all later updates will be reported
+ // to telemetry as subsequent updates. We move the first update into
+ // readyUpdate as soon as the download is complete, so any update checks
+ // after readyUpdate is no longer null are subsequent update checks.
+ return AUSTLMY.SUBSEQUENT;
+ }
+ return this._isNotify ? AUSTLMY.NOTIFY : AUSTLMY.EXTERNAL;
+ },
+
+ /**
+ * Checks for updates in the background.
+ * @param isNotify
+ * Whether or not a background update check was initiated by the
+ * application update timer notification.
+ */
+ _checkForBackgroundUpdates: function AUS__checkForBackgroundUpdates(
+ isNotify
+ ) {
+ if (!this.disabled && AppConstants.NIGHTLY_BUILD) {
+ // Scalar ID: update.suppress_prompts
+ AUSTLMY.pingSuppressPrompts();
+ }
+ if (this.disabled || this.manualUpdateOnly) {
+ // Return immediately if we are disabled by policy. Otherwise, just the
+ // telemetry we try to collect below can potentially trigger a restart
+ // prompt if the update directory isn't writable. And we shouldn't be
+ // telling the user about update failures if update is disabled.
+ // See Bug 1599590.
+ // Note that we exit unconditionally here if we are only doing manual
+ // update checks, because manual update checking uses a completely
+ // different code path (AppUpdater.jsm creates its own nsIUpdateChecker),
+ // bypassing this function completely.
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_DISABLED_BY_POLICY);
+ return false;
+ }
+
+ this._isNotify = isNotify;
+
+ // Histogram IDs:
+ // UPDATE_PING_COUNT_EXTERNAL
+ // UPDATE_PING_COUNT_NOTIFY
+ // UPDATE_PING_COUNT_SUBSEQUENT
+ AUSTLMY.pingGeneric("UPDATE_PING_COUNT_" + this._pingSuffix, true, false);
+
+ // Histogram IDs:
+ // UPDATE_UNABLE_TO_APPLY_EXTERNAL
+ // UPDATE_UNABLE_TO_APPLY_NOTIFY
+ // UPDATE_UNABLE_TO_APPLY_SUBSEQUENT
+ AUSTLMY.pingGeneric(
+ "UPDATE_UNABLE_TO_APPLY_" + this._pingSuffix,
+ getCanApplyUpdates(),
+ true
+ );
+ // Histogram IDs:
+ // UPDATE_CANNOT_STAGE_EXTERNAL
+ // UPDATE_CANNOT_STAGE_NOTIFY
+ // UPDATE_CANNOT_STAGE_SUBSEQUENT
+ AUSTLMY.pingGeneric(
+ "UPDATE_CANNOT_STAGE_" + this._pingSuffix,
+ getCanStageUpdates(),
+ true
+ );
+ if (AppConstants.platform == "win") {
+ // Histogram IDs:
+ // UPDATE_CAN_USE_BITS_EXTERNAL
+ // UPDATE_CAN_USE_BITS_NOTIFY
+ // UPDATE_CAN_USE_BITS_SUBSEQUENT
+ AUSTLMY.pingGeneric(
+ "UPDATE_CAN_USE_BITS_" + this._pingSuffix,
+ getCanUseBits()
+ );
+ }
+ // Histogram IDs:
+ // UPDATE_INVALID_LASTUPDATETIME_EXTERNAL
+ // UPDATE_INVALID_LASTUPDATETIME_NOTIFY
+ // UPDATE_INVALID_LASTUPDATETIME_SUBSEQUENT
+ // UPDATE_LAST_NOTIFY_INTERVAL_DAYS_EXTERNAL
+ // UPDATE_LAST_NOTIFY_INTERVAL_DAYS_NOTIFY
+ // UPDATE_LAST_NOTIFY_INTERVAL_DAYS_SUBSEQUENT
+ AUSTLMY.pingLastUpdateTime(this._pingSuffix);
+ // Histogram IDs:
+ // UPDATE_NOT_PREF_UPDATE_AUTO_EXTERNAL
+ // UPDATE_NOT_PREF_UPDATE_AUTO_NOTIFY
+ // UPDATE_NOT_PREF_UPDATE_AUTO_SUBSEQUENT
+ lazy.UpdateUtils.getAppUpdateAutoEnabled().then(enabled => {
+ AUSTLMY.pingGeneric(
+ "UPDATE_NOT_PREF_UPDATE_AUTO_" + this._pingSuffix,
+ enabled,
+ true
+ );
+ });
+ // Histogram IDs:
+ // UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_EXTERNAL
+ // UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_NOTIFY
+ // UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_SUBSEQUENT
+ AUSTLMY.pingBoolPref(
+ "UPDATE_NOT_PREF_UPDATE_STAGING_ENABLED_" + this._pingSuffix,
+ PREF_APP_UPDATE_STAGING_ENABLED,
+ true,
+ true
+ );
+ if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
+ // Histogram IDs:
+ // UPDATE_PREF_UPDATE_CANCELATIONS_EXTERNAL
+ // UPDATE_PREF_UPDATE_CANCELATIONS_NOTIFY
+ // UPDATE_PREF_UPDATE_CANCELATIONS_SUBSEQUENT
+ AUSTLMY.pingIntPref(
+ "UPDATE_PREF_UPDATE_CANCELATIONS_" + this._pingSuffix,
+ PREF_APP_UPDATE_CANCELATIONS,
+ 0,
+ 0
+ );
+ }
+ if (AppConstants.MOZ_MAINTENANCE_SERVICE) {
+ // Histogram IDs:
+ // UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_EXTERNAL
+ // UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_NOTIFY
+ // UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_SUBSEQUENT
+ AUSTLMY.pingBoolPref(
+ "UPDATE_NOT_PREF_UPDATE_SERVICE_ENABLED_" + this._pingSuffix,
+ PREF_APP_UPDATE_SERVICE_ENABLED,
+ true
+ );
+ // Histogram IDs:
+ // UPDATE_PREF_SERVICE_ERRORS_EXTERNAL
+ // UPDATE_PREF_SERVICE_ERRORS_NOTIFY
+ // UPDATE_PREF_SERVICE_ERRORS_SUBSEQUENT
+ AUSTLMY.pingIntPref(
+ "UPDATE_PREF_SERVICE_ERRORS_" + this._pingSuffix,
+ PREF_APP_UPDATE_SERVICE_ERRORS,
+ 0,
+ 0
+ );
+ if (AppConstants.platform == "win") {
+ // Histogram IDs:
+ // UPDATE_SERVICE_INSTALLED_EXTERNAL
+ // UPDATE_SERVICE_INSTALLED_NOTIFY
+ // UPDATE_SERVICE_INSTALLED_SUBSEQUENT
+ // UPDATE_SERVICE_MANUALLY_UNINSTALLED_EXTERNAL
+ // UPDATE_SERVICE_MANUALLY_UNINSTALLED_NOTIFY
+ // UPDATE_SERVICE_MANUALLY_UNINSTALLED_SUBSEQUENT
+ AUSTLMY.pingServiceInstallStatus(
+ this._pingSuffix,
+ isServiceInstalled()
+ );
+ }
+ }
+
+ // If a download is in progress or the patch has been staged do nothing.
+ if (this.isDownloading) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_IS_DOWNLOADING);
+ return false;
+ }
+
+ // Once we have downloaded a complete update, do not download further
+ // updates until the complete update is installed. This is important,
+ // because if we fall back from a partial update to a complete update,
+ // it might be because of changes to the patch directory (which would cause
+ // a failure to apply any partial MAR). So we really don't want to replace
+ // a downloaded complete update with a downloaded partial update. And we
+ // do not currently download complete updates if there is already a
+ // readyUpdate available.
+ if (
+ lazy.UM.readyUpdate &&
+ lazy.UM.readyUpdate.selectedPatch &&
+ lazy.UM.readyUpdate.selectedPatch.type == "complete"
+ ) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_IS_DOWNLOADED);
+ return false;
+ }
+
+ // If we start downloading an update while the readyUpdate is staging, we
+ // run the risk of eventually wanting to overwrite readyUpdate with the
+ // downloadingUpdate while the readyUpdate is still staging. Then we would
+ // have to have a weird intermediate state where the downloadingUpdate has
+ // finished downloading, but can't be moved yet. It's simpler to just not
+ // start a new update if the old one is still staging.
+ if (this.currentState == Ci.nsIApplicationUpdateService.STATE_STAGING) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_IS_DOWNLOADED);
+ return false;
+ }
+
+ // Asynchronously kick off update checking
+ (async () => {
+ let validUpdateURL = true;
+ try {
+ await lazy.CheckSvc.getUpdateURL(lazy.CheckSvc.BACKGROUND_CHECK);
+ } catch (e) {
+ validUpdateURL = false;
+ }
+
+ // The following checks are done here so they can be differentiated from
+ // foreground checks.
+ if (!lazy.UpdateUtils.OSVersion) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_OS_VERSION);
+ } else if (!lazy.UpdateUtils.ABI) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_OS_ABI);
+ } else if (!validUpdateURL) {
+ AUSTLMY.pingCheckCode(
+ this._pingSuffix,
+ AUSTLMY.CHK_INVALID_DEFAULT_URL
+ );
+ } else if (!hasUpdateMutex()) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_MUTEX);
+ } else if (isOtherInstanceRunning()) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_OTHER_INSTANCE);
+ } else if (!this.canCheckForUpdates) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_UNABLE_TO_CHECK);
+ }
+
+ let check = lazy.CheckSvc.checkForUpdates(lazy.CheckSvc.BACKGROUND_CHECK);
+ await this.onCheckComplete(await check.result);
+ })();
+
+ return true;
+ },
+
+ /**
+ * Determine the update from the specified updates that should be offered.
+ * If both valid major and minor updates are available the minor update will
+ * be offered.
+ * @param updates
+ * An array of available nsIUpdate items
+ * @return The nsIUpdate to offer.
+ */
+ selectUpdate: function AUS_selectUpdate(updates) {
+ if (!updates.length) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_NO_UPDATE_FOUND);
+ return null;
+ }
+
+ // The ping for unsupported is sent after the call to showPrompt.
+ if (updates.length == 1 && updates[0].unsupported) {
+ return updates[0];
+ }
+
+ // Choose the newest of the available minor and major updates.
+ var majorUpdate = null;
+ var minorUpdate = null;
+ var vc = Services.vc;
+ let lastCheckCode = AUSTLMY.CHK_NO_COMPAT_UPDATE_FOUND;
+
+ updates.forEach(function (aUpdate) {
+ // Ignore updates for older versions of the application and updates for
+ // the same version of the application with the same build ID.
+ if (updateIsAtLeastAsOldAsCurrentVersion(aUpdate)) {
+ LOG(
+ "UpdateService:selectUpdate - skipping update because the " +
+ "update's application version is not greater than the current " +
+ "application version"
+ );
+ lastCheckCode = AUSTLMY.CHK_UPDATE_PREVIOUS_VERSION;
+ return;
+ }
+
+ if (updateIsAtLeastAsOldAsReadyUpdate(aUpdate)) {
+ LOG(
+ "UpdateService:selectUpdate - skipping update because the " +
+ "update's application version is not greater than that of the " +
+ "currently downloaded update"
+ );
+ lastCheckCode = AUSTLMY.CHK_UPDATE_PREVIOUS_VERSION;
+ return;
+ }
+
+ if (lazy.UM.readyUpdate && !getPatchOfType(aUpdate, "partial")) {
+ LOG(
+ "UpdateService:selectUpdate - skipping update because no partial " +
+ "patch is available and an update has already been downloaded."
+ );
+ lastCheckCode = AUSTLMY.CHK_NO_PARTIAL_PATCH;
+ return;
+ }
+
+ switch (aUpdate.type) {
+ case "major":
+ if (!majorUpdate) {
+ majorUpdate = aUpdate;
+ } else if (
+ vc.compare(majorUpdate.appVersion, aUpdate.appVersion) <= 0
+ ) {
+ majorUpdate = aUpdate;
+ }
+ break;
+ case "minor":
+ if (!minorUpdate) {
+ minorUpdate = aUpdate;
+ } else if (
+ vc.compare(minorUpdate.appVersion, aUpdate.appVersion) <= 0
+ ) {
+ minorUpdate = aUpdate;
+ }
+ break;
+ default:
+ LOG(
+ "UpdateService:selectUpdate - skipping unknown update type: " +
+ aUpdate.type
+ );
+ lastCheckCode = AUSTLMY.CHK_UPDATE_INVALID_TYPE;
+ break;
+ }
+ });
+
+ let update = minorUpdate || majorUpdate;
+ if (AppConstants.platform == "macosx" && update) {
+ if (getElevationRequired()) {
+ let installAttemptVersion = Services.prefs.getCharPref(
+ PREF_APP_UPDATE_ELEVATE_VERSION,
+ null
+ );
+ if (vc.compare(installAttemptVersion, update.appVersion) != 0) {
+ Services.prefs.setCharPref(
+ PREF_APP_UPDATE_ELEVATE_VERSION,
+ update.appVersion
+ );
+ if (
+ Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)
+ ) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX);
+ }
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_NEVER)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_NEVER);
+ }
+ } else {
+ let numCancels = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CANCELATIONS_OSX,
+ 0
+ );
+ let rejectedVersion = Services.prefs.getCharPref(
+ PREF_APP_UPDATE_ELEVATE_NEVER,
+ ""
+ );
+ let maxCancels = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_CANCELATIONS_OSX_MAX,
+ DEFAULT_CANCELATIONS_OSX_MAX
+ );
+ if (numCancels >= maxCancels) {
+ LOG(
+ "UpdateService:selectUpdate - the user requires elevation to " +
+ "install this update, but the user has exceeded the max " +
+ "number of elevation attempts."
+ );
+ update.elevationFailure = true;
+ AUSTLMY.pingCheckCode(
+ this._pingSuffix,
+ AUSTLMY.CHK_ELEVATION_DISABLED_FOR_VERSION
+ );
+ } else if (vc.compare(rejectedVersion, update.appVersion) == 0) {
+ LOG(
+ "UpdateService:selectUpdate - the user requires elevation to " +
+ "install this update, but elevation is disabled for this " +
+ "version."
+ );
+ update.elevationFailure = true;
+ AUSTLMY.pingCheckCode(
+ this._pingSuffix,
+ AUSTLMY.CHK_ELEVATION_OPTOUT_FOR_VERSION
+ );
+ } else {
+ LOG(
+ "UpdateService:selectUpdate - the user requires elevation to " +
+ "install the update."
+ );
+ }
+ }
+ } else {
+ // Clear elevation-related prefs since they no longer apply (the user
+ // may have gained write access to the Firefox directory or an update
+ // was executed with a different profile).
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_VERSION)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_VERSION);
+ }
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_CANCELATIONS_OSX)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_CANCELATIONS_OSX);
+ }
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_ELEVATE_NEVER)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_ELEVATE_NEVER);
+ }
+ }
+ } else if (!update) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, lastCheckCode);
+ }
+
+ return update;
+ },
+
+ /**
+ * Determine which of the specified updates should be installed and begin the
+ * download/installation process or notify the user about the update.
+ * @param updates
+ * An array of available updates
+ */
+ _selectAndInstallUpdate: async function AUS__selectAndInstallUpdate(updates) {
+ // Return early if there's an active update. The user is already aware and
+ // is downloading or performed some user action to prevent notification.
+ if (lazy.UM.downloadingUpdate) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_HAS_ACTIVEUPDATE);
+ return;
+ }
+
+ if (this.disabled) {
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_DISABLED_BY_POLICY);
+ LOG(
+ "UpdateService:_selectAndInstallUpdate - not prompting because " +
+ "update is disabled"
+ );
+ return;
+ }
+
+ var update = this.selectUpdate(updates);
+ if (!update || update.elevationFailure) {
+ return;
+ }
+
+ if (update.unsupported) {
+ LOG(
+ "UpdateService:_selectAndInstallUpdate - update not supported for " +
+ "this system. Notifying observers. topic: update-available, " +
+ "status: unsupported"
+ );
+ Services.obs.notifyObservers(update, "update-available", "unsupported");
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_UNSUPPORTED);
+ return;
+ }
+
+ if (!getCanApplyUpdates()) {
+ LOG(
+ "UpdateService:_selectAndInstallUpdate - the user is unable to " +
+ "apply updates... prompting. Notifying observers. " +
+ "topic: update-available, status: cant-apply"
+ );
+ Services.obs.notifyObservers(null, "update-available", "cant-apply");
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_UNABLE_TO_APPLY);
+ return;
+ }
+
+ /**
+ * From this point on there are two possible outcomes:
+ * 1. download and install the update automatically
+ * 2. notify the user about the availability of an update
+ *
+ * Notes:
+ * a) if the app.update.auto preference is false then automatic download and
+ * install is disabled and the user will be notified.
+ *
+ * If the update when it is first read does not have an appVersion attribute
+ * the following deprecated behavior will occur:
+ * Update Type Outcome
+ * Major Notify
+ * Minor Auto Install
+ */
+ let updateAuto = await lazy.UpdateUtils.getAppUpdateAutoEnabled();
+ if (!updateAuto) {
+ LOG(
+ "UpdateService:_selectAndInstallUpdate - prompting because silent " +
+ "install is disabled. Notifying observers. topic: update-available, " +
+ "status: show-prompt"
+ );
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_SHOWPROMPT_PREF);
+ Services.obs.notifyObservers(update, "update-available", "show-prompt");
+ return;
+ }
+
+ LOG("UpdateService:_selectAndInstallUpdate - download the update");
+ let success = await this.downloadUpdate(update);
+ if (!success && !this.isDownloading) {
+ LOG(
+ "UpdateService:_selectAndInstallUpdate - Failed to start downloading " +
+ "update. Cleaning up downloading update."
+ );
+ cleanupDownloadingUpdate();
+ }
+ AUSTLMY.pingCheckCode(this._pingSuffix, AUSTLMY.CHK_DOWNLOAD_UPDATE);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get isAppBaseDirWritable() {
+ return isAppBaseDirWritable();
+ },
+
+ get disabledForTesting() {
+ return (
+ (Cu.isInAutomation ||
+ lazy.Marionette.running ||
+ lazy.RemoteAgent.running) &&
+ Services.prefs.getBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, false)
+ );
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get disabled() {
+ return (
+ (Services.policies && !Services.policies.isAllowed("appUpdate")) ||
+ this.disabledForTesting ||
+ Services.sysinfo.getProperty("isPackagedApp")
+ );
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get manualUpdateOnly() {
+ return (
+ Services.policies && !Services.policies.isAllowed("autoAppUpdateChecking")
+ );
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canUsuallyCheckForUpdates() {
+ if (this.disabled) {
+ LOG(
+ "UpdateService.canUsuallyCheckForUpdates - unable to automatically check " +
+ "for updates, the option has been disabled by the administrator."
+ );
+ return false;
+ }
+
+ // If we don't know the binary platform we're updating, we can't update.
+ if (!lazy.UpdateUtils.ABI) {
+ LOG(
+ "UpdateService.canUsuallyCheckForUpdates - unable to check for updates, " +
+ "unknown ABI"
+ );
+ return false;
+ }
+
+ // If we don't know the OS version we're updating, we can't update.
+ if (!lazy.UpdateUtils.OSVersion) {
+ LOG(
+ "UpdateService.canUsuallyCheckForUpdates - unable to check for updates, " +
+ "unknown OS version"
+ );
+ return false;
+ }
+
+ LOG("UpdateService.canUsuallyCheckForUpdates - able to check for updates");
+ return true;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canCheckForUpdates() {
+ if (!this.canUsuallyCheckForUpdates) {
+ return false;
+ }
+
+ if (!hasUpdateMutex()) {
+ LOG(
+ "UpdateService.canCheckForUpdates - unable to check for updates, " +
+ "unable to acquire update mutex"
+ );
+ return false;
+ }
+
+ if (isOtherInstanceRunning()) {
+ // This doesn't block update checks, but we will have to wait until either
+ // the other instance is gone or we time out waiting for it.
+ LOG(
+ "UpdateService.canCheckForUpdates - another instance is holding the " +
+ "lock, will need to wait for it prior to checking for updates"
+ );
+ }
+
+ LOG("UpdateService.canCheckForUpdates - able to check for updates");
+ return true;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get elevationRequired() {
+ return getElevationRequired();
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canUsuallyApplyUpdates() {
+ return getCanApplyUpdates();
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canApplyUpdates() {
+ return (
+ this.canUsuallyApplyUpdates &&
+ hasUpdateMutex() &&
+ !isOtherInstanceRunning()
+ );
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canUsuallyStageUpdates() {
+ return getCanStageUpdates(false);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canStageUpdates() {
+ return getCanStageUpdates();
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canUsuallyUseBits() {
+ return getCanUseBits(false) == "CanUseBits";
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get canUseBits() {
+ return getCanUseBits() == "CanUseBits";
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get isOtherInstanceHandlingUpdates() {
+ return !hasUpdateMutex() || isOtherInstanceRunning();
+ },
+
+ /**
+ * A set of download listeners to be notified by this._downloader when it
+ * receives nsIRequestObserver or nsIProgressEventSink method calls.
+ *
+ * These are stored on the UpdateService rather than on the Downloader,
+ * because they ought to persist across multiple Downloader instances.
+ */
+ _downloadListeners: new Set(),
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ addDownloadListener: function AUS_addDownloadListener(listener) {
+ let oldSize = this._downloadListeners.size;
+ this._downloadListeners.add(listener);
+
+ if (this._downloadListeners.size == oldSize) {
+ LOG(
+ "UpdateService:addDownloadListener - Warning: Didn't add duplicate " +
+ "listener"
+ );
+ return;
+ }
+
+ if (this._downloader) {
+ this._downloader.onDownloadListenerAdded();
+ }
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ removeDownloadListener: function AUS_removeDownloadListener(listener) {
+ let elementRemoved = this._downloadListeners.delete(listener);
+
+ if (!elementRemoved) {
+ LOG(
+ "UpdateService:removeDownloadListener - Warning: Didn't remove " +
+ "non-existent listener"
+ );
+ return;
+ }
+
+ if (this._downloader) {
+ this._downloader.onDownloadListenerRemoved();
+ }
+ },
+
+ /**
+ * Returns a boolean indicating whether there are any download listeners
+ */
+ get hasDownloadListeners() {
+ return !!this._downloadListeners.length;
+ },
+
+ /*
+ * Calls the provided function once with each download listener that is
+ * currently registered.
+ */
+ forEachDownloadListener: function AUS_forEachDownloadListener(fn) {
+ // Make a shallow copy in case listeners remove themselves.
+ let listeners = new Set(this._downloadListeners);
+ listeners.forEach(fn);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ downloadUpdate: async function AUS_downloadUpdate(update) {
+ if (!update) {
+ throw Components.Exception("", Cr.NS_ERROR_NULL_POINTER);
+ }
+
+ // Don't download the update if the update's version is less than the
+ // current application's version or the update's version is the same as the
+ // application's version and the build ID is the same as the application's
+ // build ID. If we already have an update ready, we want to apply those
+ // same checks against the version of the ready update, so that we don't
+ // download an update that isn't newer than the one we already have.
+ if (updateIsAtLeastAsOldAsCurrentVersion(update)) {
+ LOG(
+ "UpdateService:downloadUpdate - Skipping download of update since " +
+ "it is for an earlier or same application version and build ID.\n" +
+ "current application version: " +
+ Services.appinfo.version +
+ "\n" +
+ "update application version : " +
+ update.appVersion +
+ "\n" +
+ "current build ID: " +
+ Services.appinfo.appBuildID +
+ "\n" +
+ "update build ID : " +
+ update.buildID
+ );
+ return false;
+ }
+ if (updateIsAtLeastAsOldAsReadyUpdate(update)) {
+ LOG(
+ "UpdateService:downloadUpdate - not downloading update because the " +
+ "update that's already been downloaded is the same version or " +
+ "newer.\n" +
+ "currently downloaded update application version: " +
+ lazy.UM.readyUpdate.appVersion +
+ "\n" +
+ "available update application version : " +
+ update.appVersion +
+ "\n" +
+ "currently downloaded update build ID: " +
+ lazy.UM.readyUpdate.buildID +
+ "\n" +
+ "available update build ID : " +
+ update.buildID
+ );
+ return false;
+ }
+
+ // If a download request is in progress vs. a download ready to resume
+ if (this.isDownloading) {
+ if (update.isCompleteUpdate == this._downloader.isCompleteUpdate) {
+ LOG(
+ "UpdateService:downloadUpdate - no support for downloading more " +
+ "than one update at a time"
+ );
+ return true;
+ }
+ this._downloader.cancel();
+ }
+ this._downloader = new Downloader(this);
+ return this._downloader.downloadUpdate(update);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ stopDownload: async function AUS_stopDownload() {
+ if (this.isDownloading) {
+ await this._downloader.cancel();
+ } else if (this._retryTimer) {
+ // Download status is still considered as 'downloading' during retry.
+ // We need to cancel both retry and download at this stage.
+ this._retryTimer.cancel();
+ this._retryTimer = null;
+ if (this._downloader) {
+ await this._downloader.cancel();
+ }
+ }
+ if (this._downloader) {
+ await this._downloader.cleanup();
+ }
+ this._downloader = null;
+ },
+
+ /**
+ * Note that this is different from checking if `currentState` is
+ * `STATE_DOWNLOADING` because if we are downloading a second update, this
+ * will be `true` while `currentState` will be `STATE_PENDING`.
+ */
+ get isDownloading() {
+ return this._downloader && this._downloader.isBusy;
+ },
+
+ _logStatus: function AUS__logStatus() {
+ if (!lazy.UpdateLog.enabled) {
+ return;
+ }
+ if (this.disabled) {
+ LOG("Current UpdateService status: disabled");
+ // Return early if UpdateService is disabled by policy. Otherwise some of
+ // the getters we call to display status information may discover that the
+ // update directory is not writable, which automatically results in the
+ // permissions being fixed. Which we shouldn't really be doing if update
+ // is disabled by policy.
+ return;
+ }
+ LOG("Logging current UpdateService status:");
+ // These getters print their own logging
+ this.canCheckForUpdates;
+ this.canApplyUpdates;
+ this.canStageUpdates;
+ LOG("Elevation required: " + this.elevationRequired);
+ LOG(
+ "Other instance of the application currently running: " +
+ this.isOtherInstanceHandlingUpdates
+ );
+ LOG("Downloading: " + !!this.isDownloading);
+ if (this._downloader && this._downloader.isBusy) {
+ LOG("Downloading complete update: " + this._downloader.isCompleteUpdate);
+ LOG("Downloader using BITS: " + this._downloader.usingBits);
+ if (this._downloader._patch) {
+ // This will print its own logging
+ this._downloader._canUseBits(this._downloader._patch);
+
+ // Downloader calls QueryInterface(Ci.nsIWritablePropertyBag) on
+ // its _patch member as soon as it is assigned, so no need to do so
+ // again here.
+ let bitsResult = this._downloader._patch.getProperty("bitsResult");
+ if (bitsResult != null) {
+ LOG("Patch BITS result: " + bitsResult);
+ }
+ let internalResult =
+ this._downloader._patch.getProperty("internalResult");
+ if (internalResult != null) {
+ LOG("Patch nsIIncrementalDownload result: " + internalResult);
+ }
+ }
+ }
+ LOG("End of UpdateService status");
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get onlyDownloadUpdatesThisSession() {
+ return gOnlyDownloadUpdatesThisSession;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ set onlyDownloadUpdatesThisSession(newValue) {
+ gOnlyDownloadUpdatesThisSession = newValue;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ getStateName(state) {
+ switch (state) {
+ case Ci.nsIApplicationUpdateService.STATE_IDLE:
+ return "STATE_IDLE";
+ case Ci.nsIApplicationUpdateService.STATE_DOWNLOADING:
+ return "STATE_DOWNLOADING";
+ case Ci.nsIApplicationUpdateService.STATE_STAGING:
+ return "STATE_STAGING";
+ case Ci.nsIApplicationUpdateService.STATE_PENDING:
+ return "STATE_PENDING";
+ case Ci.nsIApplicationUpdateService.STATE_SWAP:
+ return "STATE_SWAP";
+ }
+ return `[unknown update state: ${state}]`;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get currentState() {
+ return gUpdateState;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get stateTransition() {
+ return gStateTransitionPromise.promise;
+ },
+
+ classID: UPDATESERVICE_CID,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIApplicationUpdateService",
+ "nsITimerCallback",
+ "nsIObserver",
+ ]),
+};
+
+/**
+ * A service to manage active and past updates.
+ * @constructor
+ */
+export function UpdateManager() {
+ // Load the active-update.xml file to see if there is an active update.
+ let activeUpdates = this._loadXMLFileIntoArray(FILE_ACTIVE_UPDATE_XML);
+ if (activeUpdates.length) {
+ // Set the active update directly on the var used to cache the value.
+ this._readyUpdate = activeUpdates[0];
+ if (activeUpdates.length >= 2) {
+ this._downloadingUpdate = activeUpdates[1];
+ }
+ let status = readStatusFile(getReadyUpdateDir());
+ LOG(`UpdateManager:UpdateManager - status = "${status}"`);
+ // This check is performed here since UpdateService:_postUpdateProcessing
+ // won't be called when there isn't an update.status file.
+ if (status == STATE_NONE) {
+ // Under some edgecases such as Windows system restore the
+ // active-update.xml will contain a pending update without the status
+ // file. To recover from this situation clean the updates dir and move
+ // the active update to the update history.
+ LOG(
+ "UpdateManager:UpdateManager - Found update data with no status " +
+ "file. Cleaning up..."
+ );
+ this._readyUpdate.state = STATE_FAILED;
+ this._readyUpdate.errorCode = ERR_UPDATE_STATE_NONE;
+ this._readyUpdate.statusText =
+ lazy.gUpdateBundle.GetStringFromName("statusFailed");
+ let newStatus = STATE_FAILED + ": " + ERR_UPDATE_STATE_NONE;
+ pingStateAndStatusCodes(this._readyUpdate, true, newStatus);
+ this.addUpdateToHistory(this._readyUpdate);
+ this._readyUpdate = null;
+ this.saveUpdates();
+ cleanUpReadyUpdateDir();
+ cleanUpDownloadingUpdateDir();
+ } else if (status == STATE_DOWNLOADING) {
+ // The first update we read out of activeUpdates may not be the ready
+ // update, it may be the downloading update.
+ if (this._downloadingUpdate) {
+ // If the first update we read is a downloading update, it's
+ // unexpected to have read another active update. That would seem to
+ // indicate that we were downloading two updates at once, which we don't
+ // do.
+ LOG(
+ "UpdateManager:UpdateManager - Warning: Found and discarded a " +
+ "second downloading update."
+ );
+ }
+ this._downloadingUpdate = this._readyUpdate;
+ this._readyUpdate = null;
+ }
+ }
+
+ LOG(
+ "UpdateManager:UpdateManager - Initialized downloadingUpdate to " +
+ this._downloadingUpdate
+ );
+ if (this._downloadingUpdate) {
+ LOG(
+ "UpdateManager:UpdateManager - Initialized downloadingUpdate state to " +
+ this._downloadingUpdate.state
+ );
+ }
+ LOG(
+ "UpdateManager:UpdateManager - Initialized readyUpdate to " +
+ this._readyUpdate
+ );
+ if (this._readyUpdate) {
+ LOG(
+ "UpdateManager:UpdateManager - Initialized readyUpdate state to " +
+ this._readyUpdate.state
+ );
+ }
+}
+
+UpdateManager.prototype = {
+ /**
+ * The nsIUpdate object for the update that has been downloaded.
+ */
+ _readyUpdate: null,
+
+ /**
+ * The nsIUpdate object for the update currently being downloaded.
+ */
+ _downloadingUpdate: null,
+
+ /**
+ * Whether the update history stored in _updates has changed since it was
+ * loaded.
+ */
+ _updatesDirty: false,
+
+ /**
+ * See nsIObserver.idl
+ */
+ observe: function UM_observe(subject, topic, data) {
+ // Hack to be able to run and cleanup tests by reloading the update data.
+ if (topic == "um-reload-update-data") {
+ if (!Cu.isInAutomation) {
+ return;
+ }
+ LOG("UpdateManager:observe - Reloading update data.");
+ if (this._updatesXMLSaver) {
+ this._updatesXMLSaver.disarm();
+ }
+
+ let updates = [];
+ this._updatesDirty = true;
+ this._readyUpdate = null;
+ this._downloadingUpdate = null;
+ transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE);
+ if (data != "skip-files") {
+ let activeUpdates = this._loadXMLFileIntoArray(FILE_ACTIVE_UPDATE_XML);
+ if (activeUpdates.length) {
+ this._readyUpdate = activeUpdates[0];
+ if (activeUpdates.length >= 2) {
+ this._downloadingUpdate = activeUpdates[1];
+ }
+ let status = readStatusFile(getReadyUpdateDir());
+ LOG(`UpdateManager:observe - Got status = ${status}`);
+ if (status == STATE_DOWNLOADING) {
+ this._downloadingUpdate = this._readyUpdate;
+ this._readyUpdate = null;
+ transitionState(Ci.nsIApplicationUpdateService.STATE_DOWNLOADING);
+ } else if (
+ [
+ STATE_PENDING,
+ STATE_PENDING_SERVICE,
+ STATE_PENDING_ELEVATE,
+ STATE_APPLIED,
+ STATE_APPLIED_SERVICE,
+ ].includes(status)
+ ) {
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ }
+ }
+ updates = this._loadXMLFileIntoArray(FILE_UPDATES_XML);
+ }
+ this._updatesCache = updates;
+
+ LOG(
+ "UpdateManager:observe - Reloaded downloadingUpdate as " +
+ this._downloadingUpdate
+ );
+ if (this._downloadingUpdate) {
+ LOG(
+ "UpdateManager:observe - Reloaded downloadingUpdate state as " +
+ this._downloadingUpdate.state
+ );
+ }
+ LOG(
+ "UpdateManager:observe - Reloaded readyUpdate as " + this._readyUpdate
+ );
+ if (this._readyUpdate) {
+ LOG(
+ "UpdateManager:observe - Reloaded readyUpdate state as " +
+ this._readyUpdate.state
+ );
+ }
+ }
+ },
+
+ /**
+ * Loads an updates.xml formatted file into an array of nsIUpdate items.
+ * @param fileName
+ * The file name in the updates directory to load.
+ * @return The array of nsIUpdate items held in the file.
+ */
+ _loadXMLFileIntoArray: function UM__loadXMLFileIntoArray(fileName) {
+ let updates = [];
+ let file = getUpdateFile([fileName]);
+ if (!file.exists()) {
+ LOG(
+ "UpdateManager:_loadXMLFileIntoArray - XML file does not exist. " +
+ "path: " +
+ file.path
+ );
+ return updates;
+ }
+
+ // Open the active-update.xml file with both read and write access so
+ // opening it will fail if it isn't possible to also write to the file. When
+ // opening it fails it means that it isn't possible to update and the code
+ // below will return early without loading the active-update.xml. This will
+ // also make it so notifications to update manually will still be shown.
+ let mode =
+ fileName == FILE_ACTIVE_UPDATE_XML
+ ? FileUtils.MODE_RDWR
+ : FileUtils.MODE_RDONLY;
+ let fileStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ try {
+ fileStream.init(file, mode, FileUtils.PERMS_FILE, 0);
+ } catch (e) {
+ LOG(
+ "UpdateManager:_loadXMLFileIntoArray - error initializing file " +
+ "stream. Exception: " +
+ e
+ );
+ return updates;
+ }
+ try {
+ var parser = new DOMParser();
+ var doc = parser.parseFromStream(
+ fileStream,
+ "UTF-8",
+ fileStream.available(),
+ "text/xml"
+ );
+
+ var updateCount = doc.documentElement.childNodes.length;
+ for (var i = 0; i < updateCount; ++i) {
+ var updateElement = doc.documentElement.childNodes.item(i);
+ if (
+ updateElement.nodeType != updateElement.ELEMENT_NODE ||
+ updateElement.localName != "update"
+ ) {
+ continue;
+ }
+
+ let update;
+ try {
+ update = new Update(updateElement);
+ } catch (e) {
+ LOG("UpdateManager:_loadXMLFileIntoArray - invalid update");
+ continue;
+ }
+ updates.push(update);
+ }
+ } catch (ex) {
+ LOG(
+ "UpdateManager:_loadXMLFileIntoArray - error constructing update " +
+ "list. Exception: " +
+ ex
+ );
+ }
+ fileStream.close();
+ if (!updates.length) {
+ LOG(
+ "UpdateManager:_loadXMLFileIntoArray - update xml file " +
+ fileName +
+ " exists but doesn't contain any updates"
+ );
+ // The file exists but doesn't contain any updates so remove it.
+ try {
+ file.remove(false);
+ } catch (e) {
+ LOG(
+ "UpdateManager:_loadXMLFileIntoArray - error removing " +
+ fileName +
+ " file. Exception: " +
+ e
+ );
+ }
+ }
+ return updates;
+ },
+
+ /**
+ * Loads the update history from the updates.xml file into a cache.
+ */
+ _getUpdates() {
+ if (!this._updatesCache) {
+ this._updatesCache = this._loadXMLFileIntoArray(FILE_UPDATES_XML);
+ }
+ return this._updatesCache;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ getUpdateAt: function UM_getUpdateAt(aIndex) {
+ return this._getUpdates()[aIndex];
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ getUpdateCount() {
+ return this._getUpdates().length;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get readyUpdate() {
+ return this._readyUpdate;
+ },
+ set readyUpdate(aUpdate) {
+ this._readyUpdate = aUpdate;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ get downloadingUpdate() {
+ return this._downloadingUpdate;
+ },
+ set downloadingUpdate(aUpdate) {
+ this._downloadingUpdate = aUpdate;
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ addUpdateToHistory(aUpdate) {
+ this._updatesDirty = true;
+ let updates = this._getUpdates();
+ updates.unshift(aUpdate);
+ // Limit the update history to 10 updates.
+ updates.splice(10);
+ },
+
+ /**
+ * Serializes an array of updates to an XML file or removes the file if the
+ * array length is 0.
+ * @param updates
+ * An array of nsIUpdate objects
+ * @param fileName
+ * The file name in the updates directory to write to.
+ * @return true on success, false on error
+ */
+ _writeUpdatesToXMLFile: async function UM__writeUpdatesToXMLFile(
+ updates,
+ fileName
+ ) {
+ let file;
+ try {
+ file = getUpdateFile([fileName]);
+ } catch (e) {
+ LOG(
+ "UpdateManager:_writeUpdatesToXMLFile - Unable to get XML file - " +
+ "Exception: " +
+ e
+ );
+ return false;
+ }
+ if (!updates.length) {
+ LOG(
+ "UpdateManager:_writeUpdatesToXMLFile - no updates to write. " +
+ "removing file: " +
+ file.path
+ );
+ try {
+ await IOUtils.remove(file.path);
+ } catch (e) {
+ LOG(
+ "UpdateManager:_writeUpdatesToXMLFile - Delete file exception: " + e
+ );
+ return false;
+ }
+ return true;
+ }
+
+ const EMPTY_UPDATES_DOCUMENT_OPEN =
+ '<?xml version="1.0"?><updates xmlns="' + URI_UPDATE_NS + '">';
+ const EMPTY_UPDATES_DOCUMENT_CLOSE = "</updates>";
+ try {
+ var parser = new DOMParser();
+ var doc = parser.parseFromString(
+ EMPTY_UPDATES_DOCUMENT_OPEN + EMPTY_UPDATES_DOCUMENT_CLOSE,
+ "text/xml"
+ );
+
+ for (var i = 0; i < updates.length; ++i) {
+ doc.documentElement.appendChild(updates[i].serialize(doc));
+ }
+
+ var xml =
+ EMPTY_UPDATES_DOCUMENT_OPEN +
+ doc.documentElement.innerHTML +
+ EMPTY_UPDATES_DOCUMENT_CLOSE;
+ // If the destination file existed and is removed while the following is
+ // being performed the copy of the tmp file to the destination file will
+ // fail.
+ await IOUtils.writeUTF8(file.path, xml, {
+ tmpPath: file.path + ".tmp",
+ });
+ await IOUtils.setPermissions(file.path, FileUtils.PERMS_FILE);
+ } catch (e) {
+ LOG("UpdateManager:_writeUpdatesToXMLFile - Exception: " + e);
+ return false;
+ }
+ return true;
+ },
+
+ _updatesXMLSaver: null,
+ _updatesXMLSaverCallback: null,
+ /**
+ * See nsIUpdateService.idl
+ */
+ saveUpdates: function UM_saveUpdates() {
+ if (!this._updatesXMLSaver) {
+ this._updatesXMLSaverCallback = () => this._updatesXMLSaver.finalize();
+
+ this._updatesXMLSaver = new lazy.DeferredTask(
+ () => this._saveUpdatesXML(),
+ XML_SAVER_INTERVAL_MS
+ );
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "UpdateManager: writing update xml data",
+ this._updatesXMLSaverCallback
+ );
+ } else {
+ this._updatesXMLSaver.disarm();
+ }
+
+ this._updatesXMLSaver.arm();
+ },
+
+ /**
+ * Saves the active-updates.xml and updates.xml when the updates history has
+ * been modified files.
+ */
+ _saveUpdatesXML: function UM__saveUpdatesXML() {
+ // This mechanism for how we store the updates might seem a bit odd, since,
+ // if only one update is stored, we don't know if it's the ready update or
+ // the downloading update. However, we can determine which it is by reading
+ // update.status. If we read STATE_DOWNLOADING, it must be a downloading
+ // update and otherwise it's a ready update. This method has the additional
+ // advantage of requiring no migration from when we used to only store a
+ // single active update.
+ let updates = [];
+ if (this._readyUpdate) {
+ updates.push(this._readyUpdate);
+ }
+ if (this._downloadingUpdate) {
+ updates.push(this._downloadingUpdate);
+ }
+
+ // The active update stored in the active-update.xml file will change during
+ // the lifetime of an active update and the file should always be updated
+ // when saveUpdates is called.
+ let promises = [];
+ promises[0] = this._writeUpdatesToXMLFile(updates, FILE_ACTIVE_UPDATE_XML);
+ // The update history stored in the updates.xml file should only need to be
+ // updated when an active update has been added to it in which case
+ // |_updatesDirty| will be true.
+ if (this._updatesDirty) {
+ this._updatesDirty = false;
+ promises[1] = this._writeUpdatesToXMLFile(
+ this._getUpdates(),
+ FILE_UPDATES_XML
+ );
+ }
+ return Promise.all(promises);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ refreshUpdateStatus: async function UM_refreshUpdateStatus() {
+ try {
+ LOG("UpdateManager:refreshUpdateStatus - Staging done.");
+
+ var update = this._readyUpdate;
+ if (!update) {
+ LOG("UpdateManager:refreshUpdateStatus - Missing ready update?");
+ return;
+ }
+
+ var status = readStatusFile(getReadyUpdateDir());
+ pingStateAndStatusCodes(update, false, status);
+ LOG(`UpdateManager:refreshUpdateStatus - status = ${status}`);
+
+ let parts = status.split(":");
+ update.state = parts[0];
+ if (update.state == STATE_APPLYING) {
+ LOG(
+ "UpdateManager:refreshUpdateStatus - Staging appears to have crashed."
+ );
+ update.state = STATE_FAILED;
+ update.errorCode = ERR_UPDATER_CRASHED;
+ } else if (update.state == STATE_FAILED) {
+ LOG("UpdateManager:refreshUpdateStatus - Staging failed.");
+ if (parts[1]) {
+ update.errorCode = parseInt(parts[1]) || INVALID_UPDATER_STATUS_CODE;
+ } else {
+ update.errorCode = INVALID_UPDATER_STATUS_CODE;
+ }
+ }
+
+ // Rotate the update logs so the update log isn't removed if a complete
+ // update is downloaded. By passing false the patch directory won't be
+ // removed.
+ cleanUpReadyUpdateDir(false);
+
+ if (update.state == STATE_FAILED) {
+ let isMemError = isMemoryAllocationErrorCode(update.errorCode);
+ if (
+ update.errorCode == DELETE_ERROR_STAGING_LOCK_FILE ||
+ update.errorCode == UNEXPECTED_STAGING_ERROR ||
+ isMemError
+ ) {
+ update.state = getBestPendingState();
+ writeStatusFile(getReadyUpdateDir(), update.state);
+ if (isMemError) {
+ LOG(
+ `UpdateManager:refreshUpdateStatus - Updater failed to ` +
+ `allocate enough memory to successfully stage. Setting ` +
+ `status to "${update.state}"`
+ );
+ } else {
+ LOG(
+ `UpdateManager:refreshUpdateStatus - Unexpected staging error. ` +
+ `Setting status to "${update.state}"`
+ );
+ }
+ } else if (isServiceSpecificErrorCode(update.errorCode)) {
+ // Sometimes when staging, we might encounter an error that is
+ // specific to the Maintenance Service. If this happens, we should try
+ // to update without the Service.
+ LOG(
+ `UpdateManager:refreshUpdateStatus - Encountered service ` +
+ `specific error code: ${update.errorCode}. Will try installing ` +
+ `update without the Maintenance Service. Setting state to pending`
+ );
+ update.state = STATE_PENDING;
+ writeStatusFile(getReadyUpdateDir(), update.state);
+ } else {
+ LOG(
+ "UpdateManager:refreshUpdateStatus - Attempting handleUpdateFailure"
+ );
+ if (!handleUpdateFailure(update)) {
+ LOG(
+ "UpdateManager:refreshUpdateStatus - handleUpdateFailure " +
+ "failed. Attempting to fall back to complete update."
+ );
+ await handleFallbackToCompleteUpdate();
+ }
+ }
+ }
+ if (update.state == STATE_APPLIED && shouldUseService()) {
+ LOG(
+ `UpdateManager:refreshUpdateStatus - Staging successful. ` +
+ `Setting status to "${STATE_APPLIED_SERVICE}"`
+ );
+ writeStatusFile(
+ getReadyUpdateDir(),
+ (update.state = STATE_APPLIED_SERVICE)
+ );
+ }
+
+ // Now that the active update's properties have been updated write the
+ // active-update.xml to disk. Since there have been no changes to the
+ // update history the updates.xml will not be written to disk.
+ this.saveUpdates();
+
+ // Send an observer notification which the app update doorhanger uses to
+ // display a restart notification after any langpacks have staged.
+ await promiseLangPacksUpdated(update);
+
+ if (
+ update.state == STATE_APPLIED ||
+ update.state == STATE_APPLIED_SERVICE ||
+ update.state == STATE_PENDING ||
+ update.state == STATE_PENDING_SERVICE ||
+ update.state == STATE_PENDING_ELEVATE
+ ) {
+ LOG("UpdateManager:refreshUpdateStatus - Setting state STATE_PENDING");
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ }
+
+ LOG(
+ "UpdateManager:refreshUpdateStatus - Notifying observers that " +
+ "the update was staged. topic: update-staged, status: " +
+ update.state
+ );
+ Services.obs.notifyObservers(update, "update-staged", update.state);
+ } finally {
+ // This function being called is the one thing that tells us that staging
+ // is done so be very sure that we don't exit it leaving the current
+ // state at STATE_STAGING.
+ // The only cases where we haven't already done a state transition are
+ // error cases, so if another state isn't set, assume that we hit an error
+ // and aborted the update.
+ if (
+ lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_STAGING
+ ) {
+ LOG("UpdateManager:refreshUpdateStatus - Setting state STATE_IDLE");
+ transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE);
+ }
+ }
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ elevationOptedIn: function UM_elevationOptedIn() {
+ // The user has been been made aware that the update requires elevation.
+ let update = this._readyUpdate;
+ if (!update) {
+ return;
+ }
+ let status = readStatusFile(getReadyUpdateDir());
+ let parts = status.split(":");
+ update.state = parts[0];
+ if (update.state == STATE_PENDING_ELEVATE) {
+ LOG("UpdateManager:elevationOptedIn - Setting state to pending.");
+ // Proceed with the pending update.
+ // Note: STATE_PENDING_ELEVATE stands for "pending user's approval to
+ // proceed with an elevated update". As long as we see this state, we will
+ // notify the user of the availability of an update that requires
+ // elevation. |elevationOptedIn| (this function) is called when the user
+ // gives us approval to proceed, so we want to switch to STATE_PENDING.
+ // The updater then detects whether or not elevation is required and
+ // displays the elevation prompt if necessary. This last step does not
+ // depend on the state in the status file.
+ writeStatusFile(getReadyUpdateDir(), STATE_PENDING);
+ } else {
+ LOG("UpdateManager:elevationOptedIn - Not in pending-elevate state.");
+ }
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ cleanupDownloadingUpdate: function UM_cleanupDownloadingUpdate() {
+ LOG(
+ "UpdateManager:cleanupDownloadingUpdate - cleaning up downloading update."
+ );
+ cleanupDownloadingUpdate();
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ cleanupReadyUpdate: function UM_cleanupReadyUpdate() {
+ LOG("UpdateManager:cleanupReadyUpdate - cleaning up ready update.");
+ cleanupReadyUpdate();
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ doInstallCleanup: async function UM_doInstallCleanup(isUninstall) {
+ LOG("UpdateManager:doInstallCleanup - cleaning up");
+ let completionPromises = [];
+
+ const delete_or_log = path =>
+ IOUtils.remove(path).catch(ex =>
+ console.error(`Failed to delete ${path}`, ex)
+ );
+
+ for (const key of [KEY_OLD_UPDROOT, KEY_UPDROOT]) {
+ const root = Services.dirsvc.get(key, Ci.nsIFile);
+
+ const activeUpdateXml = root.clone();
+ activeUpdateXml.append(FILE_ACTIVE_UPDATE_XML);
+ completionPromises.push(delete_or_log(activeUpdateXml.path));
+
+ const downloadingMar = root.clone();
+ downloadingMar.append(DIR_UPDATES);
+ downloadingMar.append(DIR_UPDATE_DOWNLOADING);
+ downloadingMar.append(FILE_UPDATE_MAR);
+ completionPromises.push(delete_or_log(downloadingMar.path));
+
+ const readyDir = root.clone();
+ readyDir.append(DIR_UPDATES);
+ readyDir.append(DIR_UPDATE_READY);
+ const readyMar = readyDir.clone();
+ readyMar.append(FILE_UPDATE_MAR);
+ completionPromises.push(delete_or_log(readyMar.path));
+ const readyStatus = readyDir.clone();
+ readyStatus.append(FILE_UPDATE_STATUS);
+ completionPromises.push(delete_or_log(readyStatus.path));
+ const versionFile = readyDir.clone();
+ versionFile.append(FILE_UPDATE_VERSION);
+ completionPromises.push(delete_or_log(versionFile.path));
+ }
+
+ return Promise.allSettled(completionPromises);
+ },
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ doUninstallCleanup: async function UM_doUninstallCleanup(isUninstall) {
+ LOG("UpdateManager:doUninstallCleanup - cleaning up.");
+ let completionPromises = [];
+
+ completionPromises.push(
+ IOUtils.remove(Services.dirsvc.get(KEY_UPDROOT, Ci.nsIFile).path, {
+ recursive: true,
+ }).catch(ex => console.error("Failed to remove update directory", ex))
+ );
+ completionPromises.push(
+ IOUtils.remove(Services.dirsvc.get(KEY_OLD_UPDROOT, Ci.nsIFile).path, {
+ recursive: true,
+ }).catch(ex => console.error("Failed to remove old update directory", ex))
+ );
+
+ return Promise.allSettled(completionPromises);
+ },
+
+ classID: Components.ID("{093C2356-4843-4C65-8709-D7DBCBBE7DFB}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIUpdateManager", "nsIObserver"]),
+};
+
+/**
+ * CheckerService
+ * Provides an interface for checking for new updates. When more checks are
+ * made while an equivalent check is already in-progress, they will be coalesced
+ * into a single update check request.
+ */
+export class CheckerService {
+ #nextUpdateCheckId = 1;
+
+ // Most of the update checking data is looked up via a "request key". This
+ // allows us to lookup the request key for a particular check id, since
+ // multiple checks can correspond to a single request.
+ // When a check is cancelled or completed, it will be removed from this
+ // object.
+ #requestKeyByCheckId = {};
+
+ // This object will relate request keys to update check data objects. The
+ // format of the update check data objects is defined by
+ // #makeUpdateCheckDataObject, below.
+ // When an update request is cancelled (by all of the corresponding update
+ // checks being cancelled) or completed, its key will be removed from this
+ // object.
+ #updateCheckData = {};
+
+ #makeUpdateCheckDataObject(type, promise) {
+ return { type, promise, request: null };
+ }
+
+ /**
+ * Indicates whether the passed parameter is one of the valid enumerated
+ * values that indicates a type of update check.
+ */
+ #validUpdateCheckType(checkType) {
+ return [
+ Ci.nsIUpdateChecker.BACKGROUND_CHECK,
+ Ci.nsIUpdateChecker.FOREGROUND_CHECK,
+ ].includes(checkType);
+ }
+
+ #getCanMigrate() {
+ if (AppConstants.platform != "win") {
+ return false;
+ }
+
+ // The first element of the array is whether the build target is 32 or 64
+ // bit and the third element of the array is whether the client's Windows OS
+ // system processor is 32 or 64 bit.
+ let aryABI = lazy.UpdateUtils.ABI.split("-");
+ if (aryABI[0] != "x86" || aryABI[2] != "x64") {
+ return false;
+ }
+
+ let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+
+ let regPath =
+ "SOFTWARE\\Mozilla\\" + Services.appinfo.name + "\\32to64DidMigrate";
+ let regValHKCU = lazy.WindowsRegistry.readRegKey(
+ wrk.ROOT_KEY_CURRENT_USER,
+ regPath,
+ "Never",
+ wrk.WOW64_32
+ );
+ let regValHKLM = lazy.WindowsRegistry.readRegKey(
+ wrk.ROOT_KEY_LOCAL_MACHINE,
+ regPath,
+ "Never",
+ wrk.WOW64_32
+ );
+ // The Never registry key value allows configuring a system to never migrate
+ // any of the installations.
+ if (regValHKCU === 1 || regValHKLM === 1) {
+ LOG(
+ "CheckerService:#getCanMigrate - all installations should not be " +
+ "migrated"
+ );
+ return false;
+ }
+
+ let appBaseDirPath = getAppBaseDir().path;
+ regValHKCU = lazy.WindowsRegistry.readRegKey(
+ wrk.ROOT_KEY_CURRENT_USER,
+ regPath,
+ appBaseDirPath,
+ wrk.WOW64_32
+ );
+ regValHKLM = lazy.WindowsRegistry.readRegKey(
+ wrk.ROOT_KEY_LOCAL_MACHINE,
+ regPath,
+ appBaseDirPath,
+ wrk.WOW64_32
+ );
+ // When the registry value is 1 for the installation directory path value
+ // name then the installation has already been migrated once or the system
+ // was configured to not migrate that installation.
+ if (regValHKCU === 1 || regValHKLM === 1) {
+ LOG(
+ "CheckerService:#getCanMigrate - this installation should not be " +
+ "migrated"
+ );
+ return false;
+ }
+
+ // When the registry value is 0 for the installation directory path value
+ // name then the installation has updated to Firefox 56 and can be migrated.
+ if (regValHKCU === 0 || regValHKLM === 0) {
+ LOG("CheckerService:#getCanMigrate - this installation can be migrated");
+ return true;
+ }
+
+ LOG(
+ "CheckerService:#getCanMigrate - no registry entries for this " +
+ "installation"
+ );
+ return false;
+ }
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ async getUpdateURL(checkType) {
+ LOG("CheckerService:getUpdateURL - checkType: " + checkType);
+ if (!this.#validUpdateCheckType(checkType)) {
+ LOG("CheckerService:getUpdateURL - Invalid checkType");
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let url = Services.appinfo.updateURL;
+ let updatePin;
+
+ if (Services.policies) {
+ let policies = Services.policies.getActivePolicies();
+ if (policies) {
+ if ("AppUpdateURL" in policies) {
+ url = policies.AppUpdateURL.toString();
+ }
+ if ("AppUpdatePin" in policies) {
+ updatePin = policies.AppUpdatePin;
+
+ // Scalar ID: update.version_pin
+ AUSTLMY.pingPinPolicy(updatePin);
+ }
+ }
+ }
+
+ if (!url) {
+ LOG("CheckerService:getUpdateURL - update URL not defined");
+ return null;
+ }
+
+ url = await lazy.UpdateUtils.formatUpdateURL(url);
+
+ if (checkType == Ci.nsIUpdateChecker.FOREGROUND_CHECK) {
+ url += (url.includes("?") ? "&" : "?") + "force=1";
+ }
+
+ if (this.#getCanMigrate()) {
+ url += (url.includes("?") ? "&" : "?") + "mig64=1";
+ }
+
+ if (updatePin) {
+ url +=
+ (url.includes("?") ? "&" : "?") +
+ "pin=" +
+ encodeURIComponent(updatePin);
+ }
+
+ LOG("CheckerService:getUpdateURL - update URL: " + url);
+ return url;
+ }
+
+ /**
+ * See nsIUpdateService.idl
+ */
+
+ checkForUpdates(checkType) {
+ LOG("CheckerService:checkForUpdates - checkType: " + checkType);
+ if (!this.#validUpdateCheckType(checkType)) {
+ LOG("CheckerService:checkForUpdates - Invalid checkType");
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let checkId = this.#nextUpdateCheckId;
+ this.#nextUpdateCheckId += 1;
+
+ // `checkType == FOREGROUND_CHECK`` can override `canCheckForUpdates`. But
+ // nothing should override enterprise policies.
+ if (lazy.AUS.disabled) {
+ LOG("CheckerService:checkForUpdates - disabled by policy");
+ return this.#getChecksNotAllowedObject(checkId);
+ }
+ if (
+ checkType == Ci.nsIUpdateChecker.BACKGROUND_CHECK &&
+ !lazy.AUS.canCheckForUpdates
+ ) {
+ LOG("CheckerService:checkForUpdates - !canCheckForUpdates");
+ return this.#getChecksNotAllowedObject(checkId);
+ }
+
+ // We want to combine simultaneous requests, but only ones that are
+ // equivalent. If, say, one of them uses the force parameter and one
+ // doesn't, we want those two requests to remain separate. This key will
+ // allow us to map equivalent requests together. It is also the key that we
+ // use to lookup the update check data in this.#updateCheckData.
+ let requestKey = checkType;
+
+ if (requestKey in this.#updateCheckData) {
+ LOG(
+ `CheckerService:checkForUpdates - Connecting check id ${checkId} to ` +
+ `existing check request.`
+ );
+ } else {
+ LOG(
+ `CheckerService:checkForUpdates - Making new check request for check ` +
+ `id ${checkId}.`
+ );
+ this.#updateCheckData[requestKey] = this.#makeUpdateCheckDataObject(
+ checkType,
+ this.#updateCheck(checkType, requestKey)
+ );
+ }
+
+ this.#requestKeyByCheckId[checkId] = requestKey;
+
+ return {
+ id: checkId,
+ result: this.#updateCheckData[requestKey].promise,
+ QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheck"]),
+ };
+ }
+
+ #getChecksNotAllowedObject(checkId) {
+ return {
+ id: checkId,
+ result: Promise.resolve(
+ Object.freeze({
+ checksAllowed: false,
+ succeeded: false,
+ request: null,
+ updates: [],
+ QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckResult"]),
+ })
+ ),
+ QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheck"]),
+ };
+ }
+
+ async #updateCheck(checkType, requestKey) {
+ await waitForOtherInstances();
+
+ let url;
+ try {
+ url = await this.getUpdateURL(checkType);
+ } catch (ex) {}
+
+ if (!url) {
+ LOG("CheckerService:#updateCheck - !url");
+ return this.#getCheckFailedObject("update_url_not_available");
+ }
+
+ let request = new XMLHttpRequest();
+ request.open("GET", url, true);
+ // Prevent the request from reading from the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to the cache.
+ request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ // Disable cutting edge features, like TLS 1.3, where middleboxes might
+ // brick us
+ request.channel.QueryInterface(
+ Ci.nsIHttpChannelInternal
+ ).beConservative = true;
+
+ request.overrideMimeType("text/xml");
+ // The Cache-Control header is only interpreted by proxies and the
+ // final destination. It does not help if a resource is already
+ // cached locally.
+ request.setRequestHeader("Cache-Control", "no-cache");
+ // HTTP/1.0 servers might not implement Cache-Control and
+ // might only implement Pragma: no-cache
+ request.setRequestHeader("Pragma", "no-cache");
+
+ const UPDATE_CHECK_LOAD_SUCCESS = 1;
+ const UPDATE_CHECK_LOAD_ERROR = 2;
+ const UPDATE_CHECK_CANCELLED = 3;
+
+ let result = await new Promise(resolve => {
+ // It's important that nothing potentially asynchronous happens between
+ // checking if the request has been cancelled and starting the request.
+ // If an update check cancellation happens before dispatching the request
+ // and we end up dispatching it anyways, we will never call cancel on the
+ // request later and the cancellation effectively won't happen.
+ if (!(requestKey in this.#updateCheckData)) {
+ LOG(
+ "CheckerService:#updateCheck - check was cancelled before request " +
+ "was able to start"
+ );
+ resolve(UPDATE_CHECK_CANCELLED);
+ return;
+ }
+
+ let onLoad = event => {
+ request.removeEventListener("load", onLoad);
+ LOG("CheckerService:#updateCheck - request got 'load' event");
+ resolve(UPDATE_CHECK_LOAD_SUCCESS);
+ };
+ request.addEventListener("load", onLoad);
+ let onError = event => {
+ request.removeEventListener("error", onLoad);
+ LOG("CheckerService:#updateCheck - request got 'error' event");
+ resolve(UPDATE_CHECK_LOAD_ERROR);
+ };
+ request.addEventListener("error", onError);
+
+ LOG("CheckerService:#updateCheck - sending request to: " + url);
+ request.send(null);
+ this.#updateCheckData[requestKey].request = request;
+ });
+
+ // Remove all entries for this request key. This marks the request and the
+ // associated check ids as no longer in-progress.
+ delete this.#updateCheckData[requestKey];
+ for (const checkId of Object.keys(this.#requestKeyByCheckId)) {
+ if (this.#requestKeyByCheckId[checkId] == requestKey) {
+ delete this.#requestKeyByCheckId[checkId];
+ }
+ }
+
+ if (result == UPDATE_CHECK_CANCELLED) {
+ return this.#getCheckFailedObject(Cr.NS_BINDING_ABORTED);
+ }
+
+ if (result == UPDATE_CHECK_LOAD_ERROR) {
+ let status = this.#getChannelStatus(request);
+ LOG("CheckerService:#updateCheck - Failed. request.status: " + status);
+
+ // Set MitM pref.
+ try {
+ let secInfo = request.channel.securityInfo;
+ if (secInfo.serverCert && secInfo.serverCert.issuerName) {
+ Services.prefs.setStringPref(
+ "security.pki.mitm_canary_issuer",
+ secInfo.serverCert.issuerName
+ );
+ }
+ } catch (e) {
+ LOG("CheckerService:#updateCheck - Getting secInfo failed.");
+ }
+
+ return this.#getCheckFailedObject(status, 404, request);
+ }
+
+ LOG("CheckerService:#updateCheck - request completed downloading document");
+ Services.prefs.clearUserPref("security.pki.mitm_canary_issuer");
+ // Check whether there is a mitm, i.e. check whether the root cert is
+ // built-in or not.
+ try {
+ let sslStatus = request.channel.securityInfo;
+ if (sslStatus) {
+ Services.prefs.setBoolPref(
+ "security.pki.mitm_detected",
+ !sslStatus.isBuiltCertChainRootBuiltInRoot
+ );
+ }
+ } catch (e) {
+ LOG("CheckerService:#updateCheck - Getting sslStatus failed.");
+ }
+
+ let updates;
+ try {
+ // Analyze the resulting DOM and determine the set of updates.
+ updates = this.#parseUpdates(request);
+ } catch (e) {
+ LOG(
+ "CheckerService:#updateCheck - there was a problem checking for " +
+ "updates. Exception: " +
+ e
+ );
+ let status = this.#getChannelStatus(request);
+ // If we can't find an error string specific to this status code,
+ // just use the 200 message from above, which means everything
+ // "looks" fine but there was probably an XML error or a bogus file.
+ return this.#getCheckFailedObject(status, 200, request);
+ }
+
+ LOG(
+ "CheckerService:#updateCheck - number of updates available: " +
+ updates.length
+ );
+
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_BACKGROUNDERRORS)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_BACKGROUNDERRORS);
+ }
+
+ return Object.freeze({
+ checksAllowed: true,
+ succeeded: true,
+ request,
+ updates,
+ QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckResult"]),
+ });
+ }
+
+ /**
+ * @param errorCode
+ * The error code to include in the return value. If possible, we
+ * will get the update status text based on this error code.
+ * @param defaultCode
+ * Optional. The error code to use to get the status text if there
+ * isn't status text available for `errorCode`.
+ * @param request
+ * The XMLHttpRequest used to check for updates. Or null, if one was
+ * never constructed.
+ * @returns An nsIUpdateCheckResult object indicating an error, using the
+ * error data passed to this function.
+ */
+ #getCheckFailedObject(
+ errorCode,
+ defaultCode = Cr.NS_BINDING_FAILED,
+ request = null
+ ) {
+ let update = new Update(null);
+ update.errorCode = errorCode;
+ update.statusText = getStatusTextFromCode(errorCode, defaultCode);
+
+ if (errorCode == Cr.NS_ERROR_OFFLINE) {
+ // We use a separate constant here because nsIUpdate.errorCode is signed
+ update.errorCode = NETWORK_ERROR_OFFLINE;
+ } else if (this.#isHttpStatusCode(errorCode)) {
+ update.errorCode = HTTP_ERROR_OFFSET + errorCode;
+ }
+
+ return Object.freeze({
+ checksAllowed: true,
+ succeeded: false,
+ request,
+ updates: [update],
+ QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckResult"]),
+ });
+ }
+
+ /**
+ * Returns the status code for the XMLHttpRequest
+ */
+ #getChannelStatus(request) {
+ var status = 0;
+ try {
+ status = request.status;
+ } catch (e) {}
+
+ if (status == 0) {
+ status = request.channel.QueryInterface(Ci.nsIRequest).status;
+ }
+ return status;
+ }
+
+ #isHttpStatusCode(status) {
+ return status >= 100 && status <= 599;
+ }
+
+ /**
+ * @param request
+ * The XMLHttpRequest that successfully loaded the update XML.
+ * @returns An array of 0 or more nsIUpdate objects describing the available
+ * updates.
+ * @throws If the XML document element node name is not updates.
+ */
+ #parseUpdates(request) {
+ let updatesElement = request.responseXML.documentElement;
+ if (!updatesElement) {
+ LOG("CheckerService:#parseUpdates - empty updates document?!");
+ return [];
+ }
+
+ if (updatesElement.nodeName != "updates") {
+ LOG("CheckerService:#parseUpdates - unexpected node name!");
+ throw new Error(
+ "Unexpected node name, expected: updates, got: " +
+ updatesElement.nodeName
+ );
+ }
+
+ let updates = [];
+ for (const updateElement of updatesElement.childNodes) {
+ if (
+ updateElement.nodeType != updateElement.ELEMENT_NODE ||
+ updateElement.localName != "update"
+ ) {
+ continue;
+ }
+
+ let update;
+ try {
+ update = new Update(updateElement);
+ } catch (e) {
+ LOG("CheckerService:#parseUpdates - invalid <update/>, ignoring...");
+ continue;
+ }
+ update.serviceURL = request.responseURL;
+ update.channel = lazy.UpdateUtils.UpdateChannel;
+ updates.push(update);
+ }
+
+ return updates;
+ }
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ stopCheck(checkId) {
+ if (!(checkId in this.#requestKeyByCheckId)) {
+ LOG(`CheckerService:stopCheck - Non-existent check id ${checkId}`);
+ return;
+ }
+ LOG(`CheckerService:stopCheck - Cancelling check id ${checkId}`);
+ let requestKey = this.#requestKeyByCheckId[checkId];
+ delete this.#requestKeyByCheckId[checkId];
+ if (Object.values(this.#requestKeyByCheckId).includes(requestKey)) {
+ LOG(
+ `CheckerService:stopCheck - Not actually cancelling request because ` +
+ `other check id's depend on it.`
+ );
+ } else {
+ LOG(
+ `CheckerService:stopCheck - This is the last check using this ` +
+ `request. Cancelling the request now.`
+ );
+ let request = this.#updateCheckData[requestKey].request;
+ delete this.#updateCheckData[requestKey];
+ if (request) {
+ LOG(`CheckerService:stopCheck - Aborting XMLHttpRequest`);
+ request.abort();
+ } else {
+ LOG(
+ `CheckerService:stopCheck - Not aborting XMLHttpRequest. It ` +
+ `doesn't appear to have started yet.`
+ );
+ }
+ }
+ }
+
+ /**
+ * See nsIUpdateService.idl
+ */
+ stopAllChecks() {
+ LOG("CheckerService:stopAllChecks - stopping all checks.");
+ for (const checkId of Object.keys(this.#requestKeyByCheckId)) {
+ this.stopCheck(checkId);
+ }
+ }
+
+ classID = Components.ID("{898CDC9B-E43F-422F-9CC4-2F6291B415A3}");
+ QueryInterface = ChromeUtils.generateQI(["nsIUpdateChecker"]);
+}
+
+/**
+ * Manages the download of updates
+ * @param background
+ * Whether or not this downloader is operating in background
+ * update mode.
+ * @param updateService
+ * The update service that created this downloader.
+ * @constructor
+ */
+function Downloader(updateService) {
+ LOG("Creating Downloader");
+ this.updateService = updateService;
+}
+Downloader.prototype = {
+ /**
+ * The nsIUpdatePatch that we are downloading
+ */
+ _patch: null,
+
+ /**
+ * The nsIUpdate that we are downloading
+ */
+ _update: null,
+
+ /**
+ * The nsIRequest object handling the download.
+ */
+ _request: null,
+
+ /**
+ * Whether or not the update being downloaded is a complete replacement of
+ * the user's existing installation or a patch representing the difference
+ * between the new version and the previous version.
+ */
+ isCompleteUpdate: null,
+
+ /**
+ * We get the nsIRequest from nsIBITS asynchronously. When downloadUpdate has
+ * been called, but this._request is not yet valid, _pendingRequest will be
+ * a promise that will resolve when this._request has been set.
+ */
+ _pendingRequest: null,
+
+ /**
+ * When using BITS, cancel actions happen asynchronously. This variable
+ * keeps track of any cancel action that is in-progress.
+ * If the cancel action fails, this will be set back to null so that the
+ * action can be attempted again. But if the cancel action succeeds, the
+ * resolved promise will remain stored in this variable to prevent cancel
+ * from being called twice (which, for BITS, is an error).
+ */
+ _cancelPromise: null,
+
+ /**
+ * BITS receives progress notifications slowly, unless a user is watching.
+ * This tracks what frequency notifications are happening at.
+ *
+ * This is needed because BITS downloads are started asynchronously.
+ * Specifically, this is needed to prevent a situation where the download is
+ * still starting (Downloader._pendingRequest has not resolved) when the first
+ * observer registers itself. Without this variable, there is no way of
+ * knowing whether the download was started as Active or Idle and, therefore,
+ * we don't know if we need to start Active mode when _pendingRequest
+ * resolves.
+ */
+ _bitsActiveNotifications: false,
+
+ /**
+ * This is a function that when called will stop the update process from
+ * waiting for language pack updates. This is for safety to ensure that a
+ * problem in the add-ons manager doesn't delay updates by much.
+ */
+ _langPackTimeout: null,
+
+ /**
+ * If gOnlyDownloadUpdatesThisSession is true, we prevent the update process
+ * from progressing past the downloading stage. If the download finishes,
+ * pretend that it hasn't in order to keep the current update in the
+ * "downloading" state.
+ */
+ _pretendingDownloadIsNotDone: false,
+
+ /**
+ * Cancels the active download.
+ *
+ * For a BITS download, this will cancel and remove the download job. For
+ * an nsIIncrementalDownload, this will stop the download, but leaves the
+ * data around to allow the transfer to be resumed later.
+ */
+ cancel: async function Downloader_cancel(cancelError) {
+ LOG("Downloader: cancel");
+ if (cancelError === undefined) {
+ cancelError = Cr.NS_BINDING_ABORTED;
+ }
+ if (this.usingBits) {
+ // If a cancel action is already in progress, just return when that
+ // promise resolved. Trying to cancel the same request twice is an error.
+ if (this._cancelPromise) {
+ await this._cancelPromise;
+ return;
+ }
+
+ if (this._pendingRequest) {
+ await this._pendingRequest;
+ }
+ if (this._patch.getProperty("bitsId") != null) {
+ // Make sure that we don't try to resume this download after it was
+ // cancelled.
+ this._patch.deleteProperty("bitsId");
+ }
+ try {
+ this._cancelPromise = this._request.cancelAsync(cancelError);
+ await this._cancelPromise;
+ } catch (e) {
+ // On success, we will not set the cancel promise to null because
+ // we want to prevent two cancellations of the same request. But
+ // retrying after a failed cancel is not an error, so we will set the
+ // cancel promise to null in the failure case.
+ this._cancelPromise = null;
+ throw e;
+ }
+ } else if (this._request && this._request instanceof Ci.nsIRequest) {
+ // Normally, cancelling an nsIIncrementalDownload results in it stopping
+ // the download but leaving the downloaded data so that we can resume the
+ // download later. If we've already finished the download, there is no
+ // transfer to stop.
+ // Note that this differs from the BITS case. Cancelling a BITS job, even
+ // when the transfer has completed, results in all data being deleted.
+ // Therefore, even if the transfer has completed, cancelling a BITS job
+ // has effects that we must not skip.
+ if (this._pretendingDownloadIsNotDone) {
+ LOG(
+ "Downloader: cancel - Ignoring cancel request of finished download"
+ );
+ } else {
+ this._request.cancel(cancelError);
+ }
+ }
+ },
+
+ /**
+ * Verify the downloaded file. We assume that the download is complete at
+ * this point.
+ */
+ _verifyDownload: function Downloader__verifyDownload() {
+ LOG("Downloader:_verifyDownload called");
+ if (!this._request) {
+ AUSTLMY.pingDownloadCode(
+ this.isCompleteUpdate,
+ AUSTLMY.DWNLD_ERR_VERIFY_NO_REQUEST
+ );
+ return false;
+ }
+
+ let destination = getDownloadingUpdateDir();
+ destination.append(FILE_UPDATE_MAR);
+
+ // Ensure that the file size matches the expected file size.
+ if (destination.fileSize != this._patch.size) {
+ LOG("Downloader:_verifyDownload downloaded size != expected size.");
+ AUSTLMY.pingDownloadCode(
+ this.isCompleteUpdate,
+ AUSTLMY.DWNLD_ERR_VERIFY_PATCH_SIZE_NOT_EQUAL
+ );
+ return false;
+ }
+
+ LOG("Downloader:_verifyDownload downloaded size == expected size.");
+ return true;
+ },
+
+ /**
+ * Select the patch to use given the current state of updateDir and the given
+ * set of update patches.
+ * @param update
+ * A nsIUpdate object to select a patch from
+ * @param updateDir
+ * A nsIFile representing the update directory
+ * @return A nsIUpdatePatch object to download
+ */
+ _selectPatch: function Downloader__selectPatch(update, updateDir) {
+ // Given an update to download, we will always try to download the patch
+ // for a partial update over the patch for a full update.
+
+ // Look to see if any of the patches in the Update object has been
+ // pre-selected for download, otherwise we must figure out which one
+ // to select ourselves.
+ var selectedPatch = update.selectedPatch;
+
+ var state = selectedPatch ? selectedPatch.state : STATE_NONE;
+
+ // If this is a patch that we know about, then select it. If it is a patch
+ // that we do not know about, then remove it and use our default logic.
+ var useComplete = false;
+ if (selectedPatch) {
+ LOG(
+ "Downloader:_selectPatch - found existing patch with state: " + state
+ );
+ if (state == STATE_DOWNLOADING) {
+ LOG("Downloader:_selectPatch - resuming download");
+ return selectedPatch;
+ }
+ if (
+ state == STATE_PENDING ||
+ state == STATE_PENDING_SERVICE ||
+ state == STATE_PENDING_ELEVATE ||
+ state == STATE_APPLIED ||
+ state == STATE_APPLIED_SERVICE
+ ) {
+ LOG("Downloader:_selectPatch - already downloaded");
+ return null;
+ }
+
+ // When downloading the patch failed using BITS, there hasn't been an
+ // attempt to download the patch using the internal application download
+ // mechanism, and an attempt to stage or apply the patch hasn't failed
+ // which indicates that a different patch should be downloaded since
+ // re-downloading the same patch with the internal application download
+ // mechanism will likely also fail when trying to stage or apply it then
+ // try to download the same patch using the internal application download
+ // mechanism.
+ selectedPatch.QueryInterface(Ci.nsIWritablePropertyBag);
+ if (
+ selectedPatch.getProperty("bitsResult") != null &&
+ selectedPatch.getProperty("internalResult") == null &&
+ !selectedPatch.errorCode
+ ) {
+ LOG(
+ "Downloader:_selectPatch - Falling back to non-BITS download " +
+ "mechanism for the same patch due to existing BITS result: " +
+ selectedPatch.getProperty("bitsResult")
+ );
+ return selectedPatch;
+ }
+
+ if (update && selectedPatch.type == "complete") {
+ // This is a pretty fatal error. Just bail.
+ LOG("Downloader:_selectPatch - failed to apply complete patch!");
+ cleanupDownloadingUpdate();
+ return null;
+ }
+
+ // Something went wrong when we tried to apply the previous patch.
+ // Try the complete patch next time.
+ useComplete = true;
+ selectedPatch = null;
+ }
+
+ // If we were not able to discover an update from a previous download, we
+ // select the best patch from the given set.
+ var partialPatch = getPatchOfType(update, "partial");
+ if (!useComplete) {
+ selectedPatch = partialPatch;
+ }
+ if (!selectedPatch) {
+ if (lazy.UM.readyUpdate) {
+ // If we already have a ready update, we download partials only.
+ LOG(
+ "Downloader:_selectPatch - not selecting a complete patch because " +
+ "this is not the first download of the session"
+ );
+ return null;
+ }
+
+ if (partialPatch) {
+ partialPatch.selected = false;
+ }
+ selectedPatch = getPatchOfType(update, "complete");
+ }
+
+ // if update only contains a partial patch, selectedPatch == null here if
+ // the partial patch has been attempted and fails and we're trying to get a
+ // complete patch
+ if (selectedPatch) {
+ selectedPatch.selected = true;
+ update.isCompleteUpdate = selectedPatch.type == "complete";
+ }
+
+ LOG(
+ "Downloader:_selectPatch - Patch selected. Assigning update to " +
+ "downloadingUpdate."
+ );
+ lazy.UM.downloadingUpdate = update;
+
+ return selectedPatch;
+ },
+
+ /**
+ * Whether or not the user wants to be notified that an update is being
+ * downloaded.
+ */
+ get _notifyDuringDownload() {
+ return Services.prefs.getBoolPref(
+ PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD,
+ false
+ );
+ },
+
+ _notifyDownloadStatusObservers:
+ function Downloader_notifyDownloadStatusObservers() {
+ if (this._notifyDuringDownload) {
+ let status = this.updateService.isDownloading ? "downloading" : "idle";
+ Services.obs.notifyObservers(
+ this._update,
+ "update-downloading",
+ status
+ );
+ }
+ },
+
+ /**
+ * Whether or not we are currently downloading something.
+ */
+ get isBusy() {
+ return this._request != null || this._pendingRequest != null;
+ },
+
+ get usingBits() {
+ return this._pendingRequest != null || this._request instanceof BitsRequest;
+ },
+
+ /**
+ * Returns true if the specified patch can be downloaded with BITS.
+ */
+ _canUseBits: function Downloader__canUseBits(patch) {
+ if (getCanUseBits() != "CanUseBits") {
+ // This will have printed its own logging. No need to print more.
+ return false;
+ }
+ // Regardless of success or failure, don't download the same patch with BITS
+ // twice.
+ if (patch.getProperty("bitsResult") != null) {
+ LOG(
+ "Downloader:_canUseBits - Not using BITS because it was already tried"
+ );
+ return false;
+ }
+ LOG("Downloader:_canUseBits - Patch is able to use BITS download");
+ return true;
+ },
+
+ /**
+ * Instruct the add-ons manager to start downloading language pack updates in
+ * preparation for the current update.
+ */
+ _startLangPackUpdates: function Downloader__startLangPackUpdates() {
+ if (!Services.prefs.getBoolPref(PREF_APP_UPDATE_LANGPACK_ENABLED, false)) {
+ return;
+ }
+
+ // A promise that we can resolve at some point to time out the language pack
+ // update process.
+ let timeoutPromise = new Promise(resolve => {
+ this._langPackTimeout = resolve;
+ });
+
+ let update = unwrap(this._update);
+
+ let existing = LangPackUpdates.get(update);
+ if (existing) {
+ // We have already started staging lang packs for this update, no need to
+ // do it again.
+ return;
+ }
+
+ // Note that we don't care about success or failure here, either way we will
+ // continue with the update process.
+ let langPackPromise = lazy.AddonManager.stageLangpacksForAppUpdate(
+ update.appVersion,
+ update.appVersion
+ )
+ .catch(error => {
+ LOG(
+ `Add-ons manager threw exception while updating language packs: ${error}`
+ );
+ })
+ .finally(() => {
+ this._langPackTimeout = null;
+
+ if (TelemetryStopwatch.running("UPDATE_LANGPACK_OVERTIME", update)) {
+ TelemetryStopwatch.finish("UPDATE_LANGPACK_OVERTIME", update);
+ }
+ });
+
+ LangPackUpdates.set(
+ update,
+ Promise.race([langPackPromise, timeoutPromise])
+ );
+ },
+
+ /**
+ * Download and stage the given update.
+ * @param update
+ * A nsIUpdate object to download a patch for. Cannot be null.
+ */
+ downloadUpdate: async function Downloader_downloadUpdate(update) {
+ LOG("UpdateService:downloadUpdate");
+ if (!update) {
+ AUSTLMY.pingDownloadCode(undefined, AUSTLMY.DWNLD_ERR_NO_UPDATE);
+ throw Components.Exception("", Cr.NS_ERROR_NULL_POINTER);
+ }
+
+ var updateDir = getDownloadingUpdateDir();
+
+ this._update = update;
+
+ // This function may return null, which indicates that there are no patches
+ // to download.
+ this._patch = this._selectPatch(update, updateDir);
+ if (!this._patch) {
+ LOG("Downloader:downloadUpdate - no patch to download");
+ AUSTLMY.pingDownloadCode(undefined, AUSTLMY.DWNLD_ERR_NO_UPDATE_PATCH);
+ return false;
+ }
+ // The update and the patch implement nsIWritablePropertyBag. Expose that
+ // interface immediately after a patch is assigned so that
+ // this.(_patch|_update).(get|set)Property can always safely be called.
+ this._update.QueryInterface(Ci.nsIWritablePropertyBag);
+ this._patch.QueryInterface(Ci.nsIWritablePropertyBag);
+
+ if (
+ this._update.getProperty("disableBackgroundUpdates") != null &&
+ lazy.gIsBackgroundTaskMode
+ ) {
+ LOG(
+ "Downloader:downloadUpdate - Background update disabled by update " +
+ "advertisement"
+ );
+ return false;
+ }
+
+ this.isCompleteUpdate = this._patch.type == "complete";
+
+ let canUseBits = false;
+ // Allow the advertised update to disable BITS.
+ if (this._update.getProperty("disableBITS") != null) {
+ LOG(
+ "Downloader:downloadUpdate - BITS downloads disabled by update " +
+ "advertisement"
+ );
+ } else {
+ canUseBits = this._canUseBits(this._patch);
+ }
+
+ if (!canUseBits) {
+ this._pendingRequest = null;
+
+ let patchFile = updateDir.clone();
+ patchFile.append(FILE_UPDATE_MAR);
+
+ if (lazy.gIsBackgroundTaskMode) {
+ // We don't normally run a background update if we can't use BITS, but
+ // this branch is possible because we do fall back from BITS failures by
+ // attempting an internal download.
+ // If this happens, we are just going to need to wait for interactive
+ // Firefox to download the update. We don't, however, want to be in the
+ // "downloading" state when interactive Firefox runs because we want to
+ // download the newest update available which, at that point, may not be
+ // the one that we are currently trying to download.
+ // However, we can't just unconditionally clobber the current update
+ // because interactive Firefox might already be part way through an
+ // internal update download, and we definitely don't want to interrupt
+ // that.
+ let readyUpdateDir = getReadyUpdateDir();
+ let status = readStatusFile(readyUpdateDir);
+ // nsIIncrementalDownload doesn't use an intermediate download location
+ // for partially downloaded files. If we have started an update
+ // download with it, it will be available at its ultimate location.
+ if (!(status == STATE_DOWNLOADING && patchFile.exists())) {
+ LOG(
+ "Downloader:downloadUpdate - Can't download with internal " +
+ "downloader from a background task. Cleaning up downloading " +
+ "update."
+ );
+ cleanupDownloadingUpdate();
+ }
+ return false;
+ }
+
+ // The interval is 0 since there is no need to throttle downloads.
+ let interval = 0;
+
+ LOG(
+ "Downloader:downloadUpdate - Starting nsIIncrementalDownload with " +
+ "url: " +
+ this._patch.URL +
+ ", path: " +
+ patchFile.path +
+ ", interval: " +
+ interval
+ );
+ let uri = Services.io.newURI(this._patch.URL);
+
+ this._request = Cc[
+ "@mozilla.org/network/incremental-download;1"
+ ].createInstance(Ci.nsIIncrementalDownload);
+ this._request.init(uri, patchFile, DOWNLOAD_CHUNK_SIZE, interval);
+ this._request.start(this, null);
+ } else {
+ let noProgressTimeout = BITS_IDLE_NO_PROGRESS_TIMEOUT_SECS;
+ let monitorInterval = BITS_IDLE_POLL_RATE_MS;
+ this._bitsActiveNotifications = false;
+ // The monitor's timeout should be much greater than the longest monitor
+ // poll interval. If the timeout is too short, delay in the pipe to the
+ // update agent might cause BITS to falsely report an error, causing an
+ // unnecessary fallback to nsIIncrementalDownload.
+ let monitorTimeout = Math.max(10 * monitorInterval, 10 * 60 * 1000);
+ if (this.hasDownloadListeners) {
+ noProgressTimeout = BITS_ACTIVE_NO_PROGRESS_TIMEOUT_SECS;
+ monitorInterval = BITS_ACTIVE_POLL_RATE_MS;
+ this._bitsActiveNotifications = true;
+ }
+
+ let updateRootDir = FileUtils.getDir(KEY_UPDROOT, []);
+ try {
+ updateRootDir.create(
+ Ci.nsIFile.DIRECTORY_TYPE,
+ FileUtils.PERMS_DIRECTORY
+ );
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw ex;
+ }
+ // Ignore the exception due to a directory that already exists.
+ }
+
+ let jobName = "MozillaUpdate " + updateRootDir.leafName;
+ let updatePath = updateDir.path;
+ if (!Bits.initialized) {
+ Bits.init(jobName, updatePath, monitorTimeout);
+ }
+
+ this._cancelPromise = null;
+
+ let bitsId = this._patch.getProperty("bitsId");
+ if (bitsId) {
+ LOG(
+ "Downloader:downloadUpdate - Connecting to in-progress download. " +
+ "BITS ID: " +
+ bitsId
+ );
+
+ this._pendingRequest = Bits.monitorDownload(
+ bitsId,
+ monitorInterval,
+ this,
+ null
+ );
+ } else {
+ LOG(
+ "Downloader:downloadUpdate - Starting BITS download with url: " +
+ this._patch.URL +
+ ", updateDir: " +
+ updatePath +
+ ", filename: " +
+ FILE_UPDATE_MAR
+ );
+
+ this._pendingRequest = Bits.startDownload(
+ this._patch.URL,
+ FILE_UPDATE_MAR,
+ Ci.nsIBits.PROXY_PRECONFIG,
+ noProgressTimeout,
+ monitorInterval,
+ this,
+ null
+ );
+ }
+ let request;
+ try {
+ request = await this._pendingRequest;
+ } catch (error) {
+ if (
+ (error.type == Ci.nsIBits.ERROR_TYPE_FAILED_TO_GET_BITS_JOB ||
+ error.type == Ci.nsIBits.ERROR_TYPE_FAILED_TO_CONNECT_TO_BCM) &&
+ error.action == Ci.nsIBits.ERROR_ACTION_MONITOR_DOWNLOAD &&
+ error.stage == Ci.nsIBits.ERROR_STAGE_BITS_CLIENT &&
+ error.codeType == Ci.nsIBits.ERROR_CODE_TYPE_HRESULT &&
+ error.code == HRESULT_E_ACCESSDENIED
+ ) {
+ LOG(
+ "Downloader:downloadUpdate - Failed to connect to existing " +
+ "BITS job. It is likely owned by another user."
+ );
+ // This isn't really a failure code since the BITS job may be working
+ // just fine on another account, so convert this to a code that
+ // indicates that. This will make it easier to identify in telemetry.
+ error.type = Ci.nsIBits.ERROR_TYPE_ACCESS_DENIED_EXPECTED;
+ error.codeType = Ci.nsIBits.ERROR_CODE_TYPE_NONE;
+ error.code = null;
+ // When we detect this situation, disable BITS until Firefox shuts
+ // down. There are a couple of reasons for this. First, without any
+ // kind of flag, we enter an infinite loop here where we keep trying
+ // BITS over and over again (normally setting bitsResult prevents
+ // this, but we don't know the result of the BITS job, so we don't
+ // want to set that). Second, since we are trying to update, this
+ // process must have the update mutex. We don't ever give up the
+ // update mutex, so even if the other user starts Firefox, they will
+ // not complete the BITS job while this Firefox instance is around.
+ gBITSInUseByAnotherUser = true;
+ } else {
+ this._patch.setProperty("bitsResult", Cr.NS_ERROR_FAILURE);
+ lazy.UM.saveUpdates();
+
+ LOG(
+ "Downloader:downloadUpdate - Failed to start to BITS job. " +
+ "Error: " +
+ error
+ );
+ }
+
+ this._pendingRequest = null;
+
+ AUSTLMY.pingBitsError(this.isCompleteUpdate, error);
+
+ // Try download again with nsIIncrementalDownload
+ return this.downloadUpdate(this._update);
+ }
+
+ this._request = request;
+ this._patch.setProperty("bitsId", request.bitsId);
+
+ LOG(
+ "Downloader:downloadUpdate - BITS download running. BITS ID: " +
+ request.bitsId
+ );
+
+ if (this.hasDownloadListeners) {
+ this._maybeStartActiveNotifications();
+ } else {
+ this._maybeStopActiveNotifications();
+ }
+
+ lazy.UM.saveUpdates();
+ this._pendingRequest = null;
+ }
+
+ if (!lazy.UM.readyUpdate) {
+ LOG("Downloader:downloadUpdate - Setting status to downloading");
+ writeStatusFile(getReadyUpdateDir(), STATE_DOWNLOADING);
+ }
+ if (this._patch.state != STATE_DOWNLOADING) {
+ LOG("Downloader:downloadUpdate - Setting state to downloading");
+ this._patch.state = STATE_DOWNLOADING;
+ lazy.UM.saveUpdates();
+ }
+
+ // If we are downloading a second update, we don't change the state until
+ // STATE_SWAP.
+ if (lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ LOG(
+ "Downloader:downloadUpdate - not setting state because download is " +
+ "already pending."
+ );
+ } else {
+ LOG(
+ "Downloader:downloadUpdate - setting currentState to STATE_DOWNLOADING"
+ );
+ transitionState(Ci.nsIApplicationUpdateService.STATE_DOWNLOADING);
+ }
+
+ this._startLangPackUpdates();
+
+ this._notifyDownloadStatusObservers();
+
+ return true;
+ },
+
+ /**
+ * This is run when a download listener is added.
+ */
+ onDownloadListenerAdded: function Downloader_onDownloadListenerAdded() {
+ // Increase the status update frequency when someone starts listening
+ this._maybeStartActiveNotifications();
+ },
+
+ /**
+ * This is run when a download listener is removed.
+ */
+ onDownloadListenerRemoved: function Downloader_onDownloadListenerRemoved() {
+ // Decrease the status update frequency when no one is listening
+ if (!this.hasDownloadListeners) {
+ this._maybeStopActiveNotifications();
+ }
+ },
+
+ get hasDownloadListeners() {
+ return this.updateService.hasDownloadListeners;
+ },
+
+ /**
+ * This speeds up BITS progress notifications in response to a user watching
+ * the notifications.
+ */
+ _maybeStartActiveNotifications:
+ async function Downloader__maybeStartActiveNotifications() {
+ if (
+ this.usingBits &&
+ !this._bitsActiveNotifications &&
+ this.hasDownloadListeners &&
+ this._request
+ ) {
+ LOG(
+ "Downloader:_maybeStartActiveNotifications - Starting active " +
+ "notifications"
+ );
+ this._bitsActiveNotifications = true;
+ await Promise.all([
+ this._request
+ .setNoProgressTimeout(BITS_ACTIVE_NO_PROGRESS_TIMEOUT_SECS)
+ .catch(error => {
+ LOG(
+ "Downloader:_maybeStartActiveNotifications - Failed to set " +
+ "no progress timeout. Error: " +
+ error
+ );
+ }),
+ this._request
+ .changeMonitorInterval(BITS_ACTIVE_POLL_RATE_MS)
+ .catch(error => {
+ LOG(
+ "Downloader:_maybeStartActiveNotifications - Failed to increase " +
+ "status update frequency. Error: " +
+ error
+ );
+ }),
+ ]);
+ }
+ },
+
+ /**
+ * This slows down BITS progress notifications in response to a user no longer
+ * watching the notifications.
+ */
+ _maybeStopActiveNotifications:
+ async function Downloader__maybeStopActiveNotifications() {
+ if (
+ this.usingBits &&
+ this._bitsActiveNotifications &&
+ !this.hasDownloadListeners &&
+ this._request
+ ) {
+ LOG(
+ "Downloader:_maybeStopActiveNotifications - Stopping active " +
+ "notifications"
+ );
+ this._bitsActiveNotifications = false;
+ await Promise.all([
+ this._request
+ .setNoProgressTimeout(BITS_IDLE_NO_PROGRESS_TIMEOUT_SECS)
+ .catch(error => {
+ LOG(
+ "Downloader:_maybeStopActiveNotifications - Failed to set " +
+ "no progress timeout: " +
+ error
+ );
+ }),
+ this._request
+ .changeMonitorInterval(BITS_IDLE_POLL_RATE_MS)
+ .catch(error => {
+ LOG(
+ "Downloader:_maybeStopActiveNotifications - Failed to decrease " +
+ "status update frequency: " +
+ error
+ );
+ }),
+ ]);
+ }
+ },
+
+ /**
+ * When the async request begins
+ * @param request
+ * The nsIRequest object for the transfer
+ */
+ onStartRequest: function Downloader_onStartRequest(request) {
+ if (this.usingBits) {
+ LOG("Downloader:onStartRequest");
+ } else {
+ LOG(
+ "Downloader:onStartRequest - original URI spec: " +
+ request.URI.spec +
+ ", final URI spec: " +
+ request.finalURI.spec
+ );
+ // Set finalURL in onStartRequest if it is different.
+ if (this._patch.finalURL != request.finalURI.spec) {
+ this._patch.finalURL = request.finalURI.spec;
+ lazy.UM.saveUpdates();
+ }
+ }
+
+ this.updateService.forEachDownloadListener(listener => {
+ listener.onStartRequest(request);
+ });
+ },
+
+ /**
+ * When new data has been downloaded
+ * @param request
+ * The nsIRequest object for the transfer
+ * @param progress
+ * The current number of bytes transferred
+ * @param maxProgress
+ * The total number of bytes that must be transferred
+ */
+ onProgress: function Downloader_onProgress(request, progress, maxProgress) {
+ LOG("Downloader:onProgress - progress: " + progress + "/" + maxProgress);
+
+ if (progress > this._patch.size) {
+ LOG(
+ "Downloader:onProgress - progress: " +
+ progress +
+ " is higher than patch size: " +
+ this._patch.size
+ );
+ AUSTLMY.pingDownloadCode(
+ this.isCompleteUpdate,
+ AUSTLMY.DWNLD_ERR_PATCH_SIZE_LARGER
+ );
+ this.cancel(Cr.NS_ERROR_UNEXPECTED);
+ return;
+ }
+
+ // Wait until the transfer has started (progress > 0) to verify maxProgress
+ // so that we don't check it before it is available (in which case, -1 would
+ // have been passed).
+ if (progress > 0 && maxProgress != this._patch.size) {
+ LOG(
+ "Downloader:onProgress - maxProgress: " +
+ maxProgress +
+ " is not equal to expected patch size: " +
+ this._patch.size
+ );
+ AUSTLMY.pingDownloadCode(
+ this.isCompleteUpdate,
+ AUSTLMY.DWNLD_ERR_PATCH_SIZE_NOT_EQUAL
+ );
+ this.cancel(Cr.NS_ERROR_UNEXPECTED);
+ return;
+ }
+
+ this.updateService.forEachDownloadListener(listener => {
+ if (listener instanceof Ci.nsIProgressEventSink) {
+ listener.onProgress(request, progress, maxProgress);
+ }
+ });
+ this.updateService._consecutiveSocketErrors = 0;
+ },
+
+ /**
+ * When we have new status text
+ * @param request
+ * The nsIRequest object for the transfer
+ * @param status
+ * A status code
+ * @param statusText
+ * Human readable version of |status|
+ */
+ onStatus: function Downloader_onStatus(request, status, statusText) {
+ LOG(
+ "Downloader:onStatus - status: " + status + ", statusText: " + statusText
+ );
+
+ this.updateService.forEachDownloadListener(listener => {
+ if (listener instanceof Ci.nsIProgressEventSink) {
+ listener.onStatus(request, status, statusText);
+ }
+ });
+ },
+
+ /**
+ * When data transfer ceases
+ * @param request
+ * The nsIRequest object for the transfer
+ * @param status
+ * Status code containing the reason for the cessation.
+ */
+ /* eslint-disable-next-line complexity */
+ onStopRequest: async function Downloader_onStopRequest(request, status) {
+ if (gOnlyDownloadUpdatesThisSession) {
+ LOG(
+ "Downloader:onStopRequest - End of update download detected and " +
+ "ignored because we are restricted to update downloads this " +
+ "session. We will continue with this update next session."
+ );
+ // In order to keep the update from progressing past the downloading
+ // stage, we will pretend that the download is still going.
+ // A lot of this work is done for us by just not setting this._request to
+ // null, which usually signals that the transfer has completed.
+ this._pretendingDownloadIsNotDone = true;
+ // This notification is currently used only for testing.
+ Services.obs.notifyObservers(null, "update-download-restriction-hit");
+ return;
+ }
+
+ if (!this.usingBits) {
+ LOG(
+ "Downloader:onStopRequest - downloader: nsIIncrementalDownload, " +
+ "original URI spec: " +
+ request.URI.spec +
+ ", final URI spec: " +
+ request.finalURI.spec +
+ ", status: " +
+ status
+ );
+ } else {
+ LOG("Downloader:onStopRequest - downloader: BITS, status: " + status);
+ }
+
+ let bitsCompletionError;
+ if (this.usingBits) {
+ if (Components.isSuccessCode(status)) {
+ try {
+ await request.complete();
+ } catch (e) {
+ LOG(
+ "Downloader:onStopRequest - Unable to complete BITS download: " + e
+ );
+ status = Cr.NS_ERROR_FAILURE;
+ bitsCompletionError = e;
+ }
+ } else {
+ // BITS jobs that failed to complete should still have cancel called on
+ // them to remove the job.
+ try {
+ await this.cancel();
+ } catch (e) {
+ // This will fail if the job stopped because it was cancelled.
+ // Even if this is a "real" error, there isn't really anything to do
+ // about it, and it's not really a big problem. It just means that the
+ // BITS job will stay around until it is removed automatically
+ // (default of 90 days).
+ }
+ }
+ }
+
+ var state = this._patch.state;
+ var shouldShowPrompt = false;
+ var shouldRegisterOnlineObserver = false;
+ var shouldRetrySoon = false;
+ var deleteActiveUpdate = false;
+ let migratedToReadyUpdate = false;
+ let nonDownloadFailure = false;
+ var retryTimeout = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_SOCKET_RETRYTIMEOUT,
+ DEFAULT_SOCKET_RETRYTIMEOUT
+ );
+ // Prevent the preference from setting a value greater than 10000.
+ retryTimeout = Math.min(retryTimeout, 10000);
+ var maxFail = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_SOCKET_MAXERRORS,
+ DEFAULT_SOCKET_MAX_ERRORS
+ );
+ // Prevent the preference from setting a value greater than 20.
+ maxFail = Math.min(maxFail, 20);
+ LOG(
+ "Downloader:onStopRequest - status: " +
+ status +
+ ", " +
+ "current fail: " +
+ this.updateService._consecutiveSocketErrors +
+ ", " +
+ "max fail: " +
+ maxFail +
+ ", " +
+ "retryTimeout: " +
+ retryTimeout
+ );
+ if (Components.isSuccessCode(status)) {
+ if (this._verifyDownload()) {
+ AUSTLMY.pingDownloadCode(this.isCompleteUpdate, AUSTLMY.DWNLD_SUCCESS);
+
+ LOG(
+ "Downloader:onStopRequest - Clearing readyUpdate in preparation of " +
+ "moving downloadingUpdate into readyUpdate."
+ );
+
+ // Clear out any old update before we notify anyone about the new one.
+ // It will be invalid in a moment anyways when we call
+ // `cleanUpReadyUpdateDir()`.
+ lazy.UM.readyUpdate = null;
+
+ // We're about to clobber the ready update so we can replace it with the
+ // downloading update that just finished. We need to let observers know
+ // about this.
+ if (
+ lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_PENDING
+ ) {
+ transitionState(Ci.nsIApplicationUpdateService.STATE_SWAP);
+ }
+ Services.obs.notifyObservers(this._update, "update-swap");
+
+ // Swap the downloading update into the ready update directory.
+ cleanUpReadyUpdateDir();
+ let downloadedMar = getDownloadingUpdateDir();
+ downloadedMar.append(FILE_UPDATE_MAR);
+ let readyDir = getReadyUpdateDir();
+ try {
+ downloadedMar.moveTo(readyDir, FILE_UPDATE_MAR);
+ migratedToReadyUpdate = true;
+ } catch (e) {
+ migratedToReadyUpdate = false;
+ }
+
+ if (migratedToReadyUpdate) {
+ AUSTLMY.pingMoveResult(AUSTLMY.MOVE_RESULT_SUCCESS);
+ state = getBestPendingState();
+ shouldShowPrompt = !getCanStageUpdates();
+
+ // Tell the updater.exe we're ready to apply.
+ LOG(
+ `Downloader:onStopRequest - Ready to apply. Setting state to ` +
+ `"${state}".`
+ );
+ writeStatusFile(getReadyUpdateDir(), state);
+ writeVersionFile(getReadyUpdateDir(), this._update.appVersion);
+ this._update.installDate = new Date().getTime();
+ this._update.statusText =
+ lazy.gUpdateBundle.GetStringFromName("installPending");
+ Services.prefs.setIntPref(PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS, 0);
+ } else {
+ LOG(
+ "Downloader:onStopRequest - failed to move the downloading " +
+ "update to the ready update directory."
+ );
+ AUSTLMY.pingMoveResult(AUSTLMY.MOVE_RESULT_UNKNOWN_FAILURE);
+
+ state = STATE_DOWNLOAD_FAILED;
+ status = Cr.NS_ERROR_FILE_COPY_OR_MOVE_FAILED;
+
+ const mfCode = "move_failed";
+ let message = getStatusTextFromCode(mfCode, mfCode);
+ this._update.statusText = message;
+
+ nonDownloadFailure = true;
+ deleteActiveUpdate = true;
+
+ cleanUpDownloadingUpdateDir();
+ }
+ } else {
+ LOG("Downloader:onStopRequest - download verification failed");
+ state = STATE_DOWNLOAD_FAILED;
+ status = Cr.NS_ERROR_CORRUPTED_CONTENT;
+
+ // Yes, this code is a string.
+ const vfCode = "verification_failed";
+ var message = getStatusTextFromCode(vfCode, vfCode);
+ this._update.statusText = message;
+
+ if (this._update.isCompleteUpdate || this._update.patchCount != 2) {
+ LOG("Downloader:onStopRequest - No alternative patch to try");
+ deleteActiveUpdate = true;
+ }
+
+ // Destroy the updates directory, since we're done with it.
+ cleanUpDownloadingUpdateDir();
+ }
+ } else if (status == Cr.NS_ERROR_OFFLINE) {
+ // Register an online observer to try again.
+ // The online observer will continue the incremental download by
+ // calling downloadUpdate on the active update which continues
+ // downloading the file from where it was.
+ LOG("Downloader:onStopRequest - offline, register online observer: true");
+ AUSTLMY.pingDownloadCode(
+ this.isCompleteUpdate,
+ AUSTLMY.DWNLD_RETRY_OFFLINE
+ );
+ shouldRegisterOnlineObserver = true;
+ deleteActiveUpdate = false;
+
+ // Each of NS_ERROR_NET_TIMEOUT, ERROR_CONNECTION_REFUSED,
+ // NS_ERROR_NET_RESET and NS_ERROR_DOCUMENT_NOT_CACHED can be returned
+ // when disconnecting the internet while a download of a MAR is in
+ // progress. There may be others but I have not encountered them during
+ // testing.
+ } else if (
+ (status == Cr.NS_ERROR_NET_TIMEOUT ||
+ status == Cr.NS_ERROR_CONNECTION_REFUSED ||
+ status == Cr.NS_ERROR_NET_RESET ||
+ status == Cr.NS_ERROR_DOCUMENT_NOT_CACHED) &&
+ this.updateService._consecutiveSocketErrors < maxFail
+ ) {
+ LOG("Downloader:onStopRequest - socket error, shouldRetrySoon: true");
+ let dwnldCode = AUSTLMY.DWNLD_RETRY_CONNECTION_REFUSED;
+ if (status == Cr.NS_ERROR_NET_TIMEOUT) {
+ dwnldCode = AUSTLMY.DWNLD_RETRY_NET_TIMEOUT;
+ } else if (status == Cr.NS_ERROR_NET_RESET) {
+ dwnldCode = AUSTLMY.DWNLD_RETRY_NET_RESET;
+ } else if (status == Cr.NS_ERROR_DOCUMENT_NOT_CACHED) {
+ dwnldCode = AUSTLMY.DWNLD_ERR_DOCUMENT_NOT_CACHED;
+ }
+ AUSTLMY.pingDownloadCode(this.isCompleteUpdate, dwnldCode);
+ shouldRetrySoon = true;
+ deleteActiveUpdate = false;
+ } else if (status != Cr.NS_BINDING_ABORTED && status != Cr.NS_ERROR_ABORT) {
+ if (
+ status == Cr.NS_ERROR_FILE_ACCESS_DENIED ||
+ status == Cr.NS_ERROR_FILE_READ_ONLY
+ ) {
+ LOG("Downloader:onStopRequest - permission error");
+ nonDownloadFailure = true;
+ } else {
+ LOG("Downloader:onStopRequest - non-verification failure");
+ }
+
+ let dwnldCode = AUSTLMY.DWNLD_ERR_BINDING_ABORTED;
+ if (status == Cr.NS_ERROR_ABORT) {
+ dwnldCode = AUSTLMY.DWNLD_ERR_ABORT;
+ }
+ AUSTLMY.pingDownloadCode(this.isCompleteUpdate, dwnldCode);
+
+ // Some sort of other failure, log this in the |statusText| property
+ state = STATE_DOWNLOAD_FAILED;
+
+ // XXXben - if |request| (The Incremental Download) provided a means
+ // for accessing the http channel we could do more here.
+
+ this._update.statusText = getStatusTextFromCode(
+ status,
+ Cr.NS_BINDING_FAILED
+ );
+
+ // Destroy the updates directory, since we're done with it.
+ cleanUpDownloadingUpdateDir();
+
+ deleteActiveUpdate = true;
+ }
+ if (!this.usingBits) {
+ LOG(`Downloader:onStopRequest - Setting internalResult to ${status}`);
+ this._patch.setProperty("internalResult", status);
+ } else {
+ LOG(`Downloader:onStopRequest - Setting bitsResult to ${status}`);
+ this._patch.setProperty("bitsResult", status);
+
+ // If we failed when using BITS, we want to override the retry decision
+ // since we need to retry with nsIncrementalDownload before we give up.
+ // However, if the download was cancelled, don't retry. If the transfer
+ // was cancelled, we don't want it to restart on its own.
+ if (
+ !Components.isSuccessCode(status) &&
+ status != Cr.NS_BINDING_ABORTED &&
+ status != Cr.NS_ERROR_ABORT
+ ) {
+ deleteActiveUpdate = false;
+ shouldRetrySoon = true;
+ }
+
+ // Send BITS Telemetry
+ if (Components.isSuccessCode(status)) {
+ AUSTLMY.pingBitsSuccess(this.isCompleteUpdate);
+ } else {
+ let error;
+ if (bitsCompletionError) {
+ error = bitsCompletionError;
+ } else if (status == Cr.NS_ERROR_CORRUPTED_CONTENT) {
+ error = new BitsVerificationError();
+ } else {
+ error = request.transferError;
+ if (!error) {
+ error = new BitsUnknownError();
+ }
+ }
+ AUSTLMY.pingBitsError(this.isCompleteUpdate, error);
+ }
+ }
+
+ LOG("Downloader:onStopRequest - setting state to: " + state);
+ if (this._patch.state != state) {
+ this._patch.state = state;
+ }
+ if (deleteActiveUpdate) {
+ LOG("Downloader:onStopRequest - Clearing downloadingUpdate.");
+ this._update.installDate = new Date().getTime();
+ lazy.UM.addUpdateToHistory(lazy.UM.downloadingUpdate);
+ lazy.UM.downloadingUpdate = null;
+ } else if (
+ lazy.UM.downloadingUpdate &&
+ lazy.UM.downloadingUpdate.state != state
+ ) {
+ lazy.UM.downloadingUpdate.state = state;
+ }
+ if (migratedToReadyUpdate) {
+ LOG(
+ "Downloader:onStopRequest - Moving downloadingUpdate into readyUpdate"
+ );
+ lazy.UM.readyUpdate = lazy.UM.downloadingUpdate;
+ lazy.UM.downloadingUpdate = null;
+ }
+ lazy.UM.saveUpdates();
+
+ // Only notify listeners about the stopped state if we
+ // aren't handling an internal retry.
+ if (!shouldRetrySoon && !shouldRegisterOnlineObserver) {
+ this.updateService.forEachDownloadListener(listener => {
+ listener.onStopRequest(request, status);
+ });
+ }
+
+ this._request = null;
+
+ // This notification must happen after _request is set to null so that
+ // the correct this.updateService.isDownloading value is available in
+ // _notifyDownloadStatusObservers().
+ this._notifyDownloadStatusObservers();
+
+ if (state == STATE_DOWNLOAD_FAILED) {
+ var allFailed = true;
+ // Don't bother retrying the download if we got an error that isn't
+ // download related.
+ if (!nonDownloadFailure) {
+ // If we haven't already, attempt to download without BITS
+ if (request instanceof BitsRequest) {
+ LOG(
+ "Downloader:onStopRequest - BITS download failed. Falling back " +
+ "to nsIIncrementalDownload"
+ );
+ let success = await this.downloadUpdate(this._update);
+ if (!success) {
+ LOG(
+ "Downloader:onStopRequest - Failed to fall back to " +
+ "nsIIncrementalDownload. Cleaning up downloading update."
+ );
+ cleanupDownloadingUpdate();
+ } else {
+ allFailed = false;
+ }
+ }
+
+ // Check if there is a complete update patch that can be downloaded.
+ if (
+ allFailed &&
+ !this._update.isCompleteUpdate &&
+ this._update.patchCount == 2
+ ) {
+ LOG(
+ "Downloader:onStopRequest - verification of patch failed, " +
+ "downloading complete update patch"
+ );
+ this._update.isCompleteUpdate = true;
+ let success = await this.downloadUpdate(this._update);
+
+ if (!success) {
+ LOG(
+ "Downloader:onStopRequest - Failed to fall back to complete " +
+ "patch. Cleaning up downloading update."
+ );
+ cleanupDownloadingUpdate();
+ } else {
+ allFailed = false;
+ }
+ }
+ }
+
+ if (allFailed) {
+ let downloadAttempts = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS,
+ 0
+ );
+ downloadAttempts++;
+ Services.prefs.setIntPref(
+ PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS,
+ downloadAttempts
+ );
+ let maxAttempts = Math.min(
+ Services.prefs.getIntPref(PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS, 2),
+ 10
+ );
+
+ transitionState(Ci.nsIApplicationUpdateService.STATE_IDLE);
+
+ if (downloadAttempts > maxAttempts) {
+ LOG(
+ "Downloader:onStopRequest - notifying observers of error. " +
+ "topic: update-error, status: download-attempts-exceeded, " +
+ "downloadAttempts: " +
+ downloadAttempts +
+ " " +
+ "maxAttempts: " +
+ maxAttempts
+ );
+ Services.obs.notifyObservers(
+ this._update,
+ "update-error",
+ "download-attempts-exceeded"
+ );
+ } else {
+ this._update.selectedPatch.selected = false;
+ LOG(
+ "Downloader:onStopRequest - notifying observers of error. " +
+ "topic: update-error, status: download-attempt-failed"
+ );
+ Services.obs.notifyObservers(
+ this._update,
+ "update-error",
+ "download-attempt-failed"
+ );
+ }
+ // We don't care about language pack updates now.
+ this._langPackTimeout = null;
+ LangPackUpdates.delete(unwrap(this._update));
+
+ // Prevent leaking the update object (bug 454964).
+ this._update = null;
+
+ // allFailed indicates that we didn't (successfully) call downloadUpdate
+ // to try to download a different MAR. In this case, this Downloader
+ // is no longer being used.
+ this.updateService._downloader = null;
+ }
+ // A complete download has been initiated or the failure was handled.
+ return;
+ }
+
+ // If the download has succeeded or failed, we are done with this Downloader
+ // object. However, in some cases (ex: network disconnection), we will
+ // attempt to resume using this same Downloader.
+ if (state != STATE_DOWNLOADING) {
+ this.updateService._downloader = null;
+ }
+
+ if (
+ state == STATE_PENDING ||
+ state == STATE_PENDING_SERVICE ||
+ state == STATE_PENDING_ELEVATE
+ ) {
+ if (getCanStageUpdates()) {
+ LOG(
+ "Downloader:onStopRequest - attempting to stage update: " +
+ this._update.name
+ );
+ // Stage the update
+ let stagingStarted = true;
+ try {
+ Cc["@mozilla.org/updates/update-processor;1"]
+ .createInstance(Ci.nsIUpdateProcessor)
+ .processUpdate();
+ } catch (e) {
+ LOG(
+ "Downloader:onStopRequest - failed to stage update. Exception: " + e
+ );
+ stagingStarted = false;
+ }
+ if (stagingStarted) {
+ transitionState(Ci.nsIApplicationUpdateService.STATE_STAGING);
+ } else {
+ // Fail gracefully in case the application does not support the update
+ // processor service.
+ shouldShowPrompt = true;
+ }
+ }
+ }
+
+ // If we're still waiting on language pack updates then run a timer to time
+ // out the attempt after an appropriate amount of time.
+ if (this._langPackTimeout) {
+ // Start a timer to measure how much longer it takes for the language
+ // packs to stage.
+ TelemetryStopwatch.start(
+ "UPDATE_LANGPACK_OVERTIME",
+ unwrap(this._update),
+ { inSeconds: true }
+ );
+
+ lazy.setTimeout(
+ this._langPackTimeout,
+ Services.prefs.getIntPref(
+ PREF_APP_UPDATE_LANGPACK_TIMEOUT,
+ LANGPACK_UPDATE_DEFAULT_TIMEOUT
+ )
+ );
+ }
+
+ // Do this after *everything* else, since it will likely cause the app
+ // to shut down.
+ if (shouldShowPrompt) {
+ // Wait for language packs to stage before showing any prompt to restart.
+ let update = this._update;
+ promiseLangPacksUpdated(update).then(() => {
+ LOG(
+ "Downloader:onStopRequest - Notifying observers that " +
+ "an update was downloaded. topic: update-downloaded, status: " +
+ update.state
+ );
+ transitionState(Ci.nsIApplicationUpdateService.STATE_PENDING);
+ Services.obs.notifyObservers(update, "update-downloaded", update.state);
+ });
+ }
+
+ if (shouldRegisterOnlineObserver) {
+ LOG("Downloader:onStopRequest - Registering online observer");
+ this.updateService._registerOnlineObserver();
+ } else if (shouldRetrySoon) {
+ LOG("Downloader:onStopRequest - Retrying soon");
+ this.updateService._consecutiveSocketErrors++;
+ if (this.updateService._retryTimer) {
+ this.updateService._retryTimer.cancel();
+ }
+ this.updateService._retryTimer = Cc[
+ "@mozilla.org/timer;1"
+ ].createInstance(Ci.nsITimer);
+ this.updateService._retryTimer.initWithCallback(
+ async () => {
+ await this.updateService._attemptResume();
+ },
+ retryTimeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ } else {
+ // Prevent leaking the update object (bug 454964)
+ this._update = null;
+ }
+ },
+
+ /**
+ * This function should be called when shutting down so that resources get
+ * freed properly.
+ */
+ cleanup: async function Downloader_cleanup() {
+ if (this.usingBits) {
+ if (this._pendingRequest) {
+ await this._pendingRequest;
+ }
+ this._request.shutdown();
+ }
+ },
+
+ /**
+ * See nsIInterfaceRequestor.idl
+ */
+ getInterface: function Downloader_getInterface(iid) {
+ // The network request may require proxy authentication, so provide the
+ // default nsIAuthPrompt if requested.
+ if (iid.equals(Ci.nsIAuthPrompt)) {
+ var prompt =
+ Cc["@mozilla.org/network/default-auth-prompt;1"].createInstance();
+ return prompt.QueryInterface(iid);
+ }
+ throw Components.Exception("", Cr.NS_NOINTERFACE);
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsIProgressEventSink",
+ "nsIInterfaceRequestor",
+ ]),
+};
+
+// On macOS, all browser windows can be closed without Firefox exiting. If it
+// is left in this state for a while and an update is pending, we should restart
+// Firefox on our own to apply the update. This class will do that
+// automatically.
+class RestartOnLastWindowClosed {
+ #enabled = false;
+ #hasShutdown = false;
+
+ #restartTimer = null;
+ #restartTimerExpired = false;
+
+ constructor() {
+ this.#maybeEnableOrDisable();
+
+ Services.prefs.addObserver(
+ PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED,
+ this
+ );
+ Services.obs.addObserver(this, "quit-application");
+ }
+
+ shutdown() {
+ LOG("RestartOnLastWindowClosed.shutdown - Shutting down");
+ this.#hasShutdown = true;
+
+ Services.prefs.removeObserver(
+ PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED,
+ this
+ );
+ Services.obs.removeObserver(this, "quit-application");
+
+ this.#maybeEnableOrDisable();
+ }
+
+ get shouldEnable() {
+ if (AppConstants.platform != "macosx") {
+ return false;
+ }
+ if (this.#hasShutdown) {
+ return false;
+ }
+ return Services.prefs.getBoolPref(
+ PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED,
+ false
+ );
+ }
+
+ get enabled() {
+ return this.#enabled;
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ if (data == PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_ENABLED) {
+ this.#maybeEnableOrDisable();
+ }
+ break;
+ case "quit-application":
+ this.shutdown();
+ break;
+ case "domwindowclosed":
+ this.#onWindowClose();
+ break;
+ case "domwindowopened":
+ this.#onWindowOpen();
+ break;
+ case "update-downloaded":
+ case "update-staged":
+ this.#onUpdateReady(data);
+ break;
+ }
+ }
+
+ // Returns true if any windows are open. Otherwise, false.
+ #windowsAreOpen() {
+ // eslint-disable-next-line no-unused-vars
+ for (const win of Services.wm.getEnumerator(null)) {
+ return true;
+ }
+ return false;
+ }
+
+ // Enables or disables this class's functionality based on the value of
+ // this.shouldEnable. Does nothing if the class is already in the right state
+ // (i.e. if the class should be enabled and already is, or should be disabled
+ // and already is).
+ #maybeEnableOrDisable() {
+ if (this.shouldEnable) {
+ if (this.#enabled) {
+ return;
+ }
+ LOG("RestartOnLastWindowClosed.#maybeEnableOrDisable - Enabling");
+
+ Services.obs.addObserver(this, "domwindowclosed");
+ Services.obs.addObserver(this, "domwindowopened");
+ Services.obs.addObserver(this, "update-downloaded");
+ Services.obs.addObserver(this, "update-staged");
+
+ this.#restartTimer = null;
+ this.#restartTimerExpired = false;
+
+ this.#enabled = true;
+
+ // Synchronize with external state.
+ this.#onWindowClose();
+ } else {
+ if (!this.#enabled) {
+ return;
+ }
+ LOG("RestartOnLastWindowClosed.#maybeEnableOrDisable - Disabling");
+
+ Services.obs.removeObserver(this, "domwindowclosed");
+ Services.obs.removeObserver(this, "domwindowopened");
+ Services.obs.removeObserver(this, "update-downloaded");
+ Services.obs.removeObserver(this, "update-staged");
+
+ this.#enabled = false;
+
+ if (this.#restartTimer) {
+ this.#restartTimer.cancel();
+ }
+ this.#restartTimer = null;
+ }
+ }
+
+ // Note: Since we keep track of the update state even when this class is
+ // disabled, this function will run even in that case.
+ #onUpdateReady(updateState) {
+ // Note that we do not count pending-elevate as a ready state, because we
+ // cannot silently restart in that state.
+ if (
+ [
+ STATE_APPLIED,
+ STATE_PENDING,
+ STATE_APPLIED_SERVICE,
+ STATE_PENDING_SERVICE,
+ ].includes(updateState)
+ ) {
+ if (this.#enabled) {
+ LOG("RestartOnLastWindowClosed.#onUpdateReady - update ready");
+ this.#maybeRestartBrowser();
+ }
+ } else if (this.#enabled) {
+ LOG(
+ `RestartOnLastWindowClosed.#onUpdateReady - Not counting update as ` +
+ `ready because the state is ${updateState}`
+ );
+ }
+ }
+
+ #onWindowClose() {
+ if (!this.#windowsAreOpen()) {
+ this.#onLastWindowClose();
+ }
+ }
+
+ #onLastWindowClose() {
+ if (this.#restartTimer || this.#restartTimerExpired) {
+ LOG(
+ "RestartOnLastWindowClosed.#onLastWindowClose - Restart timer is " +
+ "either already running or has already expired"
+ );
+ return;
+ }
+
+ let timeout = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_NO_WINDOW_AUTO_RESTART_DELAY_MS,
+ 5 * 60 * 1000
+ );
+
+ LOG(
+ "RestartOnLastWindowClosed.#onLastWindowClose - Last window closed. " +
+ "Starting restart timer"
+ );
+ this.#restartTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this.#restartTimer.initWithCallback(
+ () => this.#onRestartTimerExpire(),
+ timeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }
+
+ #onWindowOpen() {
+ if (this.#restartTimer) {
+ LOG(
+ "RestartOnLastWindowClosed.#onWindowOpen - Window opened. Cancelling " +
+ "restart timer."
+ );
+ this.#restartTimer.cancel();
+ }
+ this.#restartTimer = null;
+ this.#restartTimerExpired = false;
+ }
+
+ #onRestartTimerExpire() {
+ LOG("RestartOnLastWindowClosed.#onRestartTimerExpire - Timer Expired");
+
+ this.#restartTimer = null;
+ this.#restartTimerExpired = true;
+ this.#maybeRestartBrowser();
+ }
+
+ #maybeRestartBrowser() {
+ if (!this.#restartTimerExpired) {
+ LOG(
+ "RestartOnLastWindowClosed.#maybeRestartBrowser - Still waiting for " +
+ "all windows to be closed and restartTimer to expire. " +
+ "(not restarting)"
+ );
+ return;
+ }
+
+ if (lazy.AUS.currentState != Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ LOG(
+ "RestartOnLastWindowClosed.#maybeRestartBrowser - No update ready. " +
+ "(not restarting)"
+ );
+ return;
+ }
+
+ if (getElevationRequired()) {
+ // We check for STATE_PENDING_ELEVATE elsewhere, but this is actually
+ // different from that because it is technically possible that the user
+ // gave permission to elevate, but we haven't actually elevated yet.
+ // This is a bit of a corner case. We only call elevationOptedIn() right
+ // before we restart to apply the update immediately. But it is possible
+ // that something could stop the browser from shutting down.
+ LOG(
+ "RestartOnLastWindowClosed.#maybeRestartBrowser - This update will " +
+ "require user elevation (not restarting)"
+ );
+ return;
+ }
+
+ if (this.#windowsAreOpen()) {
+ LOG(
+ "RestartOnLastWindowClosed.#maybeRestartBrowser - Window " +
+ "unexpectedly still open! (not restarting)"
+ );
+ return;
+ }
+
+ if (!this.shouldEnable) {
+ LOG(
+ "RestartOnLastWindowClosed.#maybeRestartBrowser - Unexpectedly " +
+ "attempted to restart when RestartOnLastWindowClosed ought to be " +
+ "disabled! (not restarting)"
+ );
+ return;
+ }
+
+ LOG("RestartOnLastWindowClosed.#maybeRestartBrowser - Restarting now");
+ Services.telemetry.scalarAdd("update.no_window_auto_restarts", 1);
+ Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit |
+ Ci.nsIAppStartup.eRestart |
+ Ci.nsIAppStartup.eSilently
+ );
+ }
+}
+// Nothing actually uses this variable at the moment, but let's make sure that
+// we hold the reference to the RestartOnLastWindowClosed instance somewhere.
+// eslint-disable-next-line no-unused-vars
+let restartOnLastWindowClosed = new RestartOnLastWindowClosed();
diff --git a/toolkit/mozapps/update/UpdateServiceStub.sys.mjs b/toolkit/mozapps/update/UpdateServiceStub.sys.mjs
new file mode 100644
index 0000000000..415c6bbe80
--- /dev/null
+++ b/toolkit/mozapps/update/UpdateServiceStub.sys.mjs
@@ -0,0 +1,388 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ UpdateLog: "resource://gre/modules/UpdateLog.sys.mjs",
+});
+
+const DIR_UPDATES = "updates";
+const FILE_UPDATE_STATUS = "update.status";
+
+const KEY_UPDROOT = "UpdRootD";
+const KEY_OLD_UPDROOT = "OldUpdRootD";
+
+// The pref prefix below should have the hash of the install path appended to
+// ensure that this is a per-installation pref (i.e. to ensure that migration
+// happens for every install rather than once per profile)
+const PREF_PREFIX_UPDATE_DIR_MIGRATED = "app.update.migrated.updateDir3.";
+const PREF_APP_UPDATE_ALTUPDATEDIRPATH = "app.update.altUpdateDirPath";
+
+function getUpdateBaseDirNoCreate() {
+ if (Cu.isInAutomation) {
+ // This allows tests to use an alternate updates directory so they can test
+ // startup behavior.
+ const MAGIC_TEST_ROOT_PREFIX = "<test-root>";
+ const PREF_TEST_ROOT = "mochitest.testRoot";
+ let alternatePath = Services.prefs.getCharPref(
+ PREF_APP_UPDATE_ALTUPDATEDIRPATH,
+ null
+ );
+ if (alternatePath && alternatePath.startsWith(MAGIC_TEST_ROOT_PREFIX)) {
+ let testRoot = Services.prefs.getCharPref(PREF_TEST_ROOT);
+ let relativePath = alternatePath.substring(MAGIC_TEST_ROOT_PREFIX.length);
+ if (AppConstants.platform == "win") {
+ relativePath = relativePath.replace(/\//g, "\\");
+ }
+ alternatePath = testRoot + relativePath;
+ let updateDir = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsIFile
+ );
+ updateDir.initWithPath(alternatePath);
+ LOG(
+ "getUpdateBaseDirNoCreate returning test directory, path: " +
+ updateDir.path
+ );
+ return updateDir;
+ }
+ }
+
+ return FileUtils.getDir(KEY_UPDROOT, []);
+}
+
+export function UpdateServiceStub() {
+ LOG("UpdateServiceStub - Begin.");
+
+ let updateDir = getUpdateBaseDirNoCreate();
+ let prefUpdateDirMigrated =
+ PREF_PREFIX_UPDATE_DIR_MIGRATED + updateDir.leafName;
+
+ let statusFile = updateDir;
+ statusFile.append(DIR_UPDATES);
+ statusFile.append("0");
+ statusFile.append(FILE_UPDATE_STATUS);
+ updateDir = null; // We don't need updateDir anymore, plus now its nsIFile
+ // contains the status file's path
+
+ // We may need to migrate update data
+ if (
+ AppConstants.platform == "win" &&
+ !Services.prefs.getBoolPref(prefUpdateDirMigrated, false)
+ ) {
+ Services.prefs.setBoolPref(prefUpdateDirMigrated, true);
+ try {
+ migrateUpdateDirectory();
+ } catch (ex) {
+ // For the most part, migrateUpdateDirectory() catches its own errors.
+ // But there are technically things that could happen that might not be
+ // caught, like nsIFile.parent or nsIFile.append could unexpectedly fail.
+ // So we will catch any errors here, just in case.
+ LOG(
+ `UpdateServiceStub:UpdateServiceStub Failed to migrate update ` +
+ `directory. Exception: ${ex}`
+ );
+ }
+ }
+
+ // If the update.status file exists then initiate post update processing.
+ if (statusFile.exists()) {
+ let aus = Cc["@mozilla.org/updates/update-service;1"]
+ .getService(Ci.nsIApplicationUpdateService)
+ .QueryInterface(Ci.nsIObserver);
+ aus.observe(null, "post-update-processing", "");
+ }
+}
+
+UpdateServiceStub.prototype = {
+ observe() {},
+ classID: Components.ID("{e43b0010-04ba-4da6-b523-1f92580bc150}"),
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * This function should be called when there are files in the old update
+ * directory that may need to be migrated to the new update directory.
+ */
+function migrateUpdateDirectory() {
+ LOG("UpdateServiceStub:migrateUpdateDirectory Performing migration");
+
+ let sourceRootDir = FileUtils.getDir(KEY_OLD_UPDROOT, []);
+ let destRootDir = FileUtils.getDir(KEY_UPDROOT, []);
+ let hash = destRootDir.leafName;
+
+ if (!sourceRootDir.exists()) {
+ // Nothing to migrate.
+ return;
+ }
+
+ // List of files to migrate. Each is specified as a list of path components.
+ const toMigrate = [
+ ["updates.xml"],
+ ["active-update.xml"],
+ ["update-config.json"],
+ ["updates", "last-update.log"],
+ ["updates", "backup-update.log"],
+ ["updates", "downloading", FILE_UPDATE_STATUS],
+ ["updates", "downloading", "update.mar"],
+ ["updates", "0", FILE_UPDATE_STATUS],
+ ["updates", "0", "update.mar"],
+ ["updates", "0", "update.version"],
+ ["updates", "0", "update.log"],
+ ["backgroundupdate", "datareporting", "glean", "db", "data.safe.bin"],
+ ];
+
+ // Before we copy anything, double check that a different profile hasn't
+ // already performed migration. If we don't have the necessary permissions to
+ // remove the pre-migration files, we don't want to copy any old files and
+ // potentially make the current update state inconsistent.
+ for (let pathComponents of toMigrate) {
+ // Assemble the destination nsIFile.
+ let destFile = destRootDir.clone();
+ for (let pathComponent of pathComponents) {
+ destFile.append(pathComponent);
+ }
+
+ if (destFile.exists()) {
+ LOG(
+ `UpdateServiceStub:migrateUpdateDirectory Aborting migration because ` +
+ `"${destFile.path}" already exists.`
+ );
+ return;
+ }
+ }
+
+ // Before we migrate everything in toMigrate, there are a few things that
+ // need special handling.
+ let sourceRootParent = sourceRootDir.parent.parent;
+ let destRootParent = destRootDir.parent.parent;
+
+ let profileCountFile = sourceRootParent.clone();
+ profileCountFile.append(`profile_count_${hash}.json`);
+ migrateFile(profileCountFile, destRootParent);
+
+ const updatePingPrefix = `uninstall_ping_${hash}_`;
+ const updatePingSuffix = ".json";
+ try {
+ for (let file of sourceRootParent.directoryEntries) {
+ if (
+ file.leafName.startsWith(updatePingPrefix) &&
+ file.leafName.endsWith(updatePingSuffix)
+ ) {
+ migrateFile(file, destRootParent);
+ }
+ }
+ } catch (ex) {
+ // migrateFile should catch its own errors, but it is possible that
+ // sourceRootParent.directoryEntries could throw.
+ LOG(
+ `UpdateServiceStub:migrateUpdateDirectory Failed to migrate uninstall ` +
+ `ping. Exception: ${ex}`
+ );
+ }
+
+ // Migrate "backgroundupdate.moz_log" and child process logs like
+ // "backgroundupdate.child-1.moz_log".
+ const backgroundLogPrefix = `backgroundupdate`;
+ const backgroundLogSuffix = ".moz_log";
+ try {
+ for (let file of sourceRootDir.directoryEntries) {
+ if (
+ file.leafName.startsWith(backgroundLogPrefix) &&
+ file.leafName.endsWith(backgroundLogSuffix)
+ ) {
+ migrateFile(file, destRootDir);
+ }
+ }
+ } catch (ex) {
+ LOG(
+ `UpdateServiceStub:migrateUpdateDirectory Failed to migrate background ` +
+ `log file. Exception: ${ex}`
+ );
+ }
+
+ const pendingPingRelDir =
+ "backgroundupdate\\datareporting\\glean\\pending_pings";
+ let pendingPingSourceDir = sourceRootDir.clone();
+ pendingPingSourceDir.appendRelativePath(pendingPingRelDir);
+ let pendingPingDestDir = destRootDir.clone();
+ pendingPingDestDir.appendRelativePath(pendingPingRelDir);
+ // Pending ping filenames are UUIDs.
+ const pendingPingFilenameRegex =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+ if (pendingPingSourceDir.exists()) {
+ try {
+ for (let file of pendingPingSourceDir.directoryEntries) {
+ if (pendingPingFilenameRegex.test(file.leafName)) {
+ migrateFile(file, pendingPingDestDir);
+ }
+ }
+ } catch (ex) {
+ // migrateFile should catch its own errors, but it is possible that
+ // pendingPingSourceDir.directoryEntries could throw.
+ LOG(
+ `UpdateServiceStub:migrateUpdateDirectory Failed to migrate ` +
+ `pending pings. Exception: ${ex}`
+ );
+ }
+ }
+
+ // Migrate everything in toMigrate.
+ for (let pathComponents of toMigrate) {
+ let filename = pathComponents.pop();
+
+ // Assemble the source and destination nsIFile's.
+ let sourceFile = sourceRootDir.clone();
+ let destDir = destRootDir.clone();
+ for (let pathComponent of pathComponents) {
+ sourceFile.append(pathComponent);
+ destDir.append(pathComponent);
+ }
+ sourceFile.append(filename);
+
+ migrateFile(sourceFile, destDir);
+ }
+
+ // There is no reason to keep this file, and it often hangs around and could
+ // interfere with cleanup.
+ let updateLockFile = sourceRootParent.clone();
+ updateLockFile.append(`UpdateLock-${hash}`);
+ try {
+ updateLockFile.remove(false);
+ } catch (ex) {}
+
+ // We want to recursively remove empty directories out of the sourceRootDir.
+ // And if that was the only remaining update directory in sourceRootParent,
+ // we want to remove that too. But we don't want to recurse into other update
+ // directories in sourceRootParent.
+ //
+ // Potentially removes "C:\ProgramData\Mozilla\updates\<hash>" and
+ // subdirectories.
+ cleanupDir(sourceRootDir, true);
+ // Potentially removes "C:\ProgramData\Mozilla\updates"
+ cleanupDir(sourceRootDir.parent, false);
+ // Potentially removes "C:\ProgramData\Mozilla"
+ cleanupDir(sourceRootParent, false);
+}
+
+/**
+ * Attempts to move the source file to the destination directory. If the file
+ * cannot be moved, we attempt to copy it and remove the original. All errors
+ * are logged, but no exceptions are thrown. Both arguments must be of type
+ * nsIFile and are expected to be regular files.
+ *
+ * Non-existent files are silently ignored.
+ *
+ * The reason that we are migrating is to deal with problematic inherited
+ * permissions. But, luckily, neither nsIFile.moveTo nor nsIFile.copyTo preserve
+ * inherited permissions.
+ */
+function migrateFile(sourceFile, destDir) {
+ if (!sourceFile.exists()) {
+ return;
+ }
+
+ if (sourceFile.isDirectory()) {
+ LOG(
+ `UpdateServiceStub:migrateFile Aborting attempt to migrate ` +
+ `"${sourceFile.path}" because it is a directory.`
+ );
+ return;
+ }
+
+ // Create destination directory.
+ try {
+ // Pass an arbitrary value for permissions. Windows doesn't use octal
+ // permissions, so that value doesn't really do anything.
+ destDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0);
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ LOG(
+ `UpdateServiceStub:migrateFile Unable to create destination ` +
+ `directory "${destDir.path}": ${ex}`
+ );
+ }
+ }
+
+ try {
+ sourceFile.moveTo(destDir, null);
+ return;
+ } catch (ex) {}
+
+ try {
+ sourceFile.copyTo(destDir, null);
+ } catch (ex) {
+ LOG(
+ `UpdateServiceStub:migrateFile Failed to migrate file from ` +
+ `"${sourceFile.path}" to "${destDir.path}". Exception: ${ex}`
+ );
+ return;
+ }
+
+ try {
+ sourceFile.remove(false);
+ } catch (ex) {
+ LOG(
+ `UpdateServiceStub:migrateFile Successfully migrated file from ` +
+ `"${sourceFile.path}" to "${destDir.path}", but was unable to remove ` +
+ `the original. Exception: ${ex}`
+ );
+ }
+}
+
+/**
+ * If recurse is true, recurses through the directory's contents. Any empty
+ * directories are removed. Directories with remaining files are left behind.
+ *
+ * If recurse if false, we delete the directory passed as long as it is empty.
+ *
+ * All errors are silenced and not thrown.
+ *
+ * Returns true if the directory passed in was removed. Otherwise false.
+ */
+function cleanupDir(dir, recurse) {
+ let directoryEmpty = true;
+ try {
+ for (let file of dir.directoryEntries) {
+ if (!recurse) {
+ // If we aren't recursing, bail out after we find a single file. The
+ // directory isn't empty so we can't delete it, and we aren't going to
+ // clean out and remove any other directories.
+ return false;
+ }
+ if (file.isDirectory()) {
+ if (!cleanupDir(file, recurse)) {
+ directoryEmpty = false;
+ }
+ } else {
+ directoryEmpty = false;
+ }
+ }
+ } catch (ex) {
+ // If any of our nsIFile calls fail, just err on the side of caution and
+ // don't delete anything.
+ return false;
+ }
+
+ if (directoryEmpty) {
+ try {
+ dir.remove(false);
+ return true;
+ } catch (ex) {}
+ }
+ return false;
+}
+
+/**
+ * Logs a string to the error console.
+ * @param string
+ * The string to write to the error console.
+ */
+function LOG(string) {
+ lazy.UpdateLog.logPrefixedString("AUS:STB", string);
+}
diff --git a/toolkit/mozapps/update/UpdateTelemetry.sys.mjs b/toolkit/mozapps/update/UpdateTelemetry.sys.mjs
new file mode 100644
index 0000000000..20fb0ab4a4
--- /dev/null
+++ b/toolkit/mozapps/update/UpdateTelemetry.sys.mjs
@@ -0,0 +1,652 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import {
+ BitsError,
+ BitsUnknownError,
+} from "resource://gre/modules/Bits.sys.mjs";
+
+export var AUSTLMY = {
+ // Telemetry for the application update background update check occurs when
+ // the background update timer fires after the update interval which is
+ // determined by the app.update.interval preference and its telemetry
+ // histogram IDs have the suffix '_NOTIFY'.
+ // Telemetry for the externally initiated background update check occurs when
+ // a call is made to |checkForBackgroundUpdates| which is typically initiated
+ // by an application when it has determined that the application should have
+ // received an update. This has separate telemetry so it is possible to
+ // analyze using the telemetry data systems that have not been updating when
+ // they should have.
+
+ // The update check was performed by the call to checkForBackgroundUpdates in
+ // nsUpdateService.js.
+ EXTERNAL: "EXTERNAL",
+ // The update check was performed by the call to notify in nsUpdateService.js.
+ NOTIFY: "NOTIFY",
+ // The update check was performed after an update is already ready. There is
+ // currently no way for a user to initiate an update check when there is a
+ // ready update (the UI just prompts you to install the ready update). So
+ // subsequent update checks are necessarily "notify" update checks, not
+ // "external" ones.
+ SUBSEQUENT: "SUBSEQUENT",
+
+ /**
+ * Values for the UPDATE_CHECK_CODE_NOTIFY and UPDATE_CHECK_CODE_EXTERNAL
+ * Telemetry histograms.
+ */
+ // No update found (no notification)
+ CHK_NO_UPDATE_FOUND: 0,
+ // Update will be downloaded in the background (background download)
+ CHK_DOWNLOAD_UPDATE: 1,
+ // Showing prompt due to preference (update notification)
+ CHK_SHOWPROMPT_PREF: 3,
+ // Already has an active update in progress (no notification)
+ CHK_HAS_ACTIVEUPDATE: 8,
+ // A background download is already in progress (no notification)
+ CHK_IS_DOWNLOADING: 9,
+ // An update is already staged (no notification)
+ CHK_IS_STAGED: 10,
+ // An update is already downloaded (no notification)
+ CHK_IS_DOWNLOADED: 11,
+ // Note: codes 12-13 were removed along with the |app.update.enabled| pref.
+ // Unable to check for updates per hasUpdateMutex() (no notification)
+ CHK_NO_MUTEX: 14,
+ // Unable to check for updates per gCanCheckForUpdates (no notification). This
+ // should be covered by other codes and is recorded just in case.
+ CHK_UNABLE_TO_CHECK: 15,
+ // Note: code 16 was removed when the feature for disabling updates for the
+ // session was removed.
+ // Unable to perform a background check while offline (no notification)
+ CHK_OFFLINE: 17,
+ // Note: codes 18 - 21 were removed along with the certificate checking code.
+ // General update check failure and threshold reached
+ // (check failure notification)
+ CHK_GENERAL_ERROR_PROMPT: 22,
+ // General update check failure and threshold not reached (no notification)
+ CHK_GENERAL_ERROR_SILENT: 23,
+ // No compatible update found though there were updates (no notification)
+ CHK_NO_COMPAT_UPDATE_FOUND: 24,
+ // Update found for a previous version (no notification)
+ CHK_UPDATE_PREVIOUS_VERSION: 25,
+ // Update found without a type attribute (no notification)
+ CHK_UPDATE_INVALID_TYPE: 27,
+ // The system is no longer supported (system unsupported notification)
+ CHK_UNSUPPORTED: 28,
+ // Unable to apply updates (manual install to update notification)
+ CHK_UNABLE_TO_APPLY: 29,
+ // Unable to check for updates due to no OS version (no notification)
+ CHK_NO_OS_VERSION: 30,
+ // Unable to check for updates due to no OS ABI (no notification)
+ CHK_NO_OS_ABI: 31,
+ // Invalid update url (no notification)
+ CHK_INVALID_DEFAULT_URL: 32,
+ // Update elevation failures or cancelations threshold reached for this
+ // version, OSX only (no notification)
+ CHK_ELEVATION_DISABLED_FOR_VERSION: 35,
+ // User opted out of elevated updates for the available update version, OSX
+ // only (no notification)
+ CHK_ELEVATION_OPTOUT_FOR_VERSION: 36,
+ // Update checks disabled by enterprise policy
+ CHK_DISABLED_BY_POLICY: 37,
+ // Update check failed due to write error
+ CHK_ERR_WRITE_FAILURE: 38,
+ // Update check was delayed because another instance of the application is
+ // currently running
+ CHK_OTHER_INSTANCE: 39,
+ // Cannot yet download update because no partial patch is available and an
+ // update has already been downloaded.
+ CHK_NO_PARTIAL_PATCH: 40,
+
+ /**
+ * Submit a telemetry ping for the update check result code or a telemetry
+ * ping for a count type histogram count when no update was found. The no
+ * update found ping is separate since it is the typical result, is less
+ * interesting than the other result codes, and it is easier to analyze the
+ * other codes without including it.
+ *
+ * @param aSuffix
+ * The histogram id suffix for histogram IDs:
+ * UPDATE_CHECK_CODE_EXTERNAL
+ * UPDATE_CHECK_CODE_NOTIFY
+ * UPDATE_CHECK_CODE_SUBSEQUENT
+ * UPDATE_CHECK_NO_UPDATE_EXTERNAL
+ * UPDATE_CHECK_NO_UPDATE_NOTIFY
+ * UPDATE_CHECK_NO_UPDATE_SUBSEQUENT
+ * @param aCode
+ * An integer value as defined by the values that start with CHK_ in
+ * the above section.
+ */
+ pingCheckCode: function UT_pingCheckCode(aSuffix, aCode) {
+ try {
+ if (aCode == this.CHK_NO_UPDATE_FOUND) {
+ let id = "UPDATE_CHECK_NO_UPDATE_" + aSuffix;
+ // count type histogram
+ Services.telemetry.getHistogramById(id).add();
+ } else {
+ let id = "UPDATE_CHECK_CODE_" + aSuffix;
+ // enumerated type histogram
+ Services.telemetry.getHistogramById(id).add(aCode);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for a failed update check's unhandled error code
+ * when the pingCheckCode is CHK_GENERAL_ERROR_SILENT. The histogram is a
+ * keyed count type with key names that are prefixed with 'AUS_CHECK_EX_ERR_'.
+ *
+ * @param aSuffix
+ * The histogram id suffix for histogram IDs:
+ * UPDATE_CHECK_EXTENDED_ERROR_EXTERNAL
+ * UPDATE_CHECK_EXTENDED_ERROR_NOTIFY
+ * UPDATE_CHECK_EXTENDED_ERROR_SUBSEQUENT
+ * @param aCode
+ * The extended error value return by a failed update check.
+ */
+ pingCheckExError: function UT_pingCheckExError(aSuffix, aCode) {
+ try {
+ let id = "UPDATE_CHECK_EXTENDED_ERROR_" + aSuffix;
+ let val = "AUS_CHECK_EX_ERR_" + aCode;
+ // keyed count type histogram
+ Services.telemetry.getKeyedHistogramById(id).add(val);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ // The state code and if present the status error code were read on startup.
+ STARTUP: "STARTUP",
+ // The state code and status error code if present were read after staging.
+ STAGE: "STAGE",
+
+ // Patch type Complete
+ PATCH_COMPLETE: "COMPLETE",
+ // Patch type partial
+ PATCH_PARTIAL: "PARTIAL",
+ // Patch type unknown
+ PATCH_UNKNOWN: "UNKNOWN",
+
+ /**
+ * Values for the UPDATE_DOWNLOAD_CODE_COMPLETE, UPDATE_DOWNLOAD_CODE_PARTIAL,
+ * and UPDATE_DOWNLOAD_CODE_UNKNOWN Telemetry histograms.
+ */
+ DWNLD_SUCCESS: 0,
+ DWNLD_RETRY_OFFLINE: 1,
+ DWNLD_RETRY_NET_TIMEOUT: 2,
+ DWNLD_RETRY_CONNECTION_REFUSED: 3,
+ DWNLD_RETRY_NET_RESET: 4,
+ DWNLD_ERR_NO_UPDATE: 5,
+ DWNLD_ERR_NO_UPDATE_PATCH: 6,
+ DWNLD_ERR_PATCH_SIZE_LARGER: 8,
+ DWNLD_ERR_PATCH_SIZE_NOT_EQUAL: 9,
+ DWNLD_ERR_BINDING_ABORTED: 10,
+ DWNLD_ERR_ABORT: 11,
+ DWNLD_ERR_DOCUMENT_NOT_CACHED: 12,
+ DWNLD_ERR_VERIFY_NO_REQUEST: 13,
+ DWNLD_ERR_VERIFY_PATCH_SIZE_NOT_EQUAL: 14,
+ DWNLD_ERR_WRITE_FAILURE: 15,
+ // Temporary failure code to see if there are failures without an update phase
+ DWNLD_UNKNOWN_PHASE_ERR_WRITE_FAILURE: 40,
+
+ /**
+ * Submit a telemetry ping for the update download result code.
+ *
+ * @param aIsComplete
+ * If true the histogram is for a patch type complete, if false the
+ * histogram is for a patch type partial, and when undefined the
+ * histogram is for an unknown patch type. This is used to determine
+ * the histogram ID out of the following histogram IDs:
+ * UPDATE_DOWNLOAD_CODE_COMPLETE
+ * UPDATE_DOWNLOAD_CODE_PARTIAL
+ * UPDATE_DOWNLOAD_CODE_UNKNOWN
+ * @param aCode
+ * An integer value as defined by the values that start with DWNLD_ in
+ * the above section.
+ */
+ pingDownloadCode: function UT_pingDownloadCode(aIsComplete, aCode) {
+ let patchType = this.PATCH_UNKNOWN;
+ if (aIsComplete === true) {
+ patchType = this.PATCH_COMPLETE;
+ } else if (aIsComplete === false) {
+ patchType = this.PATCH_PARTIAL;
+ }
+ try {
+ let id = "UPDATE_DOWNLOAD_CODE_" + patchType;
+ // enumerated type histogram
+ Services.telemetry.getHistogramById(id).add(aCode);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ // Previous state codes are defined in pingStateAndStatusCodes() in
+ // nsUpdateService.js
+ STATE_WRITE_FAILURE: 14,
+
+ /**
+ * Submit a telemetry ping for the update status state code.
+ *
+ * @param aSuffix
+ * The histogram id suffix for histogram IDs:
+ * UPDATE_STATE_CODE_COMPLETE_STARTUP
+ * UPDATE_STATE_CODE_PARTIAL_STARTUP
+ * UPDATE_STATE_CODE_UNKNOWN_STARTUP
+ * UPDATE_STATE_CODE_COMPLETE_STAGE
+ * UPDATE_STATE_CODE_PARTIAL_STAGE
+ * UPDATE_STATE_CODE_UNKNOWN_STAGE
+ * @param aCode
+ * An integer value as defined by the values that start with STATE_ in
+ * the above section for the update state from the update.status file.
+ */
+ pingStateCode: function UT_pingStateCode(aSuffix, aCode) {
+ try {
+ let id = "UPDATE_STATE_CODE_" + aSuffix;
+ // enumerated type histogram
+ Services.telemetry.getHistogramById(id).add(aCode);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for the update status error code. This does not
+ * submit a success value which can be determined from the state code.
+ *
+ * @param aSuffix
+ * The histogram id suffix for histogram IDs:
+ * UPDATE_STATUS_ERROR_CODE_COMPLETE_STARTUP
+ * UPDATE_STATUS_ERROR_CODE_PARTIAL_STARTUP
+ * UPDATE_STATUS_ERROR_CODE_UNKNOWN_STARTUP
+ * UPDATE_STATUS_ERROR_CODE_COMPLETE_STAGE
+ * UPDATE_STATUS_ERROR_CODE_PARTIAL_STAGE
+ * UPDATE_STATUS_ERROR_CODE_UNKNOWN_STAGE
+ * @param aCode
+ * An integer value for the error code from the update.status file.
+ */
+ pingStatusErrorCode: function UT_pingStatusErrorCode(aSuffix, aCode) {
+ try {
+ let id = "UPDATE_STATUS_ERROR_CODE_" + aSuffix;
+ // enumerated type histogram
+ Services.telemetry.getHistogramById(id).add(aCode);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for a failing binary transparency result.
+ *
+ * @param aSuffix
+ * Key to use on the update.binarytransparencyresult collection.
+ * Must be one of "COMPLETE_STARTUP", "PARTIAL_STARTUP",
+ * "UNKNOWN_STARTUP", "COMPLETE_STAGE", "PARTIAL_STAGE",
+ * "UNKNOWN_STAGE".
+ * @param aCode
+ * An integer value for the error code from the update.bt file.
+ */
+ pingBinaryTransparencyResult: function UT_pingBinaryTransparencyResult(
+ aSuffix,
+ aCode
+ ) {
+ try {
+ let id = "update.binarytransparencyresult";
+ let key = aSuffix.toLowerCase().replace("_", "-");
+ Services.telemetry.keyedScalarSet(id, key, aCode);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Records a failed BITS update download using Telemetry.
+ * In addition to the BITS Result histogram, this also sends an
+ * update.bitshresult scalar value.
+ *
+ * @param aIsComplete
+ * If true the histogram is for a patch type complete, if false the
+ * histogram is for a patch type partial. This will determine the
+ * histogram id out of the following histogram ids:
+ * UPDATE_BITS_RESULT_COMPLETE
+ * UPDATE_BITS_RESULT_PARTIAL
+ * This value is also used to determine the key for the keyed scalar
+ * update.bitshresult (key is either "COMPLETE" or "PARTIAL")
+ * @param aError
+ * The BitsError that occurred. See Bits.jsm for details on BitsError.
+ */
+ pingBitsError: function UT_pingBitsError(aIsComplete, aError) {
+ if (AppConstants.platform != "win") {
+ console.error(
+ "Warning: Attempted to submit BITS telemetry on a " +
+ "non-Windows platform"
+ );
+ return;
+ }
+ if (!(aError instanceof BitsError)) {
+ console.error("Error sending BITS Error ping: Error is not a BitsError");
+ aError = new BitsUnknownError();
+ }
+ // Coerce the error to integer
+ let type = +aError.type;
+ if (isNaN(type)) {
+ console.error(
+ "Error sending BITS Error ping: Either error is not a " +
+ "BitsError, or error type is not an integer."
+ );
+ type = Ci.nsIBits.ERROR_TYPE_UNKNOWN;
+ } else if (type == Ci.nsIBits.ERROR_TYPE_SUCCESS) {
+ console.error(
+ "Error sending BITS Error ping: The error type must not " +
+ "be the success type."
+ );
+ type = Ci.nsIBits.ERROR_TYPE_UNKNOWN;
+ }
+ this._pingBitsResult(aIsComplete, type);
+
+ if (aError.codeType == Ci.nsIBits.ERROR_CODE_TYPE_HRESULT) {
+ let scalarKey;
+ if (aIsComplete) {
+ scalarKey = this.PATCH_COMPLETE;
+ } else {
+ scalarKey = this.PATCH_PARTIAL;
+ }
+ try {
+ Services.telemetry.keyedScalarSet(
+ "update.bitshresult",
+ scalarKey,
+ aError.code
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ },
+
+ /**
+ * Records a successful BITS update download using Telemetry.
+ *
+ * @param aIsComplete
+ * If true the histogram is for a patch type complete, if false the
+ * histogram is for a patch type partial. This will determine the
+ * histogram id out of the following histogram ids:
+ * UPDATE_BITS_RESULT_COMPLETE
+ * UPDATE_BITS_RESULT_PARTIAL
+ */
+ pingBitsSuccess: function UT_pingBitsSuccess(aIsComplete) {
+ if (AppConstants.platform != "win") {
+ console.error(
+ "Warning: Attempted to submit BITS telemetry on a " +
+ "non-Windows platform"
+ );
+ return;
+ }
+ this._pingBitsResult(aIsComplete, Ci.nsIBits.ERROR_TYPE_SUCCESS);
+ },
+
+ /**
+ * This is the helper function that does all the work for pingBitsError and
+ * pingBitsSuccess. It submits a telemetry ping indicating the result of the
+ * BITS update download.
+ *
+ * @param aIsComplete
+ * If true the histogram is for a patch type complete, if false the
+ * histogram is for a patch type partial. This will determine the
+ * histogram id out of the following histogram ids:
+ * UPDATE_BITS_RESULT_COMPLETE
+ * UPDATE_BITS_RESULT_PARTIAL
+ * @param aResultType
+ * The result code. This will be one of the ERROR_TYPE_* values defined
+ * in the nsIBits interface.
+ */
+ _pingBitsResult: function UT_pingBitsResult(aIsComplete, aResultType) {
+ let patchType;
+ if (aIsComplete) {
+ patchType = this.PATCH_COMPLETE;
+ } else {
+ patchType = this.PATCH_PARTIAL;
+ }
+ try {
+ let id = "UPDATE_BITS_RESULT_" + patchType;
+ Services.telemetry.getHistogramById(id).add(aResultType);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit the interval in days since the last notification for this background
+ * update check or a boolean if the last notification is in the future.
+ *
+ * @param aSuffix
+ * The histogram id suffix for histogram IDs:
+ * UPDATE_INVALID_LASTUPDATETIME_EXTERNAL
+ * UPDATE_INVALID_LASTUPDATETIME_NOTIFY
+ * UPDATE_INVALID_LASTUPDATETIME_SUBSEQUENT
+ * UPDATE_LAST_NOTIFY_INTERVAL_DAYS_EXTERNAL
+ * UPDATE_LAST_NOTIFY_INTERVAL_DAYS_NOTIFY
+ * UPDATE_LAST_NOTIFY_INTERVAL_DAYS_SUBSEQUENT
+ */
+ pingLastUpdateTime: function UT_pingLastUpdateTime(aSuffix) {
+ const PREF_APP_UPDATE_LASTUPDATETIME =
+ "app.update.lastUpdateTime.background-update-timer";
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_LASTUPDATETIME)) {
+ let lastUpdateTimeSeconds = Services.prefs.getIntPref(
+ PREF_APP_UPDATE_LASTUPDATETIME
+ );
+ if (lastUpdateTimeSeconds) {
+ let currentTimeSeconds = Math.round(Date.now() / 1000);
+ if (lastUpdateTimeSeconds > currentTimeSeconds) {
+ try {
+ let id = "UPDATE_INVALID_LASTUPDATETIME_" + aSuffix;
+ // count type histogram
+ Services.telemetry.getHistogramById(id).add();
+ } catch (e) {
+ console.error(e);
+ }
+ } else {
+ let intervalDays =
+ (currentTimeSeconds - lastUpdateTimeSeconds) / (60 * 60 * 24);
+ try {
+ let id = "UPDATE_LAST_NOTIFY_INTERVAL_DAYS_" + aSuffix;
+ // exponential type histogram
+ Services.telemetry.getHistogramById(id).add(intervalDays);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for a boolean type histogram that indicates if the
+ * service is installed and a telemetry ping for a boolean type histogram that
+ * indicates if the service was at some point installed and is now
+ * uninstalled.
+ *
+ * @param aSuffix
+ * The histogram id suffix for histogram IDs:
+ * UPDATE_SERVICE_INSTALLED_EXTERNAL
+ * UPDATE_SERVICE_INSTALLED_NOTIFY
+ * UPDATE_SERVICE_INSTALLED_SUBSEQUENT
+ * UPDATE_SERVICE_MANUALLY_UNINSTALLED_EXTERNAL
+ * UPDATE_SERVICE_MANUALLY_UNINSTALLED_NOTIFY
+ * UPDATE_SERVICE_MANUALLY_UNINSTALLED_SUBSEQUENT
+ * @param aInstalled
+ * Whether the service is installed.
+ */
+ pingServiceInstallStatus: function UT_PSIS(aSuffix, aInstalled) {
+ // Report the error but don't throw since it is more important to
+ // successfully update than to throw.
+ if (!("@mozilla.org/windows-registry-key;1" in Cc)) {
+ console.error(Cr.NS_ERROR_NOT_AVAILABLE);
+ return;
+ }
+
+ try {
+ let id = "UPDATE_SERVICE_INSTALLED_" + aSuffix;
+ // boolean type histogram
+ Services.telemetry.getHistogramById(id).add(aInstalled);
+ } catch (e) {
+ console.error(e);
+ }
+
+ let attempted = 0;
+ try {
+ let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ wrk.open(
+ wrk.ROOT_KEY_LOCAL_MACHINE,
+ "SOFTWARE\\Mozilla\\MaintenanceService",
+ wrk.ACCESS_READ | wrk.WOW64_64
+ );
+ // Was the service at some point installed, but is now uninstalled?
+ attempted = wrk.readIntValue("Attempted");
+ wrk.close();
+ } catch (e) {
+ // Since this will throw if the registry key doesn't exist (e.g. the
+ // service has never been installed) don't report an error.
+ }
+
+ try {
+ let id = "UPDATE_SERVICE_MANUALLY_UNINSTALLED_" + aSuffix;
+ if (!aInstalled && attempted) {
+ // count type histogram
+ Services.telemetry.getHistogramById(id).add();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for a count type histogram when the expected value
+ * does not equal the boolean value of a pref or if the pref isn't present
+ * when the expected value does not equal default value. This lessens the
+ * amount of data submitted to telemetry.
+ *
+ * @param aID
+ * The histogram ID to report to.
+ * @param aPref
+ * The preference to check.
+ * @param aDefault
+ * The default value when the preference isn't present.
+ * @param aExpected (optional)
+ * If specified and the value is the same as the value that will be
+ * added the value won't be added to telemetry.
+ */
+ pingBoolPref: function UT_pingBoolPref(aID, aPref, aDefault, aExpected) {
+ try {
+ let val = aDefault;
+ if (Services.prefs.getPrefType(aPref) != Ci.nsIPrefBranch.PREF_INVALID) {
+ val = Services.prefs.getBoolPref(aPref);
+ }
+ if (val != aExpected) {
+ // count type histogram
+ Services.telemetry.getHistogramById(aID).add();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for a histogram with the integer value of a
+ * preference when it is not the expected value or the default value when it
+ * is not the expected value. This lessens the amount of data submitted to
+ * telemetry.
+ *
+ * @param aID
+ * The histogram ID to report to.
+ * @param aPref
+ * The preference to check.
+ * @param aDefault
+ * The default value when the pref is not set.
+ * @param aExpected (optional)
+ * If specified and the value is the same as the value that will be
+ * added the value won't be added to telemetry.
+ */
+ pingIntPref: function UT_pingIntPref(aID, aPref, aDefault, aExpected) {
+ try {
+ let val = aDefault;
+ if (Services.prefs.getPrefType(aPref) != Ci.nsIPrefBranch.PREF_INVALID) {
+ val = Services.prefs.getIntPref(aPref);
+ }
+ if (aExpected === undefined || val != aExpected) {
+ // enumerated or exponential type histogram
+ Services.telemetry.getHistogramById(aID).add(val);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Submit a telemetry ping for all histogram types that take a single
+ * parameter to the telemetry add function and the count type histogram when
+ * the aExpected parameter is specified. If the aExpected parameter is
+ * specified and it equals the value specified by the aValue
+ * parameter the telemetry submission will be skipped.
+ *
+ * @param aID
+ * The histogram ID to report to.
+ * @param aValue
+ * The value to add when aExpected is not defined or the value to
+ * check if it is equal to when aExpected is defined.
+ * @param aExpected (optional)
+ * If specified and the value is the same as the value specified by
+ * aValue parameter the submission will be skipped.
+ */
+ pingGeneric: function UT_pingGeneric(aID, aValue, aExpected) {
+ try {
+ if (aExpected === undefined) {
+ Services.telemetry.getHistogramById(aID).add(aValue);
+ } else if (aValue != aExpected) {
+ // count type histogram
+ Services.telemetry.getHistogramById(aID).add();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ /**
+ * Valid keys for the update.moveresult scalar.
+ */
+ MOVE_RESULT_SUCCESS: "SUCCESS",
+ MOVE_RESULT_UNKNOWN_FAILURE: "UNKNOWN_FAILURE",
+
+ /**
+ * Reports the passed result of attempting to move the downloading update
+ * into the ready update directory.
+ */
+ pingMoveResult: function UT_pingMoveResult(aResult) {
+ Services.telemetry.keyedScalarAdd("update.move_result", aResult, 1);
+ },
+
+ pingSuppressPrompts: function UT_pingSuppressPrompts() {
+ try {
+ let val = Services.prefs.getBoolPref("app.update.suppressPrompts", false);
+ if (val === true) {
+ Services.telemetry.scalarSet("update.suppress_prompts", true);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ pingPinPolicy: function UT_pingPinPolicy(updatePin) {
+ try {
+ Services.telemetry.scalarSet("update.version_pin", updatePin);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+};
+
+Object.freeze(AUSTLMY);
diff --git a/toolkit/mozapps/update/common/certificatecheck.cpp b/toolkit/mozapps/update/common/certificatecheck.cpp
new file mode 100644
index 0000000000..8c53fa5fd6
--- /dev/null
+++ b/toolkit/mozapps/update/common/certificatecheck.cpp
@@ -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/. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <windows.h>
+#include <softpub.h>
+#include <wintrust.h>
+
+#include "certificatecheck.h"
+#include "updatecommon.h"
+
+static const int ENCODING = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
+
+/**
+ * Checks to see if a file stored at filePath matches the specified info.
+ *
+ * @param filePath The PE file path to check
+ * @param infoToMatch The acceptable information to match
+ * @return ERROR_SUCCESS if successful, ERROR_NOT_FOUND if the info
+ * does not match, or the last error otherwise.
+ */
+DWORD
+CheckCertificateForPEFile(LPCWSTR filePath, CertificateCheckInfo& infoToMatch) {
+ HCERTSTORE certStore = nullptr;
+ HCRYPTMSG cryptMsg = nullptr;
+ PCCERT_CONTEXT certContext = nullptr;
+ PCMSG_SIGNER_INFO signerInfo = nullptr;
+ DWORD lastError = ERROR_SUCCESS;
+
+ // Get the HCERTSTORE and HCRYPTMSG from the signed file.
+ DWORD encoding, contentType, formatType;
+ BOOL result = CryptQueryObject(
+ CERT_QUERY_OBJECT_FILE, filePath,
+ CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, CERT_QUERY_CONTENT_FLAG_ALL,
+ 0, &encoding, &contentType, &formatType, &certStore, &cryptMsg, nullptr);
+ if (!result) {
+ lastError = GetLastError();
+ LOG_WARN(("CryptQueryObject failed. (%lu)", lastError));
+ goto cleanup;
+ }
+
+ // Pass in nullptr to get the needed signer information size.
+ DWORD signerInfoSize;
+ result = CryptMsgGetParam(cryptMsg, CMSG_SIGNER_INFO_PARAM, 0, nullptr,
+ &signerInfoSize);
+ if (!result) {
+ lastError = GetLastError();
+ LOG_WARN(("CryptMsgGetParam failed. (%lu)", lastError));
+ goto cleanup;
+ }
+
+ // Allocate the needed size for the signer information.
+ signerInfo = (PCMSG_SIGNER_INFO)LocalAlloc(LPTR, signerInfoSize);
+ if (!signerInfo) {
+ lastError = GetLastError();
+ LOG_WARN(("Unable to allocate memory for Signer Info. (%lu)", lastError));
+ goto cleanup;
+ }
+
+ // Get the signer information (PCMSG_SIGNER_INFO).
+ // In particular we want the issuer and serial number.
+ result = CryptMsgGetParam(cryptMsg, CMSG_SIGNER_INFO_PARAM, 0,
+ (PVOID)signerInfo, &signerInfoSize);
+ if (!result) {
+ lastError = GetLastError();
+ LOG_WARN(("CryptMsgGetParam failed. (%lu)", lastError));
+ goto cleanup;
+ }
+
+ // Search for the signer certificate in the certificate store.
+ CERT_INFO certInfo;
+ certInfo.Issuer = signerInfo->Issuer;
+ certInfo.SerialNumber = signerInfo->SerialNumber;
+ certContext =
+ CertFindCertificateInStore(certStore, ENCODING, 0, CERT_FIND_SUBJECT_CERT,
+ (PVOID)&certInfo, nullptr);
+ if (!certContext) {
+ lastError = GetLastError();
+ LOG_WARN(("CertFindCertificateInStore failed. (%lu)", lastError));
+ goto cleanup;
+ }
+
+ if (!DoCertificateAttributesMatch(certContext, infoToMatch)) {
+ lastError = ERROR_NOT_FOUND;
+ LOG_WARN(("Certificate did not match issuer or name. (%lu)", lastError));
+ goto cleanup;
+ }
+
+cleanup:
+ if (signerInfo) {
+ LocalFree(signerInfo);
+ }
+ if (certContext) {
+ CertFreeCertificateContext(certContext);
+ }
+ if (certStore) {
+ CertCloseStore(certStore, 0);
+ }
+ if (cryptMsg) {
+ CryptMsgClose(cryptMsg);
+ }
+ return lastError;
+}
+
+/**
+ * Checks to see if a file stored at filePath matches the specified info.
+ *
+ * @param certContext The certificate context of the file
+ * @param infoToMatch The acceptable information to match
+ * @return FALSE if the info does not match or if any error occurs in the check
+ */
+BOOL DoCertificateAttributesMatch(PCCERT_CONTEXT certContext,
+ CertificateCheckInfo& infoToMatch) {
+ DWORD dwData;
+ LPWSTR szName = nullptr;
+
+ if (infoToMatch.issuer) {
+ // Pass in nullptr to get the needed size of the issuer buffer.
+ dwData = CertGetNameString(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE,
+ CERT_NAME_ISSUER_FLAG, nullptr, nullptr, 0);
+
+ if (!dwData) {
+ LOG_WARN(("CertGetNameString failed. (%lu)", GetLastError()));
+ return FALSE;
+ }
+
+ // Allocate memory for Issuer name buffer.
+ szName = (LPWSTR)LocalAlloc(LPTR, dwData * sizeof(WCHAR));
+ if (!szName) {
+ LOG_WARN(("Unable to allocate memory for issuer name. (%lu)",
+ GetLastError()));
+ return FALSE;
+ }
+
+ // Get Issuer name.
+ if (!CertGetNameStringW(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE,
+ CERT_NAME_ISSUER_FLAG, nullptr, szName, dwData)) {
+ LOG_WARN(("CertGetNameString failed. (%lu)", GetLastError()));
+ LocalFree(szName);
+ return FALSE;
+ }
+
+ // If the issuer does not match, return a failure.
+ if (!infoToMatch.issuer || wcscmp(szName, infoToMatch.issuer)) {
+ LocalFree(szName);
+ return FALSE;
+ }
+
+ LocalFree(szName);
+ szName = nullptr;
+ }
+
+ if (infoToMatch.name) {
+ // Pass in nullptr to get the needed size of the name buffer.
+ dwData = CertGetNameString(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0,
+ nullptr, nullptr, 0);
+ if (!dwData) {
+ LOG_WARN(("CertGetNameString failed. (%lu)", GetLastError()));
+ return FALSE;
+ }
+
+ // Allocate memory for the name buffer.
+ szName = (LPWSTR)LocalAlloc(LPTR, dwData * sizeof(WCHAR));
+ if (!szName) {
+ LOG_WARN(("Unable to allocate memory for subject name. (%lu)",
+ GetLastError()));
+ return FALSE;
+ }
+
+ // Obtain the name.
+ if (!(CertGetNameStringW(certContext, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0,
+ nullptr, szName, dwData))) {
+ LOG_WARN(("CertGetNameString failed. (%lu)", GetLastError()));
+ LocalFree(szName);
+ return FALSE;
+ }
+
+ // If the issuer does not match, return a failure.
+ if (!infoToMatch.name || wcscmp(szName, infoToMatch.name)) {
+ LocalFree(szName);
+ return FALSE;
+ }
+
+ // We have a match!
+ LocalFree(szName);
+ }
+
+ // If there were any errors we would have aborted by now.
+ return TRUE;
+}
+
+/**
+ * Verifies the trust of the specified file path.
+ *
+ * @param filePath The file path to check.
+ * @return ERROR_SUCCESS if successful, or the last error code otherwise.
+ */
+DWORD
+VerifyCertificateTrustForFile(LPCWSTR filePath) {
+ // Setup the file to check.
+ WINTRUST_FILE_INFO fileToCheck;
+ ZeroMemory(&fileToCheck, sizeof(fileToCheck));
+ fileToCheck.cbStruct = sizeof(WINTRUST_FILE_INFO);
+ fileToCheck.pcwszFilePath = filePath;
+
+ // Setup what to check, we want to check it is signed and trusted.
+ WINTRUST_DATA trustData;
+ ZeroMemory(&trustData, sizeof(trustData));
+ trustData.cbStruct = sizeof(trustData);
+ trustData.pPolicyCallbackData = nullptr;
+ trustData.pSIPClientData = nullptr;
+ trustData.dwUIChoice = WTD_UI_NONE;
+ trustData.fdwRevocationChecks = WTD_REVOKE_NONE;
+ trustData.dwUnionChoice = WTD_CHOICE_FILE;
+ trustData.dwStateAction = 0;
+ trustData.hWVTStateData = nullptr;
+ trustData.pwszURLReference = nullptr;
+ // no UI
+ trustData.dwUIContext = 0;
+ trustData.pFile = &fileToCheck;
+
+ GUID policyGUID = WINTRUST_ACTION_GENERIC_VERIFY_V2;
+ // Check if the file is signed by something that is trusted.
+ LONG ret = WinVerifyTrust(nullptr, &policyGUID, &trustData);
+ if (ERROR_SUCCESS == ret) {
+ // The hash that represents the subject is trusted and there were no
+ // verification errors. No publisher nor time stamp chain errors.
+ LOG(("The file \"%ls\" is signed and the signature was verified.",
+ filePath));
+ return ERROR_SUCCESS;
+ }
+
+ DWORD lastError = GetLastError();
+ LOG_WARN(
+ ("There was an error validating trust of the certificate for file"
+ " \"%ls\". Returned: %ld. (%lu)",
+ filePath, ret, lastError));
+ return ret;
+}
diff --git a/toolkit/mozapps/update/common/certificatecheck.h b/toolkit/mozapps/update/common/certificatecheck.h
new file mode 100644
index 0000000000..af9f8456df
--- /dev/null
+++ b/toolkit/mozapps/update/common/certificatecheck.h
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 _CERTIFICATECHECK_H_
+#define _CERTIFICATECHECK_H_
+
+#include <windows.h>
+#include <wincrypt.h>
+
+struct CertificateCheckInfo {
+ LPCWSTR name;
+ LPCWSTR issuer;
+};
+
+BOOL DoCertificateAttributesMatch(PCCERT_CONTEXT pCertContext,
+ CertificateCheckInfo& infoToMatch);
+DWORD VerifyCertificateTrustForFile(LPCWSTR filePath);
+DWORD CheckCertificateForPEFile(LPCWSTR filePath,
+ CertificateCheckInfo& infoToMatch);
+
+#endif
diff --git a/toolkit/mozapps/update/common/commonupdatedir.cpp b/toolkit/mozapps/update/common/commonupdatedir.cpp
new file mode 100644
index 0000000000..0ba9fcef94
--- /dev/null
+++ b/toolkit/mozapps/update/common/commonupdatedir.cpp
@@ -0,0 +1,723 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 does not use many of the features Firefox provides such as
+ * nsAString and nsIFile because code in this file will be included not only
+ * in Firefox, but also in the Mozilla Maintenance Service, the Mozilla
+ * Maintenance Service installer, and TestAUSHelper.
+ */
+
+#include <cinttypes>
+#include <cwchar>
+#include <string>
+#include "city.h"
+#include "commonupdatedir.h"
+#include "updatedefines.h"
+
+#ifdef XP_WIN
+# include <accctrl.h>
+# include <aclapi.h>
+# include <cstdarg>
+# include <errno.h>
+# include <objbase.h>
+# include <shellapi.h>
+# include <shlobj.h>
+# include <strsafe.h>
+# include <winerror.h>
+# include "nsWindowsHelpers.h"
+# include "updateutils_win.h"
+#endif
+
+#ifdef XP_WIN
+// This is the name of the old update directory
+// (i.e. C:\ProgramData\<OLD_ROOT_UPDATE_DIR_NAME>)
+# define OLD_ROOT_UPDATE_DIR_NAME "Mozilla"
+// This is the name of the current update directory
+// (i.e. C:\ProgramData\<ROOT_UPDATE_DIR_NAME>)
+// It is really important that we properly set the permissions on this
+// directory at creation time. Thus, it is really important that this code be
+// the creator of this directory. We had many problems with the old update
+// directory having been previously created by old versions of Firefox. To avoid
+// this problem in the future, we are including a UUID in the root update
+// directory name to attempt to ensure that it will be created by this code and
+// won't already exist with the wrong permissions.
+# define ROOT_UPDATE_DIR_NAME "Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38"
+// This describes the directory between the "Mozilla" directory and the install
+// path hash (i.e. C:\ProgramData\Mozilla\<UPDATE_PATH_MID_DIR_NAME>\<hash>)
+# define UPDATE_PATH_MID_DIR_NAME "updates"
+
+enum class WhichUpdateDir {
+ CurrentUpdateDir,
+ UnmigratedUpdateDir,
+};
+
+/**
+ * This is a very simple string class.
+ *
+ * This class has some substantial limitations for the sake of simplicity. It
+ * has no support whatsoever for modifying a string that already has data. There
+ * is, therefore, no append function and no support for automatically resizing
+ * strings.
+ *
+ * Error handling is also done in a slightly unusual manner. If there is ever
+ * a failure allocating or assigning to a string, it will do the simplest
+ * possible recovery: truncate itself to 0-length.
+ * This coupled with the fact that the length is cached means that an effective
+ * method of error checking is to attempt assignment and then check the length
+ * of the result.
+ */
+class SimpleAutoString {
+ private:
+ size_t mLength;
+ // Unique pointer frees the buffer when the class is deleted or goes out of
+ // scope.
+ mozilla::UniquePtr<wchar_t[]> mString;
+
+ /**
+ * Allocates enough space to store a string of the specified length.
+ */
+ bool AllocLen(size_t len) {
+ mString = mozilla::MakeUnique<wchar_t[]>(len + 1);
+ return mString.get() != nullptr;
+ }
+
+ /**
+ * Allocates a buffer of the size given.
+ */
+ bool AllocSize(size_t size) {
+ mString = mozilla::MakeUnique<wchar_t[]>(size);
+ return mString.get() != nullptr;
+ }
+
+ public:
+ SimpleAutoString() : mLength(0) {}
+
+ /*
+ * Allocates enough space for a string of the given length and formats it as
+ * an empty string.
+ */
+ bool AllocEmpty(size_t len) {
+ bool success = AllocLen(len);
+ Truncate();
+ return success;
+ }
+
+ /**
+ * These functions can potentially return null if no buffer has yet been
+ * allocated. After changing a string retrieved with MutableString, the Check
+ * method should be called to synchronize other members (ex: mLength) with the
+ * new buffer.
+ */
+ wchar_t* MutableString() { return mString.get(); }
+ const wchar_t* String() const { return mString.get(); }
+
+ size_t Length() const { return mLength; }
+
+ /**
+ * This method should be called after manually changing the string's buffer
+ * via MutableString to synchronize other members (ex: mLength) with the
+ * new buffer.
+ * Returns true if the string is now in a valid state.
+ */
+ bool Check() {
+ mLength = wcslen(mString.get());
+ return true;
+ }
+
+ void SwapBufferWith(mozilla::UniquePtr<wchar_t[]>& other) {
+ mString.swap(other);
+ if (mString) {
+ mLength = wcslen(mString.get());
+ } else {
+ mLength = 0;
+ }
+ }
+
+ void Swap(SimpleAutoString& other) {
+ mString.swap(other.mString);
+ size_t newLength = other.mLength;
+ other.mLength = mLength;
+ mLength = newLength;
+ }
+
+ /**
+ * Truncates the string to the length specified. This must not be greater than
+ * or equal to the size of the string's buffer.
+ */
+ void Truncate(size_t len = 0) {
+ if (len > mLength) {
+ return;
+ }
+ mLength = len;
+ if (mString) {
+ mString.get()[len] = L'\0';
+ }
+ }
+
+ /**
+ * Assigns a string and ensures that the resulting string is valid and has its
+ * length set properly.
+ *
+ * Note that although other similar functions in this class take length, this
+ * function takes buffer size instead because it is intended to be follow the
+ * input convention of sprintf.
+ *
+ * Returns the new length, which will be 0 if there was any failure.
+ *
+ * This function does no allocation or reallocation. If the buffer is not
+ * large enough to hold the new string, the call will fail.
+ */
+ size_t AssignSprintf(size_t bufferSize, const wchar_t* format, ...) {
+ va_list ap;
+ va_start(ap, format);
+ size_t returnValue = AssignVsprintf(bufferSize, format, ap);
+ va_end(ap);
+ return returnValue;
+ }
+ /**
+ * Same as the above, but takes a va_list like vsprintf does.
+ */
+ size_t AssignVsprintf(size_t bufferSize, const wchar_t* format, va_list ap) {
+ if (!mString) {
+ Truncate();
+ return 0;
+ }
+
+ int charsWritten = vswprintf(mString.get(), bufferSize, format, ap);
+ if (charsWritten < 0 || static_cast<size_t>(charsWritten) >= bufferSize) {
+ // charsWritten does not include the null terminator. If charsWritten is
+ // equal to the buffer size, we do not have a null terminator nor do we
+ // have room for one.
+ Truncate();
+ return 0;
+ }
+ mString.get()[charsWritten] = L'\0';
+
+ mLength = charsWritten;
+ return mLength;
+ }
+
+ /**
+ * Allocates enough space for the string and assigns a value to it with
+ * sprintf. Takes, as an argument, the maximum length that the string is
+ * expected to use (which, after adding 1 for the null byte, is the amount of
+ * space that will be allocated).
+ *
+ * Returns the new length, which will be 0 on any failure.
+ */
+ size_t AllocAndAssignSprintf(size_t maxLength, const wchar_t* format, ...) {
+ if (!AllocLen(maxLength)) {
+ Truncate();
+ return 0;
+ }
+ va_list ap;
+ va_start(ap, format);
+ size_t charsWritten = AssignVsprintf(maxLength + 1, format, ap);
+ va_end(ap);
+ return charsWritten;
+ }
+
+ /*
+ * Allocates enough for the formatted text desired. Returns maximum storable
+ * length of a string in the allocated buffer on success, or 0 on failure.
+ */
+ size_t AllocFromScprintf(const wchar_t* format, ...) {
+ va_list ap;
+ va_start(ap, format);
+ size_t returnValue = AllocFromVscprintf(format, ap);
+ va_end(ap);
+ return returnValue;
+ }
+ /**
+ * Same as the above, but takes a va_list like vscprintf does.
+ */
+ size_t AllocFromVscprintf(const wchar_t* format, va_list ap) {
+ int len = _vscwprintf(format, ap);
+ if (len < 0) {
+ Truncate();
+ return 0;
+ }
+ if (!AllocEmpty(len)) {
+ // AllocEmpty will Truncate, no need to call it here.
+ return 0;
+ }
+ return len;
+ }
+
+ /**
+ * Automatically determines how much space is necessary, allocates that much
+ * for the string, and assigns the data using swprintf. Returns the resulting
+ * length of the string, which will be 0 if the function fails.
+ */
+ size_t AutoAllocAndAssignSprintf(const wchar_t* format, ...) {
+ va_list ap;
+ va_start(ap, format);
+ size_t len = AllocFromVscprintf(format, ap);
+ va_end(ap);
+ if (len == 0) {
+ // AllocFromVscprintf will Truncate, no need to call it here.
+ return 0;
+ }
+
+ va_start(ap, format);
+ size_t charsWritten = AssignVsprintf(len + 1, format, ap);
+ va_end(ap);
+
+ if (len != charsWritten) {
+ Truncate();
+ return 0;
+ }
+ return charsWritten;
+ }
+
+ /**
+ * The following CopyFrom functions take various types of strings, allocate
+ * enough space to hold them, and then copy them into that space.
+ *
+ * They return an HRESULT that should be interpreted with the SUCCEEDED or
+ * FAILED macro.
+ */
+ HRESULT CopyFrom(const wchar_t* src) {
+ mLength = wcslen(src);
+ if (!AllocLen(mLength)) {
+ Truncate();
+ return E_OUTOFMEMORY;
+ }
+ HRESULT hrv = StringCchCopyW(mString.get(), mLength + 1, src);
+ if (FAILED(hrv)) {
+ Truncate();
+ }
+ return hrv;
+ }
+ HRESULT CopyFrom(const SimpleAutoString& src) {
+ if (!src.mString) {
+ Truncate();
+ return S_OK;
+ }
+ mLength = src.mLength;
+ if (!AllocLen(mLength)) {
+ Truncate();
+ return E_OUTOFMEMORY;
+ }
+ HRESULT hrv = StringCchCopyW(mString.get(), mLength + 1, src.mString.get());
+ if (FAILED(hrv)) {
+ Truncate();
+ }
+ return hrv;
+ }
+ HRESULT CopyFrom(const char* src) {
+ int bufferSize =
+ MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, src, -1, nullptr, 0);
+ if (bufferSize == 0) {
+ Truncate();
+ return HRESULT_FROM_WIN32(GetLastError());
+ }
+ if (!AllocSize(bufferSize)) {
+ Truncate();
+ return E_OUTOFMEMORY;
+ }
+ int charsWritten = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, src,
+ -1, mString.get(), bufferSize);
+ if (charsWritten == 0) {
+ Truncate();
+ return HRESULT_FROM_WIN32(GetLastError());
+ } else if (charsWritten != bufferSize) {
+ Truncate();
+ return E_FAIL;
+ }
+ mLength = charsWritten - 1;
+ return S_OK;
+ }
+
+ bool StartsWith(const SimpleAutoString& prefix) const {
+ if (!mString) {
+ return (prefix.mLength == 0);
+ }
+ if (!prefix.mString) {
+ return true;
+ }
+ if (prefix.mLength > mLength) {
+ return false;
+ }
+ return (wcsncmp(mString.get(), prefix.mString.get(), prefix.mLength) == 0);
+ }
+};
+
+// Deleter for use with UniquePtr
+struct CoTaskMemFreeDeleter {
+ void operator()(void* aPtr) { ::CoTaskMemFree(aPtr); }
+};
+
+/**
+ * A lot of data goes into constructing an ACL and security attributes, and the
+ * Windows documentation does not make it very clear what can be safely freed
+ * after these objects are constructed. This struct holds all of the
+ * construction data in one place so that it can be passed around and freed
+ * properly.
+ */
+struct AutoPerms {
+ SID_IDENTIFIER_AUTHORITY sidIdentifierAuthority;
+ UniqueSidPtr usersSID;
+ UniqueSidPtr adminsSID;
+ UniqueSidPtr systemSID;
+ EXPLICIT_ACCESS_W ea[3];
+ mozilla::UniquePtr<ACL, LocalFreeDeleter> acl;
+ mozilla::UniquePtr<uint8_t[]> securityDescriptorBuffer;
+ PSECURITY_DESCRIPTOR securityDescriptor;
+ SECURITY_ATTRIBUTES securityAttributes;
+};
+
+static bool GetCachedHash(const char16_t* installPath, HKEY rootKey,
+ const SimpleAutoString& regPath,
+ mozilla::UniquePtr<NS_tchar[]>& result);
+static HRESULT GetUpdateDirectory(const wchar_t* installPath,
+ WhichUpdateDir whichDir,
+ mozilla::UniquePtr<wchar_t[]>& result);
+static HRESULT GeneratePermissions(AutoPerms& result);
+static HRESULT MakeDir(const SimpleAutoString& path, const AutoPerms& perms);
+#endif // XP_WIN
+
+/**
+ * Returns a hash of the install path, suitable for uniquely identifying the
+ * particular Firefox installation that is running.
+ *
+ * This function includes a compatibility mode that should NOT be used except by
+ * GetUserUpdateDirectory. Previous implementations of this function could
+ * return a value inconsistent with what our installer would generate. When the
+ * update directory was migrated, this function was re-implemented to return
+ * values consistent with those generated by the installer. The compatibility
+ * mode is retained only so that we can properly get the old update directory
+ * when migrating it.
+ *
+ * @param installPath
+ * The null-terminated path to the installation directory (i.e. the
+ * directory that contains the binary). Must not be null. The path must
+ * not include a trailing slash.
+ * @param result
+ * The out parameter that will be set to contain the resulting hash.
+ * The value is wrapped in a UniquePtr to make cleanup easier on the
+ * caller.
+ * @return true if successful and false otherwise.
+ */
+bool GetInstallHash(const char16_t* installPath,
+ mozilla::UniquePtr<NS_tchar[]>& result) {
+ MOZ_ASSERT(installPath != nullptr,
+ "Install path must not be null in GetInstallHash");
+
+ size_t pathSize =
+ std::char_traits<char16_t>::length(installPath) * sizeof(*installPath);
+ uint64_t hash =
+ CityHash64(reinterpret_cast<const char*>(installPath), pathSize);
+
+ size_t hashStrSize = sizeof(hash) * 2 + 1; // 2 hex digits per byte + null
+ result = mozilla::MakeUnique<NS_tchar[]>(hashStrSize);
+ int charsWritten =
+ NS_tsnprintf(result.get(), hashStrSize, NS_T("%") NS_T(PRIX64), hash);
+ return !(charsWritten < 1 ||
+ static_cast<size_t>(charsWritten) > hashStrSize - 1);
+}
+
+#ifdef XP_WIN
+/**
+ * Returns true if the registry key was successfully found and read into result.
+ */
+static bool GetCachedHash(const char16_t* installPath, HKEY rootKey,
+ const SimpleAutoString& regPath,
+ mozilla::UniquePtr<NS_tchar[]>& result) {
+ // Find the size of the string we are reading before we read it so we can
+ // allocate space.
+ unsigned long bufferSize;
+ LSTATUS lrv = RegGetValueW(rootKey, regPath.String(),
+ reinterpret_cast<const wchar_t*>(installPath),
+ RRF_RT_REG_SZ, nullptr, nullptr, &bufferSize);
+ if (lrv != ERROR_SUCCESS) {
+ return false;
+ }
+ result = mozilla::MakeUnique<NS_tchar[]>(bufferSize);
+ // Now read the actual value from the registry.
+ lrv = RegGetValueW(rootKey, regPath.String(),
+ reinterpret_cast<const wchar_t*>(installPath),
+ RRF_RT_REG_SZ, nullptr, result.get(), &bufferSize);
+ return (lrv == ERROR_SUCCESS);
+}
+
+/**
+ * Returns the update directory path. The update directory needs to have
+ * different permissions from the default, so we don't really want anyone using
+ * the path without the directory already being created with the correct
+ * permissions. Therefore, this function also ensures that the base directory
+ * that needs permissions set already exists. If it does not exist, it is
+ * created with the needed permissions.
+ * The desired permissions give Full Control to SYSTEM, Administrators, and
+ * Users.
+ *
+ * @param installPath
+ * Must be the null-terminated path to the installation directory (i.e.
+ * the directory that contains the binary). The path must not include a
+ * trailing slash.
+ * @param result
+ * The out parameter that will be set to contain the resulting path.
+ * The value is wrapped in a UniquePtr to make cleanup easier on the
+ * caller.
+ *
+ * @return An HRESULT that should be tested with SUCCEEDED or FAILED.
+ */
+HRESULT
+GetCommonUpdateDirectory(const wchar_t* installPath,
+ mozilla::UniquePtr<wchar_t[]>& result) {
+ return GetUpdateDirectory(installPath, WhichUpdateDir::CurrentUpdateDir,
+ result);
+}
+
+/**
+ * This function is identical to the function above except that it gets the
+ * "old" (pre-migration) update directory.
+ *
+ * The other difference is that this function does not create the directory.
+ */
+HRESULT
+GetOldUpdateDirectory(const wchar_t* installPath,
+ mozilla::UniquePtr<wchar_t[]>& result) {
+ return GetUpdateDirectory(installPath, WhichUpdateDir::UnmigratedUpdateDir,
+ result);
+}
+
+/**
+ * This is a version of the GetCommonUpdateDirectory that can be called from
+ * Rust.
+ * The result parameter must be a valid pointer to a buffer of length
+ * MAX_PATH + 1
+ */
+extern "C" HRESULT get_common_update_directory(const wchar_t* installPath,
+ wchar_t* result) {
+ mozilla::UniquePtr<wchar_t[]> uniqueResult;
+ HRESULT hr = GetCommonUpdateDirectory(installPath, uniqueResult);
+ if (FAILED(hr)) {
+ return hr;
+ }
+ return StringCchCopyW(result, MAX_PATH + 1, uniqueResult.get());
+}
+
+/**
+ * This is a helper function that does all of the work for
+ * GetCommonUpdateDirectory and GetUserUpdateDirectory.
+ *
+ * For information on the parameters and return value, see
+ * GetCommonUpdateDirectory.
+ */
+static HRESULT GetUpdateDirectory(const wchar_t* installPath,
+ WhichUpdateDir whichDir,
+ mozilla::UniquePtr<wchar_t[]>& result) {
+ MOZ_ASSERT(installPath != nullptr,
+ "Install path must not be null in GetUpdateDirectory");
+
+ AutoPerms perms;
+ HRESULT hrv = GeneratePermissions(perms);
+ if (FAILED(hrv)) {
+ return hrv;
+ }
+
+ PWSTR baseDirParentPath;
+ hrv = SHGetKnownFolderPath(FOLDERID_ProgramData, KF_FLAG_CREATE, nullptr,
+ &baseDirParentPath);
+ // Free baseDirParentPath when it goes out of scope.
+ mozilla::UniquePtr<wchar_t, CoTaskMemFreeDeleter> baseDirParentPathUnique(
+ baseDirParentPath);
+ if (FAILED(hrv)) {
+ return hrv;
+ }
+
+ SimpleAutoString baseDir;
+ if (whichDir == WhichUpdateDir::UnmigratedUpdateDir) {
+ const wchar_t baseDirLiteral[] = NS_T(OLD_ROOT_UPDATE_DIR_NAME);
+ hrv = baseDir.CopyFrom(baseDirLiteral);
+ } else {
+ const wchar_t baseDirLiteral[] = NS_T(ROOT_UPDATE_DIR_NAME);
+ hrv = baseDir.CopyFrom(baseDirLiteral);
+ }
+ if (FAILED(hrv)) {
+ return hrv;
+ }
+
+ // Generate the base path
+ // (C:\ProgramData\Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38)
+ SimpleAutoString basePath;
+ size_t basePathLen =
+ wcslen(baseDirParentPath) + 1 /* path separator */ + baseDir.Length();
+ basePath.AllocAndAssignSprintf(basePathLen, L"%s\\%s", baseDirParentPath,
+ baseDir.String());
+ if (basePath.Length() != basePathLen) {
+ return E_FAIL;
+ }
+
+ if (whichDir == WhichUpdateDir::CurrentUpdateDir) {
+ hrv = MakeDir(basePath, perms);
+ if (FAILED(hrv)) {
+ return hrv;
+ }
+ }
+
+ // Generate what we are going to call the mid-path
+ // (C:\ProgramData\Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38\updates)
+ const wchar_t midPathDirName[] = NS_T(UPDATE_PATH_MID_DIR_NAME);
+ size_t midPathLen =
+ basePath.Length() + 1 /* path separator */ + wcslen(midPathDirName);
+ SimpleAutoString midPath;
+ midPath.AllocAndAssignSprintf(midPathLen, L"%s\\%s", basePath.String(),
+ midPathDirName);
+ if (midPath.Length() != midPathLen) {
+ return E_FAIL;
+ }
+
+ mozilla::UniquePtr<NS_tchar[]> hash;
+
+ // The Windows installer caches this hash value in the registry
+ bool gotHash = false;
+ SimpleAutoString regPath;
+ regPath.AutoAllocAndAssignSprintf(L"SOFTWARE\\Mozilla\\%S\\TaskBarIDs",
+ MOZ_APP_BASENAME);
+ if (regPath.Length() != 0) {
+ gotHash = GetCachedHash(reinterpret_cast<const char16_t*>(installPath),
+ HKEY_LOCAL_MACHINE, regPath, hash);
+ if (!gotHash) {
+ gotHash = GetCachedHash(reinterpret_cast<const char16_t*>(installPath),
+ HKEY_CURRENT_USER, regPath, hash);
+ }
+ }
+ // If we couldn't get it out of the registry, we'll just have to regenerate
+ // it.
+ if (!gotHash) {
+ bool success =
+ GetInstallHash(reinterpret_cast<const char16_t*>(installPath), hash);
+ if (!success) {
+ return E_FAIL;
+ }
+ }
+
+ size_t updatePathLen =
+ midPath.Length() + 1 /* path separator */ + wcslen(hash.get());
+ SimpleAutoString updatePath;
+ updatePath.AllocAndAssignSprintf(updatePathLen, L"%s\\%s", midPath.String(),
+ hash.get());
+ if (updatePath.Length() != updatePathLen) {
+ return E_FAIL;
+ }
+
+ updatePath.SwapBufferWith(result);
+ return S_OK;
+}
+
+/**
+ * Generates the permission set that we want to be applied to the update
+ * directory and its contents. Returns the permissions data via the result
+ * outparam.
+ *
+ * These are also the permissions that will be used to check that file
+ * permissions are correct.
+ */
+static HRESULT GeneratePermissions(AutoPerms& result) {
+ result.sidIdentifierAuthority = SECURITY_NT_AUTHORITY;
+ ZeroMemory(&result.ea, sizeof(result.ea));
+
+ // Make Users group SID and add it to the Explicit Access List.
+ PSID usersSID = nullptr;
+ BOOL success = AllocateAndInitializeSid(
+ &result.sidIdentifierAuthority, 2, SECURITY_BUILTIN_DOMAIN_RID,
+ DOMAIN_ALIAS_RID_USERS, 0, 0, 0, 0, 0, 0, &usersSID);
+ result.usersSID.reset(usersSID);
+ if (!success) {
+ return HRESULT_FROM_WIN32(GetLastError());
+ }
+ result.ea[0].grfAccessPermissions = FILE_ALL_ACCESS;
+ result.ea[0].grfAccessMode = SET_ACCESS;
+ result.ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
+ result.ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
+ result.ea[0].Trustee.TrusteeType = TRUSTEE_IS_GROUP;
+ result.ea[0].Trustee.ptstrName = static_cast<LPWSTR>(usersSID);
+
+ // Make Administrators group SID and add it to the Explicit Access List.
+ PSID adminsSID = nullptr;
+ success = AllocateAndInitializeSid(
+ &result.sidIdentifierAuthority, 2, SECURITY_BUILTIN_DOMAIN_RID,
+ DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &adminsSID);
+ result.adminsSID.reset(adminsSID);
+ if (!success) {
+ return HRESULT_FROM_WIN32(GetLastError());
+ }
+ result.ea[1].grfAccessPermissions = FILE_ALL_ACCESS;
+ result.ea[1].grfAccessMode = SET_ACCESS;
+ result.ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
+ result.ea[1].Trustee.TrusteeForm = TRUSTEE_IS_SID;
+ result.ea[1].Trustee.TrusteeType = TRUSTEE_IS_GROUP;
+ result.ea[1].Trustee.ptstrName = static_cast<LPWSTR>(adminsSID);
+
+ // Make SYSTEM user SID and add it to the Explicit Access List.
+ PSID systemSID = nullptr;
+ success = AllocateAndInitializeSid(&result.sidIdentifierAuthority, 1,
+ SECURITY_LOCAL_SYSTEM_RID, 0, 0, 0, 0, 0,
+ 0, 0, &systemSID);
+ result.systemSID.reset(systemSID);
+ if (!success) {
+ return HRESULT_FROM_WIN32(GetLastError());
+ }
+ result.ea[2].grfAccessPermissions = FILE_ALL_ACCESS;
+ result.ea[2].grfAccessMode = SET_ACCESS;
+ result.ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
+ result.ea[2].Trustee.TrusteeForm = TRUSTEE_IS_SID;
+ result.ea[2].Trustee.TrusteeType = TRUSTEE_IS_USER;
+ result.ea[2].Trustee.ptstrName = static_cast<LPWSTR>(systemSID);
+
+ PACL acl = nullptr;
+ DWORD drv = SetEntriesInAclW(3, result.ea, nullptr, &acl);
+ // Put the ACL in a unique pointer so that LocalFree is called when it goes
+ // out of scope
+ result.acl.reset(acl);
+ if (drv != ERROR_SUCCESS) {
+ return HRESULT_FROM_WIN32(drv);
+ }
+
+ result.securityDescriptorBuffer =
+ mozilla::MakeUnique<uint8_t[]>(SECURITY_DESCRIPTOR_MIN_LENGTH);
+ if (!result.securityDescriptorBuffer) {
+ return E_OUTOFMEMORY;
+ }
+ result.securityDescriptor = reinterpret_cast<PSECURITY_DESCRIPTOR>(
+ result.securityDescriptorBuffer.get());
+ success = InitializeSecurityDescriptor(result.securityDescriptor,
+ SECURITY_DESCRIPTOR_REVISION);
+ if (!success) {
+ return HRESULT_FROM_WIN32(GetLastError());
+ }
+
+ success =
+ SetSecurityDescriptorDacl(result.securityDescriptor, TRUE, acl, FALSE);
+ if (!success) {
+ return HRESULT_FROM_WIN32(GetLastError());
+ }
+
+ result.securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
+ result.securityAttributes.lpSecurityDescriptor = result.securityDescriptor;
+ result.securityAttributes.bInheritHandle = FALSE;
+ return S_OK;
+}
+
+/**
+ * Creates a directory with the permissions specified. If the directory already
+ * exists, this function will return success.
+ */
+static HRESULT MakeDir(const SimpleAutoString& path, const AutoPerms& perms) {
+ BOOL success = CreateDirectoryW(
+ path.String(),
+ const_cast<LPSECURITY_ATTRIBUTES>(&perms.securityAttributes));
+ if (success) {
+ return S_OK;
+ }
+ DWORD error = GetLastError();
+ if (error == ERROR_ALREADY_EXISTS) {
+ return S_OK;
+ }
+ return HRESULT_FROM_WIN32(error);
+}
+#endif // XP_WIN
diff --git a/toolkit/mozapps/update/common/commonupdatedir.h b/toolkit/mozapps/update/common/commonupdatedir.h
new file mode 100644
index 0000000000..5d7f88b15e
--- /dev/null
+++ b/toolkit/mozapps/update/common/commonupdatedir.h
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 COMMONUPDATEDIR_H
+#define COMMONUPDATEDIR_H
+
+#include "mozilla/UniquePtr.h"
+
+#ifdef XP_WIN
+# include <windows.h>
+typedef WCHAR NS_tchar;
+#else
+typedef char NS_tchar;
+#endif
+
+bool GetInstallHash(const char16_t* installPath,
+ mozilla::UniquePtr<NS_tchar[]>& result);
+
+#ifdef XP_WIN
+// In addition to getting the update directory, this function also creates it.
+// This is to ensure that, when it is created, it is created with the correct
+// permissions. The default permissions on the containing directory can cause
+// problems, so it is very, very important that we make sure that the
+// permissions are set properly. Thus, we won't even give out the path of the
+// update directory without ensuring that it was created with the correct
+// permissions.
+HRESULT GetCommonUpdateDirectory(const wchar_t* installPath,
+ mozilla::UniquePtr<wchar_t[]>& result);
+// Returns the old common update directory. Since this directory was used before
+// we made sure to always set the correct permissions, it is possible that the
+// permissions on this directory are set such that files can only be modified
+// or deleted by the user that created them. This function exists entirely to
+// allow us to migrate files out of the old update directory and into the new
+// one.
+HRESULT GetOldUpdateDirectory(const wchar_t* installPath,
+ mozilla::UniquePtr<wchar_t[]>& result);
+#endif
+
+#endif
diff --git a/toolkit/mozapps/update/common/moz.build b/toolkit/mozapps/update/common/moz.build
new file mode 100644
index 0000000000..2c79661c1f
--- /dev/null
+++ b/toolkit/mozapps/update/common/moz.build
@@ -0,0 +1,76 @@
+# -*- 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/.
+
+EXPORTS += [
+ "commonupdatedir.h",
+ "readstrings.h",
+ "updatecommon.h",
+ "updatedefines.h",
+ "updatererrors.h",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ EXPORTS += [
+ "pathhash.h",
+ "uachelper.h",
+ "updatehelper.cpp",
+ "updatehelper.h",
+ "updateutils_win.h",
+ ]
+
+ if CONFIG["MOZ_MAINTENANCE_SERVICE"]:
+ EXPORTS += [
+ "certificatecheck.h",
+ "registrycertificates.h",
+ ]
+
+Library("updatecommon")
+
+DEFINES["NS_NO_XPCOM"] = True
+USE_STATIC_LIBS = True
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ # This forces the creation of updatecommon.lib, which the update agent needs
+ # in order to link to updatecommon library functions.
+ NO_EXPAND_LIBS = True
+
+DisableStlWrapping()
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ SOURCES += [
+ "pathhash.cpp",
+ "uachelper.cpp",
+ "updatehelper.cpp",
+ "updateutils_win.cpp",
+ ]
+ OS_LIBS += [
+ "advapi32",
+ "ole32",
+ "rpcrt4",
+ "shell32",
+ ]
+ if CONFIG["MOZ_MAINTENANCE_SERVICE"]:
+ SOURCES += [
+ "certificatecheck.cpp",
+ "registrycertificates.cpp",
+ ]
+ OS_LIBS += [
+ "crypt32",
+ "wintrust",
+ ]
+
+SOURCES += [
+ "/other-licenses/nsis/Contrib/CityHash/cityhash/city.cpp",
+ "commonupdatedir.cpp",
+ "readstrings.cpp",
+ "updatecommon.cpp",
+]
+
+LOCAL_INCLUDES += [
+ "/other-licenses/nsis/Contrib/CityHash/cityhash",
+]
+
+DEFINES["MOZ_APP_BASENAME"] = '"%s"' % CONFIG["MOZ_APP_BASENAME"]
diff --git a/toolkit/mozapps/update/common/pathhash.cpp b/toolkit/mozapps/update/common/pathhash.cpp
new file mode 100644
index 0000000000..e70f69a755
--- /dev/null
+++ b/toolkit/mozapps/update/common/pathhash.cpp
@@ -0,0 +1,128 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <windows.h>
+#include <wincrypt.h>
+#include "pathhash.h"
+
+/**
+ * Converts a binary sequence into a hex string
+ *
+ * @param hash The binary data sequence
+ * @param hashSize The size of the binary data sequence
+ * @param hexString A buffer to store the hex string, must be of
+ * size 2 * @hashSize
+ */
+static void BinaryDataToHexString(const BYTE* hash, DWORD& hashSize,
+ LPWSTR hexString) {
+ WCHAR* p = hexString;
+ for (DWORD i = 0; i < hashSize; ++i) {
+ wsprintfW(p, L"%.2x", hash[i]);
+ p += 2;
+ }
+}
+
+/**
+ * Calculates an MD5 hash for the given input binary data
+ *
+ * @param data Any sequence of bytes
+ * @param dataSize The number of bytes inside @data
+ * @param hash Output buffer to store hash, must be freed by the caller
+ * @param hashSize The number of bytes in the output buffer
+ * @return TRUE on success
+ */
+static BOOL CalculateMD5(const char* data, DWORD dataSize, BYTE** hash,
+ DWORD& hashSize) {
+ HCRYPTPROV hProv = 0;
+ HCRYPTHASH hHash = 0;
+
+ if (!CryptAcquireContext(&hProv, nullptr, nullptr, PROV_RSA_FULL,
+ CRYPT_VERIFYCONTEXT)) {
+ if ((DWORD)NTE_BAD_KEYSET != GetLastError()) {
+ return FALSE;
+ }
+
+ // Maybe it doesn't exist, try to create it.
+ if (!CryptAcquireContext(&hProv, nullptr, nullptr, PROV_RSA_FULL,
+ CRYPT_VERIFYCONTEXT | CRYPT_NEWKEYSET)) {
+ return FALSE;
+ }
+ }
+
+ if (!CryptCreateHash(hProv, CALG_MD5, 0, 0, &hHash)) {
+ return FALSE;
+ }
+
+ if (!CryptHashData(hHash, reinterpret_cast<const BYTE*>(data), dataSize, 0)) {
+ return FALSE;
+ }
+
+ DWORD dwCount = sizeof(DWORD);
+ if (!CryptGetHashParam(hHash, HP_HASHSIZE, (BYTE*)&hashSize, &dwCount, 0)) {
+ return FALSE;
+ }
+
+ *hash = new BYTE[hashSize];
+ ZeroMemory(*hash, hashSize);
+ if (!CryptGetHashParam(hHash, HP_HASHVAL, *hash, &hashSize, 0)) {
+ return FALSE;
+ }
+
+ if (hHash) {
+ CryptDestroyHash(hHash);
+ }
+
+ if (hProv) {
+ CryptReleaseContext(hProv, 0);
+ }
+
+ return TRUE;
+}
+
+/**
+ * Converts a file path into a unique registry location for cert storage
+ *
+ * @param filePath The input file path to get a registry path from
+ * @param registryPath A buffer to write the registry path to, must
+ * be of size in WCHARs MAX_PATH + 1
+ * @return TRUE if successful
+ */
+BOOL CalculateRegistryPathFromFilePath(const LPCWSTR filePath,
+ LPWSTR registryPath) {
+ size_t filePathLen = wcslen(filePath);
+ if (!filePathLen) {
+ return FALSE;
+ }
+
+ // If the file path ends in a slash, ignore that character
+ if (filePath[filePathLen - 1] == L'\\' || filePath[filePathLen - 1] == L'/') {
+ filePathLen--;
+ }
+
+ // Copy in the full path into our own buffer.
+ // Copying in the extra slash is OK because we calculate the hash
+ // based on the filePathLen which excludes the slash.
+ // +2 to account for the possibly trailing slash and the null terminator.
+ WCHAR* lowercasePath = new WCHAR[filePathLen + 2];
+ memset(lowercasePath, 0, (filePathLen + 2) * sizeof(WCHAR));
+ wcsncpy(lowercasePath, filePath, filePathLen + 1);
+ _wcslwr(lowercasePath);
+
+ BYTE* hash;
+ DWORD hashSize = 0;
+ if (!CalculateMD5(reinterpret_cast<const char*>(lowercasePath),
+ filePathLen * 2, &hash, hashSize)) {
+ delete[] lowercasePath;
+ return FALSE;
+ }
+ delete[] lowercasePath;
+
+ LPCWSTR baseRegPath =
+ L"SOFTWARE\\Mozilla\\"
+ L"MaintenanceService\\";
+ wcsncpy(registryPath, baseRegPath, MAX_PATH);
+ BinaryDataToHexString(hash, hashSize, registryPath + wcslen(baseRegPath));
+ delete[] hash;
+ return TRUE;
+}
diff --git a/toolkit/mozapps/update/common/pathhash.h b/toolkit/mozapps/update/common/pathhash.h
new file mode 100644
index 0000000000..17f08ae95e
--- /dev/null
+++ b/toolkit/mozapps/update/common/pathhash.h
@@ -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/. */
+
+#ifndef _PATHHASH_H_
+#define _PATHHASH_H_
+
+#include <windows.h>
+
+/**
+ * Converts a file path into a unique registry location for cert storage
+ *
+ * @param filePath The input file path to get a registry path from
+ * @param registryPath A buffer to write the registry path to, must
+ * be of size in WCHARs MAX_PATH + 1
+ * @return TRUE if successful
+ */
+BOOL CalculateRegistryPathFromFilePath(const LPCWSTR filePath,
+ LPWSTR registryPath);
+
+#endif
diff --git a/toolkit/mozapps/update/common/readstrings.cpp b/toolkit/mozapps/update/common/readstrings.cpp
new file mode 100644
index 0000000000..17c2d002a1
--- /dev/null
+++ b/toolkit/mozapps/update/common/readstrings.cpp
@@ -0,0 +1,396 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <algorithm>
+#include <iterator>
+#include <string.h>
+#include <stdio.h>
+#include "readstrings.h"
+#include "updatererrors.h"
+
+#ifdef XP_WIN
+# define NS_tfopen _wfopen
+# define OPEN_MODE L"rb"
+# define NS_tstrlen wcslen
+# define NS_tstrcpy wcscpy
+#else
+# define NS_tfopen fopen
+# define OPEN_MODE "r"
+# define NS_tstrlen strlen
+# define NS_tstrcpy strcpy
+#endif
+
+// stack based FILE wrapper to ensure that fclose is called.
+class AutoFILE {
+ public:
+ explicit AutoFILE(FILE* fp) : fp_(fp) {}
+ ~AutoFILE() {
+ if (fp_) {
+ fclose(fp_);
+ }
+ }
+ operator FILE*() { return fp_; }
+
+ private:
+ FILE* fp_;
+};
+
+class AutoCharArray {
+ public:
+ explicit AutoCharArray(size_t len) { ptr_ = new char[len]; }
+ ~AutoCharArray() { delete[] ptr_; }
+ operator char*() { return ptr_; }
+
+ private:
+ char* ptr_;
+};
+
+static const char kNL[] = "\r\n";
+static const char kEquals[] = "=";
+static const char kWhitespace[] = " \t";
+static const char kRBracket[] = "]";
+
+static const char* NS_strspnp(const char* delims, const char* str) {
+ const char* d;
+ do {
+ for (d = delims; *d != '\0'; ++d) {
+ if (*str == *d) {
+ ++str;
+ break;
+ }
+ }
+ } while (*d);
+
+ return str;
+}
+
+static char* NS_strtok(const char* delims, char** str) {
+ if (!*str) {
+ return nullptr;
+ }
+
+ char* ret = (char*)NS_strspnp(delims, *str);
+
+ if (!*ret) {
+ *str = ret;
+ return nullptr;
+ }
+
+ char* i = ret;
+ do {
+ for (const char* d = delims; *d != '\0'; ++d) {
+ if (*i == *d) {
+ *i = '\0';
+ *str = ++i;
+ return ret;
+ }
+ }
+ ++i;
+ } while (*i);
+
+ *str = nullptr;
+ return ret;
+}
+
+/**
+ * Find a key in a keyList containing zero-delimited keys ending with "\0\0".
+ * Returns a zero-based index of the key in the list, or -1 if the key is not
+ * found.
+ */
+static int find_key(const char* keyList, char* key) {
+ if (!keyList) {
+ return -1;
+ }
+
+ int index = 0;
+ const char* p = keyList;
+ while (*p) {
+ if (strcmp(key, p) == 0) {
+ return index;
+ }
+
+ p += strlen(p) + 1;
+ index++;
+ }
+
+ // The key was not found if we came here
+ return -1;
+}
+
+/**
+ * A very basic parser for updater.ini taken mostly from nsINIParser.cpp
+ * that can be used by standalone apps.
+ *
+ * @param path Path to the .ini file to read
+ * @param keyList List of zero-delimited keys ending with two zero characters
+ * @param numStrings Number of strings to read into results buffer - must be
+ * equal to the number of keys
+ * @param results Array of strings. Array's length must be equal to
+ * numStrings. Each string will be populated with the value
+ * corresponding to the key with the same index in keyList.
+ * @param section Optional name of the section to read; defaults to "Strings"
+ */
+int ReadStrings(const NS_tchar* path, const char* keyList,
+ unsigned int numStrings, mozilla::UniquePtr<char[]>* results,
+ const char* section) {
+ AutoFILE fp(NS_tfopen(path, OPEN_MODE));
+
+ if (!fp) {
+ return READ_ERROR;
+ }
+
+ /* get file size */
+ if (fseek(fp, 0, SEEK_END) != 0) {
+ return READ_ERROR;
+ }
+
+ long len = ftell(fp);
+ if (len <= 0) {
+ return READ_ERROR;
+ }
+
+ size_t flen = size_t(len);
+ AutoCharArray fileContents(flen + 1);
+ if (!fileContents) {
+ return READ_STRINGS_MEM_ERROR;
+ }
+
+ /* read the file in one swoop */
+ if (fseek(fp, 0, SEEK_SET) != 0) {
+ return READ_ERROR;
+ }
+
+ size_t rd = fread(fileContents, sizeof(char), flen, fp);
+ if (rd != flen) {
+ return READ_ERROR;
+ }
+
+ fileContents[flen] = '\0';
+
+ char* buffer = fileContents;
+ bool inStringsSection = false;
+
+ unsigned int read = 0;
+
+ while (char* token = NS_strtok(kNL, &buffer)) {
+ if (token[0] == '#' || token[0] == ';') { // it's a comment
+ continue;
+ }
+
+ token = (char*)NS_strspnp(kWhitespace, token);
+ if (!*token) { // empty line
+ continue;
+ }
+
+ if (token[0] == '[') { // section header!
+ ++token;
+ char const* currSection = token;
+
+ char* rb = NS_strtok(kRBracket, &token);
+ if (!rb || NS_strtok(kWhitespace, &token)) {
+ // there's either an unclosed [Section or a [Section]Moretext!
+ // we could frankly decide that this INI file is malformed right
+ // here and stop, but we won't... keep going, looking for
+ // a well-formed [section] to continue working with
+ inStringsSection = false;
+ } else {
+ if (section) {
+ inStringsSection = strcmp(currSection, section) == 0;
+ } else {
+ inStringsSection = strcmp(currSection, "Strings") == 0;
+ }
+ }
+
+ continue;
+ }
+
+ if (!inStringsSection) {
+ // If we haven't found a section header (or we found a malformed
+ // section header), or this isn't the [Strings] section don't bother
+ // parsing this line.
+ continue;
+ }
+
+ char* key = token;
+ char* e = NS_strtok(kEquals, &token);
+ if (!e) {
+ continue;
+ }
+
+ int keyIndex = find_key(keyList, key);
+ if (keyIndex >= 0 && (unsigned int)keyIndex < numStrings) {
+ size_t valueSize = strlen(token) + 1;
+ results[keyIndex] = mozilla::MakeUnique<char[]>(valueSize);
+
+ strcpy(results[keyIndex].get(), token);
+ read++;
+ }
+ }
+
+ return (read == numStrings) ? OK : PARSE_ERROR;
+}
+
+// A wrapper function to read strings for the updater.
+// Added for compatibility with the original code.
+int ReadStrings(const NS_tchar* path, StringTable* results) {
+ const unsigned int kNumStrings = 2;
+ const char* kUpdaterKeys = "Title\0Info\0";
+ mozilla::UniquePtr<char[]> updater_strings[kNumStrings];
+
+ int result = ReadStrings(path, kUpdaterKeys, kNumStrings, updater_strings);
+
+ if (result == OK) {
+ results->title.swap(updater_strings[0]);
+ results->info.swap(updater_strings[1]);
+ }
+
+ return result;
+}
+
+IniReader::IniReader(const NS_tchar* iniPath,
+ const char* section /* = nullptr */) {
+ if (iniPath) {
+ mPath = mozilla::MakeUnique<NS_tchar[]>(NS_tstrlen(iniPath) + 1);
+ NS_tstrcpy(mPath.get(), iniPath);
+ mMaybeStatusCode = mozilla::Nothing();
+ } else {
+ mMaybeStatusCode = mozilla::Some(READ_STRINGS_MEM_ERROR);
+ }
+ if (section) {
+ mSection = mozilla::MakeUnique<char[]>(strlen(section) + 1);
+ strcpy(mSection.get(), section);
+ } else {
+ mSection.reset(nullptr);
+ }
+}
+
+bool IniReader::MaybeAddKey(const char* key, size_t& insertionIndex) {
+ if (!key || strlen(key) == 0 || mMaybeStatusCode.isSome()) {
+ return false;
+ }
+ auto existingKey = std::find_if(mKeys.begin(), mKeys.end(),
+ [=](mozilla::UniquePtr<char[]>& searchKey) {
+ return strcmp(key, searchKey.get()) == 0;
+ });
+ if (existingKey != mKeys.end()) {
+ // Key already in list
+ insertionIndex = std::distance(mKeys.begin(), existingKey);
+ return true;
+ }
+
+ // Key not already in list
+ insertionIndex = mKeys.size();
+ mKeys.emplace_back(mozilla::MakeUnique<char[]>(strlen(key) + 1));
+ strcpy(mKeys.back().get(), key);
+ return true;
+}
+
+void IniReader::AddKey(const char* key, mozilla::UniquePtr<char[]>* outputPtr) {
+ size_t insertionIndex;
+ if (!MaybeAddKey(key, insertionIndex)) {
+ return;
+ }
+
+ if (!outputPtr) {
+ return;
+ }
+
+ mNarrowOutputs.emplace_back();
+ mNarrowOutputs.back().keyIndex = insertionIndex;
+ mNarrowOutputs.back().outputPtr = outputPtr;
+}
+
+#ifdef XP_WIN
+void IniReader::AddKey(const char* key,
+ mozilla::UniquePtr<wchar_t[]>* outputPtr) {
+ size_t insertionIndex;
+ if (!MaybeAddKey(key, insertionIndex)) {
+ return;
+ }
+
+ if (!outputPtr) {
+ return;
+ }
+
+ mWideOutputs.emplace_back();
+ mWideOutputs.back().keyIndex = insertionIndex;
+ mWideOutputs.back().outputPtr = outputPtr;
+}
+
+// Returns true on success, false on failure.
+static bool ConvertToWide(const char* toConvert,
+ mozilla::UniquePtr<wchar_t[]>* result) {
+ int bufferSize = MultiByteToWideChar(CP_UTF8, 0, toConvert, -1, nullptr, 0);
+ *result = mozilla::MakeUnique<wchar_t[]>(bufferSize);
+ int charsWritten =
+ MultiByteToWideChar(CP_UTF8, 0, toConvert, -1, result->get(), bufferSize);
+ return charsWritten > 0;
+}
+#endif
+
+int IniReader::Read() {
+ if (mMaybeStatusCode.isSome()) {
+ return mMaybeStatusCode.value();
+ }
+
+ if (mKeys.empty()) {
+ // If there's nothing to read, just report success and return.
+ mMaybeStatusCode = mozilla::Some(OK);
+ return OK;
+ }
+
+ // First assemble the key list, which will be a character array of
+ // back-to-back null-terminated strings ending with a double null termination.
+ size_t keyListSize = 1; // For the final null
+ for (const auto& key : mKeys) {
+ keyListSize += strlen(key.get());
+ keyListSize += 1; // For the terminating null
+ }
+ mozilla::UniquePtr<char[]> keyList = mozilla::MakeUnique<char[]>(keyListSize);
+ char* keyListPtr = keyList.get();
+ for (const auto& key : mKeys) {
+ strcpy(keyListPtr, key.get());
+ // Point keyListPtr directly after the trailing null that strcpy wrote.
+ keyListPtr += strlen(key.get()) + 1;
+ }
+ *keyListPtr = '\0';
+
+ // Now make the array for the resulting data to be stored in
+ mozilla::UniquePtr<mozilla::UniquePtr<char[]>[]> results =
+ mozilla::MakeUnique<mozilla::UniquePtr<char[]>[]>(mKeys.size());
+
+ // Invoke ReadStrings to read the file and store the data for us
+ int statusCode = ReadStrings(mPath.get(), keyList.get(), mKeys.size(),
+ results.get(), mSection.get());
+ mMaybeStatusCode = mozilla::Some(statusCode);
+
+ if (statusCode != OK) {
+ return statusCode;
+ }
+
+ // Now populate the requested locations with the requested data.
+ for (const auto output : mNarrowOutputs) {
+ char* valueBuffer = results[output.keyIndex].get();
+ if (valueBuffer) {
+ *(output.outputPtr) =
+ mozilla::MakeUnique<char[]>(strlen(valueBuffer) + 1);
+ strcpy(output.outputPtr->get(), valueBuffer);
+ }
+ }
+
+#ifdef XP_WIN
+ for (const auto output : mWideOutputs) {
+ char* valueBuffer = results[output.keyIndex].get();
+ if (valueBuffer) {
+ if (!ConvertToWide(valueBuffer, output.outputPtr)) {
+ statusCode = STRING_CONVERSION_ERROR;
+ }
+ }
+ }
+#endif
+
+ return statusCode;
+}
diff --git a/toolkit/mozapps/update/common/readstrings.h b/toolkit/mozapps/update/common/readstrings.h
new file mode 100644
index 0000000000..9e0ebbefb5
--- /dev/null
+++ b/toolkit/mozapps/update/common/readstrings.h
@@ -0,0 +1,91 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 READSTRINGS_H__
+#define READSTRINGS_H__
+
+#include "mozilla/Maybe.h"
+#include "mozilla/UniquePtr.h"
+
+#include <vector>
+
+#ifdef XP_WIN
+# include <windows.h>
+typedef WCHAR NS_tchar;
+#else
+typedef char NS_tchar;
+#endif
+
+struct StringTable {
+ mozilla::UniquePtr<char[]> title;
+ mozilla::UniquePtr<char[]> info;
+};
+
+/**
+ * This function reads in localized strings from updater.ini
+ */
+int ReadStrings(const NS_tchar* path, StringTable* results);
+
+/**
+ * This function reads in localized strings corresponding to the keys from a
+ * given .ini
+ */
+int ReadStrings(const NS_tchar* path, const char* keyList,
+ unsigned int numStrings, mozilla::UniquePtr<char[]>* results,
+ const char* section = nullptr);
+
+/**
+ * This class is meant to be a slightly cleaner interface into the ReadStrings
+ * function.
+ */
+class IniReader {
+ public:
+ // IniReader must be initialized with the path of the INI file and a
+ // section to read from. If the section is null or not specified, the
+ // default section name ("Strings") will be used.
+ explicit IniReader(const NS_tchar* iniPath, const char* section = nullptr);
+
+ // Records a key that ought to be read from the INI file. When
+ // IniReader::Read() is invoked it will, if successful, store the value
+ // corresponding to the given key in the UniquePtr given.
+ // If IniReader::Read() has already been invoked, these functions do nothing.
+ // The given key must not be the empty string.
+ void AddKey(const char* key, mozilla::UniquePtr<char[]>* outputPtr);
+#ifdef XP_WIN
+ void AddKey(const char* key, mozilla::UniquePtr<wchar_t[]>* outputPtr);
+#endif
+ bool HasRead() { return mMaybeStatusCode.isSome(); }
+ // Performs the actual reading and assigns values to the requested locations.
+ // Returns the same possible values that `ReadStrings` returns.
+ // If this is called more than once, no action will be taken on subsequent
+ // calls, and the stored status code will be returned instead.
+ int Read();
+
+ private:
+ bool MaybeAddKey(const char* key, size_t& insertionIndex);
+
+ mozilla::UniquePtr<NS_tchar[]> mPath;
+ mozilla::UniquePtr<char[]> mSection;
+ std::vector<mozilla::UniquePtr<char[]>> mKeys;
+
+ template <class T>
+ struct ValueOutput {
+ size_t keyIndex;
+ T* outputPtr;
+ };
+
+ // Stores associations between keys and the buffers where their values will
+ // be stored.
+ std::vector<ValueOutput<mozilla::UniquePtr<char[]>>> mNarrowOutputs;
+#ifdef XP_WIN
+ std::vector<ValueOutput<mozilla::UniquePtr<wchar_t[]>>> mWideOutputs;
+#endif
+ // If we have attempted to read the INI, this will store the resulting
+ // status code.
+ mozilla::Maybe<int> mMaybeStatusCode;
+};
+
+#endif // READSTRINGS_H__
diff --git a/toolkit/mozapps/update/common/registrycertificates.cpp b/toolkit/mozapps/update/common/registrycertificates.cpp
new file mode 100644
index 0000000000..786218130a
--- /dev/null
+++ b/toolkit/mozapps/update/common/registrycertificates.cpp
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <stdio.h>
+#include <stdlib.h>
+#include <windows.h>
+
+#include "registrycertificates.h"
+#include "pathhash.h"
+#include "updatecommon.h"
+#include "updatehelper.h"
+#define MAX_KEY_LENGTH 255
+
+/**
+ * Verifies if the file path matches any certificate stored in the registry.
+ *
+ * @param filePath The file path of the application to check if allowed.
+ * @param allowFallbackKeySkip when this is TRUE the fallback registry key will
+ * be used to skip the certificate check. This is the default since the
+ * fallback registry key is located under HKEY_LOCAL_MACHINE which can't be
+ * written to by a low integrity process.
+ * Note: the maintenance service binary can be used to perform this check for
+ * testing or troubleshooting.
+ * @return TRUE if the binary matches any of the allowed certificates.
+ */
+BOOL DoesBinaryMatchAllowedCertificates(LPCWSTR basePathForUpdate,
+ LPCWSTR filePath,
+ BOOL allowFallbackKeySkip) {
+ WCHAR maintenanceServiceKey[MAX_PATH + 1];
+ if (!CalculateRegistryPathFromFilePath(basePathForUpdate,
+ maintenanceServiceKey)) {
+ return FALSE;
+ }
+
+ // We use KEY_WOW64_64KEY to always force 64-bit view.
+ // The user may have both x86 and x64 applications installed
+ // which each register information. We need a consistent place
+ // to put those certificate attributes in and hence why we always
+ // force the non redirected registry under Wow6432Node.
+ // This flag is ignored on 32bit systems.
+ HKEY baseKey;
+ LONG retCode = RegOpenKeyExW(HKEY_LOCAL_MACHINE, maintenanceServiceKey, 0,
+ KEY_READ | KEY_WOW64_64KEY, &baseKey);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not open key. (%ld)", retCode));
+ // Our tests run with a different apply directory for each test.
+ // We use this registry key on our test machines to store the
+ // allowed name/issuers.
+ retCode = RegOpenKeyExW(HKEY_LOCAL_MACHINE, TEST_ONLY_FALLBACK_KEY_PATH, 0,
+ KEY_READ | KEY_WOW64_64KEY, &baseKey);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not open fallback key. (%ld)", retCode));
+ return FALSE;
+ } else if (allowFallbackKeySkip) {
+ LOG_WARN(
+ ("Fallback key present, skipping VerifyCertificateTrustForFile "
+ "check and the certificate attribute registry matching "
+ "check."));
+ RegCloseKey(baseKey);
+ return TRUE;
+ }
+ }
+
+ // Get the number of subkeys.
+ DWORD subkeyCount = 0;
+ retCode = RegQueryInfoKeyW(baseKey, nullptr, nullptr, nullptr, &subkeyCount,
+ nullptr, nullptr, nullptr, nullptr, nullptr,
+ nullptr, nullptr);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not query info key. (%ld)", retCode));
+ RegCloseKey(baseKey);
+ return FALSE;
+ }
+
+ // Enumerate the subkeys, each subkey represents an allowed certificate.
+ for (DWORD i = 0; i < subkeyCount; i++) {
+ WCHAR subkeyBuffer[MAX_KEY_LENGTH];
+ DWORD subkeyBufferCount = MAX_KEY_LENGTH;
+ retCode = RegEnumKeyExW(baseKey, i, subkeyBuffer, &subkeyBufferCount,
+ nullptr, nullptr, nullptr, nullptr);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not enum certs. (%ld)", retCode));
+ RegCloseKey(baseKey);
+ return FALSE;
+ }
+
+ // Open the subkey for the current certificate
+ HKEY subKey;
+ retCode = RegOpenKeyExW(baseKey, subkeyBuffer, 0,
+ KEY_READ | KEY_WOW64_64KEY, &subKey);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not open subkey. (%ld)", retCode));
+ continue; // Try the next subkey
+ }
+
+ const int MAX_CHAR_COUNT = 256;
+ DWORD valueBufSize = MAX_CHAR_COUNT * sizeof(WCHAR);
+ WCHAR name[MAX_CHAR_COUNT] = {L'\0'};
+ WCHAR issuer[MAX_CHAR_COUNT] = {L'\0'};
+
+ // Get the name from the registry
+ retCode = RegQueryValueExW(subKey, L"name", 0, nullptr, (LPBYTE)name,
+ &valueBufSize);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not obtain name from registry. (%ld)", retCode));
+ RegCloseKey(subKey);
+ continue; // Try the next subkey
+ }
+
+ // Get the issuer from the registry
+ valueBufSize = MAX_CHAR_COUNT * sizeof(WCHAR);
+ retCode = RegQueryValueExW(subKey, L"issuer", 0, nullptr, (LPBYTE)issuer,
+ &valueBufSize);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Could not obtain issuer from registry. (%ld)", retCode));
+ RegCloseKey(subKey);
+ continue; // Try the next subkey
+ }
+
+ CertificateCheckInfo allowedCertificate = {
+ name,
+ issuer,
+ };
+
+ retCode = CheckCertificateForPEFile(filePath, allowedCertificate);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Error on certificate check. (%ld)", retCode));
+ RegCloseKey(subKey);
+ continue; // Try the next subkey
+ }
+
+ retCode = VerifyCertificateTrustForFile(filePath);
+ if (retCode != ERROR_SUCCESS) {
+ LOG_WARN(("Error on certificate trust check. (%ld)", retCode));
+ RegCloseKey(subKey);
+ continue; // Try the next subkey
+ }
+
+ RegCloseKey(baseKey);
+ // Raise the roof, we found a match!
+ return TRUE;
+ }
+
+ RegCloseKey(baseKey);
+ // No certificates match, :'(
+ return FALSE;
+}
diff --git a/toolkit/mozapps/update/common/registrycertificates.h b/toolkit/mozapps/update/common/registrycertificates.h
new file mode 100644
index 0000000000..9f68d1a8d9
--- /dev/null
+++ b/toolkit/mozapps/update/common/registrycertificates.h
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 _REGISTRYCERTIFICATES_H_
+#define _REGISTRYCERTIFICATES_H_
+
+#include "certificatecheck.h"
+
+BOOL DoesBinaryMatchAllowedCertificates(LPCWSTR basePathForUpdate,
+ LPCWSTR filePath,
+ BOOL allowFallbackKeySkip = TRUE);
+
+#endif
diff --git a/toolkit/mozapps/update/common/uachelper.cpp b/toolkit/mozapps/update/common/uachelper.cpp
new file mode 100644
index 0000000000..07e9bd53f9
--- /dev/null
+++ b/toolkit/mozapps/update/common/uachelper.cpp
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <windows.h>
+#include <wtsapi32.h>
+#include "uachelper.h"
+#include "updatecommon.h"
+
+// See the MSDN documentation with title: Privilege Constants
+// At the time of this writing, this documentation is located at:
+// http://msdn.microsoft.com/en-us/library/windows/desktop/bb530716%28v=vs.85%29.aspx
+LPCTSTR UACHelper::PrivsToDisable[] = {
+ SE_ASSIGNPRIMARYTOKEN_NAME, SE_AUDIT_NAME, SE_BACKUP_NAME,
+ // CreateProcess will succeed but the app will fail to launch on some WinXP
+ // machines if SE_CHANGE_NOTIFY_NAME is disabled. In particular this
+ // happens for limited user accounts on those machines. The define is kept
+ // here as a reminder that it should never be re-added. This permission is
+ // for directory watching but also from MSDN: "This privilege also causes
+ // the system to skip all traversal access checks." SE_CHANGE_NOTIFY_NAME,
+ SE_CREATE_GLOBAL_NAME, SE_CREATE_PAGEFILE_NAME, SE_CREATE_PERMANENT_NAME,
+ SE_CREATE_SYMBOLIC_LINK_NAME, SE_CREATE_TOKEN_NAME, SE_DEBUG_NAME,
+ SE_ENABLE_DELEGATION_NAME, SE_IMPERSONATE_NAME, SE_INC_BASE_PRIORITY_NAME,
+ SE_INCREASE_QUOTA_NAME, SE_INC_WORKING_SET_NAME, SE_LOAD_DRIVER_NAME,
+ SE_LOCK_MEMORY_NAME, SE_MACHINE_ACCOUNT_NAME, SE_MANAGE_VOLUME_NAME,
+ SE_PROF_SINGLE_PROCESS_NAME, SE_RELABEL_NAME, SE_REMOTE_SHUTDOWN_NAME,
+ SE_RESTORE_NAME, SE_SECURITY_NAME, SE_SHUTDOWN_NAME, SE_SYNC_AGENT_NAME,
+ SE_SYSTEM_ENVIRONMENT_NAME, SE_SYSTEM_PROFILE_NAME, SE_SYSTEMTIME_NAME,
+ SE_TAKE_OWNERSHIP_NAME, SE_TCB_NAME, SE_TIME_ZONE_NAME,
+ SE_TRUSTED_CREDMAN_ACCESS_NAME, SE_UNDOCK_NAME, SE_UNSOLICITED_INPUT_NAME};
+
+/**
+ * Opens a user token for the given session ID
+ *
+ * @param sessionID The session ID for the token to obtain
+ * @return A handle to the token to obtain which will be primary if enough
+ * permissions exist. Caller should close the handle.
+ */
+HANDLE
+UACHelper::OpenUserToken(DWORD sessionID) {
+ HMODULE module = LoadLibraryW(L"wtsapi32.dll");
+ HANDLE token = nullptr;
+ decltype(WTSQueryUserToken)* wtsQueryUserToken =
+ (decltype(WTSQueryUserToken)*)GetProcAddress(module, "WTSQueryUserToken");
+ if (wtsQueryUserToken) {
+ wtsQueryUserToken(sessionID, &token);
+ }
+ FreeLibrary(module);
+ return token;
+}
+
+/**
+ * Opens a linked token for the specified token.
+ *
+ * @param token The token to get the linked token from
+ * @return A linked token or nullptr if one does not exist.
+ * Caller should close the handle.
+ */
+HANDLE
+UACHelper::OpenLinkedToken(HANDLE token) {
+ // Magic below...
+ // UAC creates 2 tokens. One is the restricted token which we have.
+ // the other is the UAC elevated one. Since we are running as a service
+ // as the system account we have access to both.
+ TOKEN_LINKED_TOKEN tlt;
+ HANDLE hNewLinkedToken = nullptr;
+ DWORD len;
+ if (GetTokenInformation(token, (TOKEN_INFORMATION_CLASS)TokenLinkedToken,
+ &tlt, sizeof(TOKEN_LINKED_TOKEN), &len)) {
+ token = tlt.LinkedToken;
+ hNewLinkedToken = token;
+ }
+ return hNewLinkedToken;
+}
+
+/**
+ * Enables or disables a privilege for the specified token.
+ *
+ * @param token The token to adjust the privilege on.
+ * @param priv The privilege to adjust.
+ * @param enable Whether to enable or disable it
+ * @return TRUE if the token was adjusted to the specified value.
+ */
+BOOL UACHelper::SetPrivilege(HANDLE token, LPCTSTR priv, BOOL enable) {
+ LUID luidOfPriv;
+ if (!LookupPrivilegeValue(nullptr, priv, &luidOfPriv)) {
+ return FALSE;
+ }
+
+ TOKEN_PRIVILEGES tokenPriv;
+ tokenPriv.PrivilegeCount = 1;
+ tokenPriv.Privileges[0].Luid = luidOfPriv;
+ tokenPriv.Privileges[0].Attributes = enable ? SE_PRIVILEGE_ENABLED : 0;
+
+ SetLastError(ERROR_SUCCESS);
+ if (!AdjustTokenPrivileges(token, false, &tokenPriv, sizeof(tokenPriv),
+ nullptr, nullptr)) {
+ return FALSE;
+ }
+
+ return GetLastError() == ERROR_SUCCESS;
+}
+
+/**
+ * For each privilege that is specified, an attempt will be made to
+ * drop the privilege.
+ *
+ * @param token The token to adjust the privilege on.
+ * Pass nullptr for current token.
+ * @param unneededPrivs An array of unneeded privileges.
+ * @param count The size of the array
+ * @return TRUE if there were no errors
+ */
+BOOL UACHelper::DisableUnneededPrivileges(HANDLE token, LPCTSTR* unneededPrivs,
+ size_t count) {
+ HANDLE obtainedToken = nullptr;
+ if (!token) {
+ // Note: This handle is a pseudo-handle and need not be closed
+ HANDLE process = GetCurrentProcess();
+ if (!OpenProcessToken(process, TOKEN_ALL_ACCESS_P, &obtainedToken)) {
+ LOG_WARN(
+ ("Could not obtain token for current process, no "
+ "privileges changed. (%lu)",
+ GetLastError()));
+ return FALSE;
+ }
+ token = obtainedToken;
+ }
+
+ BOOL result = TRUE;
+ for (size_t i = 0; i < count; i++) {
+ if (SetPrivilege(token, unneededPrivs[i], FALSE)) {
+ LOG(("Disabled unneeded token privilege: %s.", unneededPrivs[i]));
+ } else {
+ LOG(("Could not disable token privilege value: %s. (%lu)",
+ unneededPrivs[i], GetLastError()));
+ result = FALSE;
+ }
+ }
+
+ if (obtainedToken) {
+ CloseHandle(obtainedToken);
+ }
+ return result;
+}
+
+/**
+ * Disables privileges for the specified token.
+ * The privileges to disable are in PrivsToDisable.
+ * In the future there could be new privs and we are not sure if we should
+ * explicitly disable these or not.
+ *
+ * @param token The token to drop the privilege on.
+ * Pass nullptr for current token.
+ * @return TRUE if there were no errors
+ */
+BOOL UACHelper::DisablePrivileges(HANDLE token) {
+ static const size_t PrivsToDisableSize =
+ sizeof(UACHelper::PrivsToDisable) / sizeof(UACHelper::PrivsToDisable[0]);
+
+ return DisableUnneededPrivileges(token, UACHelper::PrivsToDisable,
+ PrivsToDisableSize);
+}
+
+/**
+ * Check if the current user can elevate.
+ *
+ * @return true if the user can elevate.
+ * false otherwise.
+ */
+bool UACHelper::CanUserElevate() {
+ HANDLE token;
+ if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) {
+ return false;
+ }
+
+ TOKEN_ELEVATION_TYPE elevationType;
+ DWORD len;
+ bool canElevate =
+ GetTokenInformation(token, TokenElevationType, &elevationType,
+ sizeof(elevationType), &len) &&
+ (elevationType == TokenElevationTypeLimited);
+ CloseHandle(token);
+
+ return canElevate;
+}
diff --git a/toolkit/mozapps/update/common/uachelper.h b/toolkit/mozapps/update/common/uachelper.h
new file mode 100644
index 0000000000..c9915981a0
--- /dev/null
+++ b/toolkit/mozapps/update/common/uachelper.h
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 _UACHELPER_H_
+#define _UACHELPER_H_
+
+#include <windows.h>
+
+class UACHelper {
+ public:
+ static HANDLE OpenUserToken(DWORD sessionID);
+ static HANDLE OpenLinkedToken(HANDLE token);
+ static BOOL DisablePrivileges(HANDLE token);
+ static bool CanUserElevate();
+
+ private:
+ static BOOL SetPrivilege(HANDLE token, LPCTSTR privs, BOOL enable);
+ static BOOL DisableUnneededPrivileges(HANDLE token, LPCTSTR* unneededPrivs,
+ size_t count);
+ static LPCTSTR PrivsToDisable[];
+};
+
+#endif
diff --git a/toolkit/mozapps/update/common/updatecommon.cpp b/toolkit/mozapps/update/common/updatecommon.cpp
new file mode 100644
index 0000000000..9e00ac5716
--- /dev/null
+++ b/toolkit/mozapps/update/common/updatecommon.cpp
@@ -0,0 +1,470 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#if defined(XP_WIN)
+# include <windows.h>
+# include <winioctl.h> // for FSCTL_GET_REPARSE_POINT
+# include <shlobj.h>
+# ifndef RRF_SUBKEY_WOW6464KEY
+# define RRF_SUBKEY_WOW6464KEY 0x00010000
+# endif
+#endif
+
+#include <stdio.h>
+#include <stdarg.h>
+
+#include "updatecommon.h"
+#ifdef XP_WIN
+# include "updatehelper.h"
+# include "nsWindowsHelpers.h"
+# include "mozilla/UniquePtr.h"
+# include "mozilla/WinHeaderOnlyUtils.h"
+
+// This struct isn't in any SDK header, so this definition was copied from:
+// https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/content/ntifs/ns-ntifs-_reparse_data_buffer
+typedef struct _REPARSE_DATA_BUFFER {
+ ULONG ReparseTag;
+ USHORT ReparseDataLength;
+ USHORT Reserved;
+ union {
+ struct {
+ USHORT SubstituteNameOffset;
+ USHORT SubstituteNameLength;
+ USHORT PrintNameOffset;
+ USHORT PrintNameLength;
+ ULONG Flags;
+ WCHAR PathBuffer[1];
+ } SymbolicLinkReparseBuffer;
+ struct {
+ USHORT SubstituteNameOffset;
+ USHORT SubstituteNameLength;
+ USHORT PrintNameOffset;
+ USHORT PrintNameLength;
+ WCHAR PathBuffer[1];
+ } MountPointReparseBuffer;
+ struct {
+ UCHAR DataBuffer[1];
+ } GenericReparseBuffer;
+ } DUMMYUNIONNAME;
+} REPARSE_DATA_BUFFER, *PREPARSE_DATA_BUFFER;
+#endif
+
+UpdateLog::UpdateLog() : logFP(nullptr) {}
+
+void UpdateLog::Init(NS_tchar* logFilePath) {
+ if (logFP) {
+ return;
+ }
+
+ // When the path is over the length limit disable logging by not opening the
+ // file and not setting logFP.
+ int dstFilePathLen = NS_tstrlen(logFilePath);
+ if (dstFilePathLen > 0 && dstFilePathLen < MAXPATHLEN - 1) {
+ NS_tstrncpy(mDstFilePath, logFilePath, MAXPATHLEN);
+#if defined(XP_WIN) || defined(XP_MACOSX)
+ logFP = NS_tfopen(mDstFilePath, NS_T("w"));
+#else
+ // On platforms that have an updates directory in the installation directory
+ // (e.g. platforms other than Windows and Mac) the update log is written to
+ // a temporary file and then to the update log file. This is needed since
+ // the installation directory is moved during a replace request. This can be
+ // removed when the platform's updates directory is located outside of the
+ // installation directory.
+ logFP = tmpfile();
+#endif
+ }
+}
+
+void UpdateLog::Finish() {
+ if (!logFP) {
+ return;
+ }
+
+#if !defined(XP_WIN) && !defined(XP_MACOSX)
+ const int blockSize = 1024;
+ char buffer[blockSize];
+ fflush(logFP);
+ rewind(logFP);
+
+ FILE* updateLogFP = NS_tfopen(mDstFilePath, NS_T("wb+"));
+ while (!feof(logFP)) {
+ size_t read = fread(buffer, 1, blockSize, logFP);
+ if (ferror(logFP)) {
+ fclose(logFP);
+ logFP = nullptr;
+ fclose(updateLogFP);
+ updateLogFP = nullptr;
+ return;
+ }
+
+ size_t written = 0;
+
+ while (written < read) {
+ size_t chunkWritten = fwrite(buffer, 1, read - written, updateLogFP);
+ if (chunkWritten <= 0) {
+ fclose(logFP);
+ logFP = nullptr;
+ fclose(updateLogFP);
+ updateLogFP = nullptr;
+ return;
+ }
+
+ written += chunkWritten;
+ }
+ }
+ fclose(updateLogFP);
+ updateLogFP = nullptr;
+#endif
+
+ fclose(logFP);
+ logFP = nullptr;
+}
+
+void UpdateLog::Flush() {
+ if (!logFP) {
+ return;
+ }
+
+ fflush(logFP);
+}
+
+void UpdateLog::Printf(const char* fmt, ...) {
+ if (!logFP) {
+ return;
+ }
+
+ time_t rawtime = time(nullptr);
+ struct tm* timeinfo = localtime(&rawtime);
+
+ if (nullptr != timeinfo) {
+ // attempt to format the time similar to rfc-3339 so that it works with
+ // sort(1). xxxx-xx-xx xx:xx:xx+xxxx -> 24 chars + 1 NUL
+ const size_t buffer_size = 25;
+ char buffer[buffer_size] = {0};
+
+ if (0 == strftime(buffer, buffer_size, "%Y-%m-%d %H:%M:%S%z", timeinfo)) {
+ buffer[0] = '\0'; // reset buffer into a defined state and try posix ts
+ if (0 > snprintf(buffer, buffer_size, "%d", (int)mktime(timeinfo))) {
+ buffer[0] = '\0'; // reset and give up
+ }
+ }
+
+ fprintf(logFP, "%s: ", buffer);
+ }
+
+ va_list ap;
+ va_start(ap, fmt);
+ vfprintf(logFP, fmt, ap);
+ va_end(ap);
+
+ fprintf(logFP, "\n");
+#if defined(XP_WIN) && defined(MOZ_DEBUG)
+ // When the updater crashes on Windows the log file won't be flushed and this
+ // can make it easier to debug what is going on.
+ fflush(logFP);
+#endif
+}
+
+void UpdateLog::WarnPrintf(const char* fmt, ...) {
+ if (!logFP) {
+ return;
+ }
+
+ va_list ap;
+ va_start(ap, fmt);
+ fprintf(logFP, "*** Warning: ");
+ vfprintf(logFP, fmt, ap);
+ fprintf(logFP, "***\n");
+ va_end(ap);
+#if defined(XP_WIN) && defined(MOZ_DEBUG)
+ // When the updater crashes on Windows the log file won't be flushed and this
+ // can make it easier to debug what is going on.
+ fflush(logFP);
+#endif
+}
+
+#ifdef XP_WIN
+/**
+ * Determine if a path contains symlinks or junctions to disallowed locations
+ *
+ * @param fullPath The full path to check.
+ * @return true if the path contains invalid links or on errors,
+ * false if the check passes and the path can be used
+ */
+bool PathContainsInvalidLinks(wchar_t* const fullPath) {
+ wchar_t pathCopy[MAXPATHLEN + 1] = L"";
+ wcsncpy(pathCopy, fullPath, MAXPATHLEN);
+ wchar_t* remainingPath = nullptr;
+ wchar_t* nextToken = wcstok_s(pathCopy, L"\\", &remainingPath);
+ wchar_t* partialPath = nextToken;
+
+ while (nextToken) {
+ if ((GetFileAttributesW(partialPath) & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
+ nsAutoHandle h(CreateFileW(
+ partialPath, 0,
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
+ OPEN_EXISTING,
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
+ if (h == INVALID_HANDLE_VALUE) {
+ if (GetLastError() == ERROR_FILE_NOT_FOUND) {
+ // The path can't be an invalid link if it doesn't exist.
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ mozilla::UniquePtr<UINT8[]> byteBuffer =
+ mozilla::MakeUnique<UINT8[]>(MAXIMUM_REPARSE_DATA_BUFFER_SIZE);
+ ZeroMemory(byteBuffer.get(), MAXIMUM_REPARSE_DATA_BUFFER_SIZE);
+ REPARSE_DATA_BUFFER* buffer = (REPARSE_DATA_BUFFER*)byteBuffer.get();
+ DWORD bytes = 0;
+ if (!DeviceIoControl(h, FSCTL_GET_REPARSE_POINT, nullptr, 0, buffer,
+ MAXIMUM_REPARSE_DATA_BUFFER_SIZE, &bytes, nullptr)) {
+ return true;
+ }
+
+ wchar_t* reparseTarget = nullptr;
+ switch (buffer->ReparseTag) {
+ case IO_REPARSE_TAG_MOUNT_POINT:
+ reparseTarget =
+ buffer->MountPointReparseBuffer.PathBuffer +
+ (buffer->MountPointReparseBuffer.SubstituteNameOffset /
+ sizeof(wchar_t));
+ if (buffer->MountPointReparseBuffer.SubstituteNameLength <
+ ARRAYSIZE(L"\\??\\")) {
+ return false;
+ }
+ break;
+ case IO_REPARSE_TAG_SYMLINK:
+ reparseTarget =
+ buffer->SymbolicLinkReparseBuffer.PathBuffer +
+ (buffer->SymbolicLinkReparseBuffer.SubstituteNameOffset /
+ sizeof(wchar_t));
+ if (buffer->SymbolicLinkReparseBuffer.SubstituteNameLength <
+ ARRAYSIZE(L"\\??\\")) {
+ return false;
+ }
+ break;
+ default:
+ return true;
+ break;
+ }
+
+ if (!reparseTarget) {
+ return false;
+ }
+ if (wcsncmp(reparseTarget, L"\\??\\", ARRAYSIZE(L"\\??\\") - 1) != 0) {
+ return true;
+ }
+ }
+
+ nextToken = wcstok_s(nullptr, L"\\", &remainingPath);
+ PathAppendW(partialPath, nextToken);
+ }
+
+ return false;
+}
+
+/**
+ * Determine if a path is located within Program Files, either native or x86
+ *
+ * @param fullPath The full path to check.
+ * @return true if fullPath begins with either Program Files directory,
+ * false if it does not or if an error is encountered
+ */
+bool IsProgramFilesPath(NS_tchar* fullPath) {
+ // Make sure we don't try to compare against a short path.
+ DWORD longInstallPathChars = GetLongPathNameW(fullPath, nullptr, 0);
+ if (longInstallPathChars == 0) {
+ return false;
+ }
+ mozilla::UniquePtr<wchar_t[]> longInstallPath =
+ mozilla::MakeUnique<wchar_t[]>(longInstallPathChars);
+ if (!GetLongPathNameW(fullPath, longInstallPath.get(),
+ longInstallPathChars)) {
+ return false;
+ }
+
+ // First check for Program Files (x86).
+ {
+ PWSTR programFiles32PathRaw = nullptr;
+ // FOLDERID_ProgramFilesX86 gets native Program Files directory on a 32-bit
+ // OS or the (x86) directory on a 64-bit OS regardless of this binary's
+ // bitness.
+ if (FAILED(SHGetKnownFolderPath(FOLDERID_ProgramFilesX86, 0, nullptr,
+ &programFiles32PathRaw))) {
+ // That call should never fail on any supported OS version.
+ return false;
+ }
+ mozilla::UniquePtr<wchar_t, mozilla::CoTaskMemFreeDeleter>
+ programFiles32Path(programFiles32PathRaw);
+ // We need this path to have a trailing slash so our prefix test doesn't
+ // match on a different folder which happens to have a name beginning with
+ // the prefix we're looking for but then also more characters after that.
+ size_t length = wcslen(programFiles32Path.get());
+ if (length == 0) {
+ return false;
+ }
+ if (programFiles32Path.get()[length - 1] == L'\\') {
+ if (wcsnicmp(longInstallPath.get(), programFiles32Path.get(), length) ==
+ 0) {
+ return true;
+ }
+ } else {
+ // Allocate space for a copy of the string along with a terminator and one
+ // extra character for the trailing backslash.
+ length += 1;
+ mozilla::UniquePtr<wchar_t[]> programFiles32PathWithSlash =
+ mozilla::MakeUnique<wchar_t[]>(length + 1);
+
+ NS_tsnprintf(programFiles32PathWithSlash.get(), length + 1, NS_T("%s\\"),
+ programFiles32Path.get());
+
+ if (wcsnicmp(longInstallPath.get(), programFiles32PathWithSlash.get(),
+ length) == 0) {
+ return true;
+ }
+ }
+ }
+
+ // If we didn't find (x86), check for the native Program Files.
+ {
+ // In case we're a 32-bit binary on 64-bit Windows, we now have a problem
+ // getting the right "native" Program Files path, which is that there is no
+ // FOLDERID_* value that returns that path. So we always read that one out
+ // of its canonical registry location instead. If we're on a 32-bit OS, this
+ // will be the same path that we just checked. First get the buffer size to
+ // allocate for the path.
+ DWORD length = 0;
+ if (RegGetValueW(HKEY_LOCAL_MACHINE,
+ L"Software\\Microsoft\\Windows\\CurrentVersion",
+ L"ProgramFilesDir", RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY,
+ nullptr, nullptr, &length) != ERROR_SUCCESS) {
+ return false;
+ }
+ // RegGetValue returns the length including the terminator, but it's in
+ // bytes, so convert that to characters.
+ DWORD lengthChars = (length / sizeof(wchar_t));
+ if (lengthChars <= 1) {
+ return false;
+ }
+ mozilla::UniquePtr<wchar_t[]> programFilesNativePath =
+ mozilla::MakeUnique<wchar_t[]>(lengthChars);
+
+ // Now actually read the value.
+ if (RegGetValueW(
+ HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion",
+ L"ProgramFilesDir", RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, nullptr,
+ programFilesNativePath.get(), &length) != ERROR_SUCCESS) {
+ return false;
+ }
+ size_t nativePathStrLen =
+ wcsnlen_s(programFilesNativePath.get(), lengthChars);
+ if (nativePathStrLen == 0) {
+ return false;
+ }
+
+ // As before, append a backslash if there isn't one already.
+ if (programFilesNativePath.get()[nativePathStrLen - 1] == L'\\') {
+ if (wcsnicmp(longInstallPath.get(), programFilesNativePath.get(),
+ nativePathStrLen) == 0) {
+ return true;
+ }
+ } else {
+ // Allocate space for a copy of the string along with a terminator and one
+ // extra character for the trailing backslash.
+ nativePathStrLen += 1;
+ mozilla::UniquePtr<wchar_t[]> programFilesNativePathWithSlash =
+ mozilla::MakeUnique<wchar_t[]>(nativePathStrLen + 1);
+
+ NS_tsnprintf(programFilesNativePathWithSlash.get(), nativePathStrLen + 1,
+ NS_T("%s\\"), programFilesNativePath.get());
+
+ if (wcsnicmp(longInstallPath.get(), programFilesNativePathWithSlash.get(),
+ nativePathStrLen) == 0) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+#endif
+
+/**
+ * Performs checks of a full path for validity for this application.
+ *
+ * @param origFullPath
+ * The full path to check.
+ * @return true if the path is valid for this application and false otherwise.
+ */
+bool IsValidFullPath(NS_tchar* origFullPath) {
+ // Subtract 1 from MAXPATHLEN for null termination.
+ if (NS_tstrlen(origFullPath) > MAXPATHLEN - 1) {
+ // The path is longer than acceptable for this application.
+ return false;
+ }
+
+#ifdef XP_WIN
+ NS_tchar testPath[MAXPATHLEN] = {NS_T('\0')};
+ // GetFullPathNameW will replace / with \ which PathCanonicalizeW requires.
+ if (GetFullPathNameW(origFullPath, MAXPATHLEN, testPath, nullptr) == 0) {
+ // Unable to get the full name for the path (e.g. invalid path).
+ return false;
+ }
+
+ NS_tchar canonicalPath[MAXPATHLEN] = {NS_T('\0')};
+ if (!PathCanonicalizeW(canonicalPath, testPath)) {
+ // Path could not be canonicalized (e.g. invalid path).
+ return false;
+ }
+
+ // Check if the path passed in resolves to a differerent path.
+ if (NS_tstricmp(origFullPath, canonicalPath) != 0) {
+ // Case insensitive string comparison between the supplied path and the
+ // canonical path are not equal. This will prevent directory traversal and
+ // the use of / in paths since they are converted to \.
+ return false;
+ }
+
+ NS_tstrncpy(testPath, origFullPath, MAXPATHLEN);
+ if (!PathStripToRootW(testPath)) {
+ // It should always be possible to strip a valid path to its root.
+ return false;
+ }
+
+ if (origFullPath[0] == NS_T('\\')) {
+ // Only allow UNC server share paths.
+ if (!PathIsUNCServerShareW(testPath)) {
+ return false;
+ }
+ }
+
+ if (PathContainsInvalidLinks(canonicalPath)) {
+ return false;
+ }
+#else
+ // Only allow full paths.
+ if (origFullPath[0] != NS_T('/')) {
+ return false;
+ }
+
+ // The path must not traverse directories
+ if (NS_tstrstr(origFullPath, NS_T("/../")) != nullptr) {
+ return false;
+ }
+
+ // The path shall not have a path traversal suffix
+ const NS_tchar invalidSuffix[] = NS_T("/..");
+ size_t pathLen = NS_tstrlen(origFullPath);
+ size_t invalidSuffixLen = NS_tstrlen(invalidSuffix);
+ if (invalidSuffixLen <= pathLen &&
+ NS_tstrncmp(origFullPath + pathLen - invalidSuffixLen, invalidSuffix,
+ invalidSuffixLen) == 0) {
+ return false;
+ }
+#endif
+ return true;
+}
diff --git a/toolkit/mozapps/update/common/updatecommon.h b/toolkit/mozapps/update/common/updatecommon.h
new file mode 100644
index 0000000000..e317bdcb1f
--- /dev/null
+++ b/toolkit/mozapps/update/common/updatecommon.h
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 UPDATECOMMON_H
+#define UPDATECOMMON_H
+
+#include "updatedefines.h"
+#include <stdio.h>
+#include <time.h>
+#include "mozilla/Attributes.h"
+
+class UpdateLog {
+ public:
+ static UpdateLog& GetPrimaryLog() {
+ static UpdateLog primaryLog;
+ return primaryLog;
+ }
+
+ void Init(NS_tchar* logFilePath);
+ void Finish();
+ void Flush();
+ void Printf(const char* fmt, ...) MOZ_FORMAT_PRINTF(2, 3);
+ void WarnPrintf(const char* fmt, ...) MOZ_FORMAT_PRINTF(2, 3);
+
+ ~UpdateLog() { Finish(); }
+
+ protected:
+ UpdateLog();
+ FILE* logFP;
+ NS_tchar mDstFilePath[MAXPATHLEN];
+};
+
+bool IsValidFullPath(NS_tchar* fullPath);
+bool IsProgramFilesPath(NS_tchar* fullPath);
+
+#define LOG_WARN(args) UpdateLog::GetPrimaryLog().WarnPrintf args
+#define LOG(args) UpdateLog::GetPrimaryLog().Printf args
+#define LogInit(FILEPATH_) UpdateLog::GetPrimaryLog().Init(FILEPATH_)
+#define LogFinish() UpdateLog::GetPrimaryLog().Finish()
+#define LogFlush() UpdateLog::GetPrimaryLog().Flush()
+
+#endif
diff --git a/toolkit/mozapps/update/common/updatedefines.h b/toolkit/mozapps/update/common/updatedefines.h
new file mode 100644
index 0000000000..c58c8d20f4
--- /dev/null
+++ b/toolkit/mozapps/update/common/updatedefines.h
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 UPDATEDEFINES_H
+#define UPDATEDEFINES_H
+
+#include <stdio.h>
+#include <stdarg.h>
+#include "readstrings.h"
+
+#if defined(XP_WIN)
+# include <windows.h>
+# include <shlwapi.h>
+# include <direct.h>
+# include <io.h>
+
+# ifndef F_OK
+# define F_OK 00
+# endif
+# ifndef W_OK
+# define W_OK 02
+# endif
+# ifndef R_OK
+# define R_OK 04
+# endif
+# define S_ISDIR(s) (((s) & _S_IFMT) == _S_IFDIR)
+# define S_ISREG(s) (((s) & _S_IFMT) == _S_IFREG)
+
+# define access _access
+
+# define putenv _putenv
+# if defined(_MSC_VER) && _MSC_VER < 1900
+# define stat _stat
+# endif
+# define DELETE_DIR L"tobedeleted"
+# define CALLBACK_BACKUP_EXT L".moz-callback"
+
+# define LOG_S "%S"
+# define NS_CONCAT(x, y) x##y
+// The extra layer of indirection here allows this macro to be passed macros
+# define NS_T(str) NS_CONCAT(L, str)
+# define NS_SLASH NS_T('\\')
+static inline int mywcsprintf(WCHAR* dest, size_t count, const WCHAR* fmt,
+ ...) {
+ size_t _count = count - 1;
+ va_list varargs;
+ va_start(varargs, fmt);
+ int result = _vsnwprintf(dest, count - 1, fmt, varargs);
+ va_end(varargs);
+ dest[_count] = L'\0';
+ return result;
+}
+# define NS_tsnprintf mywcsprintf
+# define NS_taccess _waccess
+# define NS_tatoi _wtoi64
+# define NS_tchdir _wchdir
+# define NS_tchmod _wchmod
+# define NS_tfopen _wfopen
+# define NS_tmkdir(path, perms) _wmkdir(path)
+# define NS_tpid __int64
+# define NS_tremove _wremove
+// _wrename is used to avoid the link tracking service.
+# define NS_trename _wrename
+# define NS_trmdir _wrmdir
+# define NS_tstat _wstat
+# define NS_tlstat _wstat // No symlinks on Windows
+# define NS_tstat_t _stat
+# define NS_tstrcat wcscat
+# define NS_tstrcmp wcscmp
+# define NS_tstricmp wcsicmp
+# define NS_tstrncmp wcsncmp
+# define NS_tstrcpy wcscpy
+# define NS_tstrncpy wcsncpy
+# define NS_tstrlen wcslen
+# define NS_tstrchr wcschr
+# define NS_tstrrchr wcsrchr
+# define NS_tstrstr wcsstr
+# include "updateutils_win.h"
+# define NS_tDIR DIR
+# define NS_tdirent dirent
+# define NS_topendir opendir
+# define NS_tclosedir closedir
+# define NS_treaddir readdir
+#else
+# include <sys/wait.h>
+# include <unistd.h>
+
+# ifdef HAVE_FTS_H
+# include <fts.h>
+# else
+# include <sys/stat.h>
+# endif
+# include <dirent.h>
+
+# ifdef XP_MACOSX
+# include <sys/time.h>
+# endif
+
+# define LOG_S "%s"
+# define NS_T(str) str
+# define NS_SLASH NS_T('/')
+# define NS_tsnprintf snprintf
+# define NS_taccess access
+# define NS_tatoi atoi
+# define NS_tchdir chdir
+# define NS_tchmod chmod
+# define NS_tfopen fopen
+# define NS_tmkdir mkdir
+# define NS_tpid int
+# define NS_tremove remove
+# define NS_trename rename
+# define NS_trmdir rmdir
+# define NS_tstat stat
+# define NS_tstat_t stat
+# define NS_tlstat lstat
+# define NS_tstrcat strcat
+# define NS_tstrcmp strcmp
+# define NS_tstricmp strcasecmp
+# define NS_tstrncmp strncmp
+# define NS_tstrcpy strcpy
+# define NS_tstrncpy strncpy
+# define NS_tstrlen strlen
+# define NS_tstrrchr strrchr
+# define NS_tstrstr strstr
+# define NS_tDIR DIR
+# define NS_tdirent dirent
+# define NS_topendir opendir
+# define NS_tclosedir closedir
+# define NS_treaddir readdir
+#endif
+
+#define BACKUP_EXT NS_T(".moz-backup")
+
+#ifndef MAXPATHLEN
+# ifdef PATH_MAX
+# define MAXPATHLEN PATH_MAX
+# elif defined(MAX_PATH)
+# define MAXPATHLEN MAX_PATH
+# elif defined(_MAX_PATH)
+# define MAXPATHLEN _MAX_PATH
+# elif defined(CCHMAXPATH)
+# define MAXPATHLEN CCHMAXPATH
+# else
+# define MAXPATHLEN 1024
+# endif
+#endif
+
+static inline bool NS_tvsnprintf(NS_tchar* dest, size_t count,
+ const NS_tchar* fmt, ...) {
+ va_list varargs;
+ va_start(varargs, fmt);
+#if defined(XP_WIN)
+ int result = _vsnwprintf(dest, count, fmt, varargs);
+#else
+ int result = vsnprintf(dest, count, fmt, varargs);
+#endif
+ va_end(varargs);
+ // The size_t cast of result is safe because result can only be positive after
+ // the first check.
+ return result >= 0 && (size_t)result < count;
+}
+
+#endif
diff --git a/toolkit/mozapps/update/common/updatehelper.cpp b/toolkit/mozapps/update/common/updatehelper.cpp
new file mode 100644
index 0000000000..b094d9eb75
--- /dev/null
+++ b/toolkit/mozapps/update/common/updatehelper.cpp
@@ -0,0 +1,763 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <windows.h>
+
+// Needed for CreateToolhelp32Snapshot
+#include <tlhelp32.h>
+
+#include <stdio.h>
+#include <direct.h>
+#include "shlobj.h"
+
+// Needed for PathAppendW
+#include <shlwapi.h>
+
+#include "updatehelper.h"
+#include "updateutils_win.h"
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+# include "mozilla/UniquePtr.h"
+# include "pathhash.h"
+# include "registrycertificates.h"
+# include "uachelper.h"
+
+using mozilla::MakeUnique;
+using mozilla::UniquePtr;
+#endif
+
+BOOL PathGetSiblingFilePath(LPWSTR destinationBuffer, LPCWSTR siblingFilePath,
+ LPCWSTR newFileName);
+
+/**
+ * Obtains the path of a file in the same directory as the specified file.
+ *
+ * @param destinationBuffer A buffer of size MAX_PATH + 1 to store the result.
+ * @param siblingFilePath The path of another file in the same directory
+ * @param newFileName The filename of another file in the same directory
+ * @return TRUE if successful
+ */
+BOOL PathGetSiblingFilePath(LPWSTR destinationBuffer, LPCWSTR siblingFilePath,
+ LPCWSTR newFileName) {
+ if (wcslen(siblingFilePath) > MAX_PATH) {
+ return FALSE;
+ }
+
+ wcsncpy(destinationBuffer, siblingFilePath, MAX_PATH + 1);
+ if (!PathRemoveFileSpecW(destinationBuffer)) {
+ return FALSE;
+ }
+
+ return PathAppendSafe(destinationBuffer, newFileName);
+}
+
+/**
+ * Obtains the path of the secure directory used to write the status and log
+ * files for updates applied with an elevated updater or an updater that is
+ * launched using the maintenance service.
+ *
+ * Example
+ * Destination buffer value:
+ * C:\Program Files (x86)\Mozilla Maintenance Service\UpdateLogs
+ *
+ * @param outBuf
+ * A buffer of size MAX_PATH + 1 to store the result.
+ * @return TRUE if successful
+ */
+BOOL GetSecureOutputDirectoryPath(LPWSTR outBuf) {
+ PWSTR progFilesX86;
+ if (FAILED(SHGetKnownFolderPath(FOLDERID_ProgramFilesX86, KF_FLAG_CREATE,
+ nullptr, &progFilesX86))) {
+ return FALSE;
+ }
+ if (wcslen(progFilesX86) > MAX_PATH) {
+ CoTaskMemFree(progFilesX86);
+ return FALSE;
+ }
+ wcsncpy(outBuf, progFilesX86, MAX_PATH + 1);
+ CoTaskMemFree(progFilesX86);
+
+ if (!PathAppendSafe(outBuf, L"Mozilla Maintenance Service")) {
+ return FALSE;
+ }
+
+ // Create the Maintenance Service directory in case it doesn't exist.
+ if (!CreateDirectoryW(outBuf, nullptr) &&
+ GetLastError() != ERROR_ALREADY_EXISTS) {
+ return FALSE;
+ }
+
+ if (!PathAppendSafe(outBuf, L"UpdateLogs")) {
+ return FALSE;
+ }
+
+ // Create the secure update output directory in case it doesn't exist.
+ if (!CreateDirectoryW(outBuf, nullptr) &&
+ GetLastError() != ERROR_ALREADY_EXISTS) {
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Obtains the name of the update output file using the update patch directory
+ * path and file extension (must include the '.' separator) passed to this
+ * function.
+ *
+ * Example
+ * Patch directory path parameter:
+ * C:\ProgramData\Mozilla\updates\0123456789ABCDEF\updates\0
+ * File extension parameter:
+ * .status
+ * Destination buffer value:
+ * 0123456789ABCDEF.status
+ *
+ * @param patchDirPath
+ * The path to the update patch directory.
+ * @param fileExt
+ * The file extension for the file including the '.' separator.
+ * @param outBuf
+ * A buffer of size MAX_PATH + 1 to store the result.
+ * @return TRUE if successful
+ */
+BOOL GetSecureOutputFileName(LPCWSTR patchDirPath, LPCWSTR fileExt,
+ LPWSTR outBuf) {
+ size_t fullPathLen = wcslen(patchDirPath);
+ if (fullPathLen > MAX_PATH) {
+ return FALSE;
+ }
+
+ size_t relPathLen = wcslen(PATCH_DIR_PATH);
+ if (relPathLen > fullPathLen) {
+ return FALSE;
+ }
+
+ // The patch directory path must end with updates\0 for updates applied with
+ // an elevated updater or an updater that is launched using the maintenance
+ // service.
+ if (_wcsnicmp(patchDirPath + fullPathLen - relPathLen, PATCH_DIR_PATH,
+ relPathLen) != 0) {
+ return FALSE;
+ }
+
+ wcsncpy(outBuf, patchDirPath, MAX_PATH + 1);
+ if (!PathRemoveFileSpecW(outBuf)) {
+ return FALSE;
+ }
+
+ if (!PathRemoveFileSpecW(outBuf)) {
+ return FALSE;
+ }
+
+ PathStripPathW(outBuf);
+
+ size_t outBufLen = wcslen(outBuf);
+ size_t fileExtLen = wcslen(fileExt);
+ if (outBufLen + fileExtLen > MAX_PATH) {
+ return FALSE;
+ }
+
+ wcsncat(outBuf, fileExt, fileExtLen);
+
+ return TRUE;
+}
+
+/**
+ * Obtains the full path of the secure update output file using the update patch
+ * directory path and file extension (must include the '.' separator) passed to
+ * this function.
+ *
+ * Example
+ * Patch directory path parameter:
+ * C:\ProgramData\Mozilla\updates\0123456789ABCDEF\updates\0
+ * File extension parameter:
+ * .status
+ * Destination buffer value:
+ * C:\Program Files (x86)\Mozilla Maintenance
+ * Service\UpdateLogs\0123456789ABCDEF.status
+ *
+ * @param patchDirPath
+ * The path to the update patch directory.
+ * @param fileExt
+ * The file extension for the file including the '.' separator.
+ * @param outBuf
+ * A buffer of size MAX_PATH + 1 to store the result.
+ * @return TRUE if successful
+ */
+BOOL GetSecureOutputFilePath(LPCWSTR patchDirPath, LPCWSTR fileExt,
+ LPWSTR outBuf) {
+ if (!GetSecureOutputDirectoryPath(outBuf)) {
+ return FALSE;
+ }
+
+ WCHAR statusFileName[MAX_PATH + 1] = {L'\0'};
+ if (!GetSecureOutputFileName(patchDirPath, fileExt, statusFileName)) {
+ return FALSE;
+ }
+
+ return PathAppendSafe(outBuf, statusFileName);
+}
+
+/**
+ * Writes a UUID to the ID file in the secure output directory. This is used by
+ * the unelevated updater to determine whether an existing update status file in
+ * the secure output directory has been updated.
+ *
+ * @param patchDirPath
+ * The path to the update patch directory.
+ * @return TRUE if successful
+ */
+BOOL WriteSecureIDFile(LPCWSTR patchDirPath) {
+ WCHAR uuidString[MAX_PATH + 1] = {L'\0'};
+ if (!GetUUIDString(uuidString)) {
+ return FALSE;
+ }
+
+ WCHAR idFilePath[MAX_PATH + 1] = {L'\0'};
+ if (!GetSecureOutputFilePath(patchDirPath, L".id", idFilePath)) {
+ return FALSE;
+ }
+
+ FILE* idFile = _wfopen(idFilePath, L"wb+");
+ if (idFile == nullptr) {
+ return FALSE;
+ }
+
+ if (fprintf(idFile, "%ls\n", uuidString) == -1) {
+ fclose(idFile);
+ return FALSE;
+ }
+
+ fclose(idFile);
+
+ return TRUE;
+}
+
+/**
+ * Removes the update status and log files from the secure output directory.
+ *
+ * @param patchDirPath
+ * The path to the update patch directory.
+ */
+void RemoveSecureOutputFiles(LPCWSTR patchDirPath) {
+ WCHAR filePath[MAX_PATH + 1] = {L'\0'};
+ if (GetSecureOutputFilePath(patchDirPath, L".id", filePath)) {
+ (void)_wremove(filePath);
+ }
+ if (GetSecureOutputFilePath(patchDirPath, L".status", filePath)) {
+ (void)_wremove(filePath);
+ }
+ if (GetSecureOutputFilePath(patchDirPath, L".log", filePath)) {
+ (void)_wremove(filePath);
+ }
+}
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+/**
+ * Starts the upgrade process for update of the service if it is
+ * already installed.
+ *
+ * @param installDir the installation directory where
+ * maintenanceservice_installer.exe is located.
+ * @return TRUE if successful
+ */
+BOOL StartServiceUpdate(LPCWSTR installDir) {
+ // Get a handle to the local computer SCM database
+ SC_HANDLE manager = OpenSCManager(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
+ if (!manager) {
+ return FALSE;
+ }
+
+ // Open the service
+ SC_HANDLE svc = OpenServiceW(manager, SVC_NAME, SERVICE_ALL_ACCESS);
+ if (!svc) {
+ CloseServiceHandle(manager);
+ return FALSE;
+ }
+
+ // If we reach here, then the service is installed, so
+ // proceed with upgrading it.
+
+ CloseServiceHandle(manager);
+
+ // The service exists and we opened it, get the config bytes needed
+ DWORD bytesNeeded;
+ if (!QueryServiceConfigW(svc, nullptr, 0, &bytesNeeded) &&
+ GetLastError() != ERROR_INSUFFICIENT_BUFFER) {
+ CloseServiceHandle(svc);
+ return FALSE;
+ }
+
+ // Get the service config information, in particular we want the binary
+ // path of the service.
+ UniquePtr<char[]> serviceConfigBuffer = MakeUnique<char[]>(bytesNeeded);
+ if (!QueryServiceConfigW(
+ svc,
+ reinterpret_cast<QUERY_SERVICE_CONFIGW*>(serviceConfigBuffer.get()),
+ bytesNeeded, &bytesNeeded)) {
+ CloseServiceHandle(svc);
+ return FALSE;
+ }
+
+ CloseServiceHandle(svc);
+
+ QUERY_SERVICE_CONFIGW& serviceConfig =
+ *reinterpret_cast<QUERY_SERVICE_CONFIGW*>(serviceConfigBuffer.get());
+
+ PathUnquoteSpacesW(serviceConfig.lpBinaryPathName);
+
+ // Obtain the temp path of the maintenance service binary
+ WCHAR tmpService[MAX_PATH + 1] = {L'\0'};
+ if (!PathGetSiblingFilePath(tmpService, serviceConfig.lpBinaryPathName,
+ L"maintenanceservice_tmp.exe")) {
+ return FALSE;
+ }
+
+ if (wcslen(installDir) > MAX_PATH) {
+ return FALSE;
+ }
+
+ // Get the new maintenance service path from the install dir
+ WCHAR newMaintServicePath[MAX_PATH + 1] = {L'\0'};
+ wcsncpy(newMaintServicePath, installDir, MAX_PATH);
+ PathAppendSafe(newMaintServicePath, L"maintenanceservice.exe");
+
+ // Copy the temp file in alongside the maintenace service.
+ // This is a requirement for maintenance service upgrades.
+ if (!CopyFileW(newMaintServicePath, tmpService, FALSE)) {
+ return FALSE;
+ }
+
+ // Check that the copied file's certificate matches the expected name and
+ // issuer stored in the registry for this installation and that the
+ // certificate is trusted by the system's certificate store.
+ if (!DoesBinaryMatchAllowedCertificates(installDir, tmpService)) {
+ DeleteFileW(tmpService);
+ return FALSE;
+ }
+
+ // Start the upgrade comparison process
+ STARTUPINFOW si = {0};
+ si.cb = sizeof(STARTUPINFOW);
+ // No particular desktop because no UI
+ si.lpDesktop = const_cast<LPWSTR>(L""); // -Wwritable-strings
+ PROCESS_INFORMATION pi = {0};
+ WCHAR cmdLine[64] = {'\0'};
+ wcsncpy(cmdLine, L"dummyparam.exe upgrade",
+ sizeof(cmdLine) / sizeof(cmdLine[0]) - 1);
+ BOOL svcUpdateProcessStarted =
+ CreateProcessW(tmpService, cmdLine, nullptr, nullptr, FALSE, 0, nullptr,
+ installDir, &si, &pi);
+ if (svcUpdateProcessStarted) {
+ CloseHandle(pi.hProcess);
+ CloseHandle(pi.hThread);
+ }
+ return svcUpdateProcessStarted;
+}
+
+/**
+ * Executes a maintenance service command
+ *
+ * @param argc The total number of arguments in argv
+ * @param argv An array of null terminated strings to pass to the service,
+ * @return ERROR_SUCCESS if the service command was started.
+ * Less than 16000, a windows system error code from StartServiceW
+ * More than 20000, 20000 + the last state of the service constant if
+ * the last state is something other than stopped.
+ * 17001 if the SCM could not be opened
+ * 17002 if the service could not be opened
+ */
+DWORD
+StartServiceCommand(int argc, LPCWSTR* argv) {
+ DWORD lastState = WaitForServiceStop(SVC_NAME, 5);
+ if (lastState != SERVICE_STOPPED) {
+ return 20000 + lastState;
+ }
+
+ // Get a handle to the SCM database.
+ SC_HANDLE serviceManager = OpenSCManager(
+ nullptr, nullptr, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE);
+ if (!serviceManager) {
+ return 17001;
+ }
+
+ // Get a handle to the service.
+ SC_HANDLE service = OpenServiceW(serviceManager, SVC_NAME, SERVICE_START);
+ if (!service) {
+ CloseServiceHandle(serviceManager);
+ return 17002;
+ }
+
+ // Wait at most 5 seconds trying to start the service in case of errors
+ // like ERROR_SERVICE_DATABASE_LOCKED or ERROR_SERVICE_REQUEST_TIMEOUT.
+ const DWORD maxWaitMS = 5000;
+ DWORD currentWaitMS = 0;
+ DWORD lastError = ERROR_SUCCESS;
+ while (currentWaitMS < maxWaitMS) {
+ BOOL result = StartServiceW(service, argc, argv);
+ if (result) {
+ lastError = ERROR_SUCCESS;
+ break;
+ } else {
+ lastError = GetLastError();
+ }
+ Sleep(100);
+ currentWaitMS += 100;
+ }
+ CloseServiceHandle(service);
+ CloseServiceHandle(serviceManager);
+ return lastError;
+}
+
+/**
+ * Launch a service initiated action for a software update with the
+ * specified arguments.
+ *
+ * @param argc The total number of arguments in argv
+ * @param argv An array of null terminated strings to pass to the exePath,
+ * argv[0] must be the path to the updater.exe
+ * @return ERROR_SUCCESS if successful
+ */
+DWORD
+LaunchServiceSoftwareUpdateCommand(int argc, LPCWSTR* argv) {
+ // The service command is the same as the updater.exe command line except
+ // it has 4 extra args:
+ // 0) The name of the service, automatically added by Windows
+ // 1) "MozillaMaintenance" (I think this is redundant with 0)
+ // 2) The command being executed, which is "software-update"
+ // 3) The path to updater.exe (from argv[0])
+ LPCWSTR* updaterServiceArgv = new LPCWSTR[argc + 2];
+ updaterServiceArgv[0] = L"MozillaMaintenance";
+ updaterServiceArgv[1] = L"software-update";
+
+ for (int i = 0; i < argc; ++i) {
+ updaterServiceArgv[i + 2] = argv[i];
+ }
+
+ // Execute the service command by starting the service with
+ // the passed in arguments.
+ DWORD ret = StartServiceCommand(argc + 2, updaterServiceArgv);
+ delete[] updaterServiceArgv;
+ return ret;
+}
+
+/**
+ * Writes a specific failure code for the update status to a file in the secure
+ * output directory. The status file's name without the '.' separator and
+ * extension is the same as the update directory name.
+ *
+ * @param patchDirPath
+ * The path of the update patch directory.
+ * @param errorCode
+ * Error code to set
+ * @return TRUE if successful
+ */
+BOOL WriteStatusFailure(LPCWSTR patchDirPath, int errorCode) {
+ WCHAR statusFilePath[MAX_PATH + 1] = {L'\0'};
+ if (!GetSecureOutputFilePath(patchDirPath, L".status", statusFilePath)) {
+ return FALSE;
+ }
+
+ HANDLE hStatusFile = CreateFileW(statusFilePath, GENERIC_WRITE, 0, nullptr,
+ CREATE_ALWAYS, 0, nullptr);
+ if (hStatusFile == INVALID_HANDLE_VALUE) {
+ return FALSE;
+ }
+
+ char failure[32];
+ sprintf(failure, "failed: %d", errorCode);
+ DWORD toWrite = strlen(failure);
+ DWORD wrote;
+ BOOL ok = WriteFile(hStatusFile, failure, toWrite, &wrote, nullptr);
+ CloseHandle(hStatusFile);
+
+ if (!ok || wrote != toWrite) {
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Waits for a service to enter a stopped state.
+ * This function does not stop the service, it just blocks until the service
+ * is stopped.
+ *
+ * @param serviceName The service to wait for.
+ * @param maxWaitSeconds The maximum number of seconds to wait
+ * @return state of the service after a timeout or when stopped.
+ * A value of 255 is returned for an error. Typical values are:
+ * SERVICE_STOPPED 0x00000001
+ * SERVICE_START_PENDING 0x00000002
+ * SERVICE_STOP_PENDING 0x00000003
+ * SERVICE_RUNNING 0x00000004
+ * SERVICE_CONTINUE_PENDING 0x00000005
+ * SERVICE_PAUSE_PENDING 0x00000006
+ * SERVICE_PAUSED 0x00000007
+ * last status not set 0x000000CF
+ * Could no query status 0x000000DF
+ * Could not open service, access denied 0x000000EB
+ * Could not open service, invalid handle 0x000000EC
+ * Could not open service, invalid name 0x000000ED
+ * Could not open service, does not exist 0x000000EE
+ * Could not open service, other error 0x000000EF
+ * Could not open SCM, access denied 0x000000FD
+ * Could not open SCM, database does not exist 0x000000FE;
+ * Could not open SCM, other error 0x000000FF;
+ * Note: The strange choice of error codes above SERVICE_PAUSED are chosen
+ * in case Windows comes out with other service stats higher than 7, they
+ * would likely call it 8 and above. JS code that uses this in TestAUSHelper
+ * only handles values up to 255 so that's why we don't use GetLastError
+ * directly.
+ */
+DWORD
+WaitForServiceStop(LPCWSTR serviceName, DWORD maxWaitSeconds) {
+ // 0x000000CF is defined above to be not set
+ DWORD lastServiceState = 0x000000CF;
+
+ // Get a handle to the SCM database.
+ SC_HANDLE serviceManager = OpenSCManager(
+ nullptr, nullptr, SC_MANAGER_CONNECT | SC_MANAGER_ENUMERATE_SERVICE);
+ if (!serviceManager) {
+ DWORD lastError = GetLastError();
+ switch (lastError) {
+ case ERROR_ACCESS_DENIED:
+ return 0x000000FD;
+ case ERROR_DATABASE_DOES_NOT_EXIST:
+ return 0x000000FE;
+ default:
+ return 0x000000FF;
+ }
+ }
+
+ // Get a handle to the service.
+ SC_HANDLE service =
+ OpenServiceW(serviceManager, serviceName, SERVICE_QUERY_STATUS);
+ if (!service) {
+ DWORD lastError = GetLastError();
+ CloseServiceHandle(serviceManager);
+ switch (lastError) {
+ case ERROR_ACCESS_DENIED:
+ return 0x000000EB;
+ case ERROR_INVALID_HANDLE:
+ return 0x000000EC;
+ case ERROR_INVALID_NAME:
+ return 0x000000ED;
+ case ERROR_SERVICE_DOES_NOT_EXIST:
+ return 0x000000EE;
+ default:
+ return 0x000000EF;
+ }
+ }
+
+ DWORD currentWaitMS = 0;
+ SERVICE_STATUS_PROCESS ssp;
+ ssp.dwCurrentState = lastServiceState;
+ while (currentWaitMS < maxWaitSeconds * 1000) {
+ DWORD bytesNeeded;
+ if (!QueryServiceStatusEx(service, SC_STATUS_PROCESS_INFO, (LPBYTE)&ssp,
+ sizeof(SERVICE_STATUS_PROCESS), &bytesNeeded)) {
+ DWORD lastError = GetLastError();
+ switch (lastError) {
+ case ERROR_INVALID_HANDLE:
+ ssp.dwCurrentState = 0x000000D9;
+ break;
+ case ERROR_ACCESS_DENIED:
+ ssp.dwCurrentState = 0x000000DA;
+ break;
+ case ERROR_INSUFFICIENT_BUFFER:
+ ssp.dwCurrentState = 0x000000DB;
+ break;
+ case ERROR_INVALID_PARAMETER:
+ ssp.dwCurrentState = 0x000000DC;
+ break;
+ case ERROR_INVALID_LEVEL:
+ ssp.dwCurrentState = 0x000000DD;
+ break;
+ case ERROR_SHUTDOWN_IN_PROGRESS:
+ ssp.dwCurrentState = 0x000000DE;
+ break;
+ // These 3 errors can occur when the service is not yet stopped but
+ // it is stopping.
+ case ERROR_INVALID_SERVICE_CONTROL:
+ case ERROR_SERVICE_CANNOT_ACCEPT_CTRL:
+ case ERROR_SERVICE_NOT_ACTIVE:
+ currentWaitMS += 50;
+ Sleep(50);
+ continue;
+ default:
+ ssp.dwCurrentState = 0x000000DF;
+ }
+
+ // We couldn't query the status so just break out
+ break;
+ }
+
+ // The service is already in use.
+ if (ssp.dwCurrentState == SERVICE_STOPPED) {
+ break;
+ }
+ currentWaitMS += 50;
+ Sleep(50);
+ }
+
+ lastServiceState = ssp.dwCurrentState;
+ CloseServiceHandle(service);
+ CloseServiceHandle(serviceManager);
+ return lastServiceState;
+}
+#endif
+
+/**
+ * Determines if there is at least one process running for the specified
+ * application. A match will be found across any session for any user.
+ *
+ * @param process The process to check for existance
+ * @return ERROR_NOT_FOUND if the process was not found
+ * ERROR_SUCCESS if the process was found and there were no errors
+ * Other Win32 system error code for other errors
+ **/
+DWORD
+IsProcessRunning(LPCWSTR filename) {
+ // Take a snapshot of all processes in the system.
+ HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
+ if (INVALID_HANDLE_VALUE == snapshot) {
+ return GetLastError();
+ }
+
+ PROCESSENTRY32W processEntry;
+ processEntry.dwSize = sizeof(PROCESSENTRY32W);
+ if (!Process32FirstW(snapshot, &processEntry)) {
+ DWORD lastError = GetLastError();
+ CloseHandle(snapshot);
+ return lastError;
+ }
+
+ do {
+ if (wcsicmp(filename, processEntry.szExeFile) == 0) {
+ CloseHandle(snapshot);
+ return ERROR_SUCCESS;
+ }
+ } while (Process32NextW(snapshot, &processEntry));
+ CloseHandle(snapshot);
+ return ERROR_NOT_FOUND;
+}
+
+/**
+ * Waits for the specified application to exit.
+ *
+ * @param filename The application to wait for.
+ * @param maxSeconds The maximum amount of seconds to wait for all
+ * instances of the application to exit.
+ * @return ERROR_SUCCESS if no instances of the application exist
+ * WAIT_TIMEOUT if the process is still running after maxSeconds.
+ * Any other Win32 system error code.
+ */
+DWORD
+WaitForProcessExit(LPCWSTR filename, DWORD maxSeconds) {
+ DWORD applicationRunningError = WAIT_TIMEOUT;
+ for (DWORD i = 0; i < maxSeconds; i++) {
+ DWORD applicationRunningError = IsProcessRunning(filename);
+ if (ERROR_NOT_FOUND == applicationRunningError) {
+ return ERROR_SUCCESS;
+ }
+ Sleep(1000);
+ }
+
+ if (ERROR_SUCCESS == applicationRunningError) {
+ return WAIT_TIMEOUT;
+ }
+
+ return applicationRunningError;
+}
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+/**
+ * Determines if the fallback key exists or not
+ *
+ * @return TRUE if the fallback key exists and there was no error checking
+ */
+BOOL DoesFallbackKeyExist() {
+ HKEY testOnlyFallbackKey;
+ if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, TEST_ONLY_FALLBACK_KEY_PATH, 0,
+ KEY_READ | KEY_WOW64_64KEY,
+ &testOnlyFallbackKey) != ERROR_SUCCESS) {
+ return FALSE;
+ }
+
+ RegCloseKey(testOnlyFallbackKey);
+ return TRUE;
+}
+
+/**
+ * Determines if the file system for the specified file handle is local
+ * @param file path to check the filesystem type for, must be at most MAX_PATH
+ * @param isLocal out parameter which will hold TRUE if the drive is local
+ * @return TRUE if the call succeeded
+ */
+BOOL IsLocalFile(LPCWSTR file, BOOL& isLocal) {
+ WCHAR rootPath[MAX_PATH + 1] = {L'\0'};
+ if (wcslen(file) > MAX_PATH) {
+ return FALSE;
+ }
+
+ wcsncpy(rootPath, file, MAX_PATH);
+ PathStripToRootW(rootPath);
+ isLocal = GetDriveTypeW(rootPath) == DRIVE_FIXED;
+ return TRUE;
+}
+
+/**
+ * Determines the DWORD value of a registry key value
+ *
+ * @param key The base key to where the value name exists
+ * @param valueName The name of the value
+ * @param retValue Out parameter which will hold the value
+ * @return TRUE on success
+ */
+static BOOL GetDWORDValue(HKEY key, LPCWSTR valueName, DWORD& retValue) {
+ DWORD regDWORDValueSize = sizeof(DWORD);
+ LONG retCode =
+ RegQueryValueExW(key, valueName, 0, nullptr,
+ reinterpret_cast<LPBYTE>(&retValue), &regDWORDValueSize);
+ return ERROR_SUCCESS == retCode;
+}
+
+/**
+ * Determines if the the system's elevation type allows
+ * unprmopted elevation.
+ *
+ * @param isUnpromptedElevation Out parameter which specifies if unprompted
+ * elevation is allowed.
+ * @return TRUE if the user can actually elevate and the value was obtained
+ * successfully.
+ */
+BOOL IsUnpromptedElevation(BOOL& isUnpromptedElevation) {
+ if (!UACHelper::CanUserElevate()) {
+ return FALSE;
+ }
+
+ LPCWSTR UACBaseRegKey =
+ L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System";
+ HKEY baseKey;
+ LONG retCode =
+ RegOpenKeyExW(HKEY_LOCAL_MACHINE, UACBaseRegKey, 0, KEY_READ, &baseKey);
+ if (retCode != ERROR_SUCCESS) {
+ return FALSE;
+ }
+
+ DWORD consent, secureDesktop;
+ BOOL success = GetDWORDValue(baseKey, L"ConsentPromptBehaviorAdmin", consent);
+ success = success &&
+ GetDWORDValue(baseKey, L"PromptOnSecureDesktop", secureDesktop);
+
+ RegCloseKey(baseKey);
+ if (success) {
+ isUnpromptedElevation = !consent && !secureDesktop;
+ }
+
+ return success;
+}
+#endif
diff --git a/toolkit/mozapps/update/common/updatehelper.h b/toolkit/mozapps/update/common/updatehelper.h
new file mode 100644
index 0000000000..b346893835
--- /dev/null
+++ b/toolkit/mozapps/update/common/updatehelper.h
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+BOOL StartServiceUpdate(LPCWSTR installDir);
+DWORD LaunchServiceSoftwareUpdateCommand(int argc, LPCWSTR* argv);
+BOOL WriteStatusFailure(LPCWSTR updateDirPath, int errorCode);
+DWORD WaitForServiceStop(LPCWSTR serviceName, DWORD maxWaitSeconds);
+BOOL DoesFallbackKeyExist();
+BOOL IsLocalFile(LPCWSTR file, BOOL& isLocal);
+DWORD StartServiceCommand(int argc, LPCWSTR* argv);
+BOOL IsUnpromptedElevation(BOOL& isUnpromptedElevation);
+#endif
+
+DWORD WaitForProcessExit(LPCWSTR filename, DWORD maxSeconds);
+DWORD IsProcessRunning(LPCWSTR filename);
+BOOL GetSecureOutputDirectoryPath(LPWSTR outBuf);
+BOOL GetSecureOutputFilePath(LPCWSTR patchDirPath, LPCWSTR fileExt,
+ LPWSTR outBuf);
+BOOL WriteSecureIDFile(LPCWSTR patchDirPath);
+void RemoveSecureOutputFiles(LPCWSTR patchDirPath);
+
+#define PATCH_DIR_PATH L"\\updates\\0"
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+# define SVC_NAME L"MozillaMaintenance"
+
+# define BASE_SERVICE_REG_KEY L"SOFTWARE\\Mozilla\\MaintenanceService"
+
+// The test only fallback key, as its name implies, is only present on machines
+// that will use automated tests. Since automated tests always run from a
+// different directory for each test, the presence of this key bypasses the
+// "This is a valid installation directory" check. This key also stores
+// the allowed name and issuer for cert checks so that the cert check
+// code can still be run unchanged.
+# define TEST_ONLY_FALLBACK_KEY_PATH \
+ BASE_SERVICE_REG_KEY L"\\3932ecacee736d366d6436db0f55bce4"
+#endif
diff --git a/toolkit/mozapps/update/common/updatererrors.h b/toolkit/mozapps/update/common/updatererrors.h
new file mode 100644
index 0000000000..f2663d5b57
--- /dev/null
+++ b/toolkit/mozapps/update/common/updatererrors.h
@@ -0,0 +1,130 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 UPDATEERRORS_H
+#define UPDATEERRORS_H
+
+#define OK 0
+
+// Error codes that are no longer used should not be used again unless they
+// aren't used in client code (e.g. UpdateService.jsm, updates.js, etc.).
+
+#define MAR_ERROR_EMPTY_ACTION_LIST 1
+#define LOADSOURCE_ERROR_WRONG_SIZE 2
+
+// Error codes 3-16 are for general update problems.
+#define USAGE_ERROR 3
+#define CRC_ERROR 4
+#define PARSE_ERROR 5
+#define READ_ERROR 6
+#define WRITE_ERROR 7
+// #define UNEXPECTED_ERROR 8 // Replaced with errors 38-42
+#define ELEVATION_CANCELED 9
+
+// Error codes 10-14 are related to memory allocation failures.
+// Note: If more memory allocation error codes are added, the implementation of
+// isMemoryAllocationErrorCode in UpdateService.jsm should be updated to account
+// for them.
+#define READ_STRINGS_MEM_ERROR 10
+#define ARCHIVE_READER_MEM_ERROR 11
+#define BSPATCH_MEM_ERROR 12
+#define UPDATER_MEM_ERROR 13
+#define UPDATER_QUOTED_PATH_MEM_ERROR 14
+
+#define BAD_ACTION_ERROR 15
+#define STRING_CONVERSION_ERROR 16
+
+// Error codes 17-23 are related to security tasks for MAR
+// signing and MAR protection.
+#define CERT_LOAD_ERROR 17
+#define CERT_HANDLING_ERROR 18
+#define CERT_VERIFY_ERROR 19
+#define ARCHIVE_NOT_OPEN 20
+#define COULD_NOT_READ_PRODUCT_INFO_BLOCK_ERROR 21
+#define MAR_CHANNEL_MISMATCH_ERROR 22
+#define VERSION_DOWNGRADE_ERROR 23
+
+// Error codes 24-33 and 49-58 are for the Windows maintenance service.
+// Note: If more maintenance service error codes are added, the implementations
+// of IsServiceSpecificErrorCode in updater.cpp and UpdateService.jsm should be
+// updated to account for them.
+#define SERVICE_UPDATER_COULD_NOT_BE_STARTED 24
+#define SERVICE_NOT_ENOUGH_COMMAND_LINE_ARGS 25
+#define SERVICE_UPDATER_SIGN_ERROR 26
+#define SERVICE_UPDATER_COMPARE_ERROR 27
+#define SERVICE_UPDATER_IDENTITY_ERROR 28
+#define SERVICE_STILL_APPLYING_ON_SUCCESS 29
+#define SERVICE_STILL_APPLYING_ON_FAILURE 30
+#define SERVICE_UPDATER_NOT_FIXED_DRIVE 31
+#define SERVICE_COULD_NOT_LOCK_UPDATER 32
+#define SERVICE_INSTALLDIR_ERROR 33
+
+#define NO_INSTALLDIR_ERROR 34
+#define WRITE_ERROR_ACCESS_DENIED 35
+// #define WRITE_ERROR_SHARING_VIOLATION 36 // Replaced with errors 46-48
+#define WRITE_ERROR_CALLBACK_APP 37
+#define UPDATE_SETTINGS_FILE_CHANNEL 38
+#define UNEXPECTED_XZ_ERROR 39
+#define UNEXPECTED_MAR_ERROR 40
+#define UNEXPECTED_BSPATCH_ERROR 41
+#define UNEXPECTED_FILE_OPERATION_ERROR 42
+#define UNEXPECTED_STAGING_ERROR 43
+#define DELETE_ERROR_STAGING_LOCK_FILE 44
+#define DELETE_ERROR_EXPECTED_DIR 46
+#define DELETE_ERROR_EXPECTED_FILE 47
+#define RENAME_ERROR_EXPECTED_FILE 48
+
+// Error codes 24-33 and 49-58 are for the Windows maintenance service.
+// Note: If more maintenance service error codes are added, the implementations
+// of IsServiceSpecificErrorCode in updater.cpp and UpdateService.jsm should be
+// updated to account for them.
+#define SERVICE_COULD_NOT_COPY_UPDATER 49
+#define SERVICE_STILL_APPLYING_TERMINATED 50
+#define SERVICE_STILL_APPLYING_NO_EXIT_CODE 51
+#define SERVICE_INVALID_APPLYTO_DIR_STAGED_ERROR 52
+#define SERVICE_CALC_REG_PATH_ERROR 53
+#define SERVICE_INVALID_APPLYTO_DIR_ERROR 54
+#define SERVICE_INVALID_INSTALL_DIR_PATH_ERROR 55
+#define SERVICE_INVALID_WORKING_DIR_PATH_ERROR 56
+#define SERVICE_INSTALL_DIR_REG_ERROR 57
+#define SERVICE_UPDATE_STATUS_UNCHANGED 58
+
+#define WRITE_ERROR_FILE_COPY 61
+#define WRITE_ERROR_DELETE_FILE 62
+#define WRITE_ERROR_OPEN_PATCH_FILE 63
+#define WRITE_ERROR_PATCH_FILE 64
+#define WRITE_ERROR_APPLY_DIR_PATH 65
+#define WRITE_ERROR_CALLBACK_PATH 66
+#define WRITE_ERROR_FILE_ACCESS_DENIED 67
+#define WRITE_ERROR_DIR_ACCESS_DENIED 68
+#define WRITE_ERROR_DELETE_BACKUP 69
+#define WRITE_ERROR_EXTRACT 70
+#define REMOVE_FILE_SPEC_ERROR 71
+#define INVALID_APPLYTO_DIR_STAGED_ERROR 72
+#define LOCK_ERROR_PATCH_FILE 73
+#define INVALID_APPLYTO_DIR_ERROR 74
+#define INVALID_INSTALL_DIR_PATH_ERROR 75
+#define INVALID_WORKING_DIR_PATH_ERROR 76
+#define INVALID_CALLBACK_PATH_ERROR 77
+#define INVALID_CALLBACK_DIR_ERROR 78
+#define UPDATE_STATUS_UNCHANGED 79
+
+// Error codes 80 through 99 are reserved for UpdateService.jsm
+
+// The following error codes are only used by updater.exe
+// when a fallback key exists for tests.
+#define FALLBACKKEY_UNKNOWN_ERROR 100
+#define FALLBACKKEY_REGPATH_ERROR 101
+#define FALLBACKKEY_NOKEY_ERROR 102
+#define FALLBACKKEY_SERVICE_NO_STOP_ERROR 103
+#define FALLBACKKEY_LAUNCH_ERROR 104
+
+#define SILENT_UPDATE_NEEDED_ELEVATION_ERROR 105
+#define WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION 106
+
+// Error codes 110 and 111 are reserved for UpdateService.jsm
+
+#endif // UPDATEERRORS_H
diff --git a/toolkit/mozapps/update/common/updateutils_win.cpp b/toolkit/mozapps/update/common/updateutils_win.cpp
new file mode 100644
index 0000000000..fc2554e569
--- /dev/null
+++ b/toolkit/mozapps/update/common/updateutils_win.cpp
@@ -0,0 +1,166 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "updateutils_win.h"
+#include <errno.h>
+#include <shlwapi.h>
+#include <string.h>
+
+/**
+ * Note: The reason that these functions are separated from those in
+ * updatehelper.h/updatehelper.cpp is that those functions are strictly
+ * used within the updater, whereas changing functions in updateutils_win
+ * will have effects reaching beyond application update.
+ */
+
+// This section implements the minimum set of dirent APIs used by updater.cpp on
+// Windows. If updater.cpp is modified to use more of this API, we need to
+// implement those parts here too.
+static dirent gDirEnt;
+
+DIR::DIR(const WCHAR* path) : findHandle(INVALID_HANDLE_VALUE) {
+ memset(name, 0, sizeof(name));
+ wcsncpy(name, path, sizeof(name) / sizeof(name[0]));
+ wcsncat(name, L"\\*", sizeof(name) / sizeof(name[0]) - wcslen(name) - 1);
+}
+
+DIR::~DIR() {
+ if (findHandle != INVALID_HANDLE_VALUE) {
+ FindClose(findHandle);
+ }
+}
+
+dirent::dirent() { d_name[0] = L'\0'; }
+
+DIR* opendir(const WCHAR* path) { return new DIR(path); }
+
+int closedir(DIR* dir) {
+ delete dir;
+ return 0;
+}
+
+dirent* readdir(DIR* dir) {
+ WIN32_FIND_DATAW data;
+ if (dir->findHandle != INVALID_HANDLE_VALUE) {
+ BOOL result = FindNextFileW(dir->findHandle, &data);
+ if (!result) {
+ if (GetLastError() != ERROR_NO_MORE_FILES) {
+ errno = ENOENT;
+ }
+ return 0;
+ }
+ } else {
+ // Reading the first directory entry
+ dir->findHandle = FindFirstFileW(dir->name, &data);
+ if (dir->findHandle == INVALID_HANDLE_VALUE) {
+ if (GetLastError() == ERROR_FILE_NOT_FOUND) {
+ errno = ENOENT;
+ } else {
+ errno = EBADF;
+ }
+ return 0;
+ }
+ }
+ size_t direntBufferLength =
+ sizeof(gDirEnt.d_name) / sizeof(gDirEnt.d_name[0]);
+ wcsncpy(gDirEnt.d_name, data.cFileName, direntBufferLength);
+ // wcsncpy does not guarantee a null-terminated string if the source string is
+ // too long.
+ gDirEnt.d_name[direntBufferLength - 1] = '\0';
+ return &gDirEnt;
+}
+
+/**
+ * Joins a base directory path with a filename.
+ *
+ * @param base The base directory path of size MAX_PATH + 1
+ * @param extra The filename to append
+ * @return TRUE if the file name was successful appended to base
+ */
+BOOL PathAppendSafe(LPWSTR base, LPCWSTR extra) {
+ if (wcslen(base) + wcslen(extra) >= MAX_PATH) {
+ return FALSE;
+ }
+
+ return PathAppendW(base, extra);
+}
+
+/**
+ * Obtains a uuid as a wide string.
+ *
+ * @param outBuf
+ * A buffer of size MAX_PATH + 1 to store the result.
+ * @return TRUE if successful
+ */
+BOOL GetUUIDString(LPWSTR outBuf) {
+ UUID uuid;
+ RPC_WSTR uuidString = nullptr;
+
+ // Note: the return value of UuidCreate should always be RPC_S_OK on systems
+ // after Win2K / Win2003 due to the network hardware address no longer being
+ // used to create the UUID.
+ if (UuidCreate(&uuid) != RPC_S_OK) {
+ return FALSE;
+ }
+ if (UuidToStringW(&uuid, &uuidString) != RPC_S_OK) {
+ return FALSE;
+ }
+ if (!uuidString) {
+ return FALSE;
+ }
+
+ if (wcslen(reinterpret_cast<LPCWSTR>(uuidString)) > MAX_PATH) {
+ return FALSE;
+ }
+ wcsncpy(outBuf, reinterpret_cast<LPCWSTR>(uuidString), MAX_PATH + 1);
+ RpcStringFreeW(&uuidString);
+
+ return TRUE;
+}
+
+/**
+ * Build a temporary file path whose name component is a UUID.
+ *
+ * @param basePath The base directory path for the temp file
+ * @param prefix Optional prefix for the beginning of the file name
+ * @param tmpPath Output full path, with the base directory and the file
+ * name. Must already have been allocated with size >= MAX_PATH.
+ * @return TRUE if tmpPath was successfully filled in, FALSE on errors
+ */
+BOOL GetUUIDTempFilePath(LPCWSTR basePath, LPCWSTR prefix, LPWSTR tmpPath) {
+ WCHAR filename[MAX_PATH + 1] = {L"\0"};
+ if (prefix) {
+ if (wcslen(prefix) > MAX_PATH) {
+ return FALSE;
+ }
+ wcsncpy(filename, prefix, MAX_PATH + 1);
+ }
+
+ WCHAR tmpFileNameString[MAX_PATH + 1] = {L"\0"};
+ if (!GetUUIDString(tmpFileNameString)) {
+ return FALSE;
+ }
+
+ size_t tmpFileNameStringLen = wcslen(tmpFileNameString);
+ if (wcslen(filename) + tmpFileNameStringLen > MAX_PATH) {
+ return FALSE;
+ }
+ wcsncat(filename, tmpFileNameString, tmpFileNameStringLen);
+
+ size_t basePathLen = wcslen(basePath);
+ if (basePathLen > MAX_PATH) {
+ return FALSE;
+ }
+ // Use basePathLen + 1 so wcsncpy will add null termination and if a caller
+ // doesn't allocate MAX_PATH + 1 for tmpPath this won't fail when there is
+ // actually enough space allocated.
+ wcsncpy(tmpPath, basePath, basePathLen + 1);
+ if (!PathAppendSafe(tmpPath, filename)) {
+ return FALSE;
+ }
+
+ return TRUE;
+}
diff --git a/toolkit/mozapps/update/common/updateutils_win.h b/toolkit/mozapps/update/common/updateutils_win.h
new file mode 100644
index 0000000000..9de5914741
--- /dev/null
+++ b/toolkit/mozapps/update/common/updateutils_win.h
@@ -0,0 +1,47 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 WINDIRENT_H__
+#define WINDIRENT_H__
+
+/**
+ * Note: The reason that these functions are separated from those in
+ * updatehelper.h/updatehelper.cpp is that those functions are strictly
+ * used within the updater, whereas changing functions in updateutils_win
+ * will have effects reaching beyond application update.
+ */
+
+#ifndef XP_WIN
+# error This library should only be used on Windows
+#endif
+
+#include <windows.h>
+
+struct DIR {
+ explicit DIR(const WCHAR* path);
+ ~DIR();
+ HANDLE findHandle;
+ WCHAR name[MAX_PATH + 1];
+};
+
+struct dirent {
+ dirent();
+ WCHAR d_name[MAX_PATH + 1];
+};
+
+DIR* opendir(const WCHAR* path);
+int closedir(DIR* dir);
+dirent* readdir(DIR* dir);
+
+// This is the length of the UUID string including null termination returned by
+// GetUUIDString.
+#define UUID_LEN 37
+
+BOOL PathAppendSafe(LPWSTR base, LPCWSTR extra);
+BOOL GetUUIDString(LPWSTR outBuf);
+BOOL GetUUIDTempFilePath(LPCWSTR basePath, LPCWSTR prefix, LPWSTR tmpPath);
+
+#endif // WINDIRENT_H__
diff --git a/toolkit/mozapps/update/components.conf b/toolkit/mozapps/update/components.conf
new file mode 100644
index 0000000000..912900ad4d
--- /dev/null
+++ b/toolkit/mozapps/update/components.conf
@@ -0,0 +1,38 @@
+# -*- 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': '{B3C290A6-3943-4B89-8BBE-C01EB7B3B311}',
+ 'contract_ids': ['@mozilla.org/updates/update-service;1'],
+ 'esModule': 'resource://gre/modules/UpdateService.sys.mjs',
+ 'constructor': 'UpdateService',
+ 'singleton': True,
+ },
+ {
+ 'cid': '{093C2356-4843-4C65-8709-D7DBCBBE7DFB}',
+ 'contract_ids': ['@mozilla.org/updates/update-manager;1'],
+ 'esModule': 'resource://gre/modules/UpdateService.sys.mjs',
+ 'constructor': 'UpdateManager',
+ 'singleton': True,
+ },
+ {
+ 'cid': '{898CDC9B-E43F-422F-9CC4-2F6291B415A3}',
+ 'contract_ids': ['@mozilla.org/updates/update-checker;1'],
+ 'esModule': 'resource://gre/modules/UpdateService.sys.mjs',
+ 'constructor': 'CheckerService',
+ 'singleton': True,
+ },
+
+ {
+ 'cid': '{e43b0010-04ba-4da6-b523-1f92580bc150}',
+ 'contract_ids': ['@mozilla.org/updates/update-service-stub;1'],
+ 'esModule': 'resource://gre/modules/UpdateServiceStub.sys.mjs',
+ 'constructor': 'UpdateServiceStub',
+ 'categories': {'profile-after-change': 'nsUpdateServiceStub'},
+ 'singleton': True,
+ },
+]
diff --git a/toolkit/mozapps/update/content/history.js b/toolkit/mozapps/update/content/history.js
new file mode 100644
index 0000000000..51de669af3
--- /dev/null
+++ b/toolkit/mozapps/update/content/history.js
@@ -0,0 +1,96 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+var gUpdateHistory = {
+ _view: null,
+
+ /**
+ * Initialize the User Interface
+ */
+ onLoad() {
+ this._view = document.getElementById("historyItems");
+
+ var um = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ );
+ var uc = um.getUpdateCount();
+ if (uc) {
+ while (this._view.hasChildNodes()) {
+ this._view.firstChild.remove();
+ }
+
+ for (var i = 0; i < uc; ++i) {
+ var update = um.getUpdateAt(i);
+
+ if (!update || !update.name) {
+ continue;
+ }
+
+ // Don't display updates that are downloading since they don't have
+ // valid statusText for the UI (bug 485493).
+ if (!update.statusText) {
+ continue;
+ }
+
+ var element = document.createXULElement("richlistitem");
+ element.className = "update";
+
+ const topLine = document.createXULElement("hbox");
+ const nameLabel = document.createXULElement("label");
+ nameLabel.className = "update-name";
+ document.l10n.setAttributes(nameLabel, "update-full-build-name", {
+ name: update.name,
+ buildID: update.buildID,
+ });
+ topLine.appendChild(nameLabel);
+
+ if (update.detailsURL) {
+ const detailsLink = document.createXULElement("label", {
+ is: "text-link",
+ });
+ detailsLink.href = update.detailsURL;
+ document.l10n.setAttributes(detailsLink, "update-details");
+ topLine.appendChild(detailsLink);
+ }
+
+ const installedOnLabel = document.createXULElement("label");
+ installedOnLabel.className = "update-installedOn-label";
+ document.l10n.setAttributes(installedOnLabel, "update-installed-on", {
+ date: this._formatDate(update.installDate),
+ });
+
+ const statusLabel = document.createXULElement("label");
+ statusLabel.className = "update-status-label";
+ document.l10n.setAttributes(statusLabel, "update-status", {
+ status: update.statusText,
+ });
+
+ element.append(topLine, installedOnLabel, statusLabel);
+ this._view.appendChild(element);
+ }
+ }
+ var cancelbutton = document.getElementById("history").getButton("cancel");
+ cancelbutton.focus();
+ },
+
+ /**
+ * Formats a date into human readable form
+ * @param seconds
+ * A date in seconds since 1970 epoch
+ * @returns A human readable date string
+ */
+ _formatDate(seconds) {
+ var date = new Date(seconds);
+ const dtOptions = {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "numeric",
+ second: "numeric",
+ };
+ return date.toLocaleString(undefined, dtOptions);
+ },
+};
diff --git a/toolkit/mozapps/update/content/history.xhtml b/toolkit/mozapps/update/content/history.xhtml
new file mode 100644
index 0000000000..9930ed9ce1
--- /dev/null
+++ b/toolkit/mozapps/update/content/history.xhtml
@@ -0,0 +1,44 @@
+<?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/. -->
+
+<!DOCTYPE window>
+
+<window
+ windowtype="Update:History"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ style="min-width: 35em"
+ data-l10n-id="close-button-label"
+ data-l10n-attrs="title"
+ onload="gUpdateHistory.onLoad();"
+>
+ <dialog
+ id="history"
+ buttons="cancel"
+ defaultButton="cancel"
+ data-l10n-id="close-button-label"
+ data-l10n-attrs="buttonlabelcancel"
+ >
+ <linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <html:link
+ rel="stylesheet"
+ href="chrome://mozapps/skin/update/updates.css"
+ />
+
+ <html:link rel="localization" href="toolkit/updates/history.ftl" />
+ </linkset>
+
+ <script src="chrome://mozapps/content/update/history.js" />
+
+ <label data-l10n-id="history-intro"></label>
+ <separator class="thin" />
+ <richlistbox id="historyItems">
+ <label data-l10n-id="no-updates-label"></label>
+ </richlistbox>
+ <separator class="thin" />
+ </dialog>
+</window>
diff --git a/toolkit/mozapps/update/content/updateElevation.js b/toolkit/mozapps/update/content/updateElevation.js
new file mode 100644
index 0000000000..251a4b5618
--- /dev/null
+++ b/toolkit/mozapps/update/content/updateElevation.js
@@ -0,0 +1,138 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
+
+/* This is temporary until bug 1521632 is fixed */
+
+"use strict";
+
+/* import-globals-from /toolkit/content/contentAreaUtils.js */
+
+const gUpdateElevationDialog = {
+ openUpdateURL(event) {
+ if (event.button == 0) {
+ openURL(event.target.getAttribute("url"));
+ }
+ },
+ getAUSString(key, strings) {
+ if (strings) {
+ return this.strings.getFormattedString(key, strings);
+ }
+ return this.strings.getString(key);
+ },
+ _setButton(button, string) {
+ var label = this.getAUSString(string);
+ if (label.includes("%S")) {
+ label = label.replace(/%S/, this.brandName);
+ }
+ button.label = label;
+ button.setAttribute("accesskey", this.getAUSString(string + ".accesskey"));
+ },
+ onLoad() {
+ this.strings = document.getElementById("updateStrings");
+ this.brandName = document
+ .getElementById("brandStrings")
+ .getString("brandShortName");
+
+ let um = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ );
+ let update = um.readyUpdate;
+ let updateFinishedName = document.getElementById("updateFinishedName");
+ updateFinishedName.value = update.name;
+
+ let link = document.getElementById("detailsLinkLabel");
+ if (update.detailsURL) {
+ link.setAttribute("url", update.detailsURL);
+ // The details link is stealing focus so it is disabled by default and
+ // should only be enabled after onPageShow has been called.
+ link.disabled = false;
+ } else {
+ link.hidden = true;
+ }
+
+ let manualLinkLabel = document.getElementById("manualLinkLabel");
+ let manualURL = Services.urlFormatter.formatURLPref(
+ "app.update.url.manual"
+ );
+ manualLinkLabel.value = manualURL;
+ manualLinkLabel.setAttribute("url", manualURL);
+
+ let button = document.getElementById("elevateExtra2");
+ this._setButton(button, "restartLaterButton");
+ button = document.getElementById("elevateExtra1");
+ this._setButton(button, "noThanksButton");
+ button = document.getElementById("elevateAccept");
+ this._setButton(button, "restartNowButton");
+ button.focus();
+ },
+ onRestartLater() {
+ window.close();
+ },
+ onNoThanks() {
+ Services.obs.notifyObservers(null, "update-canceled");
+ let um = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ );
+ let update = um.readyUpdate;
+ um.cleanupReadyUpdate();
+ // Since the user has clicked "No Thanks", we should not prompt them to update to
+ // this version again unless they manually select "Check for Updates..."
+ // which will clear app.update.elevate.never preference.
+ let aus = Cc["@mozilla.org/updates/update-service;1"].getService(
+ Ci.nsIApplicationUpdateService
+ );
+ if (aus.elevationRequired && update) {
+ Services.prefs.setCharPref("app.update.elevate.never", update.appVersion);
+ }
+ window.close();
+ },
+ onRestartNow() {
+ // disable the "finish" (Restart) and "extra1" (Later) buttons
+ // because the Software Update wizard is still up at the point,
+ // and will remain up until we return and we close the
+ // window with a |window.close()| in wizard.xml
+ // (it was the firing the "wizardfinish" event that got us here.)
+ // This prevents the user from switching back
+ // to the Software Update dialog and clicking "Restart" or "Later"
+ // when dealing with the "confirm close" prompts.
+ // See bug #350299 for more details.
+ document.getElementById("elevateExtra2").disabled = true;
+ document.getElementById("elevateExtra1").disabled = true;
+ document.getElementById("elevateAccept").disabled = true;
+
+ // This dialog was shown because elevation was required so there is no need
+ // to check if elevation is required again.
+ let um = Cc["@mozilla.org/updates/update-manager;1"].getService(
+ Ci.nsIUpdateManager
+ );
+ um.elevationOptedIn();
+
+ // Notify all windows that an application quit has been requested.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ return;
+ }
+
+ // If already in safe mode restart in safe mode (bug 327119)
+ if (Services.appinfo.inSafeMode) {
+ Services.env.set("MOZ_SAFE_MODE_RESTART", "1");
+ }
+
+ // Restart the application
+ Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ );
+ },
+};
diff --git a/toolkit/mozapps/update/content/updateElevation.xhtml b/toolkit/mozapps/update/content/updateElevation.xhtml
new file mode 100644
index 0000000000..0504feeb42
--- /dev/null
+++ b/toolkit/mozapps/update/content/updateElevation.xhtml
@@ -0,0 +1,80 @@
+<?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/. -->
+
+<!-- This is temporary until bug 1521632 is fixed -->
+
+<window windowtype="Update:Elevation"
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="elevation-update-wizard"
+ data-l10n-attrs="title"
+ style="width: auto; height: auto"
+ onload="gUpdateElevationDialog.onLoad();">
+<dialog id="updates"
+ buttons="extra2,extra1,accept">
+
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://mozapps/content/update/updateElevation.js"/>
+
+<linkset>
+ <html:link rel="stylesheet" href="chrome://global/skin/global.css" />
+ <html:link rel="stylesheet" href="chrome://mozapps/skin/update/updates.css" />
+
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="toolkit/updates/elevation.ftl"/>
+</linkset>
+
+#if defined(XP_MACOSX) && MOZ_BUILD_APP == browser
+#include ../../../../browser/base/content/macWindow.inc.xhtml
+#endif
+
+ <stringbundleset id="updateSet">
+ <stringbundle id="brandStrings" src="chrome://branding/locale/brand.properties"/>
+ <stringbundle id="updateStrings" src="chrome://mozapps/locale/update/updates.properties"/>
+ </stringbundleset>
+
+ <vbox id="elevationBox">
+ <hbox class="update-header" flex="1">
+ <vbox class="update-header-box-1">
+ <vbox class="update-header-box-text">
+ <label class="update-header-label" data-l10n-id="elevation-finished-page"/>
+ </vbox>
+ </vbox>
+ </hbox>
+ <vbox class="update-content" flex="1">
+ <label data-l10n-id="elevation-finished-background-page"/>
+ <separator/>
+ <hbox align="center">
+ <label data-l10n-id="elevation-finished-background"/>
+ <label id="updateFinishedName" flex="1" crop="end" value=""/>
+ <label id="detailsLinkLabel" disabled="true" is="text-link"
+ data-l10n-id="elevation-details-link-label"
+ onclick="gUpdateElevationDialog.openUpdateURL(event);"/>
+ </hbox>
+ <spacer flex="1"/>
+ <label id="finishedBackgroundMoreElevated" data-l10n-id="elevation-more-elevated"/>
+ <label data-l10n-id="elevation-error-manual"/>
+ <hbox>
+ <label id="manualLinkLabel" is="text-link" value=""
+ onclick="gUpdateElevationDialog.openUpdateURL(event);"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ <separator class="groove update-buttons-separator"/>
+ <hbox id="update-button-box" pack="end">
+ <button id="elevateExtra2" dlgtype="extra2" label="" class="dialog-button"
+ oncommand="gUpdateElevationDialog.onRestartLater();" />
+ <button id="elevateExtra1" dlgtype="extra1" label="" class="dialog-button"
+ oncommand="gUpdateElevationDialog.onNoThanks();" />
+ <spacer flex="1"/>
+ <button id="elevateAccept" dlgtype="accept" label="" class="dialog-button"
+ oncommand="gUpdateElevationDialog.onRestartNow();" default="true"/>
+ </hbox>
+</dialog>
+</window>
diff --git a/toolkit/mozapps/update/docs/BackgroundUpdates.rst b/toolkit/mozapps/update/docs/BackgroundUpdates.rst
new file mode 100644
index 0000000000..7f97f58c74
--- /dev/null
+++ b/toolkit/mozapps/update/docs/BackgroundUpdates.rst
@@ -0,0 +1,221 @@
+==================
+Background Updates
+==================
+
+The purpose of the background update system is to perform application updates
+during times when Firefox is not running. It was originally implemented in `bug
+1689520 <https://bugzilla.mozilla.org/show_bug.cgi?id=1689520>`__.
+
+The system has three main tasks it needs to handle:
+
+1. :ref:`Determining whether background updates are possible <background-updates-determining>`
+
+2. :ref:`Scheduling background tasks <background-updates-scheduling>`
+
+3. :ref:`Checking for updates <background-updates-checking>`
+
+Architecturally, the background task is an instance of Firefox running in a
+special background mode, not a separate tool. This allows it to leverage
+existing functionality in Firefox, including the existing update code, but also
+keep acceptable performance characteristics for a background task by controlling
+and limiting the parts of Firefox that are loaded.
+
+Everything in this document applies only to Microsoft Windows systems. In the
+future, we would like to extend background update support to macOS (see `bug
+1653435 <https://bugzilla.mozilla.org/show_bug.cgi?id=1653435>`__), however
+support for Linux and other Unix variants is not planned due to the variation in
+OS-level scheduling affordances across distributions/configurations.
+
+Lifecycle
+=========
+
+When background updates are possible, the background update task will be invoked
+every 7 hours (by default). The first invocation initiates an update download
+which proceeds after the task exits using Windows BITS. The second invocation
+prepares and stages the update. Since `bug 1704855 <https://bugzilla.mozilla.org/show_bug.cgi?id=1704855>`__,
+this second invocation restarts automatically and installs the update as it
+starts up, and then checks for a newer update, possibly initiating another update
+download. The cycle then continues. If the user launches Firefox at any point
+in this process, it will take over. If the background update task is invoked
+while Firefox proper is running, the task exits without doing any work.
+
+.. _background-updates-determining:
+
+Determining whether background updates are possible
+===================================================
+
+Configuration
+-------------
+
+Updating Firefox, by definition, is an operation that applies to a Firefox
+installation. However, Firefox configuration is generally done via preference
+values and other files which are stored in a Firefox profile, and in general
+profiles do not correspond 1:1 with installations. This raises the question of
+how the configuration for something like the background updater should be
+managed. We deal with this question in two different ways.
+
+There are two main preferences specifically relevant to updates. Those
+are ``app.update.auto``, which controls whether updates should be
+downloaded automatically at all, even if Firefox is running, and
+``app.update.background.enabled``, to specifically control whether to
+use the background update system. We store these preferences in the
+update root directory, which is located in a per-installation location
+outside of any profile. Any profile loaded in that installation can
+observe and control these settings.
+
+But there are some other pieces of state which absolutely must come from a
+profile, such as the telemetry client ID and logging level settings (see
+`BackgroundTasksUtils.jsm <https://searchfox.org/mozilla-central/source/toolkit/components/backgroundtasks/BackgroundTasksUtils.jsm>`__).
+
+This means that, in addition to our per-installation prefs, we also need
+to be able to identify and load a profile. To do that, we leverage `the profile
+service <https://searchfox.org/mozilla-central/source/toolkit/profile/nsIToolkitProfileService.idl>`__
+to determine what the default profile for the installation would be if we were
+running a normal browser session, and the background updater always uses it.
+
+Criteria
+--------
+
+The default profile must satisfy several conditions in order for background
+updates to be scheduled. None of these confounding factors are present in fully
+default configurations, but some are relatively common. See
+`BackgroundUpdate.REASON <https://searchfox.org/mozilla-central/search?q=symbol:BackgroundUpdate%23REASON>`__
+for all the details.
+
+In order for the background task to be scheduled:
+
+- The per-installation ``app.update.background.enabled`` pref must be
+ true
+
+- The per-installation ``app.update.auto`` pref must be true (the
+ default)
+
+- The installation must have been created by an installer executable and not by
+ manually extracting an archive file
+
+- The current OS user must be capable of updating the installation based on its
+ file system permissions, either by having permission to write to application
+ files directly or by using the Mozilla Maintenance Service (which also
+ requires that it be installed and enabled, as it is by default)
+
+- BITS must be enabled via ``app.update.BITS.enabled`` (the default)
+
+- Firefox proxy server settings must not be configured (the default)
+
+- ``app.update.langpack.enabled`` must be false, or otherwise there must be no
+ langpacks installed. Background tasks cannot update addons such as langpacks,
+ because they are installed into a profile, and langpacks that are not
+ precisely matched with the version of Firefox that is installed can cause
+ YSOD failures (see `bug 1647443 <https://bugzilla.mozilla.org/show_bug.cgi?id=1647443>`__),
+ so background updating in the presence of langpacks is too risky.
+
+If any per-installation prefs are changed while the default profile is not
+running, the background update task will witness the changed prefs during its
+next scheduled run, and exit if appropriate. The background task will not be
+unscheduled at that point; that is delayed until a browser session is run with
+the default profile (it should be possible for the background update task to
+unschedule itself, but currently we prefer the simplicity of handling all
+scheduling tasks from a single location).
+
+In the extremely unusual case when prefs belonging to the default profile are
+modified outside of Firefox (with a text editor, say), then the
+background task will generally pick up those changes with no action needed,
+because it will fish the changed settings directly from the profile.
+
+.. _background-updates-scheduling:
+
+Scheduling background tasks
+===========================
+
+We use OS-level scheduling mechanisms to schedule the command ``firefox
+--backgroundtask backgroundupdate`` to run on a particular cadence. This cadence
+is controlled by the ``app.update.background.interval`` preference, which
+defaults to 7 hours.
+
+On Windows, we use the `Task Scheduler
+API <https://docs.microsoft.com/en-us/windows/win32/taskschd/task-scheduler-start-page>`__;
+on macOS this will use
+`launchd <https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html>`__.
+For platform-specific scheduling details, see the
+`TaskScheduler.jsm <https://searchfox.org/mozilla-central/source/toolkit/components/taskscheduler/TaskScheduler.jsm>`__
+module.
+
+These background tasks are scheduled per OS user and run with that user’s
+permissions. No additional privileges are requested or needed, regardless of the
+user account's status, because we have already verified that either the user has
+all the permissions they need or that the Maintenance Service can be used.
+
+Scheduling is done from within Firefox (or a background task) itself. To
+reduce shared state, only the *default* Firefox profile will interact
+with the OS-level task scheduling mechanism.
+
+.. _background-updates-checking:
+
+Checking for updates
+====================
+
+After verifying all the preconditions and exiting immediately if any do not
+hold, the ``backgroundupdate`` task then verifies that it is the only Firefox
+instance running (as determined by a multi-instance lock, see `bug
+1553982 <https://bugzilla.mozilla.org/show_bug.cgi?id=1553982>`__), since
+otherwise it would be unsafe to continue performing any update work.
+
+The task then fishes configuration settings from the default profile, namely:
+
+- A subset of update specific preferences, such as ``app.update.log``
+
+- Data reporting preferences, to ensure the task respects the user’s choices
+
+- The (legacy) Telemetry client ID, so that background update Telemetry
+ can be correlated with other Firefox Telemetry
+
+The background task creates a distinct profile for itself to load, because a
+profile must be present in order for most of the Firefox code that it relies on
+to function. This distinct profile is non-ephemeral, i.e., persistent, but not
+visible to users: see `bug 1775132
+<https://bugzilla.mozilla.org/show_bug.cgi?id=1775132>`__
+
+After setting up this profile and reading all the configuration we need
+into it, the regular
+`UpdateService.jsm <https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/UpdateService.jsm>`__
+check process is initiated. To the greatest extent possible, this process is
+identical to what happens during any regular browsing session.
+
+Specific topics
+===============
+
+User interface
+--------------
+
+The background update task must not produce any user-visible interface. If it
+did, whatever appeared would be \*disembodied\*, unconnected to any usage of
+Firefox itself and appearing to a user as a weird, scary popup that came out of
+nowhere. To this end, we disable all UI within the updater when invoking
+from a background task. See `bug
+1696276 <https://bugzilla.mozilla.org/show_bug.cgi?id=1696276>`__.
+
+This point also means that we cannot prompt for user elevation (on Windows this
+would mean a UAC prompt) from within the task, so we have to make very sure that
+we will be able to perform an update without needing to elevate. By default on
+Windows we are able to do this because of the presence of the Maintenance
+Service, but it may be disabled or not installed, so we still have to check.
+
+Staging
+-------
+
+The background update task will follow the update staging setting in the user’s
+default profile. The default setting is to enable staging, so most users will
+have it. Background update tasks recognize when an update has been staged and
+try to restart to finalize the staged update. Background tasks cannot
+finalize a staged update in all cases however; for one example, see
+`bug 1695797 <https://bugzilla.mozilla.org/show_bug.cgi?id=1695797>`__, where
+we ensure that background tasks do not finalize a staged update while other
+instances of the application are running.
+
+Staging is enabled by default because it provides a marked improvement in
+startup time for a browsing session. Without staging, browser startup following
+retrieving an update would be blocked on extracting the update archive and
+patching each individual application file. Staging does all of that in advance,
+so that all that needs to be done to complete an update (and therefore all that
+needs to be done during the startup path), is to move the already patched (that
+is, staged) files into place, a much faster and less resource intensive job.
diff --git a/toolkit/mozapps/update/docs/MaintenanceServiceTests.rst b/toolkit/mozapps/update/docs/MaintenanceServiceTests.rst
new file mode 100644
index 0000000000..b954b572f8
--- /dev/null
+++ b/toolkit/mozapps/update/docs/MaintenanceServiceTests.rst
@@ -0,0 +1,103 @@
+Maintenance Service Tests
+=========================
+
+The automated tests for the Mozilla Maintenance Service are a bit tricky. They
+are located in ``toolkit/mozapps/update/tests/unit_service_updater/`` and they
+allow for automated testing of application update using the Service.
+
+In automation, everything gets signed and the tests properly check that things
+like certificate verification work. If, however, you want to run the tests
+locally, the MAR and the updater binary will not be signed. Thus, the
+verification of those certificates will fail and the tests will not run
+properly.
+
+We don't want these tests to just always fail if someone runs large amounts of
+tests locally. To avoid this, the tests basically just unconditionally pass if
+you run them locally and don't take the time to set them up properly.
+
+If you want them to actually run locally, you will need to set up your
+environment properly.
+
+Setting Up to Run the Tests Locally
+-----------------------------------
+
+In order to run the service tests locally, we have to bypass much of the
+certificate verification. Thus, this method may not be helpful if you need to
+test that feature in particular.
+
+Add Fallback Key to Registry
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+First, you will need to add the fallback key to the registry. Normally, the
+Firefox installer writes some certificate information to a registry key in an
+installation-specific place. In testing, however, we can't get the permissions
+to write this key, nor can we have the test environment predict every possible
+installation directory that we might test with. To get around this problem, if
+the Service can't find the installation-specific key, it will check a static
+fallback location.
+
+The easiest way to correctly set up the fallback key is to copy the text below
+into a ``.reg`` file and then double click it in the file browser to merge it
+into the registry.
+
+.. code::
+
+ Windows Registry Editor Version 5.00
+
+ [HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\MaintenanceService\3932ecacee736d366d6436db0f55bce4]
+
+ [HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\MaintenanceService\3932ecacee736d366d6436db0f55bce4\0]
+ "issuer"="DigiCert SHA2 Assured ID Code Signing CA"
+ "name"="Mozilla Corporation"
+
+ [HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\MaintenanceService\3932ecacee736d366d6436db0f55bce4\1]
+ "issuer"="DigiCert Assured ID Code Signing CA-1"
+ "name"="Mozilla Corporation"
+
+ [HKEY_LOCAL_MACHINE\SOFTWARE\Mozilla\MaintenanceService\3932ecacee736d366d6436db0f55bce4\2]
+ "issuer"="Mozilla Fake CA"
+ "name"="Mozilla Fake SPC"
+
+Build without Certificate Verification
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To disable certificate verification, add this build flag to your ``mozconfig``
+file:
+
+.. code::
+
+ ac_add_options --enable-unverified-updates
+
+You will need to rebuild for this to take effect.
+
+Copy the Maintenance Service Binary
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This step will assume that you already have the Maintenance Service installed.
+
+First, move the existing Maintenance Service binary out of the way. It will
+initially be located at
+``C:\Program Files (x86)\Mozilla Maintenance Service\maintenanceservice.exe``.
+An easy way to do this is to append ``.bak`` to its name. You should probably
+restore your original Maintenance Service binary when you are done testing.
+
+Now, copy the Maintenance Service binary that you built into that directory.
+It will be located at ``<obj directory>\dist\bin\maintenanceservice.exe``.
+
+If you make changes to the Maintenance Service and rebuild, you will have to
+repeat this step.
+
+Running the Tests
+-----------------
+
+You should now be ready run a service test:
+
+.. code::
+
+ ./mach test toolkit/mozapps/update/tests/unit_service_updater/<test>
+
+Or run all of them:
+
+.. code::
+
+ ./mach test toolkit/mozapps/update/tests/unit_service_updater
diff --git a/toolkit/mozapps/update/docs/SettingUpAnUpdateServer.rst b/toolkit/mozapps/update/docs/SettingUpAnUpdateServer.rst
new file mode 100644
index 0000000000..268618a385
--- /dev/null
+++ b/toolkit/mozapps/update/docs/SettingUpAnUpdateServer.rst
@@ -0,0 +1,223 @@
+Setting Up An Update Server
+===========================
+
+The goal of this document is to provide instructions for installing a
+locally-served Firefox update for testing purposes. Note that these are not
+instructions for how to create or run a production update server. This method of
+serving updates is intended to trick Firefox into doing something that it
+normally wouldn't do: download and install the same update over and over again.
+This is useful for testing but is obviously not the correct behavior for a
+production update server.
+
+Obtaining an update MAR
+-----------------------
+
+Updates are served as MAR files. There are two common ways to obtain a
+MAR to use: download a prebuilt one, or build one yourself.
+
+Downloading a MAR
+~~~~~~~~~~~~~~~~~
+
+Prebuilt Nightly MARs can be found
+`here <https://archive.mozilla.org/pub/firefox/nightly/>`__ on
+archive.mozilla.org. Be sure that you use the one that matches your
+machine's configuration. For example, if you want the Nightly MAR from
+2019-09-17 for a 64 bit Windows machine, you probably want the MAR
+located at
+https://archive.mozilla.org/pub/firefox/nightly/2019/09/2019-09-17-09-36-29-mozilla-central/firefox-71.0a1.en-US.win64.complete.mar.
+
+Prebuilt MARs for release and beta can be found
+`here <https://archive.mozilla.org/pub/firefox/releases/>`__. Beta
+builds are those with a ``b`` in the version string. After locating the
+desired version, the MARs will be in the ``update`` directory. You want
+to use the MAR labelled ``complete``, not a partial MAR. Here is an
+example of an appropriate MAR file to use:
+https://archive.mozilla.org/pub/firefox/releases/69.0b9/update/win64/en-US/firefox-69.0b9.complete.mar.
+
+Building a MAR
+~~~~~~~~~~~~~~
+
+Building a MAR locally is more complicated. Part of the problem is that
+MARs are signed by Mozilla and so you cannot really build an "official"
+MAR yourself. This is a security measure designed to prevent anyone from
+serving malicious updates. If you want to use a locally-built MAR, the
+copy of Firefox being updated will need to be built to allow un-signed
+MARs. See :ref:`Building Firefox <Firefox Contributors' Quick Reference>`
+for more information on building Firefox locally. In order to use a locally
+built MAR, you will need to put this line in the mozconfig file in root of the
+build directory (create it if it does not exist):
+
+.. code::
+
+ ac_add_options --enable-unverified-updates
+
+Firefox should otherwise be built normally. After building, you may want
+to copy the installation of Firefox elsewhere. If you update the
+installation without moving it, attempts at further incremental builds
+will not work properly, and a clobber will be needed when building next.
+To move the installation, first call ``./mach package``, then copy
+``<obj dir>/dist/firefox`` elsewhere. The copied directory will be your
+install directory.
+
+If you are running Windows and want the `Mozilla Maintenance
+Service <https://support.mozilla.org/en-US/kb/what-mozilla-maintenance-service>`__
+to be used, there are a few additional steps to be taken here. First,
+the maintenance service needs to be "installed". Most likely, a
+different maintenance service is already installed, probably at
+``C:\Program Files (x86)\Mozilla Maintenance Service\maintenanceservice.exe``.
+Backup that file to another location and replace it with
+``<obj dir>/dist/bin/maintenanceservice.exe``. Don't forget to restore
+the backup when you are done. Next, you will need to change the
+permissions on the Firefox install directory that you created. Both that
+directory and its parent directory should have permissions preventing
+the current user from writing to it.
+
+Now that you have a build of Firefox capable of using a locally-built
+MAR, it's time to build the MAR. First, build Firefox the way you want
+it to be after updating. If you want it to be the same before and after
+updating, this step is unnecessary and you can use the same build that
+you used to create the installation. Then run these commands,
+substituting ``<obj dir>``, ``<MAR output path>``, ``<version>`` and
+``<channel>`` appropriately:
+
+.. code:: bash
+
+ $ ./mach package
+ $ touch "<obj dir>/dist/firefox/precomplete"
+ $ MAR="<obj dir>/dist/host/bin/mar.exe" MOZ_PRODUCT_VERSION=<version> MAR_CHANNEL_ID=<channel> ./tools/update-packaging/make_full_update.sh <MAR output path> "<obj dir>/dist/firefox"
+
+For macOS you should use these commands:
+
+.. code:: bash
+
+ $ ./mach package
+ $ touch "<obj dir>/dist/firefox/Firefox.app/Contents/Resources/precomplete"
+ $ MAR="<obj dir>/dist/host/bin/mar.exe" MOZ_PRODUCT_VERSION=<version> MAR_CHANNEL_ID=<channel> ./tools/update-packaging/make_full_update.sh <MAR output path> "<obj dir>/dist/firefox/Firefox.app"
+
+For a local build, ``<channel>`` can be ``default``, and ``<version>``
+can be the value from ``browser/config/version.txt`` (or something
+arbitrarily large like ``2000.0a1``).
+
+.. container:: blockIndicator note
+
+ Note: It can be a bit tricky to get the ``make_full_update.sh``
+ script to accept paths with spaces.
+
+Serving the update
+------------------
+
+Preparing the update files
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+First, create the directory that updates will be served from and put the
+MAR file in it. Then, create a file within called ``update.xml`` with
+these contents, replacing ``<mar name>``, ``<hash>`` and ``<size>`` with
+the MAR's filename, its sha512 hash, and its file size in bytes.
+
+::
+
+ <?xml version="1.0" encoding="UTF-8"?>
+ <updates>
+ <update type="minor" displayVersion="2000.0a1" appVersion="2000.0a1" platformVersion="2000.0a1" buildID="21181002100236">
+ <patch type="complete" URL="http://127.0.0.1:8000/<mar name>" hashFunction="sha512" hashValue="<hash>" size="<size>"/>
+ </update>
+ </updates>
+
+If you've downloaded the MAR you're using, you'll find the sha512 value
+in a file called SHA512SUMS in the root of the release directory on
+archive.mozilla.org for a release or beta build (you'll have to search
+it for the file name of your MAR, since it includes the sha512 for every
+file that's part of that release), and for a nightly build you'll find a
+file with a .checksums extension adjacent to your MAR that contains that
+information (for instance, for the MAR file at
+https://archive.mozilla.org/pub/firefox/nightly/2019/09/2019-09-17-09-36-29-mozilla-central/firefox-71.0a1.en-US.win64.complete.mar,
+the file
+https://archive.mozilla.org/pub/firefox/nightly/2019/09/2019-09-17-09-36-29-mozilla-central/firefox-71.0a1.en-US.win64.checksums
+contains the sha512 for that file as well as for all the other win64
+files that are part of that nightly release).
+
+If you've built your own MAR, you can obtain its sha512 checksum by
+running the following command, which should work in Linux, macOS, or
+Windows in the MozillaBuild environment:
+
+.. code::
+
+ shasum --algorithm 512 <filename>
+
+On Windows, you can get the exact file size in bytes for your MAR by
+right clicking on it in the file explorer and selecting Properties.
+You'll find the correct size in bytes at the end of the line that begins
+"Size", **not** the one that begins "Size on disk". Be sure to remove
+the commas when you paste this number into the XML file.
+
+On macOS, you can get the exact size of your MAR by running the command:
+
+.. code::
+
+ stat -f%z <filename>
+
+Or on Linux, the same command would be:
+
+.. code::
+
+ stat --format "%s" <filename>
+
+Starting your update server
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Now, start an update server to serve the update files on port 8000. An
+easy way to do this is with Python. Remember to navigate to the correct
+directory before starting the server. This is the Python2 command:
+
+.. code:: bash
+
+ $ python -m SimpleHTTPServer 8000
+
+or, this is the Python3 command:
+
+.. code:: bash
+
+ $ python3 -m http.server 8000
+
+.. container:: blockIndicator note
+
+ If you aren't sure that you started the server correctly, try using a
+ web browser to navigate to ``http://127.0.0.1:8000/update.xml`` and
+ make sure that you get the XML file you created earlier.
+
+Installing the update
+---------------------
+
+You may want to start by deleting any pending updates to ensure that no
+previously found updates interfere with installing the desired update.
+You can use this command with Firefox's browser console to determine the
+update directory:
+
+.. code::
+
+ ChromeUtils.importESModule("resource://gre/modules/FileUtils.sys.mjs").FileUtils.getDir("UpdRootD", []).path
+
+Once you have determined the update directory, close Firefox, browse to
+the directory and remove the subdirectory called ``updates``.
+
+| Next, you need to change the update URL to point to the local XML
+ file. This can be done most reliably with an enterprise policy. The
+ policy file location depends on the operating system you are using.
+| Windows/Linux: ``<install dir>/distribution/policies.json``
+| macOS: ``<install dir>/Contents/Resources/distribution/policies.json``
+| Create the ``distribution`` directory, if necessary, and put this in
+ ``policies.json``:
+
+::
+
+ {
+ "policies": {
+ "AppUpdateURL": "http://127.0.0.1:8000/update.xml"
+ }
+ }
+
+Now you are ready to update! Launch Firefox out of its installation
+directory and navigate to the Update section ``about:preferences``. You
+should see it downloading the update to the update directory. Since the
+transfer is entirely local this should finish quickly, and a "Restart to
+Update" button should appear. Click it to restart and apply the update.
diff --git a/toolkit/mozapps/update/docs/index.rst b/toolkit/mozapps/update/docs/index.rst
new file mode 100644
index 0000000000..d6cc9f5e64
--- /dev/null
+++ b/toolkit/mozapps/update/docs/index.rst
@@ -0,0 +1,10 @@
+==================
+Application Update
+==================
+
+.. toctree::
+ :maxdepth: 1
+
+ BackgroundUpdates
+ MaintenanceServiceTests
+ SettingUpAnUpdateServer
diff --git a/toolkit/mozapps/update/jar.mn b/toolkit/mozapps/update/jar.mn
new file mode 100644
index 0000000000..0ff9cdae61
--- /dev/null
+++ b/toolkit/mozapps/update/jar.mn
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 mozapps %content/mozapps/
+ content/mozapps/update/history.xhtml (content/history.xhtml)
+ content/mozapps/update/history.js (content/history.js)
+ content/mozapps/update/updateElevation.js (content/updateElevation.js)
+* content/mozapps/update/updateElevation.xhtml (content/updateElevation.xhtml)
diff --git a/toolkit/mozapps/update/metrics.yaml b/toolkit/mozapps/update/metrics.yaml
new file mode 100644
index 0000000000..e262fb0dd3
--- /dev/null
+++ b/toolkit/mozapps/update/metrics.yaml
@@ -0,0 +1,440 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - "Toolkit :: Application Update"
+
+background_update:
+ client_id:
+ type: uuid
+ description: >
+ The legacy Telemetry client ID of this installation's default profile.
+
+ The default profile is as determined by the Profile Service, namely
+ `nsIToolkitProfileService.defaultProfile`. The majority of users have
+ only one Firefox installation and only one profile, so the default profile
+ is their regular browsing profile.
+
+ It is possible for a Firefox installation to not have a default profile,
+ but in such cases the background update task will abort before sending any
+ telemetry; therefore, the legacy Telemetry client ID should always be
+ present.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1794053
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1794053
+ data_sensitivity:
+ - highly_sensitive
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ no_lint:
+ - BASELINE_PING
+ send_in_pings:
+ - background-update
+ - metrics
+ - events
+ - baseline
+
+ targeting_exists:
+ type: boolean
+ description: >
+ True if the default profile had a targeting snapshot serialized to disk,
+ and there was no exception thrown reading it.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ targeting_exception:
+ type: boolean
+ description: >
+ True if the default profile had a targeting snapshot serialized to disk,
+ but an exception was thrown reading it.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ targeting_version:
+ type: quantity
+ unit: version number
+ description: >
+ If the default profile had a targeting snapshot serialized to disk, the
+ `version` of the snapshot.
+
+ This version number does not have a physical unit: it's only useful to
+ compare between versions.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ targeting_env_firefox_version:
+ type: quantity
+ unit: version number
+ description: >
+ The `environment.firefoxVersion` of the default profile's serialized
+ targeting snapshot. At the time of writing, this version is an integer
+ representing the Firefox major version, e.g., `109`.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ targeting_env_current_date:
+ type: datetime
+ time_unit: day
+ description: >
+ The `environment.currentDate` of the default profile's serialized
+ targeting snapshot.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ targeting_env_profile_age:
+ type: datetime
+ time_unit: day
+ description: >
+ The `environment.profileAgeCreated` of the default profile's serialized
+ targeting snapshot.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1795467
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ final_state:
+ type: string
+ description: >
+ String description of the final state the update state machine reached.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ states:
+ type: string_list
+ description: >
+ Ordered list of string descriptions of the states that the update state
+ machine reached.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ reasons:
+ type: string_list
+ description: >
+ List of reasons that the background update task did not run.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ automatic_restart_attempted:
+ type: boolean
+ description: >
+ True if the background update task successfully attempted an automatic restart.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1847099
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1847099#c3
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ automatic_restart_success:
+ type: boolean
+ description: >
+ True if the background update task successfully restarted after
+ an automatic restart.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1847099
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1847099#c3
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ exit_code_success:
+ type: boolean
+ description: >
+ True if the exit code/status of the background update task is 0, which
+ means success.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ exit_code_exception:
+ type: boolean
+ description: >
+ True if the exit code/status of the background update task is 3, which
+ means an exception was thrown.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+update:
+ service_enabled:
+ type: boolean
+ description: >
+ Preference "app.update.service.enabled": whether the Mozilla Maintenance
+ Service is enabled.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ auto_download:
+ type: boolean
+ description: >
+ Per-installation preference "app.update.auto": whether to fetch and
+ install updates without user intervention.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ background_update:
+ type: boolean
+ description: >
+ Per-installation preference "app.update.background.enabled": whether to
+ fetch and install updates in the background when Firefox is not running.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ enabled:
+ type: boolean
+ description: >
+ True when policies are disabled or when the "DisableAppUpdate" is not in
+ effect.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ channel:
+ type: string
+ description: >
+ The update channel.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ can_usually_apply_updates:
+ type: boolean
+ description: >
+ Whether or not the Update Service can usually download and install
+ updates.
+ See `canUsuallyApplyUpdates` in
+ https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/nsIUpdateService.idl.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ can_usually_check_for_updates:
+ type: boolean
+ description: >
+ Whether or not the Update Service can usually check for updates.
+ See `canUsuallyCheckForUpdates` in
+ https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/nsIUpdateService.idl.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ can_usually_stage_updates:
+ type: boolean
+ description: >
+ Whether the Update Service is usually able to stage updates.
+ See `canUsuallyStageUpdates` in
+ https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/nsIUpdateService.idl.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
+
+ can_usually_use_bits:
+ type: boolean
+ description: >
+ On Windows, whether the Update Service can usually use BITS.
+ See `canUsuallyUseBits` in
+ https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/nsIUpdateService.idl.
+ lifetime: application
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - install-update@mozilla.com
+ expires: never
+ send_in_pings:
+ - background-update
diff --git a/toolkit/mozapps/update/moz.build b/toolkit/mozapps/update/moz.build
new file mode 100644
index 0000000000..dd9a1c1a91
--- /dev/null
+++ b/toolkit/mozapps/update/moz.build
@@ -0,0 +1,59 @@
+# -*- 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/.
+
+SPHINX_TREES["docs"] = "docs"
+
+XPIDL_MODULE = "update"
+
+DIRS += [
+ "updater",
+]
+
+XPIDL_SOURCES += [
+ "nsIUpdateService.idl",
+]
+
+TEST_DIRS += ["tests"]
+MARIONETTE_MANIFESTS += ["tests/marionette/marionette.toml"]
+
+EXTRA_COMPONENTS += [
+ "nsUpdateService.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "AppUpdater.sys.mjs",
+ "UpdateListener.sys.mjs",
+ "UpdateLog.sys.mjs",
+ "UpdateService.sys.mjs",
+ "UpdateServiceStub.sys.mjs",
+ "UpdateTelemetry.sys.mjs",
+]
+
+# This is Firefox-only for now simply because the `backgroundupdate` uses
+# `AppUpdater.sys.mjs`, which is Firefox-only. But there's nothing truly specific
+# to Firefox here: that module could be generalized to toolkit/, or the
+# functionality rewritten to consume App Update Service directly.
+if (
+ CONFIG["MOZ_BUILD_APP"] == "browser"
+ and CONFIG["MOZ_BACKGROUNDTASKS"]
+ and CONFIG["MOZ_UPDATE_AGENT"]
+):
+ EXTRA_JS_MODULES += [
+ "BackgroundUpdate.sys.mjs",
+ ]
+
+ EXTRA_JS_MODULES.backgroundtasks += [
+ "BackgroundTask_backgroundupdate.sys.mjs",
+ ]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Application Update")
diff --git a/toolkit/mozapps/update/nsIUpdateService.idl b/toolkit/mozapps/update/nsIUpdateService.idl
new file mode 100644
index 0000000000..ab1712587c
--- /dev/null
+++ b/toolkit/mozapps/update/nsIUpdateService.idl
@@ -0,0 +1,828 @@
+/* -*- Mode: IDL; 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 "nsISupports.idl"
+
+interface nsIRequest;
+interface nsIRequestObserver;
+interface nsISimpleEnumerator;
+interface nsIFile;
+
+webidl Element;
+webidl Document;
+
+/**
+ * An interface that describes an object representing a patch file that can
+ * be downloaded and applied to a version of this application so that it
+ * can be updated.
+ */
+[scriptable, uuid(dc8fb8a9-3a53-4031-9469-2a5197ea30e7)]
+interface nsIUpdatePatch : nsISupports
+{
+ /**
+ * The type of this patch:
+ * "partial" A binary difference between two application versions
+ * "complete" A complete patch containing all of the replacement files
+ * to update to the new version
+ */
+ readonly attribute AString type;
+
+ /**
+ * The URL this patch was being downloaded from
+ */
+ readonly attribute AString URL;
+
+ /**
+ * The final URL this patch was being downloaded from
+ */
+ attribute AString finalURL;
+
+ /**
+ * The size of this file, in bytes.
+ */
+ readonly attribute unsigned long size;
+
+ /**
+ * The state of this patch
+ */
+ attribute AString state;
+
+ /**
+ * A numeric error code that conveys additional information about the state of
+ * a failed update. If the update is not in the "failed" state the value is
+ * zero. The possible values are located in common/updatererrors.h and values between
+ * 80 and 99 are in nsUpdateService.js.
+ */
+ attribute long errorCode;
+
+ /**
+ * true if this patch is currently selected as the patch to be downloaded and
+ * installed for this update transaction, false if another patch from this
+ * update has been selected.
+ */
+ attribute boolean selected;
+
+ /**
+ * Serializes this patch object into a DOM Element
+ * @param updates
+ * The document to serialize into
+ * @returns The DOM Element created by the serialization process
+ */
+ Element serialize(in Document updates);
+};
+
+/**
+ * An interface that describes an object representing an available update to
+ * the current application - this update may have several available patches
+ * from which one must be selected to download and install, for example we
+ * might select a binary difference patch first and attempt to apply that,
+ * then if the application process fails fall back to downloading a complete
+ * file-replace patch. This object also contains information about the update
+ * that the front end and other application services can use to learn more
+ * about what is going on.
+ */
+[scriptable, uuid(e094c045-f4ff-41fd-92da-cd2effd2c7c9)]
+interface nsIUpdate : nsISupports
+{
+ /**
+ * The type of update:
+ * "major" A major new version of the Application
+ * "minor" A minor update to the Application (e.g. security update)
+ */
+ readonly attribute AString type;
+
+ /**
+ * The name of the update, or "<Application Name> <Update Version>"
+ */
+ readonly attribute AString name;
+
+ /**
+ * The string to display in the user interface for the version. If you want
+ * a real version number use appVersion.
+ */
+ readonly attribute AString displayVersion;
+
+ /**
+ * The Application version of this update.
+ */
+ readonly attribute AString appVersion;
+
+ /**
+ * The Application version prior to the application being updated.
+ */
+ readonly attribute AString previousAppVersion;
+
+ /**
+ * The Build ID of this update. Used to determine a particular build, down
+ * to the hour, minute and second of its creation. This allows the system
+ * to differentiate between several nightly builds with the same |version|
+ * for example.
+ */
+ readonly attribute AString buildID;
+
+ /**
+ * The URL to a page which offers details about the content of this
+ * update. Ideally, this page is not the release notes but some other page
+ * that summarizes the differences between this update and the previous,
+ * which also links to the release notes.
+ */
+ readonly attribute AString detailsURL;
+
+ /**
+ * The URL to the Update Service that supplied this update.
+ */
+ readonly attribute AString serviceURL;
+
+ /**
+ * The channel used to retrieve this update from the Update Service.
+ */
+ readonly attribute AString channel;
+
+ /**
+ * Whether the update is no longer supported on this system.
+ */
+ readonly attribute boolean unsupported;
+
+ /**
+ * Allows overriding the default amount of time in seconds before prompting the
+ * user to apply an update. If not specified, the value of
+ * app.update.promptWaitTime will be used.
+ */
+ attribute long long promptWaitTime;
+
+ /**
+ * Whether or not the update being downloaded is a complete replacement of
+ * the user's existing installation or a patch representing the difference
+ * between the new version and the previous version.
+ */
+ attribute boolean isCompleteUpdate;
+
+ /**
+ * When the update was installed.
+ */
+ attribute long long installDate;
+
+ /**
+ * A message associated with this update, if any.
+ */
+ attribute AString statusText;
+
+ /**
+ * The currently selected patch for this update.
+ */
+ readonly attribute nsIUpdatePatch selectedPatch;
+
+ /**
+ * The state of the selected patch:
+ * "downloading" The update is being downloaded.
+ * "pending" The update is ready to be applied.
+ * "pending-service" The update is ready to be applied with the service.
+ * "pending-elevate" The update is ready to be applied but requires elevation.
+ * "applying" The update is being applied.
+ * "applied" The update is ready to be switched to.
+ * "applied-os" The update is OS update and to be installed.
+ * "applied-service" The update is ready to be switched to with the service.
+ * "succeeded" The update was successfully applied.
+ * "download-failed" The update failed to be downloaded.
+ * "failed" The update failed to be applied.
+ */
+ attribute AString state;
+
+ /**
+ * A numeric error code that conveys additional information about the state of
+ * a failed update. If the update is not in the "failed" state the value is
+ * zero. The possible values are located in common/updatererrors.h and values between
+ * 80 and 99 are in nsUpdateService.js.
+ */
+ attribute long errorCode;
+
+ /**
+ * Whether an elevation failure has been encountered for this update.
+ */
+ attribute boolean elevationFailure;
+
+ /**
+ * The number of patches supplied by this update.
+ */
+ readonly attribute unsigned long patchCount;
+
+ /**
+ * Retrieves a patch.
+ * @param index
+ * The index of the patch to retrieve.
+ * @returns The nsIUpdatePatch at the specified index.
+ */
+ nsIUpdatePatch getPatchAt(in unsigned long index);
+
+ /**
+ * Serializes this update object into a DOM Element
+ * @param updates
+ * The document to serialize into
+ * @returns The DOM Element created by the serialization process
+ */
+ Element serialize(in Document updates);
+};
+
+/**
+ * An interface describing the result of an update check.
+ */
+[scriptable, uuid(bff08110-e79f-4a9f-a56c-348170f9208a)]
+interface nsIUpdateCheckResult : nsISupports
+{
+ /**
+ * True if update checks are allowed. otherwise false.
+ */
+ readonly attribute boolean checksAllowed;
+
+ /**
+ * True if the update check succeeded, otherwise false. Guaranteed to be false
+ * if checksAllowed is false.
+ */
+ readonly attribute boolean succeeded;
+
+ /**
+ * The XMLHttpRequest handling the update check. Depending on exactly how the
+ * check failed, it's possible for this to be null.
+ */
+ readonly attribute jsval request;
+
+ /**
+ * If `!checksAllowed`, this will always be an empty array.
+ *
+ * If `succeeded`, this will be an array of nsIUpdate objects listing
+ * available updates. The length will be 0 if there are no available updates.
+ *
+ * If `checksAllowed && !succeeded`, this will be an array containing exactly
+ * one nsIUpdate object. Most of the attributes will have no useful value
+ * since we did not successfully retrieve an update, but `errorCode` and
+ * `statusText` will be set to values that describe the error encountered when
+ * checking for updates.
+ */
+ readonly attribute Array<nsIUpdate> updates;
+};
+
+/**
+ * An interface describing an update check that may still be in-progress or may
+ * be completed.
+ */
+[scriptable, uuid(2620aa24-27aa-463a-b6d2-0734695c1f7a)]
+interface nsIUpdateCheck : nsISupports
+{
+ /**
+ * An id that represents a particular update check. Can be passed to
+ * nsIUpdateChecker::stopCheck.
+ *
+ * Ids are guaranteed to be truthy (non-zero) and non-repeating. This is
+ * just for caller convenience so that (a) it's not an error to cancel a check
+ * that already completed and (b) they can easily check `if (idVar)` to see if
+ * they stored an id.
+ */
+ readonly attribute long id;
+
+ /**
+ * A promise that resolves to the results of the update check, which will be
+ * of type nsIUpdateCheckResult.
+ */
+ readonly attribute Promise result;
+};
+
+/**
+ * An interface describing an object that knows how to check for updates. It can
+ * perform multiple update checks simultaneously or consolidate multiple check
+ * requests into a single web request, depending on whether the parameters
+ * specified for update checking match.
+ */
+[scriptable, uuid(877ace25-8bc5-452a-8586-9c1cf2871994)]
+interface nsIUpdateChecker : nsISupports
+{
+ /**
+ * Enumerated constants. See the `checkType` parameter of `checkForUpdates`
+ * for details.
+ */
+ const long BACKGROUND_CHECK = 1;
+ const long FOREGROUND_CHECK = 2;
+
+ /**
+ * Checks for available updates.
+ * @param checkType
+ * Must be either BACKGROUND_CHECK or FOREGROUND_CHECK. If
+ * FOREGROUND_CHECK is specified, the normal
+ * nsIApplicationUpdateService.canCheckForUpdates check will be
+ * overridden and the "force" parameter will be included in the
+ * update URL.
+ *
+ * Regarding the "force" parameter:
+ * Sometimes the update server throttles updates, arbitrarily
+ * refraining from returning the newest version to some clients. The
+ * force parameter overrides this behavior and tells it to
+ * unconditionally return the newest available version.
+ *
+ * It's worth noting that the update server technically supports
+ * forcing the decision in the other direction too, preventing
+ * the newest version from being returned, but this interface doesn't
+ * actually support setting the force parameter this way. If the
+ * force parameter is used, it always forces getting the newest
+ * version.
+ * @returns An nsIUpdateCheck object that describes the update check and
+ * provides a Promise that resolves to the update check results.
+ */
+ nsIUpdateCheck checkForUpdates(in long checkType);
+
+ /**
+ * Gets the update URL.
+ * @param checkType
+ * Must be either BACKGROUND_CHECK or FOREGROUND_CHECK. See the
+ * checkType parameter of nsIUpdateChecker.checkForUpdates for more
+ * details.
+ * @returns A Promise that resolves to the URL to be used to check for
+ * updates, as a string. This URL should resolve to an XML describing
+ * the updates that are available to the current Firefox
+ * installation.
+ */
+ Promise getUpdateURL(in long checkType);
+
+ /**
+ * Ends a pending update check. Has no effect if the id is invalid or the
+ * check corresponding to the id has already completed.
+ *
+ * Note that because `nsIUpdateChecker` potentially combines multiple update
+ * checks, it is not guaranteed that this will actually cause the update
+ * request to be aborted. It also doesn't guarantee that
+ * `nsIUpdateCheck.result` will resolve when this is called. This merely marks
+ * the check id as cancelled and only if there are no other check ids waiting
+ * on the request does it abort it.
+ *
+ * @param id
+ * The id of a check to stop (accessible via nsIUpdateCheck).
+ */
+ void stopCheck(in long id);
+
+ /**
+ * Ends all pending update checks.
+ */
+ void stopAllChecks();
+};
+
+/**
+ * An interface describing a global application service that handles performing
+ * background update checks and provides utilities for selecting and
+ * downloading update patches.
+ */
+[scriptable, uuid(1107d207-a263-403a-b268-05772ec10757)]
+interface nsIApplicationUpdateService : nsISupports
+{
+ /**
+ * Checks for available updates in the background using the listener provided
+ * by the application update service for background checks.
+ * @returns true if the update check was started, false if not. Note that the
+ * check starting does not necessarily mean that the check will
+ * succeed or that an update will be downloaded.
+ */
+ bool checkForBackgroundUpdates();
+
+ /**
+ * Selects the best update to install from a list of available updates.
+ * @param updates
+ * An array of updates that are available
+ */
+ nsIUpdate selectUpdate(in Array<nsIUpdate> updates);
+
+ /**
+ * Adds a listener that receives progress and state information about the
+ * update that is currently being downloaded, e.g. to update a user
+ * interface. Registered listeners will be called for all downloads and all
+ * updates during a browser session; they are not automatically removed
+ * following the first (successful or failed) download.
+ * @param listener
+ * An object implementing nsIRequestObserver and optionally
+ * nsIProgressEventSink that is to be notified of state and
+ * progress information as the update is downloaded.
+ */
+ void addDownloadListener(in nsIRequestObserver listener);
+
+ /**
+ * Removes a listener that is receiving progress and state information
+ * about the update that is currently being downloaded.
+ * @param listener
+ * The listener object to remove.
+ */
+ void removeDownloadListener(in nsIRequestObserver listener);
+
+ /**
+ * Starts downloading the update passed. Once the update is downloaded, it
+ * will automatically be prepared for installation.
+ *
+ * @param update
+ * The update to download.
+ * @returns A promise that resolves to `true` if an update download was
+ * started, otherwise `false.
+ */
+ Promise downloadUpdate(in nsIUpdate update);
+
+ /**
+ * This is the function called internally by the Application Update Service
+ * when an update check is complete. Though this can be used to potentially
+ * start an update download, `downloadUpdate` should used for that.
+ * This is mostly exposed in the interface in order to make it accessible for
+ * testing.
+ */
+ Promise onCheckComplete(in nsIUpdateCheckResult result);
+
+ /**
+ * Stop the active update download process. This is the equivalent of
+ * calling nsIRequest::Cancel on the download's nsIRequest. When downloading
+ * with nsIIncrementalDownload, this will leave the partial download in place.
+ * When downloading with BITS, any partial download progress will be removed.
+ *
+ * @returns A Promise that resolves once the download has been stopped.
+ */
+ Promise stopDownload();
+
+ /**
+ * There are a few things that can disable the Firefox updater at runtime
+ * such as Enterprise Policies. If this attribute is set to true, update
+ * should not be performed and most update interfaces will return errors.
+ */
+ readonly attribute boolean disabled;
+
+ /**
+ * Whether or not the Update Service can usually check for updates. This is a
+ * function of whether or not application update is disabled by the
+ * application and the platform the application is running on.
+ */
+ readonly attribute boolean canUsuallyCheckForUpdates;
+
+ /**
+ * Whether or not the Update Service can check for updates right now. This is
+ * a function of whether or not application update is disabled by the
+ * application, the platform the application is running on, and transient
+ * factors such as whether other instances are running.
+ */
+ readonly attribute boolean canCheckForUpdates;
+
+ /**
+ * Whether or not the installation requires elevation. Currently only
+ * implemented on OSX, returns false on other platforms.
+ */
+ readonly attribute boolean elevationRequired;
+
+ /**
+ * Whether or not the Update Service can usually download and install updates.
+ * On Windows, this is a function of whether or not the maintenance service
+ * is installed and enabled. On other systems, and as a fallback on Windows,
+ * this depends on whether the current user has write access to the install
+ * directory.
+ */
+ readonly attribute boolean canUsuallyApplyUpdates;
+
+ /**
+ * Whether or not the Update Service can download and install updates right now.
+ * On Windows, this is a function of whether or not the maintenance service
+ * is installed and enabled. On other systems, and as a fallback on Windows,
+ * this depends on whether the current user has write access to the install
+ * directory. On all systems, this includes transient factors such as whether
+ * other instances are running.
+ */
+ readonly attribute boolean canApplyUpdates;
+
+ /**
+ * Whether or not a different instance is handling updates of this
+ * installation. This currently only ever returns true on Windows
+ * when 2 instances of an application are open. Only one of the instances
+ * will actually handle updates for the installation.
+ */
+ readonly attribute boolean isOtherInstanceHandlingUpdates;
+
+ /**
+ * Whether the Update Service is usually able to stage updates.
+ */
+ readonly attribute boolean canUsuallyStageUpdates;
+
+ /**
+ * Whether the Update Service is able to stage updates right now. On all
+ * systems, this includes transient factors such as whether other instances
+ * are running.
+ */
+ readonly attribute boolean canStageUpdates;
+
+ /**
+ * On Windows, whether the Update Service can usually use BITS.
+ */
+ readonly attribute boolean canUsuallyUseBits;
+
+ /**
+ * On Windows, whether the Update Service can use BITS right now. This
+ * includes transient factors such as whether other instances are running.
+ */
+ readonly attribute boolean canUseBits;
+
+ /**
+ * Indicates whether or not the enterprise policy that allows only manual
+ * updating is active. One of the features of this policy is not being
+ * notified of updates; you are intended to need to manually tell Firefox
+ * that you want to update each time that you want to do so.
+ *
+ * This policy has some implications for the way that update checks work. We
+ * don't want to do background update checks. Without being able to notify
+ * the user, there's not really anything to do if we find one. However, we
+ * will allow "automatic" update checks when loading the update interfaces
+ * in about:preferences, the About Dialog, etc. When those interfaces are
+ * open, we do have a way of telling the user about an update without
+ * bothering them with a doorhanger.
+ */
+ readonly attribute boolean manualUpdateOnly;
+
+ /**
+ * Determines if the base directory is writable. If not, we assume that
+ * further permissions are required and that we are dealing with an elevated
+ * installation.
+ */
+ readonly attribute boolean isAppBaseDirWritable;
+
+ /**
+ * This can be set to true to prevent updates being processed beyond starting
+ * an update download. This should only be used when we are being run as a
+ * background task.
+ * This exists to prevent a particularly fast update download from beginning
+ * to stage while the background task is shutting down.
+ */
+ attribute boolean onlyDownloadUpdatesThisSession;
+
+ /**
+ * Enumerated constants describing the update states that the updater can be
+ * in.
+ * Note that update checking is not part of the states that this interface
+ * can recognize and report. This is for two reasons: 1) there are multiple
+ * kinds of update checks (ex: foreground and background) and it would make
+ * the current update state more complicated if we wanted to track both, and
+ * 2) there isn't really any reason to concern ourselves with current update
+ * checks because nsIUpdateChecker will seamlessly combine multiple identical
+ * update checks into a single request without the caller having to worry
+ * about its internal state.
+ */
+ // An update download hasn't started yet, or we failed at some point in the
+ // update process and aborted.
+ const long STATE_IDLE = 1;
+ // An update is currently being downloaded.
+ // This state begins once nsIApplicationUpdateService.downloadUpdate resolves
+ // to `true`.
+ // Note that we may be downloading an update but not be in this state because
+ // we can download a second update while we have an update ready. But there
+ // isn't much reason for currentState to track the second update. We know that
+ // it will always be in the downloading state until it finishes downloading
+ // and becomes the ready update. See STATE_UPDATE_SWAP for more details.
+ const long STATE_DOWNLOADING = 2;
+ // An update is currently being staged. Note that we do not always stage
+ // updates.
+ const long STATE_STAGING = 4;
+ // An update is pending. If the browser restarts now, it will be installed.
+ // Note that although "pending" is one of the potential update statuses, this
+ // does not correspond to exactly that status.
+ const long STATE_PENDING = 5;
+ // We had an update pending. Then we downloaded another update. Now we are
+ // in the process of removing the old update and swapping the new update into
+ // its place.
+ // Note that when the state initially changes to `STATE_SWAP`, the new update
+ // will be in `nsIUpdateManager.downloadingUpdate` but it will be moved into
+ // `nsIUpdateManager.readyUpdate` before moving to the next state.
+ const long STATE_SWAP = 6;
+
+ /**
+ * Gets a string describing the state (mostly intended to be make console
+ * logs easier to read).
+ */
+ AString getStateName(in long state);
+
+ /**
+ * The current state of the application updater. Returns one of the enumerated
+ * constants, above.
+ *
+ * The expected flow looks like this:
+ * STATE_IDLE -> STATE_DOWNLOADING -> STATE_STAGING -> STATE_PENDING
+ * If a failure is encountered at some time, we go back to STATE_IDLE.
+ * If staging is not enabled, STATE_STAGING will be skipped.
+ *
+ * We may download additional updates after we reach STATE_PENDING. If we do,
+ * the state will remain at STATE_PENDING while we download the new update. If
+ * we restart during that time, the pending update will be installed and the
+ * partially downloaded update will be discarded. If a download completes
+ * successfully, there will be a brief period where STATE_PENDING is no longer
+ * correct, because the Update Service is in the process of removing the old
+ * update and replacing it with the new update. So if we restart during that
+ * period, the update will not be correctly installed. Thus, we switch away
+ * from STATE_PENDING to STATE_SWAP during that time. Assuming that the swap
+ * is successful, the state will then switch back STATE_STAGING (assuming that
+ * staging is enabled), then to STATE_PENDING. So the full expected state flow
+ * looks more like this:
+ * STATE_IDLE -> STATE_DOWNLOADING -> STATE_STAGING -> STATE_PENDING ->
+ * STATE_SWAP -> STATE_STAGING -> STATE_PENDING ->
+ * STATE_SWAP -> STATE_STAGING -> STATE_PENDING -> ...
+ * (Omitting STATE_STAGING if staging is not enabled).
+ */
+ readonly attribute long currentState;
+
+ /**
+ * A Promise that resolves immediately after `currentState` changes.
+ */
+ readonly attribute Promise stateTransition;
+};
+
+/**
+ * An interface describing a component which handles the job of processing
+ * an update after it's been downloaded.
+ */
+[scriptable, uuid(74439497-d796-4915-8cef-3dfe43027e4d)]
+interface nsIUpdateProcessor : nsISupports
+{
+ /**
+ * Stages an update while the application is running.
+ */
+ void processUpdate();
+
+ /**
+ * The installer writes an installation-specific registry key if the
+ * Maintenance Service can be used for this installation. This function checks
+ * for that key's existence (it does not read or verify the key's contents).
+ *
+ * This function should only be called on Windows.
+ *
+ * @returns true if the registry key exists, false if it does not.
+ * @throws NS_ERROR_NOT_AVAILABLE
+ * If registry access fails.
+ * @throws NS_ERROR_NOT_IMPLEMENTED
+ * If this is called on a non-Windows platform.
+ */
+ bool getServiceRegKeyExists();
+
+ /**
+ * Attempts to restart the application manually on program exit with the same
+ * arguments it was started with, while accepting additional arguments.
+ *
+ * This function should only be called on Windows.
+ *
+ * @param argvExtra
+ * An array of strings to be passed to the application upon
+ * restart as additional arguments.
+ * @returns pidRet
+ * Returns the pid of a newly spawned child process. This value
+ * is only valid if the function returns successfully.
+ * @throws NS_ERROR_ABORT
+ * If the child process failed to spawn correctly.
+ * @throws NS_ERROR_NOT_IMPLEMENTED
+ * If this is called on a non-Windows platform.
+ * @throws NS_ERROR_NOT_AVAILABLE
+ * If the command line cannot be read.
+ */
+ long attemptAutomaticApplicationRestartWithLaunchArgs(in Array<AString> argvExtra);
+
+ /**
+ * This function is meant to be used in conjunction with
+ * RegisterApplicationRestartWithLaunchArgs() if you want the child process
+ * that invokes this function to wait for the parent process
+ * to finish execution. When the application has the argument
+ * -restart-pid <pid> this function waits for the application with
+ * <pid> to exit.
+ *
+ * This function should only be called on Windows.
+ *
+ * @param pid
+ * Which process ID to wait for.
+ * @param timeoutMS
+ * How long to wait for the process to exit in milliseconds.
+ * @throws NS_OK
+ * On successful wait.
+ * @throws NS_ERROR_NOT_IMPLEMENTED
+ * If this is called on a non-Windows platform.
+ * @throws NS_ERROR_INVALID_ARG
+ * If -restart-pid has no pid parameter.
+ * @throws NS_ERROR_ILLEGAL_VALUE
+ * If pid cannot be converted into unsigned int.
+ * @throws NS_ERROR_FAILURE
+ * If timeout elapses without process exit.
+ */
+ void waitForProcessExit(in unsigned long pid, in unsigned long timeoutMS);
+};
+
+/**
+ * Upon creation, which should happen early during startup, the sync manager
+ * creates/opens and locks a file. All other running instances of the same
+ * installation of the app also open the same lock, so we can use it to
+ * determine whether any other instance is running. If so, we'll temporarily
+ * hold off on performing update tasks until there are no other instances or
+ * until a timeout expires, whichever comes first. That way we can avoid
+ * updating behind the back of copies that are still running, so we don't force
+ * all running instances to restart (see bug 1366808, where an error was added
+ * informing the user of the need to restart any running instances that have
+ * been updated).
+ */
+[scriptable, uuid(cf4c4487-66d9-4e18-a2e9-39002245332f)]
+interface nsIUpdateSyncManager : nsISupports
+{
+ /**
+ * Returns whether another instance of this application is running.
+ * @returns true if another instance has the lock open, false if not
+ */
+ bool isOtherInstanceRunning();
+
+ /**
+ * Should only be used for testing.
+ *
+ * Closes and reopens the lock file, possibly under a different name if a
+ * parameter is given (or the path hash has changed, which should only happen
+ * if a test is forcing it).
+ */
+ void resetLock([optional] in nsIFile anAppFile);
+};
+
+/**
+ * An interface describing a global application service that maintains a list
+ * of updates previously performed as well as the current active update.
+ */
+[scriptable, uuid(0f1098e9-a447-4af9-b030-6f8f35c85f89)]
+interface nsIUpdateManager : nsISupports
+{
+ /**
+ * Gets the update at the specified index
+ * @param index
+ * The index within the updates array
+ * @returns The nsIUpdate object at the specified index
+ */
+ nsIUpdate getUpdateAt(in long index);
+
+ /**
+ * Gets the total number of updates in the history list.
+ */
+ long getUpdateCount();
+
+ /**
+ * The update that has been downloaded, or null if there isn't one.
+ */
+ attribute nsIUpdate readyUpdate;
+
+ /**
+ * The update that is currently downloading, or null if there isn't one.
+ * An update is no longer considered to be downloading once onStopRequest is
+ * called. This means that both onStopRequest handlers for download listeners
+ * and observers of the "update-downloaded" topic should expect the update
+ * that was just downloaded to be stored in readyUpdate, not
+ * downloadingUpdate.
+ */
+ attribute nsIUpdate downloadingUpdate;
+
+ /**
+ * Adds the specified update to the update history. The update history is
+ * limited to 10 items, so this may also remove the last item from the
+ * history.
+ */
+ void addUpdateToHistory(in nsIUpdate update);
+
+ /**
+ * Saves all updates to disk.
+ */
+ void saveUpdates();
+
+ /**
+ * Refresh the update status based on the information in update.status.
+ *
+ * @returns A Promise that resolves after the update status is refreshed.
+ */
+ Promise refreshUpdateStatus();
+
+ /**
+ * The user agreed to proceed with an elevated update and we are now
+ * permitted to show an elevation prompt.
+ */
+ void elevationOptedIn();
+
+ /**
+ * These functions both clean up and remove an active update without applying
+ * it. The first function does this for the update that is currently being
+ * downloaded. The second function does this for the update that has already
+ * been downloaded.
+ */
+ void cleanupDownloadingUpdate();
+ void cleanupReadyUpdate();
+
+ /**
+ * Runs cleanup that ought to happen on a Firefox paveover install to
+ * prevent a stale update from being processed when Firefox is first
+ * launched.
+ * This is best-effort. It will not throw on cleanup failure.
+ *
+ * The returned promise does not resolve with any particular value. It simply
+ * conveys that the cleanup has completed.
+ */
+ Promise doInstallCleanup();
+
+ /**
+ * Runs cleanup that ought to happen when Firefox is uninstalled to clean up
+ * old update data that is no longer needed.
+ * This is best-effort. It will not throw on cleanup failure.
+ *
+ * The returned promise does not resolve with any particular value. It simply
+ * conveys that the cleanup has completed.
+ */
+ Promise doUninstallCleanup();
+};
diff --git a/toolkit/mozapps/update/nsUpdateService.manifest b/toolkit/mozapps/update/nsUpdateService.manifest
new file mode 100644
index 0000000000..a8d2534b1e
--- /dev/null
+++ b/toolkit/mozapps/update/nsUpdateService.manifest
@@ -0,0 +1 @@
+category update-timer nsUpdateService @mozilla.org/updates/update-service;1,getService,background-update-timer,app.update.interval,43200,86400
diff --git a/toolkit/mozapps/update/pings.yaml b/toolkit/mozapps/update/pings.yaml
new file mode 100644
index 0000000000..542f8916d8
--- /dev/null
+++ b/toolkit/mozapps/update/pings.yaml
@@ -0,0 +1,35 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+---
+$schema: moz://mozilla.org/schemas/glean/pings/2-0-0
+
+background-update:
+ description: |
+ This ping measures the technical health of the background update system.
+ Said system downloads and processes updates when Firefox is not running. It
+ is expected that this ping will be analyzed by humans to gain confidence in
+ the implementation as the staged rollout of the system proceeds to the
+ release channel, before settling into an automated analysis to detect spikes
+ in background update failure rates. This ping will also help to
+ characterize the update-related settings of our user population.
+
+ Right now the background update system, and therefore this ping, is
+ restricted to Windows.
+
+ This ping is submitted only by the background update task. It should be
+ submitted once per background update task invocation. The expected schedule
+ is every 7 hours, controlled by the pref `app.update.background.interval`,
+ and subject to scheduling decisions made by the OS.
+ include_client_id: true
+ send_if_empty: false
+ reasons:
+ backgroundupdate_task: |
+ The ping was sent as part of the normal background update task execution.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1703318#c17
+ notification_emails:
+ - install-update@mozilla.com
diff --git a/toolkit/mozapps/update/tests/Makefile.in b/toolkit/mozapps/update/tests/Makefile.in
new file mode 100644
index 0000000000..b07afbb0a9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/Makefile.in
@@ -0,0 +1,13 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.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 MOZ_WINCONSOLE
+ifdef MOZ_DEBUG
+MOZ_WINCONSOLE = 1
+else
+MOZ_WINCONSOLE = 0
+endif
+endif
+
+include $(topsrcdir)/config/rules.mk
diff --git a/toolkit/mozapps/update/tests/TestAUSHelper.cpp b/toolkit/mozapps/update/tests/TestAUSHelper.cpp
new file mode 100644
index 0000000000..d1b32e3caf
--- /dev/null
+++ b/toolkit/mozapps/update/tests/TestAUSHelper.cpp
@@ -0,0 +1,462 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+#include "updatedefines.h"
+
+#ifdef XP_WIN
+# include "commonupdatedir.h"
+# include "updatehelper.h"
+# include "certificatecheck.h"
+# define NS_main wmain
+# define NS_tgetcwd _wgetcwd
+# define NS_ttoi _wtoi
+#else
+# define NS_main main
+# define NS_tgetcwd getcwd
+# define NS_ttoi atoi
+#endif
+
+#include <stdlib.h>
+#include <stdio.h>
+#include <errno.h>
+#include <string.h>
+#include <sys/stat.h>
+
+static void WriteMsg(const NS_tchar* path, const char* status) {
+ FILE* outFP = NS_tfopen(path, NS_T("wb"));
+ if (!outFP) {
+ return;
+ }
+
+ fprintf(outFP, "%s\n", status);
+ fclose(outFP);
+ outFP = nullptr;
+}
+
+static bool CheckMsg(const NS_tchar* path, const char* expected) {
+ FILE* inFP = NS_tfopen(path, NS_T("rb"));
+ if (!inFP) {
+ return false;
+ }
+
+ struct stat ms;
+ if (fstat(fileno(inFP), &ms)) {
+ fclose(inFP);
+ inFP = nullptr;
+ return false;
+ }
+
+ char* mbuf = (char*)malloc(ms.st_size + 1);
+ if (!mbuf) {
+ fclose(inFP);
+ inFP = nullptr;
+ return false;
+ }
+
+ size_t r = ms.st_size;
+ char* rb = mbuf;
+ size_t c = fread(rb, sizeof(char), 50, inFP);
+ r -= c;
+ if (c == 0 && r) {
+ free(mbuf);
+ fclose(inFP);
+ inFP = nullptr;
+ return false;
+ }
+ mbuf[ms.st_size] = '\0';
+ rb = mbuf;
+
+ bool isMatch = strcmp(rb, expected) == 0;
+ free(mbuf);
+ fclose(inFP);
+ inFP = nullptr;
+ return isMatch;
+}
+
+int NS_main(int argc, NS_tchar** argv) {
+ if (argc == 2) {
+ if (!NS_tstrcmp(argv[1], NS_T("post-update-async")) ||
+ !NS_tstrcmp(argv[1], NS_T("post-update-sync"))) {
+ NS_tchar exePath[MAXPATHLEN];
+#ifdef XP_WIN
+ if (!::GetModuleFileNameW(0, exePath, MAXPATHLEN)) {
+ return 1;
+ }
+#else
+ if (!NS_tvsnprintf(exePath, sizeof(exePath) / sizeof(exePath[0]),
+ NS_T("%s"), argv[0])) {
+ return 1;
+ }
+#endif
+ NS_tchar runFilePath[MAXPATHLEN];
+ if (!NS_tvsnprintf(runFilePath,
+ sizeof(runFilePath) / sizeof(runFilePath[0]),
+ NS_T("%s.running"), exePath)) {
+ return 1;
+ }
+#ifdef XP_WIN
+ if (!NS_taccess(runFilePath, F_OK)) {
+ // This makes it possible to check if the post update process was
+ // launched twice which happens when the service performs an update.
+ NS_tchar runFilePathBak[MAXPATHLEN];
+ if (!NS_tvsnprintf(runFilePathBak,
+ sizeof(runFilePathBak) / sizeof(runFilePathBak[0]),
+ NS_T("%s.bak"), runFilePath)) {
+ return 1;
+ }
+ MoveFileExW(runFilePath, runFilePathBak, MOVEFILE_REPLACE_EXISTING);
+ }
+#endif
+ WriteMsg(runFilePath, "running");
+
+ if (!NS_tstrcmp(argv[1], NS_T("post-update-sync"))) {
+#ifdef XP_WIN
+ Sleep(2000);
+#else
+ sleep(2);
+#endif
+ }
+
+ NS_tchar logFilePath[MAXPATHLEN];
+ if (!NS_tvsnprintf(logFilePath,
+ sizeof(logFilePath) / sizeof(logFilePath[0]),
+ NS_T("%s.log"), exePath)) {
+ return 1;
+ }
+ WriteMsg(logFilePath, "post-update");
+ return 0;
+ }
+ }
+
+ if (argc < 3) {
+ fprintf(
+ stderr,
+ "\n"
+ "Application Update Service Test Helper\n"
+ "\n"
+ "Usage: WORKINGDIR INFILE OUTFILE -s SECONDS [FILETOLOCK]\n"
+ " or: WORKINGDIR LOGFILE [ARG2 ARG3...]\n"
+ " or: signature-check filepath\n"
+ " or: setup-symlink dir1 dir2 file symlink\n"
+ " or: remove-symlink dir1 dir2 file symlink\n"
+ " or: check-symlink symlink\n"
+ " or: check-umask existing-umask\n"
+ " or: post-update\n"
+ " or: create-update-dir\n"
+ "\n"
+ " WORKINGDIR \tThe relative path to the working directory to use.\n"
+ " INFILE \tThe relative path from the working directory for the "
+ "file to\n"
+ " \tread actions to perform such as finish.\n"
+ " OUTFILE \tThe relative path from the working directory for the "
+ "file to\n"
+ " \twrite status information.\n"
+ " SECONDS \tThe number of seconds to sleep.\n"
+ " FILETOLOCK \tThe relative path from the working directory to an "
+ "existing\n"
+ " \tfile to open exlusively.\n"
+ " \tOnly available on Windows platforms and silently "
+ "ignored on\n"
+ " \tother platforms.\n"
+ " LOGFILE \tThe relative path from the working directory to log "
+ "the\n"
+ " \tcommand line arguments.\n"
+ " ARG2 ARG3...\tArguments to write to the LOGFILE after the preceding "
+ "command\n"
+ " \tline arguments.\n"
+ "\n"
+ "Note: All paths must be relative.\n"
+ "\n");
+ return 1;
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("check-signature"))) {
+#if defined(XP_WIN) && defined(MOZ_MAINTENANCE_SERVICE)
+ if (ERROR_SUCCESS == VerifyCertificateTrustForFile(argv[2])) {
+ return 0;
+ } else {
+ return 1;
+ }
+#else
+ // Not implemented on non-Windows platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("setup-symlink"))) {
+#ifdef XP_UNIX
+ NS_tchar path[MAXPATHLEN];
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]), NS_T("%s/%s"),
+ NS_T("/tmp"), argv[2])) {
+ return 1;
+ }
+ if (mkdir(path, 0755)) {
+ return 1;
+ }
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]), NS_T("%s/%s/%s"),
+ NS_T("/tmp"), argv[2], argv[3])) {
+ return 1;
+ }
+ if (mkdir(path, 0755)) {
+ return 1;
+ }
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]),
+ NS_T("%s/%s/%s/%s"), NS_T("/tmp"), argv[2], argv[3],
+ argv[4])) {
+ return 1;
+ }
+ FILE* file = NS_tfopen(path, NS_T("w"));
+ if (file) {
+ fputs(NS_T("test"), file);
+ fclose(file);
+ }
+ if (symlink(path, argv[5]) != 0) {
+ return 1;
+ }
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]), NS_T("%s/%s"),
+ NS_T("/tmp"), argv[2])) {
+ return 1;
+ }
+ if (argc > 6 && !NS_tstrcmp(argv[6], NS_T("change-perm"))) {
+ if (chmod(path, 0644)) {
+ return 1;
+ }
+ }
+ return 0;
+#else
+ // Not implemented on non-Unix platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("remove-symlink"))) {
+#ifdef XP_UNIX
+ // The following can be called at the start of a test in case these symlinks
+ // need to be removed if they already exist and at the end of a test to
+ // remove the symlinks created by the test so ignore file doesn't exist
+ // errors.
+ NS_tchar path[MAXPATHLEN];
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]), NS_T("%s/%s"),
+ NS_T("/tmp"), argv[2])) {
+ return 1;
+ }
+ if (chmod(path, 0755) && errno != ENOENT) {
+ return 1;
+ }
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]),
+ NS_T("%s/%s/%s/%s"), NS_T("/tmp"), argv[2], argv[3],
+ argv[4])) {
+ return 1;
+ }
+ if (unlink(path) && errno != ENOENT) {
+ return 1;
+ }
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]), NS_T("%s/%s/%s"),
+ NS_T("/tmp"), argv[2], argv[3])) {
+ return 1;
+ }
+ if (rmdir(path) && errno != ENOENT) {
+ return 1;
+ }
+ if (!NS_tvsnprintf(path, sizeof(path) / sizeof(path[0]), NS_T("%s/%s"),
+ NS_T("/tmp"), argv[2])) {
+ return 1;
+ }
+ if (rmdir(path) && errno != ENOENT) {
+ return 1;
+ }
+ return 0;
+#else
+ // Not implemented on non-Unix platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("check-symlink"))) {
+#ifdef XP_UNIX
+ struct stat ss;
+ if (lstat(argv[2], &ss)) {
+ return 1;
+ }
+ return S_ISLNK(ss.st_mode) ? 0 : 1;
+#else
+ // Not implemented on non-Unix platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("check-umask"))) {
+#ifdef XP_UNIX
+ // Discover the current value of the umask. There is no way to read the
+ // umask without changing it. The system call is specified as unable to
+ // fail.
+ uint32_t umask = ::umask(0777);
+ ::umask(umask);
+
+ NS_tchar logFilePath[MAXPATHLEN];
+ if (!NS_tvsnprintf(logFilePath,
+ sizeof(logFilePath) / sizeof(logFilePath[0]), NS_T("%s"),
+ argv[2])) {
+ return 1;
+ }
+
+ FILE* logFP = NS_tfopen(logFilePath, NS_T("wb"));
+ if (!logFP) {
+ return 1;
+ }
+ fprintf(logFP, "check-umask\numask-%d\n", umask);
+
+ fclose(logFP);
+ logFP = nullptr;
+
+ return 0;
+#else
+ // Not implemented on non-Unix platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("wait-for-service-stop"))) {
+#if defined(XP_WIN) && defined(MOZ_MAINTENANCE_SERVICE)
+ const int maxWaitSeconds = NS_ttoi(argv[3]);
+ LPCWSTR serviceName = argv[2];
+ DWORD serviceState = WaitForServiceStop(serviceName, maxWaitSeconds);
+ if (SERVICE_STOPPED == serviceState) {
+ return 0;
+ } else {
+ return serviceState;
+ }
+#else
+ // Not implemented on non-Windows platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("wait-for-application-exit"))) {
+#ifdef XP_WIN
+ const int maxWaitSeconds = NS_ttoi(argv[3]);
+ LPCWSTR application = argv[2];
+ DWORD ret = WaitForProcessExit(application, maxWaitSeconds);
+ if (ERROR_SUCCESS == ret) {
+ return 0;
+ } else if (WAIT_TIMEOUT == ret) {
+ return 1;
+ } else {
+ return 2;
+ }
+#else
+ // Not implemented on non-Windows platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("launch-service"))) {
+#if defined(XP_WIN) && defined(MOZ_MAINTENANCE_SERVICE)
+ DWORD ret =
+ LaunchServiceSoftwareUpdateCommand(argc - 2, (LPCWSTR*)argv + 2);
+ if (ret != ERROR_SUCCESS) {
+ // 192 is used to avoid reusing a possible return value from the call to
+ // WaitForServiceStop
+ return 0x000000C0;
+ }
+ // Wait a maximum of 120 seconds.
+ DWORD lastState = WaitForServiceStop(SVC_NAME, 120);
+ if (SERVICE_STOPPED == lastState) {
+ return 0;
+ }
+ return lastState;
+#else
+ // Not implemented on non-Windows platforms
+ return 1;
+#endif
+ }
+
+ if (!NS_tstrcmp(argv[1], NS_T("create-update-dir"))) {
+#ifdef XP_WIN
+ mozilla::UniquePtr<wchar_t[]> updateDir;
+ HRESULT result = GetCommonUpdateDirectory(argv[2], updateDir);
+ return SUCCEEDED(result) ? 0 : 1;
+#else
+ // Not implemented on non-Windows platforms
+ return 1;
+#endif
+ }
+
+ if (NS_tchdir(argv[1]) != 0) {
+ return 1;
+ }
+
+ // File in use test helper section
+ if (!NS_tstrcmp(argv[4], NS_T("-s"))) {
+ // Note: glibc's getcwd() allocates the buffer dynamically using malloc(3)
+ // if buf (the 1st param) is NULL so free cwd when it is no longer needed.
+ NS_tchar* cwd = NS_tgetcwd(nullptr, 0);
+ NS_tchar inFilePath[MAXPATHLEN];
+ if (!NS_tvsnprintf(inFilePath, sizeof(inFilePath) / sizeof(inFilePath[0]),
+ NS_T("%s/%s"), cwd, argv[2])) {
+ return 1;
+ }
+ NS_tchar outFilePath[MAXPATHLEN];
+ if (!NS_tvsnprintf(outFilePath,
+ sizeof(outFilePath) / sizeof(outFilePath[0]),
+ NS_T("%s/%s"), cwd, argv[3])) {
+ return 1;
+ }
+ free(cwd);
+
+ int seconds = NS_ttoi(argv[5]);
+#ifdef XP_WIN
+ HANDLE hFile = INVALID_HANDLE_VALUE;
+ if (argc == 7) {
+ hFile = CreateFileW(argv[6], DELETE | GENERIC_WRITE, 0, nullptr,
+ OPEN_EXISTING, 0, nullptr);
+ if (hFile == INVALID_HANDLE_VALUE) {
+ WriteMsg(outFilePath, "error_locking");
+ return 1;
+ }
+ }
+
+ WriteMsg(outFilePath, "sleeping");
+ int i = 0;
+ while (!CheckMsg(inFilePath, "finish\n") && i++ <= seconds) {
+ Sleep(1000);
+ }
+
+ if (argc == 7) {
+ CloseHandle(hFile);
+ }
+#else
+ WriteMsg(outFilePath, "sleeping");
+ int i = 0;
+ while (!CheckMsg(inFilePath, "finish\n") && i++ <= seconds) {
+ sleep(1);
+ }
+#endif
+ WriteMsg(outFilePath, "finished");
+ return 0;
+ }
+
+ {
+ // Command line argument test helper section
+ NS_tchar logFilePath[MAXPATHLEN];
+ if (!NS_tvsnprintf(logFilePath,
+ sizeof(logFilePath) / sizeof(logFilePath[0]), NS_T("%s"),
+ argv[2])) {
+ return 1;
+ }
+
+ FILE* logFP = NS_tfopen(logFilePath, NS_T("wb"));
+ if (!logFP) {
+ return 1;
+ }
+ for (int i = 1; i < argc; ++i) {
+ fprintf(logFP, LOG_S "\n", argv[i]);
+ }
+
+ fclose(logFP);
+ logFP = nullptr;
+ }
+
+ return 0;
+}
diff --git a/toolkit/mozapps/update/tests/TestAUSReadStrings.cpp b/toolkit/mozapps/update/tests/TestAUSReadStrings.cpp
new file mode 100644
index 0000000000..d4c090772b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/TestAUSReadStrings.cpp
@@ -0,0 +1,210 @@
+/* -*- 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/. */
+
+/**
+ * This binary tests the updater's ReadStrings ini parser and should run in a
+ * directory with a Unicode character to test bug 473417.
+ */
+#ifdef XP_WIN
+# include <windows.h>
+# define NS_main wmain
+# define PATH_SEPARATOR_CHAR L'\\'
+// On Windows, argv[0] can also have forward slashes instead
+# define ALT_PATH_SEPARATOR_CHAR L'/'
+#else
+# define NS_main main
+# define PATH_SEPARATOR_CHAR '/'
+#endif
+
+#include <stdio.h>
+#include <stdarg.h>
+#include <string.h>
+
+#include "updater/progressui.h"
+#include "common/readstrings.h"
+#include "common/updatererrors.h"
+#include "common/updatedefines.h"
+#include "mozilla/ArrayUtils.h"
+
+#ifndef MAXPATHLEN
+# ifdef PATH_MAX
+# define MAXPATHLEN PATH_MAX
+# elif defined(MAX_PATH)
+# define MAXPATHLEN MAX_PATH
+# elif defined(_MAX_PATH)
+# define MAXPATHLEN _MAX_PATH
+# elif defined(CCHMAXPATH)
+# define MAXPATHLEN CCHMAXPATH
+# else
+# define MAXPATHLEN 1024
+# endif
+#endif
+
+#define TEST_NAME "Updater ReadStrings"
+
+using namespace mozilla;
+
+static int gFailCount = 0;
+
+/**
+ * Prints the given failure message and arguments using printf, prepending
+ * "TEST-UNEXPECTED-FAIL " for the benefit of the test harness and
+ * appending "\n" to eliminate having to type it at each call site.
+ */
+void fail(const char* msg, ...) {
+ va_list ap;
+
+ printf("TEST-UNEXPECTED-FAIL | ");
+
+ va_start(ap, msg);
+ vprintf(msg, ap);
+ va_end(ap);
+
+ putchar('\n');
+ ++gFailCount;
+}
+
+int NS_main(int argc, NS_tchar** argv) {
+ printf("Running TestAUSReadStrings tests\n");
+
+ int rv = 0;
+ int retval;
+ NS_tchar inifile[MAXPATHLEN];
+ StringTable testStrings;
+
+ NS_tchar* slash = NS_tstrrchr(argv[0], PATH_SEPARATOR_CHAR);
+#ifdef ALT_PATH_SEPARATOR_CHAR
+ NS_tchar* altslash = NS_tstrrchr(argv[0], ALT_PATH_SEPARATOR_CHAR);
+ slash = (slash > altslash) ? slash : altslash;
+#endif // ALT_PATH_SEPARATOR_CHAR
+
+ if (!slash) {
+ fail("%s | unable to find platform specific path separator (check 1)",
+ TEST_NAME);
+ return 20;
+ }
+
+ *(++slash) = '\0';
+ // Test success when the ini file exists with both Title and Info in the
+ // Strings section and the values for Title and Info.
+ NS_tsnprintf(inifile, ArrayLength(inifile), NS_T("%sTestAUSReadStrings1.ini"),
+ argv[0]);
+ retval = ReadStrings(inifile, &testStrings);
+ if (retval == OK) {
+ if (strcmp(testStrings.title.get(),
+ "Title Test - \xD0\x98\xD1\x81\xD0\xBF\xD1\x8B"
+ "\xD1\x82\xD0\xB0\xD0\xBD\xD0\xB8\xD0\xB5 "
+ "\xCE\x94\xCE\xBF\xCE\xBA\xCE\xB9\xCE\xBC\xCE\xAE "
+ "\xE3\x83\x86\xE3\x82\xB9\xE3\x83\x88 "
+ "\xE6\xB8\xAC\xE8\xA9\xA6 "
+ "\xE6\xB5\x8B\xE8\xAF\x95") != 0) {
+ rv = 21;
+ fail("%s | Title ini value incorrect (check 3)", TEST_NAME);
+ }
+
+ if (strcmp(testStrings.info.get(),
+ "Info Test - \xD0\x98\xD1\x81\xD0\xBF\xD1\x8B"
+ "\xD1\x82\xD0\xB0\xD0\xBD\xD0\xB8\xD0\xB5 "
+ "\xCE\x94\xCE\xBF\xCE\xBA\xCE\xB9\xCE\xBC\xCE\xAE "
+ "\xE3\x83\x86\xE3\x82\xB9\xE3\x83\x88 "
+ "\xE6\xB8\xAC\xE8\xA9\xA6 "
+ "\xE6\xB5\x8B\xE8\xAF\x95\xE2\x80\xA6") != 0) {
+ rv = 22;
+ fail("%s | Info ini value incorrect (check 4)", TEST_NAME);
+ }
+ } else {
+ fail("%s | ReadStrings returned %i (check 2)", TEST_NAME, retval);
+ rv = 23;
+ }
+
+ // Test failure when the ini file exists without Title and with Info in the
+ // Strings section.
+ NS_tsnprintf(inifile, ArrayLength(inifile), NS_T("%sTestAUSReadStrings2.ini"),
+ argv[0]);
+ retval = ReadStrings(inifile, &testStrings);
+ if (retval != PARSE_ERROR) {
+ rv = 24;
+ fail("%s | ReadStrings returned %i (check 5)", TEST_NAME, retval);
+ }
+
+ // Test failure when the ini file exists with Title and without Info in the
+ // Strings section.
+ NS_tsnprintf(inifile, ArrayLength(inifile), NS_T("%sTestAUSReadStrings3.ini"),
+ argv[0]);
+ retval = ReadStrings(inifile, &testStrings);
+ if (retval != PARSE_ERROR) {
+ rv = 25;
+ fail("%s | ReadStrings returned %i (check 6)", TEST_NAME, retval);
+ }
+
+ // Test failure when the ini file doesn't exist
+ NS_tsnprintf(inifile, ArrayLength(inifile),
+ NS_T("%sTestAUSReadStringsBogus.ini"), argv[0]);
+ retval = ReadStrings(inifile, &testStrings);
+ if (retval != READ_ERROR) {
+ rv = 26;
+ fail("%s | ini file doesn't exist (check 7)", TEST_NAME);
+ }
+
+ // Test reading a non-default section name
+ NS_tsnprintf(inifile, ArrayLength(inifile), NS_T("%sTestAUSReadStrings3.ini"),
+ argv[0]);
+ retval =
+ ReadStrings(inifile, "Title\0", 1, &testStrings.title, "BogusSection2");
+ if (retval == OK) {
+ if (strcmp(testStrings.title.get(), "Bogus Title") != 0) {
+ rv = 27;
+ fail("%s | Title ini value incorrect (check 9)", TEST_NAME);
+ }
+ } else {
+ fail("%s | ReadStrings returned %i (check 8)", TEST_NAME, retval);
+ rv = 28;
+ }
+
+ // Test reading an exceedingly long string
+ NS_tsnprintf(inifile, ArrayLength(inifile), NS_T("%sTestAUSReadStrings4.ini"),
+ argv[0]);
+ retval = ReadStrings(inifile, "LongValue\0", 1, &testStrings.title);
+ const char* expectedValue =
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam id "
+ "ipsum condimentum, faucibus ante porta, vehicula metus. Nunc nec luctus "
+ "lorem. Nunc mattis viverra nisl, eu ornare dui feugiat id. Aenean "
+ "commodo ligula porttitor elit aliquam, ut luctus nunc aliquam. In eu "
+ "eros at nunc pulvinar porta. Praesent porta felis vitae massa "
+ "sollicitudin, a vestibulum dolor rutrum. Aenean finibus, felis ac "
+ "dictum hendrerit, ligula arcu semper enim, rhoncus consequat arcu orci "
+ "nec est. Sed auctor hendrerit rhoncus. Maecenas dignissim lorem et "
+ "tellus maximus, sit amet pretium urna imperdiet. Duis ut libero "
+ "volutpat, rhoncus mi non, placerat lacus. Nunc id tortor in quam "
+ "lacinia luctus. Nam eu maximus ipsum, eu bibendum enim. Ut iaculis "
+ "maximus ipsum in condimentum. Aliquam tellus nulla, congue quis pretium "
+ "a, posuere quis ligula. Donec vel quam ipsum. Pellentesque congue urna "
+ "eget porttitor pulvinar. Proin non risus lacus. Vestibulum molestie et "
+ "ligula sit amet pellentesque. Phasellus luctus auctor lorem, vel "
+ "dapibus ante iaculis sed. Cras ligula ex, vehicula a dui vel, posuere "
+ "fermentum elit. Vestibulum et nisi at libero maximus interdum a non ex. "
+ "Ut ut leo in metus convallis porta a et libero. Pellentesque fringilla "
+ "dolor sit amet eleifend fermentum. Quisque blandit dolor facilisis "
+ "purus vulputate sodales eget ac arcu. Nulla pulvinar feugiat accumsan. "
+ "Phasellus auctor nisl eget diam auctor, sit amet imperdiet mauris "
+ "condimentum. In a risus ut felis lobortis facilisis.";
+ if (retval == OK) {
+ if (strcmp(testStrings.title.get(), expectedValue) != 0) {
+ rv = 29;
+ fail("%s | LongValue ini value incorrect (check 10)", TEST_NAME);
+ }
+ } else {
+ fail("%s | ReadStrings returned %i (check 11)", TEST_NAME, retval);
+ rv = 30;
+ }
+
+ if (rv == 0) {
+ printf("TEST-PASS | %s | all checks passed\n", TEST_NAME);
+ } else {
+ fail("%s | %i out of 9 checks failed", TEST_NAME, gFailCount);
+ }
+
+ return rv;
+}
diff --git a/toolkit/mozapps/update/tests/TestAUSReadStrings1.ini b/toolkit/mozapps/update/tests/TestAUSReadStrings1.ini
new file mode 100644
index 0000000000..5ab13c185d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/TestAUSReadStrings1.ini
@@ -0,0 +1,47 @@
+; This file is in the UTF-8 encoding
+
+[BogusSection1]
+
+; Comment
+
+Title=Bogus Title
+
+; Comment
+
+Info=Bogus Info
+
+; Comment
+
+[Strings]
+
+Bogus1=Bogus1
+
+; Comment
+
+Title=Title Test - Испытание Δοκιμή テスト 測試 测试
+
+; Comment
+
+Bogus2=Bogus2
+
+; Comment
+
+Info=Info Test - Испытание Δοκιμή テスト 測試 测试…
+
+; Comment
+
+Bogus3=Bogus3
+
+; Comment
+
+[BogusSection2]
+
+; Comment
+
+Title=Bogus Title
+
+; Comment
+
+Info=Bogus Info
+
+; Comment
diff --git a/toolkit/mozapps/update/tests/TestAUSReadStrings2.ini b/toolkit/mozapps/update/tests/TestAUSReadStrings2.ini
new file mode 100644
index 0000000000..8291a7c94c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/TestAUSReadStrings2.ini
@@ -0,0 +1,39 @@
+; This file is in the UTF-8 encoding
+
+[BogusSection1]
+
+; Comment
+
+Title=Bogus Title
+
+; Comment
+
+Info=Bogus Info
+
+; Comment
+
+[Strings]
+
+Bogus1=Bogus1
+
+; Comment
+
+Info=Info
+
+; Comment
+
+Bogus2=Bogus2
+
+; Comment
+
+[BogusSection2]
+
+; Comment
+
+Title=Bogus Title
+
+; Comment
+
+Info=Bogus Info
+
+; Comment
diff --git a/toolkit/mozapps/update/tests/TestAUSReadStrings3.ini b/toolkit/mozapps/update/tests/TestAUSReadStrings3.ini
new file mode 100644
index 0000000000..a64d1232e2
--- /dev/null
+++ b/toolkit/mozapps/update/tests/TestAUSReadStrings3.ini
@@ -0,0 +1,39 @@
+; This file is in the UTF-8 encoding
+
+[BogusSection1]
+
+; Comment
+
+Title=Bogus Title
+
+; Comment
+
+Info=Bogus Info
+
+; Comment
+
+[Strings]
+
+Bogus1=Bogus1
+
+; Comment
+
+Title=Title
+
+; Comment
+
+Bogus2=Bogus2
+
+; Comment
+
+[BogusSection2]
+
+; Comment
+
+Title=Bogus Title
+
+; Comment
+
+Info=Bogus Info
+
+; Comment
diff --git a/toolkit/mozapps/update/tests/TestAUSReadStrings4.ini b/toolkit/mozapps/update/tests/TestAUSReadStrings4.ini
new file mode 100644
index 0000000000..b930455e8b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/TestAUSReadStrings4.ini
@@ -0,0 +1,5 @@
+; This file is in the UTF-8 encoding
+
+[Strings]
+
+LongValue=Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam id ipsum condimentum, faucibus ante porta, vehicula metus. Nunc nec luctus lorem. Nunc mattis viverra nisl, eu ornare dui feugiat id. Aenean commodo ligula porttitor elit aliquam, ut luctus nunc aliquam. In eu eros at nunc pulvinar porta. Praesent porta felis vitae massa sollicitudin, a vestibulum dolor rutrum. Aenean finibus, felis ac dictum hendrerit, ligula arcu semper enim, rhoncus consequat arcu orci nec est. Sed auctor hendrerit rhoncus. Maecenas dignissim lorem et tellus maximus, sit amet pretium urna imperdiet. Duis ut libero volutpat, rhoncus mi non, placerat lacus. Nunc id tortor in quam lacinia luctus. Nam eu maximus ipsum, eu bibendum enim. Ut iaculis maximus ipsum in condimentum. Aliquam tellus nulla, congue quis pretium a, posuere quis ligula. Donec vel quam ipsum. Pellentesque congue urna eget porttitor pulvinar. Proin non risus lacus. Vestibulum molestie et ligula sit amet pellentesque. Phasellus luctus auctor lorem, vel dapibus ante iaculis sed. Cras ligula ex, vehicula a dui vel, posuere fermentum elit. Vestibulum et nisi at libero maximus interdum a non ex. Ut ut leo in metus convallis porta a et libero. Pellentesque fringilla dolor sit amet eleifend fermentum. Quisque blandit dolor facilisis purus vulputate sodales eget ac arcu. Nulla pulvinar feugiat accumsan. Phasellus auctor nisl eget diam auctor, sit amet imperdiet mauris condimentum. In a risus ut felis lobortis facilisis.
diff --git a/toolkit/mozapps/update/tests/browser/browser.bits.toml b/toolkit/mozapps/update/tests/browser/browser.bits.toml
new file mode 100644
index 0000000000..ab75a4734b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser.bits.toml
@@ -0,0 +1,135 @@
+[DEFAULT]
+run-if = ["os == 'win'"] # "BITS is only available on Windows."
+skip-if = [
+ "msix", # Updater is disabled in MSIX builds
+]
+dupe-manifest = true
+tags = "appupdate bits"
+head = "head.js"
+support-files = [
+ "../data/shared.js",
+ "../data/sharedUpdateXML.js",
+ "../data/app_update.sjs",
+ "downloadPage.html",
+ "testConstants.js",
+]
+
+prefs = [
+ "app.update.BITS.enabled=true",
+ "app.update.langpack.enabled=true",
+]
+
+# BITS Download Tests
+#####################
+
+# About Dialog Application Update Tests
+
+["browser_aboutDialog_bc_downloaded.js"]
+
+["browser_aboutDialog_bc_downloaded_staged.js"]
+
+["browser_aboutDialog_bc_downloaded_staging.js"]
+
+["browser_aboutDialog_bc_downloaded_stagingFailure.js"]
+
+["browser_aboutDialog_bc_downloading.js"]
+
+["browser_aboutDialog_bc_downloading_notify.js"]
+
+["browser_aboutDialog_bc_downloading_staging.js"]
+
+["browser_aboutDialog_bc_multiUpdate.js"]
+
+["browser_aboutDialog_fc_downloadAuto.js"]
+
+["browser_aboutDialog_fc_downloadAuto_staging.js"]
+
+["browser_aboutDialog_fc_downloadOptIn.js"]
+
+["browser_aboutDialog_fc_downloadOptIn_staging.js"]
+
+["browser_aboutDialog_fc_patch_completeBadSize.js"]
+
+["browser_aboutDialog_fc_patch_partialBadSize.js"]
+
+["browser_aboutDialog_fc_patch_partialBadSize_complete.js"]
+
+["browser_aboutDialog_fc_patch_partialBadSize_completeBadSize.js"]
+
+# about:preferences Application Update Tests
+
+["browser_aboutPrefs_bc_downloaded.js"]
+
+["browser_aboutPrefs_bc_downloaded_staged.js"]
+
+["browser_aboutPrefs_bc_downloaded_staging.js"]
+
+["browser_aboutPrefs_bc_downloaded_stagingFailure.js"]
+
+["browser_aboutPrefs_bc_downloading.js"]
+
+["browser_aboutPrefs_bc_downloading_staging.js"]
+
+["browser_aboutPrefs_bc_multiUpdate.js"]
+
+["browser_aboutPrefs_fc_downloadAuto.js"]
+
+["browser_aboutPrefs_fc_downloadAuto_staging.js"]
+
+["browser_aboutPrefs_fc_downloadOptIn.js"]
+
+["browser_aboutPrefs_fc_downloadOptIn_staging.js"]
+
+["browser_aboutPrefs_fc_patch_completeBadSize.js"]
+
+["browser_aboutPrefs_fc_patch_partialBadSize.js"]
+
+["browser_aboutPrefs_fc_patch_partialBadSize_complete.js"]
+
+["browser_aboutPrefs_fc_patch_partialBadSize_completeBadSize.js"]
+
+# Doorhanger Application Update Tests
+
+["browser_doorhanger_bc_downloadAutoFailures.js"]
+
+["browser_doorhanger_bc_downloadAutoFailures_bgWin.js"]
+
+["browser_doorhanger_bc_downloadOptIn.js"]
+
+["browser_doorhanger_bc_downloadOptIn_bgWin.js"]
+
+["browser_doorhanger_bc_downloadOptIn_staging.js"]
+
+["browser_doorhanger_bc_downloaded.js"]
+
+["browser_doorhanger_bc_downloaded_disableBITS.js"]
+
+["browser_doorhanger_bc_downloaded_staged.js"]
+
+["browser_doorhanger_bc_multiUpdate.js"]
+
+["browser_doorhanger_bc_multiUpdate_promptWaitTime.js"]
+
+["browser_doorhanger_bc_patch_completeBadSize.js"]
+
+["browser_doorhanger_bc_patch_partialBadSize.js"]
+
+["browser_doorhanger_bc_patch_partialBadSize_complete.js"]
+
+["browser_doorhanger_bc_patch_partialBadSize_completeBadSize.js"]
+
+["browser_doorhanger_sp_patch_completeApplyFailure.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure_complete.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure_completeBadSize.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure_complete_staging.js"]
+
+# Telemetry Update Ping Tests
+
+["browser_telemetry_updatePing_downloaded_ready.js"]
+
+["browser_telemetry_updatePing_staged_ready.js"]
diff --git a/toolkit/mozapps/update/tests/browser/browser.toml b/toolkit/mozapps/update/tests/browser/browser.toml
new file mode 100644
index 0000000000..50a0e9afb3
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser.toml
@@ -0,0 +1,203 @@
+[DEFAULT]
+tags = "appupdate internal"
+head = "head.js"
+support-files = [
+ "../data/shared.js",
+ "../data/sharedUpdateXML.js",
+ "../data/app_update.sjs",
+ "downloadPage.html",
+ "testConstants.js",
+]
+prefs = [
+ "app.update.BITS.enabled=false",
+ "app.update.langpack.enabled=true",
+]
+skip-if = ["os == 'win' && msix"] # Updater is disabled in MSIX builds
+
+# About Dialog Application Update Tests
+
+["browser_aboutDialog_AppUpdater_stop_checking.js"]
+
+["browser_aboutDialog_AppUpdater_stop_download_and_install.js"]
+
+["browser_aboutDialog_AppUpdater_stop_download_failed.js"]
+
+["browser_aboutDialog_AppUpdater_stop_downloading.js"]
+
+["browser_aboutDialog_AppUpdater_stop_internal_error.js"]
+
+["browser_aboutDialog_AppUpdater_stop_no_update.js"]
+
+["browser_aboutDialog_AppUpdater_stop_ready_for_restart.js"]
+
+["browser_aboutDialog_AppUpdater_stop_staging.js"]
+
+["browser_aboutDialog_AppUpdater_stop_swap.js"]
+
+["browser_aboutDialog_bc_downloaded.js"]
+
+["browser_aboutDialog_bc_downloaded_staged.js"]
+
+["browser_aboutDialog_bc_downloaded_staging.js"]
+
+["browser_aboutDialog_bc_downloaded_stagingFailure.js"]
+
+["browser_aboutDialog_bc_downloading.js"]
+
+["browser_aboutDialog_bc_downloading_notify.js"]
+
+["browser_aboutDialog_bc_downloading_staging.js"]
+
+["browser_aboutDialog_bc_multiUpdate.js"]
+
+["browser_aboutDialog_fc_apply_blocked.js"]
+
+["browser_aboutDialog_fc_check_cantApply.js"]
+run-if = ["os == 'win'"] # "test must be able to prevent file deletion."
+
+["browser_aboutDialog_fc_check_malformedXML.js"]
+
+["browser_aboutDialog_fc_check_noUpdate.js"]
+
+["browser_aboutDialog_fc_check_otherInstance.js"]
+run-if = ["os == 'win'"] # "Windows only feature."
+
+["browser_aboutDialog_fc_check_unsupported.js"]
+
+["browser_aboutDialog_fc_downloadAuto.js"]
+skip-if = ["tsan"] # Bug 1683730
+
+["browser_aboutDialog_fc_downloadAuto_staging.js"]
+
+["browser_aboutDialog_fc_downloadOptIn.js"]
+
+["browser_aboutDialog_fc_downloadOptIn_staging.js"]
+
+["browser_aboutDialog_fc_network_failure.js"]
+
+["browser_aboutDialog_fc_network_offline.js"]
+
+["browser_aboutDialog_fc_patch_completeBadSize.js"]
+
+["browser_aboutDialog_fc_patch_partialBadSize.js"]
+
+["browser_aboutDialog_fc_patch_partialBadSize_complete.js"]
+
+["browser_aboutDialog_fc_patch_partialBadSize_completeBadSize.js"]
+
+["browser_aboutDialog_internalError.js"]
+
+["browser_aboutPrefs_backgroundUpdateSetting.js"]
+
+["browser_aboutPrefs_bc_downloaded.js"]
+
+["browser_aboutPrefs_bc_downloaded_staged.js"]
+
+["browser_aboutPrefs_bc_downloaded_staging.js"]
+
+["browser_aboutPrefs_bc_downloaded_stagingFailure.js"]
+
+# about:preferences Application Update Tests
+
+["browser_aboutPrefs_bc_downloading.js"]
+
+["browser_aboutPrefs_bc_downloading_staging.js"]
+
+["browser_aboutPrefs_bc_multiUpdate.js"]
+
+["browser_aboutPrefs_fc_apply_blocked.js"]
+
+["browser_aboutPrefs_fc_check_cantApply.js"]
+run-if = ["os == 'win'"] # "test must be able to prevent file deletion."
+
+["browser_aboutPrefs_fc_check_malformedXML.js"]
+
+["browser_aboutPrefs_fc_check_noUpdate.js"]
+
+["browser_aboutPrefs_fc_check_otherInstance.js"]
+run-if = ["os == 'win'"] # "Windows only feature."
+
+["browser_aboutPrefs_fc_check_unsupported.js"]
+
+["browser_aboutPrefs_fc_downloadAuto.js"]
+
+["browser_aboutPrefs_fc_downloadAuto_staging.js"]
+
+["browser_aboutPrefs_fc_downloadOptIn.js"]
+
+["browser_aboutPrefs_fc_downloadOptIn_staging.js"]
+
+["browser_aboutPrefs_fc_network_failure.js"]
+
+["browser_aboutPrefs_fc_network_offline.js"]
+
+["browser_aboutPrefs_fc_patch_completeBadSize.js"]
+
+["browser_aboutPrefs_fc_patch_partialBadSize.js"]
+
+["browser_aboutPrefs_fc_patch_partialBadSize_complete.js"]
+
+["browser_aboutPrefs_fc_patch_partialBadSize_completeBadSize.js"]
+
+["browser_aboutPrefs_internalError.js"]
+
+["browser_aboutPrefs_settings.js"]
+
+# Doorhanger Application Update Tests
+
+["browser_doorhanger_bc_check_cantApply.js"]
+run-if = ["os == 'win'"] # "test must be able to prevent file deletion."
+
+["browser_doorhanger_bc_check_malformedXML.js"]
+
+["browser_doorhanger_bc_check_unsupported.js"]
+
+["browser_doorhanger_bc_downloadAutoFailures.js"]
+
+["browser_doorhanger_bc_downloadAutoFailures_bgWin.js"]
+
+["browser_doorhanger_bc_downloadOptIn.js"]
+
+["browser_doorhanger_bc_downloadOptIn_bgWin.js"]
+
+["browser_doorhanger_bc_downloadOptIn_staging.js"]
+
+["browser_doorhanger_bc_downloaded.js"]
+
+["browser_doorhanger_bc_downloaded_staged.js"]
+
+["browser_doorhanger_bc_multiUpdate.js"]
+
+["browser_doorhanger_bc_multiUpdate_promptWaitTime.js"]
+
+["browser_doorhanger_bc_patch_completeBadSize.js"]
+
+["browser_doorhanger_bc_patch_partialBadSize.js"]
+
+["browser_doorhanger_bc_patch_partialBadSize_complete.js"]
+
+["browser_doorhanger_bc_patch_partialBadSize_completeBadSize.js"]
+
+["browser_doorhanger_sp_patch_completeApplyFailure.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure_complete.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure_completeBadSize.js"]
+
+["browser_doorhanger_sp_patch_partialApplyFailure_complete_staging.js"]
+
+# Elevation Dialog Tests
+
+["browser_elevationDialog.js"]
+
+# Memory Fallback Tests
+
+["browser_memory_allocation_error_fallback.js"]
+
+# Telemetry Update Ping Tests
+
+["browser_telemetry_updatePing_downloaded_ready.js"]
+
+["browser_telemetry_updatePing_staged_ready.js"]
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_checking.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_checking.js
new file mode 100644
index 0000000000..370f658656
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_checking.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the checking state causes the interface to return to the `NEVER_CHECKED`
+// state.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_checking() {
+ let params = { queryString: "&noUpdates=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ // Omit the continue file to keep us in the checking state.
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "checkForUpdates",
+ },
+ ]);
+
+ // Ideally this would go in a cleanup function. But this needs to happen
+ // before any other cleanup functions and for some reason cleanup functions
+ // do not always seem to execute in reverse registration order.
+ dump("Cleanup: Waiting for checking to finish.\n");
+ await continueFileHandler(CONTINUE_CHECK);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_and_install.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_and_install.js
new file mode 100644
index 0000000000..74e179043d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_and_install.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the "download and install" state causes the interface to return to the
+// `NEVER_CHECKED` state.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_download_and_install() {
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ noContinue: true,
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "checkForUpdates",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_failed.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_failed.js
new file mode 100644
index 0000000000..8900765f81
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_download_failed.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the "downloadFailed" state doesn't cause a shift to any other state, such
+// as internal error.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_download_failed() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "complete", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "complete", internalResult: gBadSizeResult };
+ } else {
+ downloadInfo[0] = { patchType: "complete", internalResult: gBadSizeResult };
+ }
+
+ let params = { queryString: "&completePatchOnly=1&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "downloadFailed",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_downloading.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_downloading.js
new file mode 100644
index 0000000000..d0de7e03b9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_downloading.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` during
+// the downloading state causes the interface to return to the `NEVER_CHECKED`
+// state.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_downloading() {
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ // Omit continue file to keep the UI in the downloading state.
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "checkForUpdates",
+ // The update will still be in the downloading state even though
+ // AppUpdater has stopped because stopping AppUpdater doesn't stop the
+ // Application Update Service from continuing with the update.
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ expectedStateOverride: Ci.nsIApplicationUpdateService.STATE_DOWNLOADING,
+ },
+ ]);
+
+ // Ideally this would go in a cleanup function. But this needs to happen
+ // before any other cleanup functions and for some reason cleanup functions
+ // do not always seem to execute in reverse registration order.
+ dump("Cleanup: Waiting for downloading to finish.\n");
+ await continueFileHandler(CONTINUE_DOWNLOAD);
+ if (gAUS.currentState == Ci.nsIApplicationUpdateService.STATE_DOWNLOADING) {
+ await gAUS.stateTransition;
+ }
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_internal_error.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_internal_error.js
new file mode 100644
index 0000000000..69c13783bf
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_internal_error.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+add_setup(function setup_internalErrorTest() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(AppUpdater.prototype, "aus").get(() => {
+ throw new Error("intentional test error");
+ });
+ sandbox.stub(AppUpdater.prototype, "checker").get(() => {
+ throw new Error("intentional test error");
+ });
+ sandbox.stub(AppUpdater.prototype, "um").get(() => {
+ throw new Error("intentional test error");
+ });
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the "internal error" state doesn't cause a shift to any other state.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_internal_error() {
+ let params = {};
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "internalError",
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "internalError",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_no_update.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_no_update.js
new file mode 100644
index 0000000000..65a52ccc87
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_no_update.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the "noUpdatesFound" state doesn't cause a shift to any other state, such
+// as internal error.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_no_update() {
+ let params = { queryString: "&noUpdates=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "noUpdatesFound",
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "noUpdatesFound",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_ready_for_restart.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_ready_for_restart.js
new file mode 100644
index 0000000000..8c9d1f788f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_ready_for_restart.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the "ready for restart" state doesn't cause a shift to any other state,
+// such as internal error.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_ready_for_restart() {
+ let params = { backgroundUpdate: true, waitForUpdateState: STATE_PENDING };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_staging.js
new file mode 100644
index 0000000000..dd822e6391
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_staging.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that while a download is in-progress, calling `AppUpdater.stop()` while
+// in the staging state causes the interface to return to the `NEVER_CHECKED`
+// state.
+// This is less a test of the About dialog than of AppUpdater, but it's easier
+// to test it via the About dialog just because there is already a testing
+// framework for the About dialog.
+add_task(async function aboutDialog_AppUpdater_stop_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ // Don't pass a continue file in order to leave us in the staging state.
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "checkForUpdates",
+ // The update will still be in the staging state even though AppUpdater
+ // has stopped because stopping AppUpdater doesn't stop the Application
+ // Update Service from continuing with the update.
+ checkActiveUpdate: { state: STATE_PENDING },
+ expectedStateOverride: Ci.nsIApplicationUpdateService.STATE_STAGING,
+ },
+ ]);
+
+ // Ideally this would go in a cleanup function. But this needs to happen
+ // before any other cleanup functions and for some reason cleanup functions
+ // do not always seem to execute in reverse registration order.
+ dump("Cleanup: Waiting for staging to finish.\n");
+ await continueFileHandler(CONTINUE_STAGING);
+ if (gAUS.currentState == Ci.nsIApplicationUpdateService.STATE_STAGING) {
+ await gAUS.stateTransition;
+ }
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_swap.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_swap.js
new file mode 100644
index 0000000000..02441dea53
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_AppUpdater_stop_swap.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const FIRST_UPDATE_VERSION = "999998.0";
+const SECOND_UPDATE_VERSION = "999999.0";
+
+function prepareToDownloadVersion(version) {
+ setUpdateURL(
+ URL_HTTP_UPDATE_SJS +
+ `?detailsURL=${gDetailsURL}&promptWaitTime=0&appVersion=${version}`
+ );
+}
+
+add_task(async function aboutDialog_backgroundCheck_multiUpdate() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let params = {
+ version: FIRST_UPDATE_VERSION,
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ () => {
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION);
+ gAUS.checkForBackgroundUpdates();
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ // Don't pass a continue file in order to leave us in the staging state.
+ },
+ aboutDialog => {
+ aboutDialog.gAppUpdater._appUpdater.stop();
+ },
+ {
+ panelId: "checkForUpdates",
+ checkActiveUpdate: { state: STATE_PENDING },
+ expectedStateOverride: Ci.nsIApplicationUpdateService.STATE_STAGING,
+ },
+ ]);
+
+ // Ideally this would go in a cleanup function. But this needs to happen
+ // before any other cleanup functions and for some reason cleanup functions
+ // do not always seem to execute in reverse registration order.
+ dump("Cleanup: Waiting for staging to finish.\n");
+ await continueFileHandler(CONTINUE_STAGING);
+ if (gAUS.currentState == Ci.nsIApplicationUpdateService.STATE_STAGING) {
+ await gAUS.stateTransition;
+ }
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded.js
new file mode 100644
index 0000000000..0955750c93
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates
+// with the update downloaded when the About Dialog is opened.
+add_task(async function aboutDialog_backgroundCheck_downloaded() {
+ let params = { backgroundUpdate: true, waitForUpdateState: STATE_PENDING };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staged.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staged.js
new file mode 100644
index 0000000000..2d7bd64d05
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staged.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates
+// with the update downloaded and staged when the About Dialog is opened.
+add_task(async function aboutDialog_backgroundCheck_downloaded_staged() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ continueFile: CONTINUE_STAGING,
+ waitForUpdateState: STATE_APPLIED,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staging.js
new file mode 100644
index 0000000000..b60a8f128d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staging.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates
+// with the update downloaded and the About Dialog opened during staging.
+add_task(async function aboutDialog_backgroundCheck_downloaded_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let lankpackCall = mockLangpackInstall();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ async aboutDialog => {
+ // Once the state is applied but langpacks aren't complete the about
+ // dialog should still be showing applying.
+ TestUtils.waitForCondition(() => {
+ return readStatusFile() == STATE_APPLIED;
+ });
+
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ "applying",
+ "UI should still show as applying."
+ );
+
+ let { appVersion, resolve } = await lankpackCall;
+ is(
+ appVersion,
+ Services.appinfo.version,
+ "Should see the right app version."
+ );
+ resolve();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_stagingFailure.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_stagingFailure.js
new file mode 100644
index 0000000000..c05b2daa74
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_stagingFailure.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates
+// with the update downloaded and staging has failed when the About Dialog is
+// opened.
+add_task(
+ async function aboutDialog_backgroundCheck_downloaded_stagingFailure() {
+ Services.env.set("MOZ_TEST_STAGING_ERROR", "1");
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&completePatchOnly=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+ }
+);
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading.js
new file mode 100644
index 0000000000..6c2a7486a9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates
+// with the About Dialog opened during downloading.
+add_task(async function aboutDialog_backgroundCheck_downloading() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD, false]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ let lankpackCall = mockLangpackInstall();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ async function aboutDialog_downloading() {
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The window's doorhanger is closed."
+ );
+ ok(
+ !PanelUI.menuButton.hasAttribute("badge-status"),
+ "The window does not have a badge."
+ );
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ async aboutDialog => {
+ // Once the state is pending but langpacks aren't complete the about
+ // dialog should still be showing downloading.
+ TestUtils.waitForCondition(() => {
+ return readStatusFile() == STATE_PENDING;
+ });
+
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ "downloading",
+ "UI should still show as downloading."
+ );
+
+ let { appVersion, resolve } = await lankpackCall;
+ is(
+ appVersion,
+ Services.appinfo.version,
+ "Should see the right app version."
+ );
+ resolve();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_notify.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_notify.js
new file mode 100644
index 0000000000..cf067efe7d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_notify.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates with the
+// "notify during download" feature turned on.
+add_task(async function aboutDialog_backgroundCheck_downloading_notify() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD, true]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ async function aboutDialog_downloading_notification() {
+ await TestUtils.waitForCondition(
+ () => PanelUI.menuButton.hasAttribute("badge-status"),
+ "Waiting for update badge",
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test.
+ logTestInfo(e);
+ });
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The window's doorhanger is closed."
+ );
+ ok(
+ PanelUI.menuButton.hasAttribute("badge-status"),
+ "The window has a badge."
+ );
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-downloading",
+ "The downloading badge is showing for the background window"
+ );
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_staging.js
new file mode 100644
index 0000000000..3f6b476e1b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloading_staging.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog background check for updates
+// with the About Dialog opened during downloading and stages the update.
+add_task(async function aboutDialog_backgroundCheck_downloading_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ let lankpackCall = mockLangpackInstall();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ async aboutDialog => {
+ // Once the state is applied but langpacks aren't complete the about
+ // dialog should still be showing applying.
+ TestUtils.waitForCondition(() => {
+ return readStatusFile() == STATE_APPLIED;
+ });
+
+ is(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ "applying",
+ "UI should still show as applying."
+ );
+
+ let { appVersion, resolve } = await lankpackCall;
+ is(
+ appVersion,
+ Services.appinfo.version,
+ "Should see the right app version."
+ );
+ resolve();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_multiUpdate.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_multiUpdate.js
new file mode 100644
index 0000000000..b6c893ea15
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_multiUpdate.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const FIRST_UPDATE_VERSION = "999998.0";
+const SECOND_UPDATE_VERSION = "999999.0";
+
+function prepareToDownloadVersion(version) {
+ setUpdateURL(
+ URL_HTTP_UPDATE_SJS +
+ `?detailsURL=${gDetailsURL}&promptWaitTime=0&appVersion=${version}`
+ );
+}
+
+add_task(async function aboutDialog_backgroundCheck_multiUpdate() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let params = {
+ version: FIRST_UPDATE_VERSION,
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ () => {
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION);
+ gAUS.checkForBackgroundUpdates();
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_apply_blocked.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_apply_blocked.js
new file mode 100644
index 0000000000..6cb2b9d10d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_apply_blocked.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const BUILDER_URL = "https://example.com/document-builder.sjs?html=";
+const PAGE_MARKUP = `
+<html>
+<head>
+ <script>
+ window.onbeforeunload = function() {
+ return true;
+ };
+ </script>
+</head>
+<body>TEST PAGE</body>
+</html>
+`;
+const TEST_URL = BUILDER_URL + encodeURI(PAGE_MARKUP);
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+ });
+});
+
+// Test for About Dialog foreground check for updates
+// and apply but restart is blocked by a page.
+add_task(async function aboutDialog_foregroundCheck_apply_blocked() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ let aboutDialog;
+ let handlePromise = (async () => {
+ let dialog = await PromptTestUtils.waitForPrompt(window, {
+ modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT,
+ promptType: "confirmEx",
+ });
+ Assert.equal(
+ aboutDialog.gAppUpdater.selectedPanel.id,
+ "restarting",
+ "The restarting panel should be displayed"
+ );
+
+ await PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 });
+ })();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1&promptWaitTime=0" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ async function getAboutDialogHandle(dialog) {
+ aboutDialog = dialog;
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ forceApply: true,
+ },
+ async function ensureDialogHasBeenCanceled() {
+ await handlePromise;
+ },
+ // A final check to ensure that we are back in the apply state.
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab, { skipPermitUnload: true });
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_cantApply.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_cantApply.js
new file mode 100644
index 0000000000..cd659ef74b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_cantApply.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// without the ability to apply updates.
+add_task(async function aboutDialog_foregroundCheck_cantApply() {
+ lockWriteTestFile();
+
+ let params = {};
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "manualUpdate",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_malformedXML.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_malformedXML.js
new file mode 100644
index 0000000000..529a4c7a63
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_malformedXML.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a malformed update XML file.
+add_task(async function aboutDialog_foregroundCheck_malformedXML() {
+ let params = { queryString: "&xmlMalformed=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "checkingFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_noUpdate.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_noUpdate.js
new file mode 100644
index 0000000000..2bd23cddf0
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_noUpdate.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with no update available.
+add_task(async function aboutDialog_foregroundCheck_noUpdate() {
+ let params = { queryString: "&noUpdates=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "noUpdatesFound",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_otherInstance.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_otherInstance.js
new file mode 100644
index 0000000000..fa1110effd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_otherInstance.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with another application instance handling updates.
+add_task(async function aboutDialog_foregroundCheck_otherInstance() {
+ setOtherInstanceHandlingUpdates();
+
+ let params = {};
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "otherInstanceHandlingUpdates",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_unsupported.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_unsupported.js
new file mode 100644
index 0000000000..22e3425967
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_unsupported.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with an unsupported update.
+add_task(async function aboutDialog_foregroundCheck_unsupported() {
+ let params = { queryString: "&unsupported=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "unsupportedSystem",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto.js
new file mode 100644
index 0000000000..a80c9deac8
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with an automatic download.
+add_task(async function aboutDialog_foregroundCheck_downloadAuto() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1&promptWaitTime=0" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ async function aboutDialog_restart_notification() {
+ await TestUtils.waitForCondition(
+ () => PanelUI.menuButton.hasAttribute("badge-status"),
+ "Waiting for update badge",
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test.
+ logTestInfo(e);
+ });
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The window's doorhanger is closed."
+ );
+ ok(
+ PanelUI.menuButton.hasAttribute("badge-status"),
+ "The window has a badge."
+ );
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-restart",
+ "The restart badge is showing for the background window"
+ );
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto_staging.js
new file mode 100644
index 0000000000..e212b0c611
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadAuto_staging.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with an automatic download and update staging.
+add_task(async function aboutDialog_foregroundCheck_downloadAuto_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn.js
new file mode 100644
index 0000000000..5a2ff513ad
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a manual download.
+add_task(async function aboutDialog_foregroundCheck_downloadOptIn() {
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn_staging.js
new file mode 100644
index 0000000000..3ed331ec64
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn_staging.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a manual download and update staging.
+add_task(async function aboutDialog_foregroundCheck_downloadOptIn_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_failure.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_failure.js
new file mode 100644
index 0000000000..8e25b96d7f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_failure.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates which fails, because it is
+// impossible to connect to the update server
+add_task(async function aboutDialog_foregroundCheck_network_failure() {
+ let params = {
+ baseURL: "https://localhost:7777",
+ };
+
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingFailed",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_offline.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_offline.js
new file mode 100644
index 0000000000..9f81573e23
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_network_offline.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates which fails, because the
+// browser is in offline mode and `localhost` cannot be resolved.
+add_task(async function aboutDialog_foregroundCheck_network_offline() {
+ info("[OFFLINE] setting Services.io.offline (do not forget to reset it!)");
+ // avoid that real network connectivity changes influence the test execution
+ Services.io.manageOfflineStatus = false;
+ Services.io.offline = true;
+ registerCleanupFunction(() => {
+ info("[ONLINE] Resetting Services.io.offline");
+ Services.io.offline = false;
+ Services.io.manageOfflineStatus = true;
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["network.disable-localhost-when-offline", true],
+ ["network.dns.offline-localhost", false],
+ ],
+ });
+
+ await runAboutDialogUpdateTest({}, [
+ {
+ panelId: "checkingFailed",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_completeBadSize.js
new file mode 100644
index 0000000000..fdc7d3407e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_completeBadSize.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a complete bad size patch.
+add_task(async function aboutDialog_foregroundCheck_completeBadSize() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "complete", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "complete", internalResult: gBadSizeResult };
+ } else {
+ downloadInfo[0] = { patchType: "complete", internalResult: gBadSizeResult };
+ }
+
+ let params = { queryString: "&completePatchOnly=1&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize.js
new file mode 100644
index 0000000000..411fb25969
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a partial bad size patch.
+add_task(async function aboutDialog_foregroundCheck_partialBadSize() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "partial", internalResult: gBadSizeResult };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: gBadSizeResult };
+ }
+
+ let params = { queryString: "&partialPatchOnly=1&invalidPartialSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_complete.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_complete.js
new file mode 100644
index 0000000000..396be1e930
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_complete.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a partial bad size patch and a complete patch.
+add_task(async function aboutDialog_foregroundCheck_partialBadSize_complete() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "partial", internalResult: gBadSizeResult };
+ downloadInfo[2] = { patchType: "complete", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "complete", internalResult: "0" };
+ }
+
+ let params = { queryString: "&invalidPartialSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_completeBadSize.js
new file mode 100644
index 0000000000..fb10250a49
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_patch_partialBadSize_completeBadSize.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for About Dialog foreground check for updates
+// with a partial bad size patch and a complete bad size patch.
+add_task(
+ async function aboutDialog_foregroundCheck_partialBadSize_completeBadSize() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: gBadSizeResult };
+ downloadInfo[1] = {
+ patchType: "partial",
+ internalResult: gBadSizeResult,
+ };
+ downloadInfo[2] = { patchType: "complete", bitsResult: gBadSizeResult };
+ downloadInfo[3] = {
+ patchType: "complete",
+ internalResult: gBadSizeResult,
+ };
+ } else {
+ downloadInfo[0] = {
+ patchType: "partial",
+ internalResult: gBadSizeResult,
+ };
+ downloadInfo[1] = {
+ patchType: "complete",
+ internalResult: gBadSizeResult,
+ };
+ }
+
+ let params = { queryString: "&invalidPartialSize=1&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+ }
+);
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutDialog_internalError.js b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_internalError.js
new file mode 100644
index 0000000000..d8d20ea05a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutDialog_internalError.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+add_setup(function setup_internalErrorTest() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(AppUpdater.prototype, "aus").get(() => {
+ throw new Error("intentional test error");
+ });
+ sandbox.stub(AppUpdater.prototype, "checker").get(() => {
+ throw new Error("intentional test error");
+ });
+ sandbox.stub(AppUpdater.prototype, "um").get(() => {
+ throw new Error("intentional test error");
+ });
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+// Test for the About dialog's internal error handling.
+add_task(async function aboutDialog_internalError() {
+ let params = {};
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "internalError",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_backgroundUpdateSetting.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_backgroundUpdateSetting.js
new file mode 100644
index 0000000000..88afa8fdbe
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_backgroundUpdateSetting.js
@@ -0,0 +1,172 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 tests the background update UI in about:preferences.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+const BACKGROUND_UPDATE_PREF = "app.update.background.enabled";
+
+add_task(async function testBackgroundUpdateSettingUI() {
+ if (!AppConstants.MOZ_UPDATE_AGENT) {
+ // The element that we are testing in about:preferences is #ifdef'ed out of
+ // the file if MOZ_UPDATE_AGENT isn't defined. So there is nothing to
+ // test in that case.
+ logTestInfo(
+ `
+===============================================================================
+WARNING! This test involves background update, but background tasks are
+ disabled. This test will unconditionally pass since the feature it
+ wants to test isn't available.
+===============================================================================
+`
+ );
+ // Some of our testing environments do not consider a test to have passed if
+ // it didn't make any assertions.
+ ok(true, "Unconditionally passing test");
+ return;
+ }
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences"
+ );
+
+ const originalBackgroundUpdateVal = await UpdateUtils.readUpdateConfigSetting(
+ BACKGROUND_UPDATE_PREF
+ );
+ const originalUpdateAutoVal = await UpdateUtils.getAppUpdateAutoEnabled();
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.removeTab(tab);
+ await UpdateUtils.writeUpdateConfigSetting(
+ BACKGROUND_UPDATE_PREF,
+ originalBackgroundUpdateVal
+ );
+ await UpdateUtils.setAppUpdateAutoEnabled(originalUpdateAutoVal);
+ });
+
+ // If auto update is disabled, the control for background update should be
+ // disabled, since we cannot update in the background if we can't update
+ // automatically.
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED],
+ async perInstallationPrefsSupported => {
+ let backgroundUpdateCheckbox =
+ content.document.getElementById("backgroundUpdate");
+ is(
+ backgroundUpdateCheckbox.hidden,
+ !perInstallationPrefsSupported,
+ `The background update UI should ${
+ perInstallationPrefsSupported ? "not" : ""
+ } be hidden when and perInstallationPrefsSupported is ` +
+ `${perInstallationPrefsSupported}`
+ );
+ if (perInstallationPrefsSupported) {
+ is(
+ backgroundUpdateCheckbox.disabled,
+ true,
+ `The background update UI should be disabled when auto update is ` +
+ `disabled`
+ );
+ }
+ }
+ );
+
+ if (!UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED) {
+ // The remaining tests only make sense on platforms where per-installation
+ // prefs are supported and the UI will ever actually be displayed
+ return;
+ }
+
+ await UpdateUtils.setAppUpdateAutoEnabled(true);
+ await UpdateUtils.writeUpdateConfigSetting(BACKGROUND_UPDATE_PREF, true);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let backgroundUpdateCheckbox =
+ content.document.getElementById("backgroundUpdate");
+ is(
+ backgroundUpdateCheckbox.disabled,
+ false,
+ `The background update UI should not be disabled when auto update is ` +
+ `enabled`
+ );
+
+ is(
+ backgroundUpdateCheckbox.checked,
+ true,
+ "After enabling background update, the checkbox should be checked"
+ );
+
+ // Note that this action results in asynchronous activity. Normally when
+ // we change the update config, we await on the function to wait for the
+ // value to be written to the disk. We can't easily await on the UI state
+ // though. Luckily, we don't have to because reads/writes of the config file
+ // are serialized. So when we verify the written value by awaiting on
+ // readUpdateConfigSetting(), that will also wait for the value to be
+ // written to disk and for this UI to react to that.
+ backgroundUpdateCheckbox.click();
+ });
+
+ is(
+ await UpdateUtils.readUpdateConfigSetting(BACKGROUND_UPDATE_PREF),
+ false,
+ "Toggling the checkbox should have changed the setting value to false"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let backgroundUpdateCheckbox =
+ content.document.getElementById("backgroundUpdate");
+ is(
+ backgroundUpdateCheckbox.checked,
+ false,
+ "After toggling the checked checkbox, it should be unchecked."
+ );
+
+ // Like the last call like this one, this initiates asynchronous behavior.
+ backgroundUpdateCheckbox.click();
+ });
+
+ is(
+ await UpdateUtils.readUpdateConfigSetting(BACKGROUND_UPDATE_PREF),
+ true,
+ "Toggling the checkbox should have changed the setting value to true"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ is(
+ content.document.getElementById("backgroundUpdate").checked,
+ true,
+ "After toggling the unchecked checkbox, it should be checked"
+ );
+ });
+
+ // Test that the UI reacts to observed setting changes properly.
+ await UpdateUtils.writeUpdateConfigSetting(BACKGROUND_UPDATE_PREF, false);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ is(
+ content.document.getElementById("backgroundUpdate").checked,
+ false,
+ "Externally disabling background update should uncheck the checkbox"
+ );
+ });
+
+ await UpdateUtils.writeUpdateConfigSetting(BACKGROUND_UPDATE_PREF, true);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ is(
+ content.document.getElementById("backgroundUpdate").checked,
+ true,
+ "Externally enabling background update should check the checkbox"
+ );
+ });
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded.js
new file mode 100644
index 0000000000..6df4ee7ff5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences background check for updates
+// with the update downloaded when about:preferences is opened.
+add_task(async function aboutPrefs_backgroundCheck_downloaded() {
+ let params = { backgroundUpdate: true, waitForUpdateState: STATE_PENDING };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staged.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staged.js
new file mode 100644
index 0000000000..749a8f0b07
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staged.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences background check for updates
+// with the update downloaded and staged when about:preferences is opened.
+add_task(async function aboutPrefs_backgroundCheck_downloaded_staged() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ continueFile: CONTINUE_STAGING,
+ waitForUpdateState: STATE_APPLIED,
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staging.js
new file mode 100644
index 0000000000..73804cdf7a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_staging.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences background check for updates
+// with the update downloaded and about:preferences opened during staging.
+add_task(async function aboutPrefs_backgroundCheck_downloaded_staged() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let lankpackCall = mockLangpackInstall();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ async tab => {
+ // Once the state is pending but langpacks aren't complete the about
+ // dialog should still be showing downloading.
+ TestUtils.waitForCondition(() => {
+ return readStatusFile() == STATE_APPLIED;
+ });
+
+ let updateDeckId = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => {
+ return content.gAppUpdater.selectedPanel.id;
+ }
+ );
+
+ is(updateDeckId, "applying", "UI should still show as applying.");
+
+ let { appVersion, resolve } = await lankpackCall;
+ is(
+ appVersion,
+ Services.appinfo.version,
+ "Should see the right app version."
+ );
+ resolve();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_stagingFailure.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_stagingFailure.js
new file mode 100644
index 0000000000..9755fe167b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloaded_stagingFailure.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences background check for updates
+// with the update downloaded and staging has failed when the about:preferences
+// is opened.
+add_task(async function aboutPrefs_backgroundCheck_downloaded_stagingFailure() {
+ Services.env.set("MOZ_TEST_STAGING_ERROR", "1");
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&completePatchOnly=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading.js
new file mode 100644
index 0000000000..d9034296b0
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences background check for updates
+// with about:preferences opened during downloading.
+add_task(async function aboutPrefs_backgroundCheck_downloading() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ let lankpackCall = mockLangpackInstall();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ async tab => {
+ // Once the state is pending but langpacks aren't complete the about
+ // dialog should still be showing downloading.
+ TestUtils.waitForCondition(() => {
+ return readStatusFile() == STATE_PENDING;
+ });
+
+ let updateDeckId = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => {
+ return content.gAppUpdater.selectedPanel.id;
+ }
+ );
+
+ is(updateDeckId, "downloading", "UI should still show as downloading.");
+
+ let { appVersion, resolve } = await lankpackCall;
+ is(
+ appVersion,
+ Services.appinfo.version,
+ "Should see the right app version."
+ );
+ resolve();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading_staging.js
new file mode 100644
index 0000000000..8085ec4c2c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_downloading_staging.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences background check for updates
+// with about:preferences opened during downloading and stages the update.
+add_task(async function aboutPrefs_backgroundCheck_downloading_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ let lankpackCall = mockLangpackInstall();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&useSlowDownloadMar=1&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_DOWNLOADING,
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ async tab => {
+ // Once the state is pending but langpacks aren't complete the about
+ // dialog should still be showing downloading.
+ TestUtils.waitForCondition(() => {
+ return readStatusFile() == STATE_APPLIED;
+ });
+
+ let updateDeckId = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => {
+ return content.gAppUpdater.selectedPanel.id;
+ }
+ );
+
+ is(updateDeckId, "applying", "UI should still show as applying.");
+
+ let { appVersion, resolve } = await lankpackCall;
+ is(
+ appVersion,
+ Services.appinfo.version,
+ "Should see the right app version."
+ );
+ resolve();
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_multiUpdate.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_multiUpdate.js
new file mode 100644
index 0000000000..9ccf593db5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_bc_multiUpdate.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const FIRST_UPDATE_VERSION = "999998.0";
+const SECOND_UPDATE_VERSION = "999999.0";
+
+function prepareToDownloadVersion(version) {
+ setUpdateURL(
+ URL_HTTP_UPDATE_SJS +
+ `?detailsURL=${gDetailsURL}&promptWaitTime=0&appVersion=${version}`
+ );
+}
+
+add_task(async function aboutPrefs_backgroundCheck_multiUpdate() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let params = {
+ version: FIRST_UPDATE_VERSION,
+ backgroundUpdate: true,
+ waitForUpdateState: STATE_PENDING,
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ () => {
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION);
+ gAUS.checkForBackgroundUpdates();
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_apply_blocked.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_apply_blocked.js
new file mode 100644
index 0000000000..22daa5e256
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_apply_blocked.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const BUILDER_URL = "https://example.com/document-builder.sjs?html=";
+const PAGE_MARKUP = `
+<html>
+<head>
+ <script>
+ window.onbeforeunload = function() {
+ return true;
+ };
+ </script>
+</head>
+<body>TEST PAGE</body>
+</html>
+`;
+const TEST_URL = BUILDER_URL + encodeURI(PAGE_MARKUP);
+
+add_setup(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+ });
+});
+
+// Test for About Dialog foreground check for updates
+// and apply but restart is blocked by a page.
+add_task(async function aboutDialog_foregroundCheck_apply_blocked() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ let prefsTab;
+ let handlePromise = (async () => {
+ let dialog = await PromptTestUtils.waitForPrompt(window, {
+ modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT,
+ promptType: "confirmEx",
+ });
+ await SpecialPowers.spawn(prefsTab.linkedBrowser, [], async () => {
+ Assert.equal(
+ content.gAppUpdater.selectedPanel.id,
+ "restarting",
+ "The restarting panel should be displayed"
+ );
+ });
+
+ await PromptTestUtils.handlePrompt(dialog, { buttonNumClick: 1 });
+ })();
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1&promptWaitTime=0" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ async function getPrefsTab(tab) {
+ prefsTab = tab;
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ forceApply: true,
+ },
+ async function ensureDialogHasBeenCanceled() {
+ await handlePromise;
+ },
+ // A final check to ensure that we are back in the apply state.
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab, { skipPermitUnload: true });
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_cantApply.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_cantApply.js
new file mode 100644
index 0000000000..aac9b33134
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_cantApply.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// without the ability to apply updates.
+add_task(async function aboutPrefs_foregroundCheck_cantApply() {
+ lockWriteTestFile();
+
+ let params = {};
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "manualUpdate",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_malformedXML.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_malformedXML.js
new file mode 100644
index 0000000000..6d99a58e86
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_malformedXML.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a malformed update XML file.
+add_task(async function aboutPrefs_foregroundCheck_malformedXML() {
+ let params = { queryString: "&xmlMalformed=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "checkingFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_noUpdate.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_noUpdate.js
new file mode 100644
index 0000000000..a8f6c072f7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_noUpdate.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with no update available.
+add_task(async function aboutPrefs_foregroundCheck_noUpdate() {
+ let params = { queryString: "&noUpdates=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "noUpdatesFound",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_otherInstance.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_otherInstance.js
new file mode 100644
index 0000000000..271c8f2837
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_otherInstance.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with another application instance handling updates.
+add_task(async function aboutPrefs_foregroundCheck_otherInstance() {
+ setOtherInstanceHandlingUpdates();
+
+ let params = {};
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "otherInstanceHandlingUpdates",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_unsupported.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_unsupported.js
new file mode 100644
index 0000000000..e18ce31bc4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_check_unsupported.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with an unsupported update.
+add_task(async function aboutPrefs_foregroundCheck_unsupported() {
+ let params = { queryString: "&unsupported=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "unsupportedSystem",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto.js
new file mode 100644
index 0000000000..bd5fd18289
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with an automatic download.
+add_task(async function aboutPrefs_foregroundCheck_downloadAuto() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto_staging.js
new file mode 100644
index 0000000000..1d9d082edd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadAuto_staging.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with an automatic download and update staging.
+add_task(async function aboutPrefs_foregroundCheck_downloadAuto_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn.js
new file mode 100644
index 0000000000..115e875b74
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a manual download.
+add_task(async function aboutPrefs_foregroundCheck_downloadOptIn() {
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn_staging.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn_staging.js
new file mode 100644
index 0000000000..cff5d74701
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_downloadOptIn_staging.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a manual download and update staging.
+add_task(async function aboutPrefs_foregroundCheck_downloadOptIn_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: "0" };
+ }
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "applying",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: CONTINUE_STAGING,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_failure.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_failure.js
new file mode 100644
index 0000000000..fbf91c0e54
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_failure.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates which fails,
+// because it is impossible to connect to the update server.
+add_task(async function aboutPrefs_foregroundCheck_network_failure() {
+ let params = {
+ baseURL: "https://localhost:7777",
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingFailed",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_offline.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_offline.js
new file mode 100644
index 0000000000..7b5b6899b3
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_network_offline.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates which fails because
+// the browser is in offline mode and `localhost` cannot be resolved.
+add_task(async function aboutPrefs_foregroundCheck_network_offline() {
+ info("[OFFLINE] Setting Services.io.offline (do not forget to reset it!)");
+ // avoid that real network connectivity changes influence the test execution
+ Services.io.manageOfflineStatus = false;
+ Services.io.offline = true;
+ registerCleanupFunction(() => {
+ info("[ONLINE] Resetting Services.io.offline");
+ Services.io.offline = false;
+ Services.io.manageOfflineStatus = true;
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["network.disable-localhost-when-offline", true],
+ ["network.dns.offline-localhost", false],
+ ],
+ });
+
+ await runAboutPrefsUpdateTest({}, [
+ {
+ panelId: "checkingFailed",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_completeBadSize.js
new file mode 100644
index 0000000000..a36d3d9807
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_completeBadSize.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a complete bad size patch.
+add_task(async function aboutPrefs_foregroundCheck_completeBadSize() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "complete", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "complete", internalResult: gBadSizeResult };
+ } else {
+ downloadInfo[0] = { patchType: "complete", internalResult: gBadSizeResult };
+ }
+
+ let params = { queryString: "&completePatchOnly=1&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize.js
new file mode 100644
index 0000000000..060acd405a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a partial bad size patch.
+add_task(async function aboutPrefs_foregroundCheck_partialBadSize() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "partial", internalResult: gBadSizeResult };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: gBadSizeResult };
+ }
+
+ let params = { queryString: "&partialPatchOnly=1&invalidPartialSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_complete.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_complete.js
new file mode 100644
index 0000000000..c6e5b5b20b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_complete.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a partial bad size patch and a complete patch.
+add_task(async function aboutPrefs_foregroundCheck_partialBadSize_complete() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "partial", internalResult: gBadSizeResult };
+ downloadInfo[2] = { patchType: "complete", bitsResult: "0" };
+ } else {
+ downloadInfo[0] = { patchType: "partial", internalResult: gBadSizeResult };
+ downloadInfo[1] = { patchType: "complete", internalResult: "0" };
+ }
+
+ let params = { queryString: "&invalidPartialSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_completeBadSize.js
new file mode 100644
index 0000000000..62abdc3af5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_fc_patch_partialBadSize_completeBadSize.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for about:preferences foreground check for updates
+// with a partial bad size patch and a complete bad size patch.
+add_task(
+ async function aboutPrefs_foregroundCheck_partialBadSize_completeBadSize() {
+ let downloadInfo = [];
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED)) {
+ downloadInfo[0] = { patchType: "partial", bitsResult: gBadSizeResult };
+ downloadInfo[1] = {
+ patchType: "partial",
+ internalResult: gBadSizeResult,
+ };
+ downloadInfo[2] = { patchType: "complete", bitsResult: gBadSizeResult };
+ downloadInfo[3] = {
+ patchType: "complete",
+ internalResult: gBadSizeResult,
+ };
+ } else {
+ downloadInfo[0] = {
+ patchType: "partial",
+ internalResult: gBadSizeResult,
+ };
+ downloadInfo[1] = {
+ patchType: "complete",
+ internalResult: gBadSizeResult,
+ };
+ }
+
+ let params = { queryString: "&invalidPartialSize=1&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "downloadFailed",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ ]);
+ }
+);
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_internalError.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_internalError.js
new file mode 100644
index 0000000000..95a7ec1063
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_internalError.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+add_setup(function setup_internalErrorTest() {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(AppUpdater.prototype, "aus").get(() => {
+ throw new Error("intentional test error");
+ });
+ sandbox.stub(AppUpdater.prototype, "checker").get(() => {
+ throw new Error("intentional test error");
+ });
+ sandbox.stub(AppUpdater.prototype, "um").get(() => {
+ throw new Error("intentional test error");
+ });
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+});
+
+// Test for about:preferences internal error handling.
+add_task(async function aboutPrefs_internalError() {
+ let params = {};
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "internalError",
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_settings.js b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_settings.js
new file mode 100644
index 0000000000..db1b538d14
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_aboutPrefs_settings.js
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+// Changes, then verifies the value of app.update.auto via the about:preferences
+// UI. Requires a tab with about:preferences open to be passed in.
+async function changeAndVerifyPref(tab, newConfigValue) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ newConfigValue }],
+ async function ({ newConfigValue }) {
+ let radioId = newConfigValue ? "autoDesktop" : "manualDesktop";
+ let radioElement = content.document.getElementById(radioId);
+ let updateRadioGroup = radioElement.radioGroup;
+ let promise = ContentTaskUtils.waitForEvent(
+ updateRadioGroup,
+ "ProcessedUpdatePrefChange"
+ );
+ radioElement.click();
+ await promise;
+
+ is(
+ updateRadioGroup.value,
+ `${newConfigValue}`,
+ "Update preference should match expected"
+ );
+ is(
+ updateRadioGroup.disabled,
+ false,
+ "Update preferences should no longer be disabled"
+ );
+ }
+ );
+
+ let configValueRead = await UpdateUtils.getAppUpdateAutoEnabled();
+ is(
+ configValueRead,
+ newConfigValue,
+ "Value returned should have matched the expected value"
+ );
+}
+
+async function changeAndVerifyUpdateWrites({
+ tab,
+ newConfigValue,
+ discardUpdate,
+ expectPrompt,
+ expectRemainingUpdate,
+}) {
+ // A value of 1 will keep the update and a value of 0 will discard the update
+ // when the prompt service is called when the value of app.update.auto is
+ // changed to false.
+ let confirmExReply = discardUpdate ? 0 : 1;
+ let didPrompt = false;
+ let promptService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx(...args) {
+ promptService._confirmExArgs = args;
+ didPrompt = true;
+ return confirmExReply;
+ },
+ };
+ Services.prompt = promptService;
+ await changeAndVerifyPref(tab, newConfigValue);
+ is(
+ didPrompt,
+ expectPrompt,
+ `We should ${expectPrompt ? "" : "not "}be prompted`
+ );
+ is(
+ !!gUpdateManager.readyUpdate,
+ expectRemainingUpdate,
+ `There should ${expectRemainingUpdate ? "" : "not "}be a ready update`
+ );
+}
+
+add_task(async function testUpdateAutoPrefUI() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences"
+ );
+
+ // Hack: make the test run faster:
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.gMainPane._minUpdatePrefDisableTime = 10;
+ });
+
+ info("Enable automatic updates and check that works.");
+ await changeAndVerifyPref(tab, true);
+ ok(
+ !gUpdateManager.downloadingUpdate,
+ "There should not be a downloading update"
+ );
+ ok(!gUpdateManager.readyUpdate, "There should not be a ready update");
+
+ info("Disable automatic updates and check that works.");
+ await changeAndVerifyPref(tab, false);
+ ok(
+ !gUpdateManager.downloadingUpdate,
+ "There should not be a downloading update"
+ );
+ ok(!gUpdateManager.readyUpdate, "There should not be a ready update");
+
+ let patchProps = { state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { checkInterval: "1" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_PENDING);
+ reloadUpdateManagerData();
+ ok(!!gUpdateManager.readyUpdate, "There should be a ready update");
+
+ let { prompt } = Services;
+ registerCleanupFunction(() => {
+ Services.prompt = prompt;
+ });
+
+ // Setting the value to false will call the prompt service and when we
+ // don't discard the update there should still be an active update afterwards.
+ await changeAndVerifyUpdateWrites({
+ tab,
+ newConfigValue: false,
+ discardUpdate: false,
+ expectPrompt: true,
+ expectRemainingUpdate: true,
+ });
+
+ // Setting the value to true should not call the prompt service so there
+ // should still be an active update, even if we indicate we can discard
+ // the update in a hypothetical prompt.
+ await changeAndVerifyUpdateWrites({
+ tab,
+ newConfigValue: true,
+ discardUpdate: true,
+ expectPrompt: false,
+ expectRemainingUpdate: true,
+ });
+
+ // Setting the value to false will call the prompt service, and we do
+ // discard the update, so there should not be an active update.
+ await changeAndVerifyUpdateWrites({
+ tab,
+ newConfigValue: false,
+ discardUpdate: true,
+ expectPrompt: true,
+ expectRemainingUpdate: false,
+ });
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_cantApply.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_cantApply.js
new file mode 100644
index 0000000000..90f4c385cc
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_cantApply.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_check_cantApply() {
+ lockWriteTestFile();
+
+ let params = { checkAttempts: 1, queryString: "&promptWaitTime=0" };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { whatsNew: gDetailsURL, manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_malformedXML.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_malformedXML.js
new file mode 100644
index 0000000000..d83bc70b6f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_malformedXML.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_check_malformedXML() {
+ const maxBackgroundErrors = 10;
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_BACKGROUNDMAXERRORS, maxBackgroundErrors]],
+ });
+
+ let params = {
+ checkAttempts: maxBackgroundErrors,
+ queryString: "&xmlMalformed=1",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If the update check fails 10 consecutive attempts then the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { whatsNew: gDetailsURL, manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_unsupported.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_unsupported.js
new file mode 100644
index 0000000000..02aaab1064
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_check_unsupported.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for doorhanger background check for updates
+// with an unsupported update.
+add_task(async function doorhanger_bc_check_unsupported() {
+ let params = { checkAttempts: 1, queryString: "&unsupported=1" };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-unsupported",
+ button: "button",
+ pageURLs: { manual: gDetailsURL },
+ },
+ async function doorhanger_unsupported_persist() {
+ await TestUtils.waitForCondition(
+ () => PanelUI.menuButton.hasAttribute("badge-status"),
+ "Waiting for update badge",
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test.
+ logTestInfo(e);
+ });
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The window's doorhanger is closed."
+ );
+ ok(
+ PanelUI.menuButton.hasAttribute("badge-status"),
+ "The window has a badge."
+ );
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-unsupported",
+ "The correct badge is showing for the background window"
+ );
+
+ // Test persistence of the badge when the client has restarted by
+ // resetting the UpdateListener.
+ UpdateListener.reset();
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The window's doorhanger is closed."
+ );
+ ok(
+ !PanelUI.menuButton.hasAttribute("badge-status"),
+ "The window does not have a badge."
+ );
+ UpdateListener.maybeShowUnsupportedNotification();
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The window's doorhanger is closed."
+ );
+ ok(
+ PanelUI.menuButton.hasAttribute("badge-status"),
+ "The window has a badge."
+ );
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-unsupported",
+ "The correct badge is showing for the background window."
+ );
+ },
+ ]);
+
+ params = {
+ checkAttempts: 1,
+ queryString: "&invalidCompleteSize=1&promptWaitTime=0",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ async function doorhanger_unsupported_removed() {
+ // Test that finding an update removes the app.update.unsupported.url
+ // preference.
+ let unsupportedURL = Services.prefs.getCharPref(
+ PREF_APP_UPDATE_UNSUPPORTED_URL,
+ null
+ );
+ ok(
+ !unsupportedURL,
+ "The " + PREF_APP_UPDATE_UNSUPPORTED_URL + " preference was removed."
+ );
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures.js
new file mode 100644
index 0000000000..3678a440d2
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloadAutoFailures() {
+ const maxBackgroundErrors = 5;
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_BACKGROUNDMAXERRORS, maxBackgroundErrors]],
+ });
+
+ let params = { checkAttempts: 1, queryString: "&badURL=1" };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If the update download fails maxBackgroundErrors download attempts then
+ // show the update available prompt.
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ // If the update process is unable to install the update show the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures_bgWin.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures_bgWin.js
new file mode 100644
index 0000000000..a4502d7626
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadAutoFailures_bgWin.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloadAutoFailures_bgWin() {
+ function getBackgroundWindowHandler(destroyWindow) {
+ return async function () {
+ await TestUtils.waitForCondition(
+ () => PanelUI.menuButton.hasAttribute("badge-status"),
+ "Background window has a badge.",
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test.
+ logTestInfo(e);
+ });
+ ok(
+ PanelUI.menuButton.hasAttribute("badge-status"),
+ "PanelUI.menuButton should have a 'badge-status' attribute"
+ );
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The doorhanger is not showing for the background window"
+ );
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-available",
+ "The badge is showing for the background window"
+ );
+
+ let buttonEl = getNotificationButton(
+ extraWindow,
+ "update-available",
+ "button"
+ );
+ buttonEl.click();
+
+ if (destroyWindow) {
+ // The next popup may be shown during closeWindow or promiseFocus
+ // calls.
+ let waitForPopupShown = new Promise(resolve => {
+ window.addEventListener(
+ "popupshown",
+ () => {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+ await BrowserTestUtils.closeWindow(extraWindow);
+ await SimpleTest.promiseFocus(window);
+ await waitForPopupShown;
+ }
+ };
+ }
+
+ const maxBackgroundErrors = 5;
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_BACKGROUNDMAXERRORS, maxBackgroundErrors]],
+ });
+
+ let extraWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(extraWindow);
+
+ let params = { checkAttempts: 1, queryString: "&badURL=1", popupShown: true };
+ await runDoorhangerUpdateTest(params, [
+ getBackgroundWindowHandler(false),
+ getBackgroundWindowHandler(true),
+ {
+ // If the update process is unable to install the update show the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn.js
new file mode 100644
index 0000000000..6b8a5dad6c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloadOptIn() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "app.releaseNotesURL.prompt",
+ `${URL_HOST}/%LOCALE%/firefox/%VERSION%/releasenotes/?utm_source=firefox-browser&utm_medium=firefox-desktop&utm_campaign=updateprompt`,
+ ],
+ ],
+ });
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let version = "9999999.0";
+
+ let params = {
+ checkAttempts: 1,
+ queryString: "&invalidCompleteSize=1&promptWaitTime=0",
+ version,
+ };
+
+ let versionString = version;
+ switch (UpdateUtils.getUpdateChannel(false)) {
+ case "beta":
+ case "aurora":
+ versionString += "beta";
+ break;
+ }
+
+ await runDoorhangerUpdateTest(params, [
+ {
+ // Test that the Learn More link opens the correct release notes page.
+ notificationId: "update-available",
+ button: n => n.querySelector(".popup-notification-learnmore-link"),
+ checkActiveUpdate: null,
+ pageURLs: {
+ manual: Services.urlFormatter.formatURL(
+ `${URL_HOST}/%LOCALE%/firefox/${versionString}/releasenotes/?utm_source=firefox-browser&utm_medium=firefox-desktop&utm_campaign=updateprompt`
+ ),
+ },
+ },
+ {
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ popupShown: true,
+ },
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_bgWin.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_bgWin.js
new file mode 100644
index 0000000000..17dcce57ce
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_bgWin.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloadOptIn_bgWin() {
+ function getBackgroundWindowHandler() {
+ return async function () {
+ await TestUtils.waitForCondition(
+ () => PanelUI.menuButton.hasAttribute("badge-status"),
+ "Background window has a badge.",
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test.
+ logTestInfo(e);
+ });
+ ok(
+ PanelUI.menuButton.hasAttribute("badge-status"),
+ "PanelUI.menuButton should have a 'badge-status' attribute"
+ );
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "The doorhanger is not showing for the background window"
+ );
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-available",
+ "The badge is showing for the background window"
+ );
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ PanelUI.notificationPanel,
+ "popupshown"
+ );
+ await BrowserTestUtils.closeWindow(extraWindow);
+ await SimpleTest.promiseFocus(window);
+ await popupShownPromise;
+
+ let buttonEl = getNotificationButton(
+ window,
+ "update-available",
+ "button"
+ );
+ buttonEl.click();
+ };
+ }
+
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let extraWindow = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(extraWindow);
+
+ let params = { checkAttempts: 1, queryString: "&promptWaitTime=0" };
+ await runDoorhangerUpdateTest(params, [
+ getBackgroundWindowHandler(),
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_staging.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_staging.js
new file mode 100644
index 0000000000..7ba2d67964
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloadOptIn_staging.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloadOptIn_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Tests the app.update.promptWaitTime pref
+ [PREF_APP_UPDATE_PROMPTWAITTIME, 0],
+ [PREF_APP_UPDATE_STAGING_ENABLED, true],
+ ],
+ });
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+
+ let params = { checkAttempts: 1, queryString: "&invalidCompleteSize=1" };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded.js
new file mode 100644
index 0000000000..e29dad26fa
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloaded() {
+ let params = {
+ checkAttempts: 1,
+ queryString: "&invalidCompleteSize=1&promptWaitTime=0",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_disableBITS.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_disableBITS.js
new file mode 100644
index 0000000000..5f89c95322
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_disableBITS.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloaded_disableBITS() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_BITS_ENABLED, true]],
+ });
+
+ let params = {
+ checkAttempts: 1,
+ queryString: "&promptWaitTime=0&disableBITS=true",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+
+ let patch = getPatchOfType(
+ "partial",
+ gUpdateManager.readyUpdate
+ ).QueryInterface(Ci.nsIWritablePropertyBag);
+ ok(
+ !patch.getProperty("bitsId"),
+ "The selected patch should not have a bitsId property"
+ );
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_staged.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_staged.js
new file mode 100644
index 0000000000..50416608f2
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_downloaded_staged.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_downloaded_staged() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let params = {
+ checkAttempts: 1,
+ queryString: "&invalidCompleteSize=1&promptWaitTime=0",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate.js
new file mode 100644
index 0000000000..07e7bf51fa
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test downloads 2 updates sequentially, and ensures that the doorhanger
+ * and badge do what they are supposed to do:
+ * First thing after the first download, the doorhanger should be displayed.
+ * Then download the next update.
+ * While that update stages, the badge should be hidden to prevent restarting
+ * to update while the update is staging.
+ * Once the staging completes, the badge should return. The doorhanger should
+ * not be shown at this time, because it has already been shown this
+ * session.
+ */
+
+const FIRST_UPDATE_VERSION = "999998.0";
+const SECOND_UPDATE_VERSION = "999999.0";
+
+function prepareToDownloadVersion(version) {
+ setUpdateURL(
+ URL_HTTP_UPDATE_SJS +
+ `?detailsURL=${gDetailsURL}&promptWaitTime=0&appVersion=${version}`
+ );
+}
+
+add_task(async function doorhanger_bc_multiUpdate() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let params = {
+ checkAttempts: 1,
+ queryString: "&promptWaitTime=0",
+ version: FIRST_UPDATE_VERSION,
+ slowStaging: true,
+ };
+ await runDoorhangerUpdateTest(params, [
+ () => {
+ return continueFileHandler(CONTINUE_STAGING);
+ },
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ },
+ async () => {
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-restart",
+ "Should have restart badge"
+ );
+
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION);
+ let updateSwapped = waitForEvent("update-swap");
+ gAUS.checkForBackgroundUpdates();
+ await updateSwapped;
+ // The badge should be hidden while we swap from one update to the other
+ // to prevent restarting to update while staging is occurring. But since
+ // it will be waiting on the same event we are waiting on, wait an
+ // additional tick to let the other update-swap listeners run.
+ await TestUtils.waitForTick();
+
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "",
+ "Should not have restart badge during staging"
+ );
+
+ await continueFileHandler(CONTINUE_STAGING);
+
+ try {
+ await TestUtils.waitForCondition(
+ () =>
+ PanelUI.menuButton.getAttribute("badge-status") == "update-restart",
+ "Waiting for update restart badge to return after staging"
+ );
+ } catch (ex) {}
+
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-restart",
+ "Restart badge should be restored after staging completes"
+ );
+ is(
+ PanelUI.notificationPanel.state,
+ "closed",
+ "Should not open a second doorhanger"
+ );
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate_promptWaitTime.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate_promptWaitTime.js
new file mode 100644
index 0000000000..00c61bcbb4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_multiUpdate_promptWaitTime.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test downloads 2 updates sequentially, and ensures that the doorhanger
+ * and badge do what they are supposed to do. However, the first update has a
+ * long promptWaitTime, and the second has a short one and the badge wait time
+ * is set to 0. This should result in this behavior:
+ * First thing after the first download, the badge should be displayed, but
+ * not the doorhanger.
+ * Then download the next update.
+ * While that update stages, the badge should be hidden to prevent restarting
+ * to update while the update is staging.
+ * Once the staging completes, the doorhanger should be shown. Despite the
+ * long promptWaitTime of the initial update, this patch's short wait time
+ * means that the doorhanger should be shown soon rather than in a long
+ * time.
+ */
+
+const FIRST_UPDATE_VERSION = "999998.0";
+const SECOND_UPDATE_VERSION = "999999.0";
+const LONG_PROMPT_WAIT_TIME_SEC = 10 * 60 * 60; // 10 hours
+
+function prepareToDownloadVersion(version, promptWaitTime) {
+ setUpdateURL(
+ URL_HTTP_UPDATE_SJS +
+ `?detailsURL=${gDetailsURL}&promptWaitTime=${promptWaitTime}&appVersion=${version}`
+ );
+}
+
+add_task(async function doorhanger_bc_multiUpdate() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_APP_UPDATE_STAGING_ENABLED, true],
+ [PREF_APP_UPDATE_BADGEWAITTIME, 0],
+ ],
+ });
+
+ let params = {
+ checkAttempts: 1,
+ queryString: `&promptWaitTime=${LONG_PROMPT_WAIT_TIME_SEC}`,
+ version: FIRST_UPDATE_VERSION,
+ slowStaging: true,
+ };
+ await runDoorhangerUpdateTest(params, [
+ async () => {
+ await continueFileHandler(CONTINUE_STAGING);
+
+ try {
+ await TestUtils.waitForCondition(
+ () =>
+ PanelUI.menuButton.getAttribute("badge-status") == "update-restart",
+ "Waiting for update restart badge to return after staging"
+ );
+ } catch (ex) {}
+
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "update-restart",
+ "Should have restart badge"
+ );
+
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION, 0);
+ let updateSwapped = waitForEvent("update-swap");
+ gAUS.checkForBackgroundUpdates();
+ await updateSwapped;
+ // The badge should be hidden while we swap from one update to the other
+ // to prevent restarting to update while staging is occurring. But since
+ // it will be waiting on the same event we are waiting on, wait an
+ // additional tick to let the other update-swap listeners run.
+ await TestUtils.waitForTick();
+
+ is(
+ PanelUI.menuButton.getAttribute("badge-status"),
+ "",
+ "Should not have restart badge during staging"
+ );
+
+ await continueFileHandler(CONTINUE_STAGING);
+ },
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_completeBadSize.js
new file mode 100644
index 0000000000..3c29d6b4b0
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_completeBadSize.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_patch_completeBadSize() {
+ let params = {
+ checkAttempts: 1,
+ queryString: "&completePatchOnly=1&invalidCompleteSize=1",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If the update download fails maxBackgroundErrors download attempts then
+ // show the update available prompt.
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ // If the update process is unable to install the update show the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize.js
new file mode 100644
index 0000000000..68854b3f26
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_patch_partialBadSize() {
+ let params = {
+ checkAttempts: 1,
+ queryString: "&partialPatchOnly=1&invalidPartialSize=1",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If the update download fails maxBackgroundErrors download attempts then
+ // show the update available prompt.
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ // If the update process is unable to install the update show the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_complete.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_complete.js
new file mode 100644
index 0000000000..f3c9b1f51f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_complete.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_patch_partialBadSize_complete() {
+ let params = {
+ checkAttempts: 1,
+ queryString: "&invalidPartialSize=1&promptWaitTime=0",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_completeBadSize.js
new file mode 100644
index 0000000000..a18e2f6444
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_bc_patch_partialBadSize_completeBadSize.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_bc_patch_partialBadSize_completeBadSize() {
+ let params = {
+ checkAttempts: 1,
+ queryString: "&invalidPartialSize=1&invalidCompleteSize=1",
+ };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If the update download fails maxBackgroundErrors download attempts then
+ // show the update available prompt.
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ notificationId: "update-available",
+ button: "button",
+ checkActiveUpdate: null,
+ },
+ {
+ // If the update process is unable to install the update show the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_completeApplyFailure.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_completeApplyFailure.js
new file mode 100644
index 0000000000..5c7c937d81
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_completeApplyFailure.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_sp_patch_completeApplyFailure() {
+ let patchProps = { state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { checkInterval: "1" };
+ let updates = getLocalUpdateString(updateProps, patches);
+
+ let params = { updates };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If the update process is unable to install the update show the manual
+ // update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure.js
new file mode 100644
index 0000000000..45434c8361
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_sp_patch_partialApplyFailure() {
+ let patchProps = { type: "partial", state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { isCompleteUpdate: "false", checkInterval: "1" };
+ let updates = getLocalUpdateString(updateProps, patches);
+
+ let params = { updates };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If there is only an invalid patch show the manual update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete.js
new file mode 100644
index 0000000000..bf533dab04
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function doorhanger_sp_patch_partialApplyFailure_complete() {
+ let patchProps = { type: "partial", state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ patchProps = { selected: "false" };
+ patches += getLocalPatchString(patchProps);
+ let updateProps = { isCompleteUpdate: "false", promptWaitTime: "0" };
+ let updates = getLocalUpdateString(updateProps, patches);
+
+ let params = { updates };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_PENDING },
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_completeBadSize.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_completeBadSize.js
new file mode 100644
index 0000000000..df17bc1220
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_completeBadSize.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ async function doorhanger_sp_patch_partialApplyFailure_completeBadSize() {
+ // Because of the way the test is simulating failure it has to pretend it has
+ // already retried.
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS, 0]],
+ });
+
+ let patchProps = { type: "partial", state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ patchProps = { size: "1234", selected: "false" };
+ patches += getLocalPatchString(patchProps);
+ let updateProps = { isCompleteUpdate: "false" };
+ let updates = getLocalUpdateString(updateProps, patches);
+
+ let params = { updates };
+ await runDoorhangerUpdateTest(params, [
+ {
+ // If there is only an invalid patch show the manual update doorhanger.
+ notificationId: "update-manual",
+ button: "button",
+ checkActiveUpdate: null,
+ pageURLs: { manual: URL_MANUAL_UPDATE },
+ },
+ ]);
+ }
+);
diff --git a/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete_staging.js b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete_staging.js
new file mode 100644
index 0000000000..a99c04b0be
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_doorhanger_sp_patch_partialApplyFailure_complete_staging.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ async function doorhanger_sp_patch_partialApplyFailure_complete_staging() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let patchProps = { type: "partial", state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ patchProps = { selected: "false" };
+ patches += getLocalPatchString(patchProps);
+ let updateProps = { isCompleteUpdate: "false", promptWaitTime: "0" };
+ let updates = getLocalUpdateString(updateProps, patches);
+
+ let params = { updates };
+ await runDoorhangerUpdateTest(params, [
+ {
+ notificationId: "update-restart",
+ button: "secondaryButton",
+ checkActiveUpdate: { state: STATE_APPLIED },
+ },
+ ]);
+ }
+);
diff --git a/toolkit/mozapps/update/tests/browser/browser_elevationDialog.js b/toolkit/mozapps/update/tests/browser/browser_elevationDialog.js
new file mode 100644
index 0000000000..6aa32a7fc9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_elevationDialog.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function elevation_dialog() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_DISABLEDFORTESTING, false]],
+ });
+
+ // Create a mock of nsIAppStartup's quit method so clicking the restart button
+ // won't restart the application.
+ let { startup } = Services;
+ let appStartup = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAppStartup"]),
+ quit(mode) {
+ if (elevationDialog) {
+ elevationDialog.close();
+ elevationDialog = null;
+ }
+ },
+ };
+ Services.startup = appStartup;
+ registerCleanupFunction(() => {
+ Services.startup = startup;
+ });
+
+ registerCleanupFunction(async () => {
+ let win = Services.wm.getMostRecentWindow("Update:Elevation");
+ if (win) {
+ win.close();
+ await TestUtils.waitForCondition(
+ () => !Services.wm.getMostRecentWindow("Update:Elevation"),
+ "The Update Elevation dialog should have closed"
+ );
+ }
+ });
+
+ // Test clicking the "Restart Later" button
+ let elevationDialog = await waitForElevationDialog();
+ await TestUtils.waitForTick();
+ elevationDialog.document.getElementById("elevateExtra2").click();
+ await TestUtils.waitForCondition(
+ () => !Services.wm.getMostRecentWindow("Update:Elevation"),
+ "The Update Elevation dialog should have closed"
+ );
+ ok(!!gUpdateManager.readyUpdate, "There should be a ready update");
+ is(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING_ELEVATE,
+ "The ready update state should equal " + STATE_PENDING_ELEVATE
+ );
+ is(
+ readStatusFile(),
+ STATE_PENDING_ELEVATE,
+ "The status file state should equal " + STATE_PENDING_ELEVATE
+ );
+
+ // Test clicking the "No Thanks" button
+ elevationDialog = await waitForElevationDialog();
+ await TestUtils.waitForTick();
+ elevationDialog.document.getElementById("elevateExtra1").click();
+ await TestUtils.waitForCondition(
+ () => !Services.wm.getMostRecentWindow("Update:Elevation"),
+ "The Update Elevation dialog should have closed"
+ );
+ ok(!gUpdateManager.readyUpdate, "There should not be a ready update");
+ is(
+ readStatusFile(),
+ STATE_NONE,
+ "The status file state should equal " + STATE_NONE
+ );
+
+ // Test clicking the "Restart <brandShortName>" button
+ elevationDialog = await waitForElevationDialog();
+ await TestUtils.waitForTick();
+ elevationDialog.document.getElementById("elevateAccept").click();
+ await TestUtils.waitForCondition(
+ () => !Services.wm.getMostRecentWindow("Update:Elevation"),
+ "The Update Elevation dialog should have closed"
+ );
+ ok(!!gUpdateManager.readyUpdate, "There should be a ready update");
+ is(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING_ELEVATE,
+ "The active update state should equal " + STATE_PENDING_ELEVATE
+ );
+ is(
+ readStatusFile(),
+ STATE_PENDING,
+ "The status file state should equal " + STATE_PENDING
+ );
+});
+
+/**
+ * Waits for the Update Elevation Dialog to load.
+ *
+ * @return A promise that returns the domWindow for the Update Elevation Dialog
+ * and resolves when the Update Elevation Dialog loads.
+ */
+function waitForElevationDialog() {
+ return new Promise(resolve => {
+ var listener = {
+ onOpenWindow: aXULWindow => {
+ debugDump("Update Elevation dialog shown...");
+ Services.wm.removeListener(listener);
+
+ async function elevationDialogOnLoad() {
+ domwindow.removeEventListener("load", elevationDialogOnLoad, true);
+ let chromeURI =
+ "chrome://mozapps/content/update/updateElevation.xhtml";
+ is(
+ domwindow.document.location.href,
+ chromeURI,
+ "Update Elevation appeared"
+ );
+ resolve(domwindow);
+ }
+
+ var domwindow = aXULWindow.docShell.domWindow;
+ domwindow.addEventListener("load", elevationDialogOnLoad, true);
+ },
+ onCloseWindow: aXULWindow => {},
+ };
+
+ Services.wm.addListener(listener);
+ // Add the active-update.xml and update.status files used for these tests,
+ // reload the update manager, and then simulate startup so the Update
+ // Elevation Dialog is opened.
+ let patchProps = { state: STATE_PENDING_ELEVATE };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { checkInterval: "1" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_PENDING_ELEVATE);
+ reloadUpdateManagerData();
+ testPostUpdateProcessing();
+ });
+}
diff --git a/toolkit/mozapps/update/tests/browser/browser_memory_allocation_error_fallback.js b/toolkit/mozapps/update/tests/browser/browser_memory_allocation_error_fallback.js
new file mode 100644
index 0000000000..55ec2e14a1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_memory_allocation_error_fallback.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * When the updater fails with a memory allocation error, we should fall back to
+ * updating without staging.
+ */
+
+const READ_STRINGS_MEM_ERROR = 10;
+const ARCHIVE_READER_MEM_ERROR = 11;
+const BSPATCH_MEM_ERROR = 12;
+const UPDATER_MEM_ERROR = 13;
+const UPDATER_QUOTED_PATH_MEM_ERROR = 14;
+
+const EXPECTED_STATUS =
+ AppConstants.platform == "win" ? STATE_PENDING_SVC : STATE_PENDING;
+
+add_setup(async function setup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_APP_UPDATE_STAGING_ENABLED, true],
+ [PREF_APP_UPDATE_SERVICE_ENABLED, true],
+ ],
+ });
+
+ registerCleanupFunction(() => {
+ Services.env.set("MOZ_FORCE_ERROR_CODE", "");
+ });
+});
+
+async function memAllocErrorFallback(errorCode) {
+ Services.env.set("MOZ_FORCE_ERROR_CODE", errorCode.toString());
+
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = {
+ queryString: "&invalidCompleteSize=1",
+ backgroundUpdate: true,
+ continueFile: CONTINUE_STAGING,
+ waitForUpdateState: EXPECTED_STATUS,
+ };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: EXPECTED_STATUS },
+ continueFile: null,
+ },
+ ]);
+}
+
+function cleanup() {
+ reloadUpdateManagerData(true);
+ removeUpdateFiles(true);
+}
+
+add_task(async function memAllocErrorFallback_READ_STRINGS_MEM_ERROR() {
+ await memAllocErrorFallback(READ_STRINGS_MEM_ERROR);
+ cleanup();
+});
+
+add_task(async function memAllocErrorFallback_ARCHIVE_READER_MEM_ERROR() {
+ await memAllocErrorFallback(ARCHIVE_READER_MEM_ERROR);
+ cleanup();
+});
+
+add_task(async function memAllocErrorFallback_BSPATCH_MEM_ERROR() {
+ await memAllocErrorFallback(BSPATCH_MEM_ERROR);
+ cleanup();
+});
+
+add_task(async function memAllocErrorFallback_UPDATER_MEM_ERROR() {
+ await memAllocErrorFallback(UPDATER_MEM_ERROR);
+ cleanup();
+});
+
+add_task(async function memAllocErrorFallback_UPDATER_QUOTED_PATH_MEM_ERROR() {
+ await memAllocErrorFallback(UPDATER_QUOTED_PATH_MEM_ERROR);
+ cleanup();
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_downloaded_ready.js b/toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_downloaded_ready.js
new file mode 100644
index 0000000000..b6847972f4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_downloaded_ready.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryArchiveTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryArchiveTesting.sys.mjs"
+);
+
+/**
+ * Test that UpdatePing telemetry with a payload reason of ready is sent for a
+ * staged update.
+ *
+ * Please note that this is really a Telemetry test, not an
+ * "update UI" test like the rest of the tests in this directory.
+ * This test does not live in toolkit/components/telemetry/tests to prevent
+ * duplicating the code for all the test dependencies. Unfortunately, due
+ * to a limitation in the build system, we were not able to simply reference
+ * the dependencies as "support-files" in the test manifest.
+ */
+add_task(async function telemetry_updatePing_ready() {
+ let archiveChecker = new TelemetryArchiveTesting.Checker();
+ await archiveChecker.promiseInit();
+
+ let updateParams = "";
+ await runTelemetryUpdateTest(updateParams, "update-downloaded");
+
+ // We cannot control when the ping will be generated/archived after we trigger
+ // an update, so let's make sure to have one before moving on with validation.
+ let updatePing;
+ await TestUtils.waitForCondition(
+ async function () {
+ // Check that the ping made it into the Telemetry archive.
+ // The test data is defined in ../data/sharedUpdateXML.js
+ updatePing = await archiveChecker.promiseFindPing("update", [
+ [["payload", "reason"], "ready"],
+ [["payload", "targetBuildId"], "20080811053724"],
+ ]);
+ return !!updatePing;
+ },
+ "Make sure the ping is generated before trying to validate it.",
+ 500,
+ 100
+ );
+
+ ok(updatePing, "The 'update' ping must be correctly sent.");
+
+ // We don't know the exact value for the other fields, so just check
+ // that they're available.
+ for (let f of ["targetVersion", "targetChannel", "targetDisplayVersion"]) {
+ ok(
+ f in updatePing.payload,
+ `${f} must be available in the update ping payload.`
+ );
+ Assert.equal(
+ typeof updatePing.payload[f],
+ "string",
+ `${f} must have the correct format.`
+ );
+ }
+
+ // Also make sure that the ping contains both a client id and an
+ // environment section.
+ ok("clientId" in updatePing, "The update ping must report a client id.");
+ ok(
+ "environment" in updatePing,
+ "The update ping must report the environment."
+ );
+});
diff --git a/toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_staged_ready.js b/toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_staged_ready.js
new file mode 100644
index 0000000000..ee11210369
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/browser_telemetry_updatePing_staged_ready.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryArchiveTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryArchiveTesting.sys.mjs"
+);
+
+/**
+ * Test that UpdatePing telemetry with a payload reason of ready is sent for a
+ * staged update.
+ *
+ * Please note that this is really a Telemetry test, not an
+ * "update UI" test like the rest of the tests in this directory.
+ * This test does not live in toolkit/components/telemetry/tests to prevent
+ * duplicating the code for all the test dependencies. Unfortunately, due
+ * to a limitation in the build system, we were not able to simply reference
+ * the dependencies as "support-files" in the test manifest.
+ */
+add_task(async function telemetry_updatePing_ready() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_STAGING_ENABLED, true]],
+ });
+
+ let archiveChecker = new TelemetryArchiveTesting.Checker();
+ await archiveChecker.promiseInit();
+
+ let updateParams = "";
+ await runTelemetryUpdateTest(updateParams, "update-staged");
+
+ // We cannot control when the ping will be generated/archived after we trigger
+ // an update, so let's make sure to have one before moving on with validation.
+ let updatePing;
+ await TestUtils.waitForCondition(
+ async function () {
+ // Check that the ping made it into the Telemetry archive.
+ // The test data is defined in ../data/sharedUpdateXML.js
+ updatePing = await archiveChecker.promiseFindPing("update", [
+ [["payload", "reason"], "ready"],
+ [["payload", "targetBuildId"], "20080811053724"],
+ ]);
+ return !!updatePing;
+ },
+ "Make sure the ping is generated before trying to validate it.",
+ 500,
+ 100
+ );
+
+ ok(updatePing, "The 'update' ping must be correctly sent.");
+
+ // We don't know the exact value for the other fields, so just check
+ // that they're available.
+ for (let f of ["targetVersion", "targetChannel", "targetDisplayVersion"]) {
+ ok(
+ f in updatePing.payload,
+ `${f} must be available in the update ping payload.`
+ );
+ Assert.equal(
+ typeof updatePing.payload[f],
+ "string",
+ `${f} must have the correct format.`
+ );
+ }
+
+ // Also make sure that the ping contains both a client id and an
+ // environment section.
+ ok("clientId" in updatePing, "The update ping must report a client id.");
+ ok(
+ "environment" in updatePing,
+ "The update ping must report the environment."
+ );
+});
diff --git a/toolkit/mozapps/update/tests/browser/downloadPage.html b/toolkit/mozapps/update/tests/browser/downloadPage.html
new file mode 100644
index 0000000000..4810e2e0d6
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/downloadPage.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Download page</title>
+ <meta charset="utf-8">
+</head>
+<body>
+<!-- just use simple.mar since we have it available and it will result in a download dialog -->
+<a id="download-link" href="http://example.com/browser/browser/base/content/test/appUpdate/simple.mar" data-link-type="download">
+ Download
+</a>
+</body>
+</html>
diff --git a/toolkit/mozapps/update/tests/browser/head.js b/toolkit/mozapps/update/tests/browser/head.js
new file mode 100644
index 0000000000..a18a72e581
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/head.js
@@ -0,0 +1,1353 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ UpdateListener: "resource://gre/modules/UpdateListener.sys.mjs",
+});
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+
+const BIN_SUFFIX = AppConstants.platform == "win" ? ".exe" : "";
+const FILE_UPDATER_BIN =
+ "updater" + (AppConstants.platform == "macosx" ? ".app" : BIN_SUFFIX);
+const FILE_UPDATER_BIN_BAK = FILE_UPDATER_BIN + ".bak";
+
+const LOG_FUNCTION = info;
+
+const MAX_UPDATE_COPY_ATTEMPTS = 10;
+
+const DATA_URI_SPEC =
+ "chrome://mochitests/content/browser/toolkit/mozapps/update/tests/browser/";
+/* import-globals-from testConstants.js */
+Services.scriptloader.loadSubScript(DATA_URI_SPEC + "testConstants.js", this);
+
+var gURLData = URL_HOST + "/" + REL_PATH_DATA;
+const URL_MANUAL_UPDATE = gURLData + "downloadPage.html";
+
+const gBadSizeResult = Cr.NS_ERROR_UNEXPECTED.toString();
+
+/* import-globals-from ../data/shared.js */
+Services.scriptloader.loadSubScript(DATA_URI_SPEC + "shared.js", this);
+
+let gOriginalUpdateAutoValue = null;
+
+// Some elements append a trailing /. After the chrome tests are removed this
+// code can be changed so URL_HOST already has a trailing /.
+const gDetailsURL = URL_HOST + "/";
+
+// Set to true to log additional information for debugging. To log additional
+// information for individual tests set gDebugTest to false here and to true
+// globally in the test.
+gDebugTest = false;
+
+// This is to accommodate the TV task which runs the tests with --verify.
+requestLongerTimeout(10);
+
+/**
+ * Common tasks to perform for all tests before each one has started.
+ */
+add_setup(async function setupTestCommon() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_APP_UPDATE_BADGEWAITTIME, 1800],
+ [PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS, 0],
+ [PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS, 2],
+ [PREF_APP_UPDATE_LOG, gDebugTest],
+ [PREF_APP_UPDATE_PROMPTWAITTIME, 3600],
+ [PREF_APP_UPDATE_SERVICE_ENABLED, false],
+ ],
+ });
+
+ // We need to keep the update sync manager from thinking two instances are
+ // running because of the mochitest parent instance, which means we need to
+ // override the directory service with a fake executable path and then reset
+ // the lock. But leaving the directory service overridden causes problems for
+ // these tests, so we need to restore the real service immediately after.
+ // To form the path, we'll use the real executable path with a token appended
+ // (the path needs to be absolute, but not to point to a real file).
+ // This block is loosely copied from adjustGeneralPaths() in another update
+ // test file, xpcshellUtilsAUS.js, but this is a much more limited version;
+ // it's been copied here both because the full function is overkill and also
+ // because making it general enough to run in both xpcshell and mochitest
+ // would have been unreasonably difficult.
+ let exePath = Services.dirsvc.get(XRE_EXECUTABLE_FILE, Ci.nsIFile);
+ let dirProvider = {
+ getFile: function AGP_DP_getFile(aProp, aPersistent) {
+ // Set the value of persistent to false so when this directory provider is
+ // unregistered it will revert back to the original provider.
+ aPersistent.value = false;
+ switch (aProp) {
+ case XRE_EXECUTABLE_FILE:
+ exePath.append("browser-test");
+ return exePath;
+ }
+ return null;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ let ds = Services.dirsvc.QueryInterface(Ci.nsIDirectoryService);
+ ds.QueryInterface(Ci.nsIProperties).undefine(XRE_EXECUTABLE_FILE);
+ ds.registerProvider(dirProvider);
+
+ let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ syncManager.resetLock();
+
+ ds.unregisterProvider(dirProvider);
+
+ setUpdateTimerPrefs();
+ reloadUpdateManagerData(true);
+ removeUpdateFiles(true);
+ UpdateListener.reset();
+ AppMenuNotifications.removeNotification(/.*/);
+ // Most app update mochitest-browser-chrome tests expect auto update to be
+ // enabled. Those that don't will explicitly change this.
+ await setAppUpdateAutoEnabledHelper(true);
+});
+
+/**
+ * Common tasks to perform for all tests after each one has finished.
+ */
+registerCleanupFunction(async () => {
+ AppMenuNotifications.removeNotification(/.*/);
+ Services.env.set("MOZ_TEST_SKIP_UPDATE_STAGE", "");
+ Services.env.set("MOZ_TEST_SLOW_SKIP_UPDATE_STAGE", "");
+ Services.env.set("MOZ_TEST_STAGING_ERROR", "");
+ UpdateListener.reset();
+ AppMenuNotifications.removeNotification(/.*/);
+ reloadUpdateManagerData(true);
+ // Pass false when the log files are needed for troubleshooting the tests.
+ removeUpdateFiles(true);
+ // Always try to restore the original updater files. If none of the updater
+ // backup files are present then this is just a no-op.
+ await finishTestRestoreUpdaterBackup();
+ // Reset the update lock once again so that we know the lock we're
+ // interested in here will be closed properly (normally that happens during
+ // XPCOM shutdown, but that isn't consistent during tests).
+ let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ syncManager.resetLock();
+});
+
+/**
+ * Overrides the add-ons manager language pack staging with a mocked version.
+ * The returned promise resolves when language pack staging begins returning an
+ * object with the new appVersion and platformVersion and functions to resolve
+ * or reject the install.
+ */
+function mockLangpackInstall() {
+ let original = XPIExports.XPIInstall.stageLangpacksForAppUpdate;
+ registerCleanupFunction(() => {
+ XPIExports.XPIInstall.stageLangpacksForAppUpdate = original;
+ });
+
+ let stagingCall = Promise.withResolvers();
+ XPIExports.XPIInstall.stageLangpacksForAppUpdate = (
+ appVersion,
+ platformVersion
+ ) => {
+ let result = Promise.withResolvers();
+ stagingCall.resolve({
+ appVersion,
+ platformVersion,
+ resolve: result.resolve,
+ reject: result.reject,
+ });
+
+ return result.promise;
+ };
+
+ return stagingCall.promise;
+}
+
+/**
+ * Creates and locks the app update write test file so it is possible to test
+ * when the user doesn't have write access to update. Since this is only
+ * possible on Windows the function throws when it is called on other platforms.
+ * This uses registerCleanupFunction to remove the lock and the file when the
+ * test completes.
+ *
+ * @throws If the function is called on a platform other than Windows.
+ */
+function lockWriteTestFile() {
+ if (AppConstants.platform != "win") {
+ throw new Error("Windows only test function called");
+ }
+ let file = getUpdateDirFile(FILE_UPDATE_TEST).QueryInterface(
+ Ci.nsILocalFileWin
+ );
+ // Remove the file if it exists just in case.
+ if (file.exists()) {
+ file.readOnly = false;
+ file.remove(false);
+ }
+ file.create(file.NORMAL_FILE_TYPE, 0o444);
+ file.readOnly = true;
+ registerCleanupFunction(() => {
+ file.readOnly = false;
+ file.remove(false);
+ });
+}
+
+/**
+ * Closes the update mutex handle in nsUpdateService.js if it exists and then
+ * creates a new update mutex handle so the update code thinks there is another
+ * instance of the application handling updates.
+ *
+ * @throws If the function is called on a platform other than Windows.
+ */
+function setOtherInstanceHandlingUpdates() {
+ if (AppConstants.platform != "win") {
+ throw new Error("Windows only test function called");
+ }
+ gAUS.observe(null, "test-close-handle-update-mutex", "");
+ let handle = createMutex(getPerInstallationMutexName());
+ registerCleanupFunction(() => {
+ closeHandle(handle);
+ });
+}
+
+/**
+ * Gets the update version info for the update url parameters to send to
+ * app_update.sjs.
+ *
+ * @param aAppVersion (optional)
+ * The application version for the update snippet. If not specified the
+ * current application version will be used.
+ * @return The url parameters for the application and platform version to send
+ * to app_update.sjs.
+ */
+function getVersionParams(aAppVersion) {
+ let appInfo = Services.appinfo;
+ return "&appVersion=" + (aAppVersion ? aAppVersion : appInfo.version);
+}
+
+/**
+ * Prevent nsIUpdateTimerManager from notifying nsIApplicationUpdateService
+ * to check for updates by setting the app update last update time to the
+ * current time minus one minute in seconds and the interval time to 12 hours
+ * in seconds.
+ */
+function setUpdateTimerPrefs() {
+ let now = Math.round(Date.now() / 1000) - 60;
+ Services.prefs.setIntPref(PREF_APP_UPDATE_LASTUPDATETIME, now);
+ Services.prefs.setIntPref(PREF_APP_UPDATE_INTERVAL, 43200);
+}
+
+/*
+ * Sets the value of the App Auto Update setting and sets it back to the
+ * original value at the start of the test when the test finishes.
+ *
+ * @param enabled
+ * The value to set App Auto Update to.
+ */
+async function setAppUpdateAutoEnabledHelper(enabled) {
+ if (gOriginalUpdateAutoValue == null) {
+ gOriginalUpdateAutoValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ registerCleanupFunction(async () => {
+ await UpdateUtils.setAppUpdateAutoEnabled(gOriginalUpdateAutoValue);
+ });
+ }
+ await UpdateUtils.setAppUpdateAutoEnabled(enabled);
+}
+
+/**
+ * Gets the specified button for the notification.
+ *
+ * @param win
+ * The window to get the notification button for.
+ * @param notificationId
+ * The ID of the notification to get the button for.
+ * @param button
+ * The anonid of the button to get, or a function to find it.
+ * @return The button element.
+ */
+function getNotificationButton(win, notificationId, button) {
+ let notification = win.document.getElementById(
+ `appMenu-${notificationId}-notification`
+ );
+ ok(!notification.hidden, `${notificationId} notification is showing`);
+ if (typeof button === "function") {
+ return button(notification);
+ }
+ return notification[button];
+}
+
+/**
+ * For staging tests the test updater must be used and this restores the backed
+ * up real updater if it exists and tries again on failure since Windows debug
+ * builds at times leave the file in use. After success moveRealUpdater is
+ * called to continue the setup of the test updater.
+ */
+function setupTestUpdater() {
+ return (async function () {
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_STAGING_ENABLED)) {
+ try {
+ restoreUpdaterBackup();
+ } catch (e) {
+ logTestInfo(
+ "Attempt to restore the backed up updater failed... " +
+ "will try again, Exception: " +
+ e
+ );
+ await TestUtils.waitForTick();
+ await setupTestUpdater();
+ return;
+ }
+ await moveRealUpdater();
+ }
+ })();
+}
+
+/**
+ * Backs up the real updater and tries again on failure since Windows debug
+ * builds at times leave the file in use. After success it will call
+ * copyTestUpdater to continue the setup of the test updater.
+ */
+function moveRealUpdater() {
+ return (async function () {
+ try {
+ // Move away the real updater
+ let greBinDir = getGREBinDir();
+ let updater = greBinDir.clone();
+ updater.append(FILE_UPDATER_BIN);
+ updater.moveTo(greBinDir, FILE_UPDATER_BIN_BAK);
+
+ let greDir = getGREDir();
+ let updateSettingsIni = greDir.clone();
+ updateSettingsIni.append(FILE_UPDATE_SETTINGS_INI);
+ if (updateSettingsIni.exists()) {
+ updateSettingsIni.moveTo(greDir, FILE_UPDATE_SETTINGS_INI_BAK);
+ }
+
+ let precomplete = greDir.clone();
+ precomplete.append(FILE_PRECOMPLETE);
+ if (precomplete.exists()) {
+ precomplete.moveTo(greDir, FILE_PRECOMPLETE_BAK);
+ }
+ } catch (e) {
+ logTestInfo(
+ "Attempt to move the real updater out of the way failed... " +
+ "will try again, Exception: " +
+ e
+ );
+ await TestUtils.waitForTick();
+ await moveRealUpdater();
+ return;
+ }
+
+ await copyTestUpdater();
+ })();
+}
+
+/**
+ * Copies the test updater and tries again on failure since Windows debug builds
+ * at times leave the file in use.
+ */
+function copyTestUpdater(attempt = 0) {
+ return (async function () {
+ try {
+ // Copy the test updater
+ let greBinDir = getGREBinDir();
+ let testUpdaterDir = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ let relPath = REL_PATH_DATA;
+ let pathParts = relPath.split("/");
+ for (let i = 0; i < pathParts.length; ++i) {
+ testUpdaterDir.append(pathParts[i]);
+ }
+
+ let testUpdater = testUpdaterDir.clone();
+ testUpdater.append(FILE_UPDATER_BIN);
+ testUpdater.copyToFollowingLinks(greBinDir, FILE_UPDATER_BIN);
+
+ let greDir = getGREDir();
+ let updateSettingsIni = greDir.clone();
+ updateSettingsIni.append(FILE_UPDATE_SETTINGS_INI);
+ writeFile(updateSettingsIni, UPDATE_SETTINGS_CONTENTS);
+
+ let precomplete = greDir.clone();
+ precomplete.append(FILE_PRECOMPLETE);
+ writeFile(precomplete, PRECOMPLETE_CONTENTS);
+ } catch (e) {
+ if (attempt < MAX_UPDATE_COPY_ATTEMPTS) {
+ logTestInfo(
+ "Attempt to copy the test updater failed... " +
+ "will try again, Exception: " +
+ e
+ );
+ await TestUtils.waitForTick();
+ await copyTestUpdater(attempt++);
+ }
+ }
+ })();
+}
+
+/**
+ * Restores the updater and updater related file that if there a backup exists.
+ * This is called in setupTestUpdater before the backup of the real updater is
+ * done in case the previous test failed to restore the file when a test has
+ * finished. This is also called in finishTestRestoreUpdaterBackup to restore
+ * the files when a test finishes.
+ */
+function restoreUpdaterBackup() {
+ let greBinDir = getGREBinDir();
+ let updater = greBinDir.clone();
+ let updaterBackup = greBinDir.clone();
+ updater.append(FILE_UPDATER_BIN);
+ updaterBackup.append(FILE_UPDATER_BIN_BAK);
+ if (updaterBackup.exists()) {
+ if (updater.exists()) {
+ updater.remove(true);
+ }
+ updaterBackup.moveTo(greBinDir, FILE_UPDATER_BIN);
+ }
+
+ let greDir = getGREDir();
+ let updateSettingsIniBackup = greDir.clone();
+ updateSettingsIniBackup.append(FILE_UPDATE_SETTINGS_INI_BAK);
+ if (updateSettingsIniBackup.exists()) {
+ let updateSettingsIni = greDir.clone();
+ updateSettingsIni.append(FILE_UPDATE_SETTINGS_INI);
+ if (updateSettingsIni.exists()) {
+ updateSettingsIni.remove(false);
+ }
+ updateSettingsIniBackup.moveTo(greDir, FILE_UPDATE_SETTINGS_INI);
+ }
+
+ let precomplete = greDir.clone();
+ let precompleteBackup = greDir.clone();
+ precomplete.append(FILE_PRECOMPLETE);
+ precompleteBackup.append(FILE_PRECOMPLETE_BAK);
+ if (precompleteBackup.exists()) {
+ if (precomplete.exists()) {
+ precomplete.remove(false);
+ }
+ precompleteBackup.moveTo(greDir, FILE_PRECOMPLETE);
+ } else if (precomplete.exists()) {
+ if (readFile(precomplete) == PRECOMPLETE_CONTENTS) {
+ precomplete.remove(false);
+ }
+ }
+}
+
+/**
+ * When a test finishes this will repeatedly attempt to restore the real updater
+ * and the other files for the updater if a backup of the file exists.
+ */
+function finishTestRestoreUpdaterBackup() {
+ return (async function () {
+ try {
+ // Windows debug builds keep the updater file in use for a short period of
+ // time after the updater process exits.
+ restoreUpdaterBackup();
+ } catch (e) {
+ logTestInfo(
+ "Attempt to restore the backed up updater failed... " +
+ "will try again, Exception: " +
+ e
+ );
+
+ await TestUtils.waitForTick();
+ await finishTestRestoreUpdaterBackup();
+ }
+ })();
+}
+
+/**
+ * Waits for the About Dialog to load.
+ *
+ * @return A promise that returns the domWindow for the About Dialog and
+ * resolves when the About Dialog loads.
+ */
+function waitForAboutDialog() {
+ return new Promise(resolve => {
+ var listener = {
+ onOpenWindow: aXULWindow => {
+ debugDump("About dialog shown...");
+ Services.wm.removeListener(listener);
+
+ async function aboutDialogOnLoad() {
+ domwindow.removeEventListener("load", aboutDialogOnLoad, true);
+ let chromeURI = "chrome://browser/content/aboutDialog.xhtml";
+ is(
+ domwindow.document.location.href,
+ chromeURI,
+ "About dialog appeared"
+ );
+ resolve(domwindow);
+ }
+
+ var domwindow = aXULWindow.docShell.domWindow;
+ domwindow.addEventListener("load", aboutDialogOnLoad, true);
+ },
+ onCloseWindow: aXULWindow => {},
+ };
+
+ Services.wm.addListener(listener);
+ openAboutDialog();
+ });
+}
+
+/**
+ * Return the first UpdatePatch with the given type.
+ *
+ * @param type
+ * The type of the patch ("complete" or "partial")
+ * @param update
+ * The nsIUpdate to select a patch from.
+ * @return A nsIUpdatePatch object matching the type specified
+ */
+function getPatchOfType(type, update) {
+ if (update) {
+ for (let i = 0; i < update.patchCount; ++i) {
+ let patch = update.getPatchAt(i);
+ if (patch && patch.type == type) {
+ return patch;
+ }
+ }
+ }
+ return null;
+}
+
+/**
+ * Runs a Doorhanger update test. This will set various common prefs for
+ * updating and runs the provided list of steps.
+ *
+ * @param params
+ * An object containing parameters used to run the test.
+ * @param steps
+ * An array of test steps to perform. A step will either be an object
+ * containing expected conditions and actions or a function to call.
+ * @return A promise which will resolve once all of the steps have been run.
+ */
+function runDoorhangerUpdateTest(params, steps) {
+ function processDoorhangerStep(step) {
+ if (typeof step == "function") {
+ return step();
+ }
+
+ const {
+ notificationId,
+ button,
+ checkActiveUpdate,
+ pageURLs,
+ expectedStateOverride,
+ } = step;
+ return (async function () {
+ if (!params.popupShown && !PanelUI.isNotificationPanelOpen) {
+ await BrowserTestUtils.waitForEvent(
+ PanelUI.notificationPanel,
+ "popupshown"
+ );
+ }
+ const shownNotificationId = AppMenuNotifications.activeNotification.id;
+ is(
+ shownNotificationId,
+ notificationId,
+ "The right notification showed up."
+ );
+
+ let expectedState = Ci.nsIApplicationUpdateService.STATE_IDLE;
+ if (expectedStateOverride) {
+ expectedState = expectedStateOverride;
+ } else if (notificationId == "update-restart") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_PENDING;
+ }
+ let actualState = gAUS.currentState;
+ is(
+ actualState,
+ expectedState,
+ `The current update state should be ` +
+ `"${gAUS.getStateName(expectedState)}". Actual: ` +
+ `"${gAUS.getStateName(actualState)}"`
+ );
+
+ if (checkActiveUpdate) {
+ let activeUpdate =
+ checkActiveUpdate.state == STATE_DOWNLOADING
+ ? gUpdateManager.downloadingUpdate
+ : gUpdateManager.readyUpdate;
+ ok(!!activeUpdate, "There should be an active update");
+ is(
+ activeUpdate.state,
+ checkActiveUpdate.state,
+ `The active update state should equal ${checkActiveUpdate.state}`
+ );
+ } else {
+ ok(
+ !gUpdateManager.downloadingUpdate,
+ "There should not be a downloading update"
+ );
+ ok(!gUpdateManager.readyUpdate, "There should not be a ready update");
+ }
+
+ let buttonEl = getNotificationButton(window, notificationId, button);
+ buttonEl.click();
+
+ if (pageURLs && pageURLs.manual !== undefined) {
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ pageURLs.manual,
+ `The page's url should equal ${pageURLs.manual}`
+ );
+ gBrowser.removeTab(gBrowser.selectedTab);
+ }
+ })();
+ }
+
+ return (async function () {
+ if (params.slowStaging) {
+ Services.env.set("MOZ_TEST_SLOW_SKIP_UPDATE_STAGE", "1");
+ } else {
+ Services.env.set("MOZ_TEST_SKIP_UPDATE_STAGE", "1");
+ }
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_APP_UPDATE_DISABLEDFORTESTING, false],
+ [PREF_APP_UPDATE_URL_DETAILS, gDetailsURL],
+ [PREF_APP_UPDATE_URL_MANUAL, URL_MANUAL_UPDATE],
+ ],
+ });
+
+ await setupTestUpdater();
+
+ let baseURL = URL_HTTP_UPDATE_SJS;
+ if (params.baseURL) {
+ baseURL = params.baseURL;
+ }
+ let queryString = params.queryString ? params.queryString : "";
+ let updateURL =
+ baseURL +
+ "?detailsURL=" +
+ gDetailsURL +
+ queryString +
+ getVersionParams(params.version);
+ setUpdateURL(updateURL);
+ if (params.checkAttempts) {
+ // Perform a background check doorhanger test.
+ executeSoon(() => {
+ (async function () {
+ gAUS.checkForBackgroundUpdates();
+ for (var i = 0; i < params.checkAttempts - 1; i++) {
+ await waitForEvent("update-error", "check-attempt-failed");
+ gAUS.checkForBackgroundUpdates();
+ }
+ })();
+ });
+ } else {
+ // Perform a startup processing doorhanger test.
+ writeStatusFile(STATE_FAILED_CRC_ERROR);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(params.updates), true);
+ reloadUpdateManagerData();
+ testPostUpdateProcessing();
+ }
+
+ for (let step of steps) {
+ await processDoorhangerStep(step);
+ }
+ })();
+}
+
+/**
+ * Runs an About Dialog update test. This will set various common prefs for
+ * updating and runs the provided list of steps.
+ *
+ * @param params
+ * An object containing parameters used to run the test.
+ * @param steps
+ * An array of test steps to perform. A step will either be an object
+ * containing expected conditions and actions or a function to call.
+ * @return A promise which will resolve once all of the steps have been run.
+ */
+function runAboutDialogUpdateTest(params, steps) {
+ let aboutDialog;
+ function processAboutDialogStep(step) {
+ if (typeof step == "function") {
+ return step(aboutDialog);
+ }
+
+ const {
+ panelId,
+ checkActiveUpdate,
+ continueFile,
+ downloadInfo,
+ forceApply,
+ noContinue,
+ expectedStateOverride,
+ } = step;
+ return (async function () {
+ await TestUtils.waitForCondition(
+ () =>
+ aboutDialog.gAppUpdater &&
+ aboutDialog.gAppUpdater.selectedPanel?.id == panelId,
+ "Waiting for the expected panel ID: " + panelId,
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the panel
+ // ID and the expected panel ID is printed in the log.
+ logTestInfo(e);
+ });
+ let { selectedPanel } = aboutDialog.gAppUpdater;
+ is(selectedPanel.id, panelId, "The panel ID should equal " + panelId);
+ ok(
+ BrowserTestUtils.isVisible(selectedPanel),
+ "The panel should be visible"
+ );
+
+ if (
+ panelId == "downloading" &&
+ gAUS.currentState == Ci.nsIApplicationUpdateService.STATE_IDLE
+ ) {
+ // Now that `AUS.downloadUpdate` is async, we start showing the
+ // downloading panel while `AUS.downloadUpdate` is still resolving.
+ // But the below checks assume that this resolution has already
+ // happened. So we need to wait for things to actually resolve.
+ debugDump("Waiting for downloading state to actually start");
+ await gAUS.stateTransition;
+
+ // Check that the checks that we made above are still valid.
+ selectedPanel = aboutDialog.gAppUpdater.selectedPanel;
+ is(selectedPanel.id, panelId, "The panel ID should equal " + panelId);
+ ok(
+ BrowserTestUtils.isVisible(selectedPanel),
+ "The panel should be visible"
+ );
+ }
+
+ let expectedState = Ci.nsIApplicationUpdateService.STATE_IDLE;
+ if (expectedStateOverride) {
+ expectedState = expectedStateOverride;
+ } else if (panelId == "apply") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_PENDING;
+ } else if (panelId == "downloading") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_DOWNLOADING;
+ } else if (panelId == "applying") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_STAGING;
+ }
+ let actualState = gAUS.currentState;
+ is(
+ actualState,
+ expectedState,
+ `The current update state should be ` +
+ `"${gAUS.getStateName(expectedState)}". Actual: ` +
+ `"${gAUS.getStateName(actualState)}"`
+ );
+
+ if (checkActiveUpdate) {
+ let activeUpdate =
+ checkActiveUpdate.state == STATE_DOWNLOADING
+ ? gUpdateManager.downloadingUpdate
+ : gUpdateManager.readyUpdate;
+ ok(!!activeUpdate, "There should be an active update");
+ is(
+ activeUpdate.state,
+ checkActiveUpdate.state,
+ "The active update state should equal " + checkActiveUpdate.state
+ );
+ } else {
+ ok(
+ !gUpdateManager.downloadingUpdate,
+ "There should not be a downloading update"
+ );
+ ok(!gUpdateManager.readyUpdate, "There should not be a ready update");
+ }
+
+ // Some tests just want to stop at the downloading state. These won't
+ // include a continue file in that state.
+ if (panelId == "downloading" && continueFile) {
+ for (let i = 0; i < downloadInfo.length; ++i) {
+ let data = downloadInfo[i];
+ await continueFileHandler(continueFile);
+ let patch = getPatchOfType(
+ data.patchType,
+ gUpdateManager.downloadingUpdate
+ );
+ // The update is removed early when the last download fails so check
+ // that there is a patch before proceeding.
+ let isLastPatch = i == downloadInfo.length - 1;
+ if (!isLastPatch || patch) {
+ let resultName = data.bitsResult ? "bitsResult" : "internalResult";
+ patch.QueryInterface(Ci.nsIWritablePropertyBag);
+ await TestUtils.waitForCondition(
+ () => patch.getProperty(resultName) == data[resultName],
+ "Waiting for expected patch property " +
+ resultName +
+ " value: " +
+ data[resultName],
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the
+ // property value and the expected property value is printed in
+ // the log.
+ logTestInfo(e);
+ });
+ is(
+ "" + patch.getProperty(resultName),
+ data[resultName],
+ "The patch property " +
+ resultName +
+ " value should equal " +
+ data[resultName]
+ );
+
+ // Check the download status text. It should be something like,
+ // "1.4 of 1.4 KB".
+ let expectedText = DownloadUtils.getTransferTotal(
+ data[resultName] == gBadSizeResult ? 0 : patch.size,
+ patch.size
+ );
+ Assert.ok(
+ expectedText,
+ "Sanity check: Expected download status text should be non-empty"
+ );
+ if (aboutDialog.document.hasPendingL10nMutations) {
+ await BrowserTestUtils.waitForEvent(
+ aboutDialog.document,
+ "L10nMutationsFinished"
+ );
+ }
+ Assert.equal(
+ aboutDialog.document.querySelector(
+ `#downloading label[data-l10n-name="download-status"]`
+ ).textContent,
+ expectedText,
+ "Download status text should be correct"
+ );
+ }
+ }
+ } else if (continueFile) {
+ await continueFileHandler(continueFile);
+ }
+
+ let linkPanels = [
+ "downloadFailed",
+ "manualUpdate",
+ "unsupportedSystem",
+ "internalError",
+ ];
+ if (linkPanels.includes(panelId)) {
+ // The unsupportedSystem panel uses the update's detailsURL and the
+ // downloadFailed and manualUpdate panels use the app.update.url.manual
+ // preference.
+ let selector = "label.text-link";
+ if (selectedPanel.ownerDocument.hasPendingL10nMutations) {
+ await BrowserTestUtils.waitForEvent(
+ selectedPanel.ownerDocument,
+ "L10nMutationsFinished"
+ );
+ }
+ let link = selectedPanel.querySelector(selector);
+ is(
+ link.href,
+ gDetailsURL,
+ `The panel's link href should equal ${gDetailsURL}`
+ );
+ const assertNonEmptyText = (node, description) => {
+ let textContent = node.textContent.trim();
+ ok(textContent, `${description}, got "${textContent}"`);
+ };
+ assertNonEmptyText(
+ link,
+ `The panel's link should have non-empty textContent`
+ );
+ let linkWrapperClone = link.parentNode.cloneNode(true);
+ linkWrapperClone.querySelector(selector).remove();
+ assertNonEmptyText(
+ linkWrapperClone,
+ `The panel's link should have text around the link`
+ );
+ }
+
+ // Automatically click the download button unless `noContinue` was passed.
+ let buttonPanels = ["downloadAndInstall", "apply"];
+ if (buttonPanels.includes(panelId) && !noContinue) {
+ let buttonEl = selectedPanel.querySelector("button");
+ await TestUtils.waitForCondition(
+ () => aboutDialog.document.activeElement == buttonEl,
+ "The button should receive focus"
+ );
+ ok(!buttonEl.disabled, "The button should be enabled");
+ // Don't click the button on the apply panel since this will restart the
+ // application.
+ if (panelId != "apply" || forceApply) {
+ buttonEl.click();
+ }
+ }
+ })();
+ }
+
+ return (async function () {
+ Services.env.set("MOZ_TEST_SLOW_SKIP_UPDATE_STAGE", "1");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_APP_UPDATE_DISABLEDFORTESTING, false],
+ [PREF_APP_UPDATE_URL_MANUAL, gDetailsURL],
+ ],
+ });
+
+ await setupTestUpdater();
+
+ let baseURL = URL_HTTP_UPDATE_SJS;
+ if (params.baseURL) {
+ baseURL = params.baseURL;
+ }
+ let queryString = params.queryString ? params.queryString : "";
+ let updateURL =
+ baseURL +
+ "?detailsURL=" +
+ gDetailsURL +
+ queryString +
+ getVersionParams(params.version);
+ if (params.backgroundUpdate) {
+ setUpdateURL(updateURL);
+ gAUS.checkForBackgroundUpdates();
+ if (params.continueFile) {
+ await continueFileHandler(params.continueFile);
+ }
+ if (params.waitForUpdateState) {
+ let whichUpdate =
+ params.waitForUpdateState == STATE_DOWNLOADING
+ ? "downloadingUpdate"
+ : "readyUpdate";
+ await TestUtils.waitForCondition(
+ () =>
+ gUpdateManager[whichUpdate] &&
+ gUpdateManager[whichUpdate].state == params.waitForUpdateState,
+ "Waiting for update state: " + params.waitForUpdateState,
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the panel
+ // ID and the expected panel ID is printed in the log.
+ logTestInfo(e);
+ });
+ // Display the UI after the update state equals the expected value.
+ is(
+ gUpdateManager[whichUpdate].state,
+ params.waitForUpdateState,
+ "The update state value should equal " + params.waitForUpdateState
+ );
+ }
+ } else {
+ updateURL += "&slowUpdateCheck=1&useSlowDownloadMar=1";
+ setUpdateURL(updateURL);
+ }
+
+ aboutDialog = await waitForAboutDialog();
+ registerCleanupFunction(() => {
+ aboutDialog.close();
+ });
+
+ for (let step of steps) {
+ await processAboutDialogStep(step);
+ }
+ })();
+}
+
+/**
+ * Runs an about:preferences update test. This will set various common prefs for
+ * updating and runs the provided list of steps.
+ *
+ * @param params
+ * An object containing parameters used to run the test.
+ * @param steps
+ * An array of test steps to perform. A step will either be an object
+ * containing expected conditions and actions or a function to call.
+ * @return A promise which will resolve once all of the steps have been run.
+ */
+function runAboutPrefsUpdateTest(params, steps) {
+ let tab;
+ function processAboutPrefsStep(step) {
+ if (typeof step == "function") {
+ return step(tab);
+ }
+
+ const {
+ panelId,
+ checkActiveUpdate,
+ continueFile,
+ downloadInfo,
+ forceApply,
+ expectedStateOverride,
+ } = step;
+ return (async function () {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ panelId }],
+ async ({ panelId }) => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.gAppUpdater.selectedPanel?.id == panelId,
+ "Waiting for the expected panel ID: " + panelId,
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the panel
+ // ID and the expected panel ID is printed in the log. Use info here
+ // instead of logTestInfo since logTestInfo isn't available in the
+ // content task.
+ info(e);
+ });
+ is(
+ content.gAppUpdater.selectedPanel.id,
+ panelId,
+ "The panel ID should equal " + panelId
+ );
+ }
+ );
+
+ if (
+ panelId == "downloading" &&
+ gAUS.currentState == Ci.nsIApplicationUpdateService.STATE_IDLE
+ ) {
+ // Now that `AUS.downloadUpdate` is async, we start showing the
+ // downloading panel while `AUS.downloadUpdate` is still resolving.
+ // But the below checks assume that this resolution has already
+ // happened. So we need to wait for things to actually resolve.
+ debugDump("Waiting for downloading state to actually start");
+ await gAUS.stateTransition;
+
+ // Check that the checks that we made above are still valid.
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ panelId }],
+ ({ panelId }) => {
+ is(
+ content.gAppUpdater.selectedPanel.id,
+ panelId,
+ "The panel ID should equal " + panelId
+ );
+ }
+ );
+ }
+
+ let expectedState = Ci.nsIApplicationUpdateService.STATE_IDLE;
+ if (expectedStateOverride) {
+ expectedState = expectedStateOverride;
+ } else if (panelId == "apply") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_PENDING;
+ } else if (panelId == "downloading") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_DOWNLOADING;
+ } else if (panelId == "applying") {
+ expectedState = Ci.nsIApplicationUpdateService.STATE_STAGING;
+ }
+ let actualState = gAUS.currentState;
+ is(
+ actualState,
+ expectedState,
+ `The current update state should be ` +
+ `"${gAUS.getStateName(expectedState)}". Actual: ` +
+ `"${gAUS.getStateName(actualState)}"`
+ );
+
+ if (checkActiveUpdate) {
+ let activeUpdate =
+ checkActiveUpdate.state == STATE_DOWNLOADING
+ ? gUpdateManager.downloadingUpdate
+ : gUpdateManager.readyUpdate;
+ ok(!!activeUpdate, "There should be an active update");
+ is(
+ activeUpdate.state,
+ checkActiveUpdate.state,
+ "The active update state should equal " + checkActiveUpdate.state
+ );
+ } else {
+ ok(
+ !gUpdateManager.downloadingUpdate,
+ "There should not be a downloading update"
+ );
+ ok(!gUpdateManager.readyUpdate, "There should not be a ready update");
+ }
+
+ if (panelId == "downloading") {
+ if (!downloadInfo) {
+ logTestInfo("no downloadinfo, possible error?");
+ }
+ for (let i = 0; i < downloadInfo.length; ++i) {
+ let data = downloadInfo[i];
+ // The About Dialog tests always specify a continue file.
+ await continueFileHandler(continueFile);
+ let patch = getPatchOfType(
+ data.patchType,
+ gUpdateManager.downloadingUpdate
+ );
+ // The update is removed early when the last download fails so check
+ // that there is a patch before proceeding.
+ let isLastPatch = i == downloadInfo.length - 1;
+ if (!isLastPatch || patch) {
+ let resultName = data.bitsResult ? "bitsResult" : "internalResult";
+ patch.QueryInterface(Ci.nsIWritablePropertyBag);
+ await TestUtils.waitForCondition(
+ () => patch.getProperty(resultName) == data[resultName],
+ "Waiting for expected patch property " +
+ resultName +
+ " value: " +
+ data[resultName],
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the
+ // property value and the expected property value is printed in
+ // the log.
+ logTestInfo(e);
+ });
+ is(
+ "" + patch.getProperty(resultName),
+ data[resultName],
+ "The patch property " +
+ resultName +
+ " value should equal " +
+ data[resultName]
+ );
+
+ // Check the download status text. It should be something like,
+ // "Downloading update — 1.4 of 1.4 KB". We check only the second
+ // part to make sure that the downloaded size is updated correctly.
+ let actualText = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async () => {
+ const { document } = content;
+ if (document.hasPendingL10nMutations) {
+ await ContentTaskUtils.waitForEvent(
+ document,
+ "L10nMutationsFinished"
+ );
+ }
+ return document.getElementById("downloading").textContent;
+ }
+ );
+ let expectedSuffix = DownloadUtils.getTransferTotal(
+ data[resultName] == gBadSizeResult ? 0 : patch.size,
+ patch.size
+ );
+ Assert.ok(
+ expectedSuffix,
+ "Sanity check: Expected download status text should be non-empty"
+ );
+ Assert.ok(
+ actualText.endsWith(expectedSuffix),
+ "Download status text should end as expected: " +
+ JSON.stringify({ actualText, expectedSuffix })
+ );
+ }
+ }
+ } else if (continueFile) {
+ await continueFileHandler(continueFile);
+ }
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ panelId, gDetailsURL, forceApply }],
+ async ({ panelId, gDetailsURL, forceApply }) => {
+ let linkPanels = [
+ "downloadFailed",
+ "manualUpdate",
+ "unsupportedSystem",
+ "internalError",
+ ];
+ if (linkPanels.includes(panelId)) {
+ let { selectedPanel } = content.gAppUpdater;
+ // The unsupportedSystem panel uses the update's detailsURL and the
+ // downloadFailed and manualUpdate panels use the app.update.url.manual
+ // preference.
+ let selector = "label.text-link";
+ // The downloadFailed panel in about:preferences uses an anchor
+ // instead of a label for the link.
+ if (selectedPanel.id == "downloadFailed") {
+ selector = "a.text-link";
+ }
+ // The manualUpdate panel in about:preferences uses
+ // the moz-support-link element which doesn't have
+ // the .text-link class.
+ if (selectedPanel.id == "manualUpdate") {
+ selector = "a.manualLink";
+ }
+ if (selectedPanel.ownerDocument.hasPendingL10nMutations) {
+ await ContentTaskUtils.waitForEvent(
+ selectedPanel.ownerDocument,
+ "L10nMutationsFinished"
+ );
+ }
+ let link = selectedPanel.querySelector(selector);
+ is(
+ link.href,
+ gDetailsURL,
+ `The panel's link href should equal ${gDetailsURL}`
+ );
+ const assertNonEmptyText = (node, description) => {
+ let textContent = node.textContent.trim();
+ ok(textContent, `${description}, got "${textContent}"`);
+ };
+ assertNonEmptyText(
+ link,
+ `The panel's link should have non-empty textContent`
+ );
+ let linkWrapperClone = link.parentNode.cloneNode(true);
+ linkWrapperClone.querySelector(selector).remove();
+ assertNonEmptyText(
+ linkWrapperClone,
+ `The panel's link should have text around the link`
+ );
+ }
+
+ let buttonPanels = ["downloadAndInstall", "apply"];
+ if (buttonPanels.includes(panelId)) {
+ let { selectedPanel } = content.gAppUpdater;
+ let buttonEl = selectedPanel.querySelector("button");
+ // Note: The about:preferences doesn't focus the button like the
+ // About Dialog does.
+ ok(!buttonEl.disabled, "The button should be enabled");
+ // Don't click the button on the apply panel since this will restart
+ // the application.
+ if (selectedPanel.id != "apply" || forceApply) {
+ buttonEl.click();
+ }
+ }
+ }
+ );
+ })();
+ }
+
+ return (async function () {
+ Services.env.set("MOZ_TEST_SLOW_SKIP_UPDATE_STAGE", "1");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_APP_UPDATE_DISABLEDFORTESTING, false],
+ [PREF_APP_UPDATE_URL_MANUAL, gDetailsURL],
+ ],
+ });
+
+ await setupTestUpdater();
+
+ let baseURL = URL_HTTP_UPDATE_SJS;
+ if (params.baseURL) {
+ baseURL = params.baseURL;
+ }
+ let queryString = params.queryString ? params.queryString : "";
+ let updateURL =
+ baseURL +
+ "?detailsURL=" +
+ gDetailsURL +
+ queryString +
+ getVersionParams(params.version);
+ if (params.backgroundUpdate) {
+ setUpdateURL(updateURL);
+ gAUS.checkForBackgroundUpdates();
+ if (params.continueFile) {
+ await continueFileHandler(params.continueFile);
+ }
+ if (params.waitForUpdateState) {
+ // Wait until the update state equals the expected value before
+ // displaying the UI.
+ let whichUpdate =
+ params.waitForUpdateState == STATE_DOWNLOADING
+ ? "downloadingUpdate"
+ : "readyUpdate";
+ await TestUtils.waitForCondition(
+ () =>
+ gUpdateManager[whichUpdate] &&
+ gUpdateManager[whichUpdate].state == params.waitForUpdateState,
+ "Waiting for update state: " + params.waitForUpdateState,
+ undefined,
+ 200
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the panel
+ // ID and the expected panel ID is printed in the log.
+ logTestInfo(e);
+ });
+ is(
+ gUpdateManager[whichUpdate].state,
+ params.waitForUpdateState,
+ "The update state value should equal " + params.waitForUpdateState
+ );
+ }
+ } else {
+ updateURL += "&slowUpdateCheck=1&useSlowDownloadMar=1";
+ setUpdateURL(updateURL);
+ }
+
+ tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences"
+ );
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.removeTab(tab);
+ });
+
+ // Scroll the UI into view so it is easier to troubleshoot tests.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ content.document.getElementById("updatesCategory").scrollIntoView();
+ });
+
+ for (let step of steps) {
+ await processAboutPrefsStep(step);
+ }
+ })();
+}
+
+/**
+ * Removes the modified update-settings.ini file so the updater will fail to
+ * stage an update.
+ */
+function removeUpdateSettingsIni() {
+ if (Services.prefs.getBoolPref(PREF_APP_UPDATE_STAGING_ENABLED)) {
+ let greDir = getGREDir();
+ let updateSettingsIniBak = greDir.clone();
+ updateSettingsIniBak.append(FILE_UPDATE_SETTINGS_INI_BAK);
+ if (updateSettingsIniBak.exists()) {
+ let updateSettingsIni = greDir.clone();
+ updateSettingsIni.append(FILE_UPDATE_SETTINGS_INI);
+ updateSettingsIni.remove(false);
+ }
+ }
+}
+
+/**
+ * Runs a telemetry update test. This will set various common prefs for
+ * updating, checks for an update, and waits for the specified observer
+ * notification.
+ *
+ * @param updateParams
+ * Params which will be sent to app_update.sjs.
+ * @param event
+ * The observer notification to wait for before proceeding.
+ * @param stageFailure (optional)
+ * Whether to force a staging failure by removing the modified
+ * update-settings.ini file.
+ * @return A promise which will resolve after the .
+ */
+function runTelemetryUpdateTest(updateParams, event, stageFailure = false) {
+ return (async function () {
+ Services.telemetry.clearScalars();
+ Services.env.set("MOZ_TEST_SKIP_UPDATE_STAGE", "1");
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_APP_UPDATE_DISABLEDFORTESTING, false]],
+ });
+
+ await setupTestUpdater();
+
+ if (stageFailure) {
+ removeUpdateSettingsIni();
+ }
+
+ let updateURL =
+ URL_HTTP_UPDATE_SJS +
+ "?detailsURL=" +
+ gDetailsURL +
+ updateParams +
+ getVersionParams();
+ setUpdateURL(updateURL);
+ gAUS.checkForBackgroundUpdates();
+ await waitForEvent(event);
+ })();
+}
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser.toml b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser.toml
new file mode 100644
index 0000000000..6d44600c7a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser.toml
@@ -0,0 +1,24 @@
+[DEFAULT]
+head = "head.js"
+prefs = [
+ "app.update.BITS.enabled=false",
+ "browser.policies.alternatePath='<test-root>/toolkit/mozapps/update/tests/browser/manual_app_update_only/config_manual_app_update_only.json'",
+]
+support-files = [
+ "!/toolkit/mozapps/update/tests/browser/head.js",
+ "config_manual_app_update_only.json",
+ "../../data/shared.js",
+ "../../data/app_update.sjs",
+ "../testConstants.js",
+]
+skip-if = ["os == 'win' && msix"] # Updater is disabled in MSIX builds
+
+["browser_aboutDialog_fc_autoUpdateFalse.js"]
+
+["browser_aboutDialog_fc_autoUpdateTrue.js"]
+
+["browser_aboutPrefs_fc_autoUpdateFalse.js"]
+
+["browser_aboutPrefs_fc_autoUpdateTrue.js"]
+
+["browser_noBackgroundUpdate.js"]
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateFalse.js b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateFalse.js
new file mode 100644
index 0000000000..169e66033a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateFalse.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_manual_app_update_policy() {
+ await setAppUpdateAutoEnabledHelper(false);
+
+ is(
+ Services.policies.isAllowed("autoAppUpdateChecking"),
+ false,
+ "autoAppUpdateChecking should be disabled by policy"
+ );
+ is(gAUS.manualUpdateOnly, true, "gAUS.manualUpdateOnly should be true");
+
+ let downloadInfo = [{ patchType: "partial", internalResult: "0" }];
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateTrue.js b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateTrue.js
new file mode 100644
index 0000000000..0a59f59d71
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutDialog_fc_autoUpdateTrue.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_manual_app_update_policy() {
+ await setAppUpdateAutoEnabledHelper(true);
+
+ is(
+ Services.policies.isAllowed("autoAppUpdateChecking"),
+ false,
+ "autoAppUpdateChecking should be disabled by policy"
+ );
+ is(gAUS.manualUpdateOnly, true, "gAUS.manualUpdateOnly should be true");
+
+ let downloadInfo = [{ patchType: "partial", internalResult: "0" }];
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutDialogUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateFalse.js b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateFalse.js
new file mode 100644
index 0000000000..2d9608951e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateFalse.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_manual_app_update_policy() {
+ await setAppUpdateAutoEnabledHelper(false);
+
+ is(
+ Services.policies.isAllowed("autoAppUpdateChecking"),
+ false,
+ "autoAppUpdateChecking should be disabled by policy"
+ );
+ is(gAUS.manualUpdateOnly, true, "gAUS.manualUpdateOnly should be true");
+
+ let downloadInfo = [{ patchType: "partial", internalResult: "0" }];
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ async tab => {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let setting = content.document.getElementById(
+ "updateSettingsContainer"
+ );
+ is(
+ setting.hidden,
+ true,
+ "Update choices should be disabled when manualUpdateOnly"
+ );
+ });
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateTrue.js b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateTrue.js
new file mode 100644
index 0000000000..b7b0c1027a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_aboutPrefs_fc_autoUpdateTrue.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_manual_app_update_policy() {
+ await setAppUpdateAutoEnabledHelper(true);
+
+ is(
+ Services.policies.isAllowed("autoAppUpdateChecking"),
+ false,
+ "autoAppUpdateChecking should be disabled by policy"
+ );
+ is(gAUS.manualUpdateOnly, true, "gAUS.manualUpdateOnly should be true");
+
+ let downloadInfo = [{ patchType: "partial", internalResult: "0" }];
+ // Since the partial should be successful specify an invalid size for the
+ // complete update.
+ let params = { queryString: "&invalidCompleteSize=1" };
+ await runAboutPrefsUpdateTest(params, [
+ {
+ panelId: "checkingForUpdates",
+ checkActiveUpdate: null,
+ continueFile: CONTINUE_CHECK,
+ },
+ {
+ panelId: "downloadAndInstall",
+ checkActiveUpdate: null,
+ continueFile: null,
+ },
+ {
+ panelId: "downloading",
+ checkActiveUpdate: { state: STATE_DOWNLOADING },
+ continueFile: CONTINUE_DOWNLOAD,
+ downloadInfo,
+ },
+ {
+ panelId: "apply",
+ checkActiveUpdate: { state: STATE_PENDING },
+ continueFile: null,
+ },
+ async tab => {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let setting = content.document.getElementById(
+ "updateSettingsContainer"
+ );
+ is(
+ setting.hidden,
+ true,
+ "Update choices should be disabled when manualUpdateOnly"
+ );
+ });
+ },
+ ]);
+});
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_noBackgroundUpdate.js b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_noBackgroundUpdate.js
new file mode 100644
index 0000000000..38b27e31ad
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/browser_noBackgroundUpdate.js
@@ -0,0 +1,17 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_manual_app_update_policy() {
+ // Unfortunately, we can't really test the other background update entry
+ // point, gAUS.notify, because it doesn't return anything and it would be
+ // a bit overkill to edit the nsITimerCallback interface just for this test.
+ // But the two entry points just immediately call the same function, so this
+ // should probably be alright.
+ is(
+ gAUS.checkForBackgroundUpdates(),
+ false,
+ "gAUS.checkForBackgroundUpdates() should not proceed with update check"
+ );
+});
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/config_manual_app_update_only.json b/toolkit/mozapps/update/tests/browser/manual_app_update_only/config_manual_app_update_only.json
new file mode 100644
index 0000000000..4e7c785bc1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/config_manual_app_update_only.json
@@ -0,0 +1,5 @@
+{
+ "policies": {
+ "ManualAppUpdateOnly": true
+ }
+}
diff --git a/toolkit/mozapps/update/tests/browser/manual_app_update_only/head.js b/toolkit/mozapps/update/tests/browser/manual_app_update_only/head.js
new file mode 100644
index 0000000000..2a7576963b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/manual_app_update_only/head.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/mozapps/update/tests/browser/head.js",
+ this
+);
diff --git a/toolkit/mozapps/update/tests/browser/testConstants.js b/toolkit/mozapps/update/tests/browser/testConstants.js
new file mode 100644
index 0000000000..915391054b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/browser/testConstants.js
@@ -0,0 +1,7 @@
+const REL_PATH_DATA = "browser/toolkit/mozapps/update/tests/browser/";
+const URL_HOST = "http://127.0.0.1:8888";
+const URL_PATH_UPDATE_XML = "/" + REL_PATH_DATA + "app_update.sjs";
+const URL_HTTP_UPDATE_SJS = URL_HOST + URL_PATH_UPDATE_XML;
+const CONTINUE_CHECK = "continueCheck";
+const CONTINUE_DOWNLOAD = "continueDownload";
+const CONTINUE_STAGING = "continueStaging";
diff --git a/toolkit/mozapps/update/tests/data/app_update.sjs b/toolkit/mozapps/update/tests/data/app_update.sjs
new file mode 100644
index 0000000000..2081118547
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/app_update.sjs
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+/**
+ * Server side http server script for application update tests.
+ */
+
+// Definitions from test and other files used by the tests
+/* global getState */
+
+function getTestDataFile(aFilename) {
+ let file = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ let pathParts = REL_PATH_DATA.split("/");
+ for (let i = 0; i < pathParts.length; ++i) {
+ file.append(pathParts[i]);
+ }
+ if (aFilename) {
+ file.append(aFilename);
+ }
+ return file;
+}
+
+function loadHelperScript(aScriptFile) {
+ let scriptSpec = Services.io.newFileURI(aScriptFile).spec;
+ Services.scriptloader.loadSubScript(scriptSpec, this);
+}
+
+var scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+scriptFile.initWithPath(getState("__LOCATION__"));
+scriptFile = scriptFile.parent;
+/* import-globals-from ../browser/testConstants.js */
+scriptFile.append("testConstants.js");
+loadHelperScript(scriptFile);
+
+/* import-globals-from sharedUpdateXML.js */
+scriptFile = getTestDataFile("sharedUpdateXML.js");
+loadHelperScript(scriptFile);
+
+const SERVICE_URL = URL_HOST + "/" + REL_PATH_DATA + FILE_SIMPLE_MAR;
+const BAD_SERVICE_URL = URL_HOST + "/" + REL_PATH_DATA + "not_here.mar";
+
+// A value of 10 caused the tests to intermittently fail on Mac OS X so be
+// careful when changing this value.
+const SLOW_RESPONSE_INTERVAL = 100;
+const MAX_SLOW_RESPONSE_RETRIES = 200;
+var gSlowDownloadTimer;
+var gSlowCheckTimer;
+
+function handleRequest(aRequest, aResponse) {
+ let params = {};
+ if (aRequest.queryString) {
+ params = parseQueryString(aRequest.queryString);
+ }
+
+ let statusCode = params.statusCode ? parseInt(params.statusCode) : 200;
+ let statusReason = params.statusReason ? params.statusReason : "OK";
+ aResponse.setStatusLine(aRequest.httpVersion, statusCode, statusReason);
+ aResponse.setHeader("Cache-Control", "no-cache", false);
+
+ // When a mar download is started by the update service it can finish
+ // downloading before the ui has loaded. By specifying a serviceURL for the
+ // update patch that points to this file and has a slowDownloadMar param the
+ // mar will be downloaded asynchronously which will allow the ui to load
+ // before the download completes.
+ if (params.slowDownloadMar) {
+ aResponse.processAsync();
+ aResponse.setHeader("Content-Type", "binary/octet-stream");
+ aResponse.setHeader("Content-Length", SIZE_SIMPLE_MAR);
+
+ // BITS will first make a HEAD request followed by a GET request.
+ if (aRequest.method == "HEAD") {
+ aResponse.finish();
+ return;
+ }
+
+ let retries = 0;
+ gSlowDownloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ gSlowDownloadTimer.initWithCallback(
+ function (aTimer) {
+ let continueFile = getTestDataFile(CONTINUE_DOWNLOAD);
+ retries++;
+ if (continueFile.exists() || retries == MAX_SLOW_RESPONSE_RETRIES) {
+ try {
+ // If the continue file is in use try again the next time the timer
+ // fires unless the retries has reached the value defined by
+ // MAX_SLOW_RESPONSE_RETRIES in which case let the test remove the
+ // continue file.
+ if (retries < MAX_SLOW_RESPONSE_RETRIES) {
+ continueFile.remove(false);
+ }
+ gSlowDownloadTimer.cancel();
+ aResponse.write(readFileBytes(getTestDataFile(FILE_SIMPLE_MAR)));
+ aResponse.finish();
+ } catch (e) {}
+ }
+ },
+ SLOW_RESPONSE_INTERVAL,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ return;
+ }
+
+ if (params.uiURL) {
+ aResponse.write(
+ '<html><head><meta http-equiv="content-type" content=' +
+ '"text/html; charset=utf-8"></head><body>' +
+ params.uiURL +
+ "<br><br>this is a test mar that will not " +
+ "affect your build.</body></html>"
+ );
+ return;
+ }
+
+ if (params.xmlMalformed) {
+ respond(aResponse, params, "xml error");
+ return;
+ }
+
+ if (params.noUpdates) {
+ respond(aResponse, params, getRemoteUpdatesXMLString(""));
+ return;
+ }
+
+ if (params.unsupported) {
+ let detailsURL = params.detailsURL ? params.detailsURL : URL_HOST;
+ let unsupportedXML = getRemoteUpdatesXMLString(
+ ' <update type="major" ' +
+ 'unsupported="true" ' +
+ 'detailsURL="' +
+ detailsURL +
+ '"></update>\n'
+ );
+ respond(aResponse, params, unsupportedXML);
+ return;
+ }
+
+ let size;
+ let patches = "";
+ let url = "";
+ if (params.useSlowDownloadMar) {
+ url = URL_HTTP_UPDATE_SJS + "?slowDownloadMar=1";
+ } else {
+ url = params.badURL ? BAD_SERVICE_URL : SERVICE_URL;
+ }
+ if (!params.partialPatchOnly) {
+ size = SIZE_SIMPLE_MAR + (params.invalidCompleteSize ? "1" : "");
+ let patchProps = { type: "complete", url, size };
+ patches += getRemotePatchString(patchProps);
+ }
+
+ if (!params.completePatchOnly) {
+ size = SIZE_SIMPLE_MAR + (params.invalidPartialSize ? "1" : "");
+ let patchProps = { type: "partial", url, size };
+ patches += getRemotePatchString(patchProps);
+ }
+
+ let updateProps = {};
+ if (params.type) {
+ updateProps.type = params.type;
+ }
+
+ if (params.name) {
+ updateProps.name = params.name;
+ }
+
+ if (params.appVersion) {
+ updateProps.appVersion = params.appVersion;
+ }
+
+ if (params.displayVersion) {
+ updateProps.displayVersion = params.displayVersion;
+ }
+
+ if (params.buildID) {
+ updateProps.buildID = params.buildID;
+ }
+
+ if (params.promptWaitTime) {
+ updateProps.promptWaitTime = params.promptWaitTime;
+ }
+
+ if (params.disableBITS) {
+ updateProps.disableBITS = params.disableBITS;
+ }
+
+ let updates = getRemoteUpdateString(updateProps, patches);
+ let xml = getRemoteUpdatesXMLString(updates);
+ respond(aResponse, params, xml);
+}
+
+function respond(aResponse, aParams, aResponseString) {
+ if (aParams.slowUpdateCheck) {
+ let retries = 0;
+ aResponse.processAsync();
+ gSlowCheckTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ gSlowCheckTimer.initWithCallback(
+ function (aTimer) {
+ retries++;
+ let continueFile = getTestDataFile(CONTINUE_CHECK);
+ if (continueFile.exists() || retries == MAX_SLOW_RESPONSE_RETRIES) {
+ try {
+ // If the continue file is in use try again the next time the timer
+ // fires unless the retries has reached the value defined by
+ // MAX_SLOW_RESPONSE_RETRIES in which case let the test remove the
+ // continue file.
+ if (retries < MAX_SLOW_RESPONSE_RETRIES) {
+ continueFile.remove(false);
+ }
+ gSlowCheckTimer.cancel();
+ aResponse.write(aResponseString);
+ aResponse.finish();
+ } catch (e) {}
+ }
+ },
+ SLOW_RESPONSE_INTERVAL,
+ Ci.nsITimer.TYPE_REPEATING_SLACK
+ );
+ } else {
+ aResponse.write(aResponseString);
+ }
+}
+
+/**
+ * Helper function to create a JS object representing the url parameters from
+ * the request's queryString.
+ *
+ * @param aQueryString
+ * The request's query string.
+ * @return A JS object representing the url parameters from the request's
+ * queryString.
+ */
+function parseQueryString(aQueryString) {
+ let paramArray = aQueryString.split("&");
+ let regex = /^([^=]+)=(.*)$/;
+ let params = {};
+ for (let i = 0, sz = paramArray.length; i < sz; i++) {
+ let match = regex.exec(paramArray[i]);
+ if (!match) {
+ throw Components.Exception(
+ "Bad parameter in queryString! '" + paramArray[i] + "'",
+ Cr.NS_ERROR_ILLEGAL_VALUE
+ );
+ }
+ params[decodeURIComponent(match[1])] = decodeURIComponent(match[2]);
+ }
+
+ return params;
+}
diff --git a/toolkit/mozapps/update/tests/data/complete.exe b/toolkit/mozapps/update/tests/data/complete.exe
new file mode 100644
index 0000000000..da9cdf0cc0
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete.exe
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/complete.mar b/toolkit/mozapps/update/tests/data/complete.mar
new file mode 100644
index 0000000000..375fd7bd08
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete.mar
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/complete.png b/toolkit/mozapps/update/tests/data/complete.png
new file mode 100644
index 0000000000..2990a539ff
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete.png
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/complete_log_success_mac b/toolkit/mozapps/update/tests/data/complete_log_success_mac
new file mode 100644
index 0000000000..4f992a1374
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_log_success_mac
@@ -0,0 +1,332 @@
+UPDATE TYPE complete
+PREPARE REMOVEFILE Contents/Resources/searchplugins/searchpluginstext0
+PREPARE REMOVEFILE Contents/Resources/searchplugins/searchpluginspng0.png
+PREPARE REMOVEFILE Contents/Resources/removed-files
+PREPARE REMOVEFILE Contents/Resources/precomplete
+PREPARE REMOVEFILE Contents/Resources/2/20/20text0
+PREPARE REMOVEFILE Contents/Resources/2/20/20png0.png
+PREPARE REMOVEFILE Contents/Resources/0/0exe0.exe
+PREPARE REMOVEFILE Contents/Resources/0/00/00text0
+PREPARE REMOVEFILE Contents/MacOS/exe0.exe
+PREPARE REMOVEDIR Contents/Resources/searchplugins/
+PREPARE REMOVEDIR Contents/Resources/defaults/pref/
+PREPARE REMOVEDIR Contents/Resources/defaults/
+PREPARE REMOVEDIR Contents/Resources/2/20/
+PREPARE REMOVEDIR Contents/Resources/2/
+PREPARE REMOVEDIR Contents/Resources/0/00/
+PREPARE REMOVEDIR Contents/Resources/0/
+PREPARE REMOVEDIR Contents/Resources/
+PREPARE REMOVEDIR Contents/MacOS/
+PREPARE REMOVEDIR Contents/
+PREPARE ADD Contents/Resources/searchplugins/searchpluginstext0
+PREPARE ADD Contents/Resources/searchplugins/searchpluginspng1.png
+PREPARE ADD Contents/Resources/searchplugins/searchpluginspng0.png
+PREPARE ADD Contents/Resources/removed-files
+PREPARE ADD Contents/Resources/precomplete
+PREPARE ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+PREPARE ADD Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+PREPARE ADD Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+PREPARE ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+PREPARE ADD Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+PREPARE ADD Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+PREPARE ADD Contents/Resources/1/10/10text0
+PREPARE ADD Contents/Resources/0/0exe0.exe
+PREPARE ADD Contents/Resources/0/00/00text1
+PREPARE ADD Contents/Resources/0/00/00text0
+PREPARE ADD Contents/Resources/0/00/00png0.png
+PREPARE ADD Contents/MacOS/exe0.exe
+PREPARE REMOVEDIR Contents/Resources/9/99/
+PREPARE REMOVEDIR Contents/Resources/9/99/
+PREPARE REMOVEDIR Contents/Resources/9/98/
+PREPARE REMOVEFILE Contents/Resources/9/97/970/97xtext0
+PREPARE REMOVEFILE Contents/Resources/9/97/970/97xtext1
+PREPARE REMOVEDIR Contents/Resources/9/97/970/
+PREPARE REMOVEFILE Contents/Resources/9/97/971/97xtext0
+PREPARE REMOVEFILE Contents/Resources/9/97/971/97xtext1
+PREPARE REMOVEDIR Contents/Resources/9/97/971/
+PREPARE REMOVEDIR Contents/Resources/9/97/
+PREPARE REMOVEFILE Contents/Resources/9/96/96text0
+PREPARE REMOVEFILE Contents/Resources/9/96/96text1
+PREPARE REMOVEDIR Contents/Resources/9/96/
+PREPARE REMOVEDIR Contents/Resources/9/95/
+PREPARE REMOVEDIR Contents/Resources/9/95/
+PREPARE REMOVEDIR Contents/Resources/9/94/
+PREPARE REMOVEDIR Contents/Resources/9/94/
+PREPARE REMOVEDIR Contents/Resources/9/93/
+PREPARE REMOVEDIR Contents/Resources/9/92/
+PREPARE REMOVEDIR Contents/Resources/9/91/
+PREPARE REMOVEDIR Contents/Resources/9/90/
+PREPARE REMOVEDIR Contents/Resources/9/90/
+PREPARE REMOVEDIR Contents/Resources/8/89/
+PREPARE REMOVEDIR Contents/Resources/8/89/
+PREPARE REMOVEDIR Contents/Resources/8/88/
+PREPARE REMOVEFILE Contents/Resources/8/87/870/87xtext0
+PREPARE REMOVEFILE Contents/Resources/8/87/870/87xtext1
+PREPARE REMOVEDIR Contents/Resources/8/87/870/
+PREPARE REMOVEFILE Contents/Resources/8/87/871/87xtext0
+PREPARE REMOVEFILE Contents/Resources/8/87/871/87xtext1
+PREPARE REMOVEDIR Contents/Resources/8/87/871/
+PREPARE REMOVEDIR Contents/Resources/8/87/
+PREPARE REMOVEFILE Contents/Resources/8/86/86text0
+PREPARE REMOVEFILE Contents/Resources/8/86/86text1
+PREPARE REMOVEDIR Contents/Resources/8/86/
+PREPARE REMOVEDIR Contents/Resources/8/85/
+PREPARE REMOVEDIR Contents/Resources/8/85/
+PREPARE REMOVEDIR Contents/Resources/8/84/
+PREPARE REMOVEDIR Contents/Resources/8/84/
+PREPARE REMOVEDIR Contents/Resources/8/83/
+PREPARE REMOVEDIR Contents/Resources/8/82/
+PREPARE REMOVEDIR Contents/Resources/8/81/
+PREPARE REMOVEDIR Contents/Resources/8/80/
+PREPARE REMOVEDIR Contents/Resources/8/80/
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtest.exe
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtext0
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtext1
+PREPARE REMOVEDIR Contents/Resources/7/70/
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtest.exe
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtext0
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtext1
+PREPARE REMOVEDIR Contents/Resources/7/71/
+PREPARE REMOVEFILE Contents/Resources/7/7text0
+PREPARE REMOVEFILE Contents/Resources/7/7text1
+PREPARE REMOVEDIR Contents/Resources/7/
+PREPARE REMOVEDIR Contents/Resources/6/
+PREPARE REMOVEFILE Contents/Resources/5/5text1
+PREPARE REMOVEFILE Contents/Resources/5/5text0
+PREPARE REMOVEFILE Contents/Resources/5/5test.exe
+PREPARE REMOVEFILE Contents/Resources/5/5text0
+PREPARE REMOVEFILE Contents/Resources/5/5text1
+PREPARE REMOVEDIR Contents/Resources/5/
+PREPARE REMOVEFILE Contents/Resources/4/4text1
+PREPARE REMOVEFILE Contents/Resources/4/4text0
+PREPARE REMOVEDIR Contents/Resources/4/
+PREPARE REMOVEFILE Contents/Resources/3/3text1
+PREPARE REMOVEFILE Contents/Resources/3/3text0
+EXECUTE REMOVEFILE Contents/Resources/searchplugins/searchpluginstext0
+EXECUTE REMOVEFILE Contents/Resources/searchplugins/searchpluginspng0.png
+EXECUTE REMOVEFILE Contents/Resources/removed-files
+EXECUTE REMOVEFILE Contents/Resources/precomplete
+EXECUTE REMOVEFILE Contents/Resources/2/20/20text0
+EXECUTE REMOVEFILE Contents/Resources/2/20/20png0.png
+EXECUTE REMOVEFILE Contents/Resources/0/0exe0.exe
+EXECUTE REMOVEFILE Contents/Resources/0/00/00text0
+EXECUTE REMOVEFILE Contents/MacOS/exe0.exe
+EXECUTE REMOVEDIR Contents/Resources/searchplugins/
+EXECUTE REMOVEDIR Contents/Resources/defaults/pref/
+EXECUTE REMOVEDIR Contents/Resources/defaults/
+EXECUTE REMOVEDIR Contents/Resources/2/20/
+EXECUTE REMOVEDIR Contents/Resources/2/
+EXECUTE REMOVEDIR Contents/Resources/0/00/
+EXECUTE REMOVEDIR Contents/Resources/0/
+EXECUTE REMOVEDIR Contents/Resources/
+EXECUTE REMOVEDIR Contents/MacOS/
+EXECUTE REMOVEDIR Contents/
+EXECUTE ADD Contents/Resources/searchplugins/searchpluginstext0
+EXECUTE ADD Contents/Resources/searchplugins/searchpluginspng1.png
+EXECUTE ADD Contents/Resources/searchplugins/searchpluginspng0.png
+EXECUTE ADD Contents/Resources/removed-files
+EXECUTE ADD Contents/Resources/precomplete
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+EXECUTE ADD Contents/Resources/1/10/10text0
+EXECUTE ADD Contents/Resources/0/0exe0.exe
+EXECUTE ADD Contents/Resources/0/00/00text1
+EXECUTE ADD Contents/Resources/0/00/00text0
+EXECUTE ADD Contents/Resources/0/00/00png0.png
+EXECUTE ADD Contents/MacOS/exe0.exe
+EXECUTE REMOVEDIR Contents/Resources/9/99/
+EXECUTE REMOVEDIR Contents/Resources/9/99/
+EXECUTE REMOVEDIR Contents/Resources/9/98/
+EXECUTE REMOVEFILE Contents/Resources/9/97/970/97xtext0
+EXECUTE REMOVEFILE Contents/Resources/9/97/970/97xtext1
+EXECUTE REMOVEDIR Contents/Resources/9/97/970/
+EXECUTE REMOVEFILE Contents/Resources/9/97/971/97xtext0
+EXECUTE REMOVEFILE Contents/Resources/9/97/971/97xtext1
+EXECUTE REMOVEDIR Contents/Resources/9/97/971/
+EXECUTE REMOVEDIR Contents/Resources/9/97/
+EXECUTE REMOVEFILE Contents/Resources/9/96/96text0
+EXECUTE REMOVEFILE Contents/Resources/9/96/96text1
+EXECUTE REMOVEDIR Contents/Resources/9/96/
+EXECUTE REMOVEDIR Contents/Resources/9/95/
+EXECUTE REMOVEDIR Contents/Resources/9/95/
+EXECUTE REMOVEDIR Contents/Resources/9/94/
+EXECUTE REMOVEDIR Contents/Resources/9/94/
+EXECUTE REMOVEDIR Contents/Resources/9/93/
+EXECUTE REMOVEDIR Contents/Resources/9/92/
+EXECUTE REMOVEDIR Contents/Resources/9/91/
+EXECUTE REMOVEDIR Contents/Resources/9/90/
+EXECUTE REMOVEDIR Contents/Resources/9/90/
+EXECUTE REMOVEDIR Contents/Resources/8/89/
+EXECUTE REMOVEDIR Contents/Resources/8/89/
+EXECUTE REMOVEDIR Contents/Resources/8/88/
+EXECUTE REMOVEFILE Contents/Resources/8/87/870/87xtext0
+EXECUTE REMOVEFILE Contents/Resources/8/87/870/87xtext1
+EXECUTE REMOVEDIR Contents/Resources/8/87/870/
+EXECUTE REMOVEFILE Contents/Resources/8/87/871/87xtext0
+EXECUTE REMOVEFILE Contents/Resources/8/87/871/87xtext1
+EXECUTE REMOVEDIR Contents/Resources/8/87/871/
+EXECUTE REMOVEDIR Contents/Resources/8/87/
+EXECUTE REMOVEFILE Contents/Resources/8/86/86text0
+EXECUTE REMOVEFILE Contents/Resources/8/86/86text1
+EXECUTE REMOVEDIR Contents/Resources/8/86/
+EXECUTE REMOVEDIR Contents/Resources/8/85/
+EXECUTE REMOVEDIR Contents/Resources/8/85/
+EXECUTE REMOVEDIR Contents/Resources/8/84/
+EXECUTE REMOVEDIR Contents/Resources/8/84/
+EXECUTE REMOVEDIR Contents/Resources/8/83/
+EXECUTE REMOVEDIR Contents/Resources/8/82/
+EXECUTE REMOVEDIR Contents/Resources/8/81/
+EXECUTE REMOVEDIR Contents/Resources/8/80/
+EXECUTE REMOVEDIR Contents/Resources/8/80/
+EXECUTE REMOVEFILE Contents/Resources/7/70/7xtest.exe
+EXECUTE REMOVEFILE Contents/Resources/7/70/7xtext0
+EXECUTE REMOVEFILE Contents/Resources/7/70/7xtext1
+EXECUTE REMOVEDIR Contents/Resources/7/70/
+EXECUTE REMOVEFILE Contents/Resources/7/71/7xtest.exe
+EXECUTE REMOVEFILE Contents/Resources/7/71/7xtext0
+EXECUTE REMOVEFILE Contents/Resources/7/71/7xtext1
+EXECUTE REMOVEDIR Contents/Resources/7/71/
+EXECUTE REMOVEFILE Contents/Resources/7/7text0
+EXECUTE REMOVEFILE Contents/Resources/7/7text1
+EXECUTE REMOVEDIR Contents/Resources/7/
+EXECUTE REMOVEDIR Contents/Resources/6/
+EXECUTE REMOVEFILE Contents/Resources/5/5text1
+EXECUTE REMOVEFILE Contents/Resources/5/5text0
+EXECUTE REMOVEFILE Contents/Resources/5/5test.exe
+EXECUTE REMOVEFILE Contents/Resources/5/5text0
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEFILE Contents/Resources/5/5text1
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEDIR Contents/Resources/5/
+EXECUTE REMOVEFILE Contents/Resources/4/4text1
+EXECUTE REMOVEFILE Contents/Resources/4/4text0
+EXECUTE REMOVEDIR Contents/Resources/4/
+EXECUTE REMOVEFILE Contents/Resources/3/3text1
+EXECUTE REMOVEFILE Contents/Resources/3/3text0
+FINISH REMOVEFILE Contents/Resources/searchplugins/searchpluginstext0
+FINISH REMOVEFILE Contents/Resources/searchplugins/searchpluginspng0.png
+FINISH REMOVEFILE Contents/Resources/removed-files
+FINISH REMOVEFILE Contents/Resources/precomplete
+FINISH REMOVEFILE Contents/Resources/2/20/20text0
+FINISH REMOVEFILE Contents/Resources/2/20/20png0.png
+FINISH REMOVEFILE Contents/Resources/0/0exe0.exe
+FINISH REMOVEFILE Contents/Resources/0/00/00text0
+FINISH REMOVEFILE Contents/MacOS/exe0.exe
+FINISH REMOVEDIR Contents/Resources/searchplugins/
+removing directory: Contents/Resources/searchplugins/, rv: 0
+FINISH REMOVEDIR Contents/Resources/defaults/pref/
+removing directory: Contents/Resources/defaults/pref/, rv: 0
+FINISH REMOVEDIR Contents/Resources/defaults/
+removing directory: Contents/Resources/defaults/, rv: 0
+FINISH REMOVEDIR Contents/Resources/2/20/
+FINISH REMOVEDIR Contents/Resources/2/
+FINISH REMOVEDIR Contents/Resources/0/00/
+removing directory: Contents/Resources/0/00/, rv: 0
+FINISH REMOVEDIR Contents/Resources/0/
+removing directory: Contents/Resources/0/, rv: 0
+FINISH REMOVEDIR Contents/Resources/
+removing directory: Contents/Resources/, rv: 0
+FINISH REMOVEDIR Contents/MacOS/
+removing directory: Contents/MacOS/, rv: 0
+FINISH REMOVEDIR Contents/
+removing directory: Contents/, rv: 0
+FINISH ADD Contents/Resources/searchplugins/searchpluginstext0
+FINISH ADD Contents/Resources/searchplugins/searchpluginspng1.png
+FINISH ADD Contents/Resources/searchplugins/searchpluginspng0.png
+FINISH ADD Contents/Resources/removed-files
+FINISH ADD Contents/Resources/precomplete
+FINISH ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+FINISH ADD Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+FINISH ADD Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+FINISH ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+FINISH ADD Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+FINISH ADD Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+FINISH ADD Contents/Resources/1/10/10text0
+FINISH ADD Contents/Resources/0/0exe0.exe
+FINISH ADD Contents/Resources/0/00/00text1
+FINISH ADD Contents/Resources/0/00/00text0
+FINISH ADD Contents/Resources/0/00/00png0.png
+FINISH ADD Contents/MacOS/exe0.exe
+FINISH REMOVEDIR Contents/Resources/9/99/
+FINISH REMOVEDIR Contents/Resources/9/99/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/9/98/
+FINISH REMOVEFILE Contents/Resources/9/97/970/97xtext0
+FINISH REMOVEFILE Contents/Resources/9/97/970/97xtext1
+FINISH REMOVEDIR Contents/Resources/9/97/970/
+FINISH REMOVEFILE Contents/Resources/9/97/971/97xtext0
+FINISH REMOVEFILE Contents/Resources/9/97/971/97xtext1
+FINISH REMOVEDIR Contents/Resources/9/97/971/
+FINISH REMOVEDIR Contents/Resources/9/97/
+FINISH REMOVEFILE Contents/Resources/9/96/96text0
+FINISH REMOVEFILE Contents/Resources/9/96/96text1
+FINISH REMOVEDIR Contents/Resources/9/96/
+FINISH REMOVEDIR Contents/Resources/9/95/
+FINISH REMOVEDIR Contents/Resources/9/95/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/9/94/
+FINISH REMOVEDIR Contents/Resources/9/94/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/9/93/
+FINISH REMOVEDIR Contents/Resources/9/92/
+removing directory: Contents/Resources/9/92/, rv: 0
+FINISH REMOVEDIR Contents/Resources/9/91/
+removing directory: Contents/Resources/9/91/, rv: 0
+FINISH REMOVEDIR Contents/Resources/9/90/
+FINISH REMOVEDIR Contents/Resources/9/90/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/89/
+FINISH REMOVEDIR Contents/Resources/8/89/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/88/
+FINISH REMOVEFILE Contents/Resources/8/87/870/87xtext0
+FINISH REMOVEFILE Contents/Resources/8/87/870/87xtext1
+FINISH REMOVEDIR Contents/Resources/8/87/870/
+FINISH REMOVEFILE Contents/Resources/8/87/871/87xtext0
+FINISH REMOVEFILE Contents/Resources/8/87/871/87xtext1
+FINISH REMOVEDIR Contents/Resources/8/87/871/
+FINISH REMOVEDIR Contents/Resources/8/87/
+FINISH REMOVEFILE Contents/Resources/8/86/86text0
+FINISH REMOVEFILE Contents/Resources/8/86/86text1
+FINISH REMOVEDIR Contents/Resources/8/86/
+FINISH REMOVEDIR Contents/Resources/8/85/
+FINISH REMOVEDIR Contents/Resources/8/85/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/84/
+FINISH REMOVEDIR Contents/Resources/8/84/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/83/
+FINISH REMOVEDIR Contents/Resources/8/82/
+removing directory: Contents/Resources/8/82/, rv: 0
+FINISH REMOVEDIR Contents/Resources/8/81/
+removing directory: Contents/Resources/8/81/, rv: 0
+FINISH REMOVEDIR Contents/Resources/8/80/
+FINISH REMOVEDIR Contents/Resources/8/80/
+directory no longer exists; skipping
+FINISH REMOVEFILE Contents/Resources/7/70/7xtest.exe
+FINISH REMOVEFILE Contents/Resources/7/70/7xtext0
+FINISH REMOVEFILE Contents/Resources/7/70/7xtext1
+FINISH REMOVEDIR Contents/Resources/7/70/
+FINISH REMOVEFILE Contents/Resources/7/71/7xtest.exe
+FINISH REMOVEFILE Contents/Resources/7/71/7xtext0
+FINISH REMOVEFILE Contents/Resources/7/71/7xtext1
+FINISH REMOVEDIR Contents/Resources/7/71/
+FINISH REMOVEFILE Contents/Resources/7/7text0
+FINISH REMOVEFILE Contents/Resources/7/7text1
+FINISH REMOVEDIR Contents/Resources/7/
+FINISH REMOVEDIR Contents/Resources/6/
+FINISH REMOVEFILE Contents/Resources/5/5text1
+FINISH REMOVEFILE Contents/Resources/5/5text0
+FINISH REMOVEFILE Contents/Resources/5/5test.exe
+FINISH REMOVEDIR Contents/Resources/5/
+FINISH REMOVEFILE Contents/Resources/4/4text1
+FINISH REMOVEFILE Contents/Resources/4/4text0
+FINISH REMOVEDIR Contents/Resources/4/
+FINISH REMOVEFILE Contents/Resources/3/3text1
+FINISH REMOVEFILE Contents/Resources/3/3text0
+succeeded
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/complete_log_success_win b/toolkit/mozapps/update/tests/data/complete_log_success_win
new file mode 100644
index 0000000000..c5a03dc9d6
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_log_success_win
@@ -0,0 +1,320 @@
+UPDATE TYPE complete
+PREPARE REMOVEFILE searchplugins/searchpluginstext0
+PREPARE REMOVEFILE searchplugins/searchpluginspng0.png
+PREPARE REMOVEFILE removed-files
+PREPARE REMOVEFILE precomplete
+PREPARE REMOVEFILE exe0.exe
+PREPARE REMOVEFILE 2/20/20text0
+PREPARE REMOVEFILE 2/20/20png0.png
+PREPARE REMOVEFILE 0/0exe0.exe
+PREPARE REMOVEFILE 0/00/00text0
+PREPARE REMOVEDIR searchplugins/
+PREPARE REMOVEDIR defaults/pref/
+PREPARE REMOVEDIR defaults/
+PREPARE REMOVEDIR 2/20/
+PREPARE REMOVEDIR 2/
+PREPARE REMOVEDIR 0/00/
+PREPARE REMOVEDIR 0/
+PREPARE ADD searchplugins/searchpluginstext0
+PREPARE ADD searchplugins/searchpluginspng1.png
+PREPARE ADD searchplugins/searchpluginspng0.png
+PREPARE ADD removed-files
+PREPARE ADD precomplete
+PREPARE ADD exe0.exe
+PREPARE ADD distribution/extensions/extensions1/extensions1text0
+PREPARE ADD distribution/extensions/extensions1/extensions1png1.png
+PREPARE ADD distribution/extensions/extensions1/extensions1png0.png
+PREPARE ADD distribution/extensions/extensions0/extensions0text0
+PREPARE ADD distribution/extensions/extensions0/extensions0png1.png
+PREPARE ADD distribution/extensions/extensions0/extensions0png0.png
+PREPARE ADD 1/10/10text0
+PREPARE ADD 0/0exe0.exe
+PREPARE ADD 0/00/00text1
+PREPARE ADD 0/00/00text0
+PREPARE ADD 0/00/00png0.png
+PREPARE REMOVEDIR 9/99/
+PREPARE REMOVEDIR 9/99/
+PREPARE REMOVEDIR 9/98/
+PREPARE REMOVEFILE 9/97/970/97xtext0
+PREPARE REMOVEFILE 9/97/970/97xtext1
+PREPARE REMOVEDIR 9/97/970/
+PREPARE REMOVEFILE 9/97/971/97xtext0
+PREPARE REMOVEFILE 9/97/971/97xtext1
+PREPARE REMOVEDIR 9/97/971/
+PREPARE REMOVEDIR 9/97/
+PREPARE REMOVEFILE 9/96/96text0
+PREPARE REMOVEFILE 9/96/96text1
+PREPARE REMOVEDIR 9/96/
+PREPARE REMOVEDIR 9/95/
+PREPARE REMOVEDIR 9/95/
+PREPARE REMOVEDIR 9/94/
+PREPARE REMOVEDIR 9/94/
+PREPARE REMOVEDIR 9/93/
+PREPARE REMOVEDIR 9/92/
+PREPARE REMOVEDIR 9/91/
+PREPARE REMOVEDIR 9/90/
+PREPARE REMOVEDIR 9/90/
+PREPARE REMOVEDIR 8/89/
+PREPARE REMOVEDIR 8/89/
+PREPARE REMOVEDIR 8/88/
+PREPARE REMOVEFILE 8/87/870/87xtext0
+PREPARE REMOVEFILE 8/87/870/87xtext1
+PREPARE REMOVEDIR 8/87/870/
+PREPARE REMOVEFILE 8/87/871/87xtext0
+PREPARE REMOVEFILE 8/87/871/87xtext1
+PREPARE REMOVEDIR 8/87/871/
+PREPARE REMOVEDIR 8/87/
+PREPARE REMOVEFILE 8/86/86text0
+PREPARE REMOVEFILE 8/86/86text1
+PREPARE REMOVEDIR 8/86/
+PREPARE REMOVEDIR 8/85/
+PREPARE REMOVEDIR 8/85/
+PREPARE REMOVEDIR 8/84/
+PREPARE REMOVEDIR 8/84/
+PREPARE REMOVEDIR 8/83/
+PREPARE REMOVEDIR 8/82/
+PREPARE REMOVEDIR 8/81/
+PREPARE REMOVEDIR 8/80/
+PREPARE REMOVEDIR 8/80/
+PREPARE REMOVEFILE 7/70/7xtest.exe
+PREPARE REMOVEFILE 7/70/7xtext0
+PREPARE REMOVEFILE 7/70/7xtext1
+PREPARE REMOVEDIR 7/70/
+PREPARE REMOVEFILE 7/71/7xtest.exe
+PREPARE REMOVEFILE 7/71/7xtext0
+PREPARE REMOVEFILE 7/71/7xtext1
+PREPARE REMOVEDIR 7/71/
+PREPARE REMOVEFILE 7/7text0
+PREPARE REMOVEFILE 7/7text1
+PREPARE REMOVEDIR 7/
+PREPARE REMOVEDIR 6/
+PREPARE REMOVEFILE 5/5text1
+PREPARE REMOVEFILE 5/5text0
+PREPARE REMOVEFILE 5/5test.exe
+PREPARE REMOVEFILE 5/5text0
+PREPARE REMOVEFILE 5/5text1
+PREPARE REMOVEDIR 5/
+PREPARE REMOVEFILE 4/4text1
+PREPARE REMOVEFILE 4/4text0
+PREPARE REMOVEDIR 4/
+PREPARE REMOVEFILE 3/3text1
+PREPARE REMOVEFILE 3/3text0
+EXECUTE REMOVEFILE searchplugins/searchpluginstext0
+EXECUTE REMOVEFILE searchplugins/searchpluginspng0.png
+EXECUTE REMOVEFILE removed-files
+EXECUTE REMOVEFILE precomplete
+EXECUTE REMOVEFILE exe0.exe
+EXECUTE REMOVEFILE 2/20/20text0
+EXECUTE REMOVEFILE 2/20/20png0.png
+EXECUTE REMOVEFILE 0/0exe0.exe
+EXECUTE REMOVEFILE 0/00/00text0
+EXECUTE REMOVEDIR searchplugins/
+EXECUTE REMOVEDIR defaults/pref/
+EXECUTE REMOVEDIR defaults/
+EXECUTE REMOVEDIR 2/20/
+EXECUTE REMOVEDIR 2/
+EXECUTE REMOVEDIR 0/00/
+EXECUTE REMOVEDIR 0/
+EXECUTE ADD searchplugins/searchpluginstext0
+EXECUTE ADD searchplugins/searchpluginspng1.png
+EXECUTE ADD searchplugins/searchpluginspng0.png
+EXECUTE ADD removed-files
+EXECUTE ADD precomplete
+EXECUTE ADD exe0.exe
+EXECUTE ADD distribution/extensions/extensions1/extensions1text0
+EXECUTE ADD distribution/extensions/extensions1/extensions1png1.png
+EXECUTE ADD distribution/extensions/extensions1/extensions1png0.png
+EXECUTE ADD distribution/extensions/extensions0/extensions0text0
+EXECUTE ADD distribution/extensions/extensions0/extensions0png1.png
+EXECUTE ADD distribution/extensions/extensions0/extensions0png0.png
+EXECUTE ADD 1/10/10text0
+EXECUTE ADD 0/0exe0.exe
+EXECUTE ADD 0/00/00text1
+EXECUTE ADD 0/00/00text0
+EXECUTE ADD 0/00/00png0.png
+EXECUTE REMOVEDIR 9/99/
+EXECUTE REMOVEDIR 9/99/
+EXECUTE REMOVEDIR 9/98/
+EXECUTE REMOVEFILE 9/97/970/97xtext0
+EXECUTE REMOVEFILE 9/97/970/97xtext1
+EXECUTE REMOVEDIR 9/97/970/
+EXECUTE REMOVEFILE 9/97/971/97xtext0
+EXECUTE REMOVEFILE 9/97/971/97xtext1
+EXECUTE REMOVEDIR 9/97/971/
+EXECUTE REMOVEDIR 9/97/
+EXECUTE REMOVEFILE 9/96/96text0
+EXECUTE REMOVEFILE 9/96/96text1
+EXECUTE REMOVEDIR 9/96/
+EXECUTE REMOVEDIR 9/95/
+EXECUTE REMOVEDIR 9/95/
+EXECUTE REMOVEDIR 9/94/
+EXECUTE REMOVEDIR 9/94/
+EXECUTE REMOVEDIR 9/93/
+EXECUTE REMOVEDIR 9/92/
+EXECUTE REMOVEDIR 9/91/
+EXECUTE REMOVEDIR 9/90/
+EXECUTE REMOVEDIR 9/90/
+EXECUTE REMOVEDIR 8/89/
+EXECUTE REMOVEDIR 8/89/
+EXECUTE REMOVEDIR 8/88/
+EXECUTE REMOVEFILE 8/87/870/87xtext0
+EXECUTE REMOVEFILE 8/87/870/87xtext1
+EXECUTE REMOVEDIR 8/87/870/
+EXECUTE REMOVEFILE 8/87/871/87xtext0
+EXECUTE REMOVEFILE 8/87/871/87xtext1
+EXECUTE REMOVEDIR 8/87/871/
+EXECUTE REMOVEDIR 8/87/
+EXECUTE REMOVEFILE 8/86/86text0
+EXECUTE REMOVEFILE 8/86/86text1
+EXECUTE REMOVEDIR 8/86/
+EXECUTE REMOVEDIR 8/85/
+EXECUTE REMOVEDIR 8/85/
+EXECUTE REMOVEDIR 8/84/
+EXECUTE REMOVEDIR 8/84/
+EXECUTE REMOVEDIR 8/83/
+EXECUTE REMOVEDIR 8/82/
+EXECUTE REMOVEDIR 8/81/
+EXECUTE REMOVEDIR 8/80/
+EXECUTE REMOVEDIR 8/80/
+EXECUTE REMOVEFILE 7/70/7xtest.exe
+EXECUTE REMOVEFILE 7/70/7xtext0
+EXECUTE REMOVEFILE 7/70/7xtext1
+EXECUTE REMOVEDIR 7/70/
+EXECUTE REMOVEFILE 7/71/7xtest.exe
+EXECUTE REMOVEFILE 7/71/7xtext0
+EXECUTE REMOVEFILE 7/71/7xtext1
+EXECUTE REMOVEDIR 7/71/
+EXECUTE REMOVEFILE 7/7text0
+EXECUTE REMOVEFILE 7/7text1
+EXECUTE REMOVEDIR 7/
+EXECUTE REMOVEDIR 6/
+EXECUTE REMOVEFILE 5/5text1
+EXECUTE REMOVEFILE 5/5text0
+EXECUTE REMOVEFILE 5/5test.exe
+EXECUTE REMOVEFILE 5/5text0
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEFILE 5/5text1
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEDIR 5/
+EXECUTE REMOVEFILE 4/4text1
+EXECUTE REMOVEFILE 4/4text0
+EXECUTE REMOVEDIR 4/
+EXECUTE REMOVEFILE 3/3text1
+EXECUTE REMOVEFILE 3/3text0
+FINISH REMOVEFILE searchplugins/searchpluginstext0
+FINISH REMOVEFILE searchplugins/searchpluginspng0.png
+FINISH REMOVEFILE removed-files
+FINISH REMOVEFILE precomplete
+FINISH REMOVEFILE exe0.exe
+FINISH REMOVEFILE 2/20/20text0
+FINISH REMOVEFILE 2/20/20png0.png
+FINISH REMOVEFILE 0/0exe0.exe
+FINISH REMOVEFILE 0/00/00text0
+FINISH REMOVEDIR searchplugins/
+removing directory: searchplugins/, rv: 0
+FINISH REMOVEDIR defaults/pref/
+removing directory: defaults/pref/, rv: 0
+FINISH REMOVEDIR defaults/
+removing directory: defaults/, rv: 0
+FINISH REMOVEDIR 2/20/
+FINISH REMOVEDIR 2/
+FINISH REMOVEDIR 0/00/
+removing directory: 0/00/, rv: 0
+FINISH REMOVEDIR 0/
+removing directory: 0/, rv: 0
+FINISH ADD searchplugins/searchpluginstext0
+FINISH ADD searchplugins/searchpluginspng1.png
+FINISH ADD searchplugins/searchpluginspng0.png
+FINISH ADD removed-files
+FINISH ADD precomplete
+FINISH ADD exe0.exe
+FINISH ADD distribution/extensions/extensions1/extensions1text0
+FINISH ADD distribution/extensions/extensions1/extensions1png1.png
+FINISH ADD distribution/extensions/extensions1/extensions1png0.png
+FINISH ADD distribution/extensions/extensions0/extensions0text0
+FINISH ADD distribution/extensions/extensions0/extensions0png1.png
+FINISH ADD distribution/extensions/extensions0/extensions0png0.png
+FINISH ADD 1/10/10text0
+FINISH ADD 0/0exe0.exe
+FINISH ADD 0/00/00text1
+FINISH ADD 0/00/00text0
+FINISH ADD 0/00/00png0.png
+FINISH REMOVEDIR 9/99/
+FINISH REMOVEDIR 9/99/
+directory no longer exists; skipping
+FINISH REMOVEDIR 9/98/
+FINISH REMOVEFILE 9/97/970/97xtext0
+FINISH REMOVEFILE 9/97/970/97xtext1
+FINISH REMOVEDIR 9/97/970/
+FINISH REMOVEFILE 9/97/971/97xtext0
+FINISH REMOVEFILE 9/97/971/97xtext1
+FINISH REMOVEDIR 9/97/971/
+FINISH REMOVEDIR 9/97/
+FINISH REMOVEFILE 9/96/96text0
+FINISH REMOVEFILE 9/96/96text1
+FINISH REMOVEDIR 9/96/
+FINISH REMOVEDIR 9/95/
+FINISH REMOVEDIR 9/95/
+directory no longer exists; skipping
+FINISH REMOVEDIR 9/94/
+FINISH REMOVEDIR 9/94/
+directory no longer exists; skipping
+FINISH REMOVEDIR 9/93/
+FINISH REMOVEDIR 9/92/
+removing directory: 9/92/, rv: 0
+FINISH REMOVEDIR 9/91/
+removing directory: 9/91/, rv: 0
+FINISH REMOVEDIR 9/90/
+FINISH REMOVEDIR 9/90/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/89/
+FINISH REMOVEDIR 8/89/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/88/
+FINISH REMOVEFILE 8/87/870/87xtext0
+FINISH REMOVEFILE 8/87/870/87xtext1
+FINISH REMOVEDIR 8/87/870/
+FINISH REMOVEFILE 8/87/871/87xtext0
+FINISH REMOVEFILE 8/87/871/87xtext1
+FINISH REMOVEDIR 8/87/871/
+FINISH REMOVEDIR 8/87/
+FINISH REMOVEFILE 8/86/86text0
+FINISH REMOVEFILE 8/86/86text1
+FINISH REMOVEDIR 8/86/
+FINISH REMOVEDIR 8/85/
+FINISH REMOVEDIR 8/85/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/84/
+FINISH REMOVEDIR 8/84/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/83/
+FINISH REMOVEDIR 8/82/
+removing directory: 8/82/, rv: 0
+FINISH REMOVEDIR 8/81/
+removing directory: 8/81/, rv: 0
+FINISH REMOVEDIR 8/80/
+FINISH REMOVEDIR 8/80/
+directory no longer exists; skipping
+FINISH REMOVEFILE 7/70/7xtest.exe
+FINISH REMOVEFILE 7/70/7xtext0
+FINISH REMOVEFILE 7/70/7xtext1
+FINISH REMOVEDIR 7/70/
+FINISH REMOVEFILE 7/71/7xtest.exe
+FINISH REMOVEFILE 7/71/7xtext0
+FINISH REMOVEFILE 7/71/7xtext1
+FINISH REMOVEDIR 7/71/
+FINISH REMOVEFILE 7/7text0
+FINISH REMOVEFILE 7/7text1
+FINISH REMOVEDIR 7/
+FINISH REMOVEDIR 6/
+FINISH REMOVEFILE 5/5text1
+FINISH REMOVEFILE 5/5text0
+FINISH REMOVEFILE 5/5test.exe
+FINISH REMOVEDIR 5/
+FINISH REMOVEFILE 4/4text1
+FINISH REMOVEFILE 4/4text0
+FINISH REMOVEDIR 4/
+FINISH REMOVEFILE 3/3text1
+FINISH REMOVEFILE 3/3text0
+succeeded
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/complete_mac.mar b/toolkit/mozapps/update/tests/data/complete_mac.mar
new file mode 100644
index 0000000000..c54088610a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_mac.mar
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/complete_precomplete b/toolkit/mozapps/update/tests/data/complete_precomplete
new file mode 100644
index 0000000000..ae7a0013ff
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_precomplete
@@ -0,0 +1,18 @@
+remove "searchplugins/searchpluginstext0"
+remove "searchplugins/searchpluginspng1.png"
+remove "searchplugins/searchpluginspng0.png"
+remove "removed-files"
+remove "precomplete"
+remove "exe0.exe"
+remove "1/10/10text0"
+remove "0/0exe0.exe"
+remove "0/00/00text1"
+remove "0/00/00text0"
+remove "0/00/00png0.png"
+rmdir "searchplugins/"
+rmdir "defaults/pref/"
+rmdir "defaults/"
+rmdir "1/10/"
+rmdir "1/"
+rmdir "0/00/"
+rmdir "0/"
diff --git a/toolkit/mozapps/update/tests/data/complete_precomplete_mac b/toolkit/mozapps/update/tests/data/complete_precomplete_mac
new file mode 100644
index 0000000000..8d81a36d66
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_precomplete_mac
@@ -0,0 +1,21 @@
+remove "Contents/Resources/searchplugins/searchpluginstext0"
+remove "Contents/Resources/searchplugins/searchpluginspng1.png"
+remove "Contents/Resources/searchplugins/searchpluginspng0.png"
+remove "Contents/Resources/removed-files"
+remove "Contents/Resources/precomplete"
+remove "Contents/Resources/1/10/10text0"
+remove "Contents/Resources/0/0exe0.exe"
+remove "Contents/Resources/0/00/00text1"
+remove "Contents/Resources/0/00/00text0"
+remove "Contents/Resources/0/00/00png0.png"
+remove "Contents/MacOS/exe0.exe"
+rmdir "Contents/Resources/searchplugins/"
+rmdir "Contents/Resources/defaults/pref/"
+rmdir "Contents/Resources/defaults/"
+rmdir "Contents/Resources/1/10/"
+rmdir "Contents/Resources/1/"
+rmdir "Contents/Resources/0/00/"
+rmdir "Contents/Resources/0/"
+rmdir "Contents/Resources/"
+rmdir "Contents/MacOS/"
+rmdir "Contents/"
diff --git a/toolkit/mozapps/update/tests/data/complete_removed-files b/toolkit/mozapps/update/tests/data/complete_removed-files
new file mode 100644
index 0000000000..e45c43c1f8
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_removed-files
@@ -0,0 +1,41 @@
+text0
+text1
+3/3text0
+3/3text1
+4/exe0.exe
+4/4text0
+4/4text1
+4/
+5/5text0
+5/5text1
+5/*
+6/
+7/*
+8/80/
+8/81/
+8/82/
+8/83/
+8/84/
+8/85/*
+8/86/*
+8/87/*
+8/88/*
+8/89/*
+8/80/
+8/84/*
+8/85/*
+8/89/
+9/90/
+9/91/
+9/92/
+9/93/
+9/94/
+9/95/*
+9/96/*
+9/97/*
+9/98/*
+9/99/*
+9/90/
+9/94/*
+9/95/*
+9/99/
diff --git a/toolkit/mozapps/update/tests/data/complete_removed-files_mac b/toolkit/mozapps/update/tests/data/complete_removed-files_mac
new file mode 100644
index 0000000000..955dc5b340
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_removed-files_mac
@@ -0,0 +1,41 @@
+Contents/Resources/text0
+Contents/Resources/text1
+Contents/Resources/3/3text0
+Contents/Resources/3/3text1
+Contents/Resources/4/exe0.exe
+Contents/Resources/4/4text0
+Contents/Resources/4/4text1
+Contents/Resources/4/
+Contents/Resources/5/5text0
+Contents/Resources/5/5text1
+Contents/Resources/5/*
+Contents/Resources/6/
+Contents/Resources/7/*
+Contents/Resources/8/80/
+Contents/Resources/8/81/
+Contents/Resources/8/82/
+Contents/Resources/8/83/
+Contents/Resources/8/84/
+Contents/Resources/8/85/*
+Contents/Resources/8/86/*
+Contents/Resources/8/87/*
+Contents/Resources/8/88/*
+Contents/Resources/8/89/*
+Contents/Resources/8/80/
+Contents/Resources/8/84/*
+Contents/Resources/8/85/*
+Contents/Resources/8/89/
+Contents/Resources/9/90/
+Contents/Resources/9/91/
+Contents/Resources/9/92/
+Contents/Resources/9/93/
+Contents/Resources/9/94/
+Contents/Resources/9/95/*
+Contents/Resources/9/96/*
+Contents/Resources/9/97/*
+Contents/Resources/9/98/*
+Contents/Resources/9/99/*
+Contents/Resources/9/90/
+Contents/Resources/9/94/*
+Contents/Resources/9/95/*
+Contents/Resources/9/99/
diff --git a/toolkit/mozapps/update/tests/data/complete_update_manifest b/toolkit/mozapps/update/tests/data/complete_update_manifest
new file mode 100644
index 0000000000..383a324f63
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/complete_update_manifest
@@ -0,0 +1,59 @@
+type "complete"
+add "precomplete"
+add "searchplugins/searchpluginstext0"
+add "searchplugins/searchpluginspng1.png"
+add "searchplugins/searchpluginspng0.png"
+add "removed-files"
+add-if "extensions/extensions1" "extensions/extensions1/extensions1text0"
+add-if "extensions/extensions1" "extensions/extensions1/extensions1png1.png"
+add-if "extensions/extensions1" "extensions/extensions1/extensions1png0.png"
+add-if "extensions/extensions0" "extensions/extensions0/extensions0text0"
+add-if "extensions/extensions0" "extensions/extensions0/extensions0png1.png"
+add-if "extensions/extensions0" "extensions/extensions0/extensions0png0.png"
+add "exe0.exe"
+add "1/10/10text0"
+add "0/0exe0.exe"
+add "0/00/00text1"
+add "0/00/00text0"
+add "0/00/00png0.png"
+remove "text1"
+remove "text0"
+rmrfdir "9/99/"
+rmdir "9/99/"
+rmrfdir "9/98/"
+rmrfdir "9/97/"
+rmrfdir "9/96/"
+rmrfdir "9/95/"
+rmrfdir "9/95/"
+rmrfdir "9/94/"
+rmdir "9/94/"
+rmdir "9/93/"
+rmdir "9/92/"
+rmdir "9/91/"
+rmdir "9/90/"
+rmdir "9/90/"
+rmrfdir "8/89/"
+rmdir "8/89/"
+rmrfdir "8/88/"
+rmrfdir "8/87/"
+rmrfdir "8/86/"
+rmrfdir "8/85/"
+rmrfdir "8/85/"
+rmrfdir "8/84/"
+rmdir "8/84/"
+rmdir "8/83/"
+rmdir "8/82/"
+rmdir "8/81/"
+rmdir "8/80/"
+rmdir "8/80/"
+rmrfdir "7/"
+rmdir "6/"
+remove "5/5text1"
+remove "5/5text0"
+rmrfdir "5/"
+remove "4/exe0.exe"
+remove "4/4text1"
+remove "4/4text0"
+rmdir "4/"
+remove "3/3text1"
+remove "3/3text0"
diff --git a/toolkit/mozapps/update/tests/data/old_version.mar b/toolkit/mozapps/update/tests/data/old_version.mar
new file mode 100644
index 0000000000..b48f1d5fa4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/old_version.mar
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/partial.exe b/toolkit/mozapps/update/tests/data/partial.exe
new file mode 100644
index 0000000000..3949fd2a0e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial.exe
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/partial.mar b/toolkit/mozapps/update/tests/data/partial.mar
new file mode 100644
index 0000000000..b6b04bbdbf
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial.mar
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/partial.png b/toolkit/mozapps/update/tests/data/partial.png
new file mode 100644
index 0000000000..9246f586c7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial.png
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/partial_log_failure_mac b/toolkit/mozapps/update/tests/data/partial_log_failure_mac
new file mode 100644
index 0000000000..3b2933ebd2
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_log_failure_mac
@@ -0,0 +1,192 @@
+UPDATE TYPE partial
+PREPARE ADD Contents/Resources/searchplugins/searchpluginstext0
+PREPARE PATCH Contents/Resources/searchplugins/searchpluginspng1.png
+PREPARE PATCH Contents/Resources/searchplugins/searchpluginspng0.png
+PREPARE ADD Contents/Resources/precomplete
+PREPARE ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+PREPARE ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+PREPARE PATCH Contents/Resources/0/0exe0.exe
+PREPARE ADD Contents/Resources/0/00/00text0
+PREPARE PATCH Contents/Resources/0/00/00png0.png
+PREPARE PATCH Contents/MacOS/exe0.exe
+PREPARE ADD Contents/Resources/2/20/20text0
+PREPARE ADD Contents/Resources/2/20/20png0.png
+PREPARE ADD Contents/Resources/0/00/00text2
+PREPARE REMOVEFILE Contents/Resources/1/10/10text0
+PREPARE REMOVEFILE Contents/Resources/0/00/00text1
+PREPARE REMOVEDIR Contents/Resources/9/99/
+PREPARE REMOVEDIR Contents/Resources/9/99/
+PREPARE REMOVEDIR Contents/Resources/9/98/
+PREPARE REMOVEFILE Contents/Resources/9/97/970/97xtext0
+PREPARE REMOVEFILE Contents/Resources/9/97/970/97xtext1
+PREPARE REMOVEDIR Contents/Resources/9/97/970/
+PREPARE REMOVEFILE Contents/Resources/9/97/971/97xtext0
+PREPARE REMOVEFILE Contents/Resources/9/97/971/97xtext1
+PREPARE REMOVEDIR Contents/Resources/9/97/971/
+PREPARE REMOVEDIR Contents/Resources/9/97/
+PREPARE REMOVEFILE Contents/Resources/9/96/96text0
+PREPARE REMOVEFILE Contents/Resources/9/96/96text1
+PREPARE REMOVEDIR Contents/Resources/9/96/
+PREPARE REMOVEDIR Contents/Resources/9/95/
+PREPARE REMOVEDIR Contents/Resources/9/95/
+PREPARE REMOVEDIR Contents/Resources/9/94/
+PREPARE REMOVEDIR Contents/Resources/9/94/
+PREPARE REMOVEDIR Contents/Resources/9/93/
+PREPARE REMOVEDIR Contents/Resources/9/92/
+PREPARE REMOVEDIR Contents/Resources/9/91/
+PREPARE REMOVEDIR Contents/Resources/9/90/
+PREPARE REMOVEDIR Contents/Resources/9/90/
+PREPARE REMOVEDIR Contents/Resources/8/89/
+PREPARE REMOVEDIR Contents/Resources/8/89/
+PREPARE REMOVEDIR Contents/Resources/8/88/
+PREPARE REMOVEFILE Contents/Resources/8/87/870/87xtext0
+PREPARE REMOVEFILE Contents/Resources/8/87/870/87xtext1
+PREPARE REMOVEDIR Contents/Resources/8/87/870/
+PREPARE REMOVEFILE Contents/Resources/8/87/871/87xtext0
+PREPARE REMOVEFILE Contents/Resources/8/87/871/87xtext1
+PREPARE REMOVEDIR Contents/Resources/8/87/871/
+PREPARE REMOVEDIR Contents/Resources/8/87/
+PREPARE REMOVEFILE Contents/Resources/8/86/86text0
+PREPARE REMOVEFILE Contents/Resources/8/86/86text1
+PREPARE REMOVEDIR Contents/Resources/8/86/
+PREPARE REMOVEDIR Contents/Resources/8/85/
+PREPARE REMOVEDIR Contents/Resources/8/85/
+PREPARE REMOVEDIR Contents/Resources/8/84/
+PREPARE REMOVEDIR Contents/Resources/8/84/
+PREPARE REMOVEDIR Contents/Resources/8/83/
+PREPARE REMOVEDIR Contents/Resources/8/82/
+PREPARE REMOVEDIR Contents/Resources/8/81/
+PREPARE REMOVEDIR Contents/Resources/8/80/
+PREPARE REMOVEDIR Contents/Resources/8/80/
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtest.exe
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtext0
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtext1
+PREPARE REMOVEDIR Contents/Resources/7/70/
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtest.exe
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtext0
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtext1
+PREPARE REMOVEDIR Contents/Resources/7/71/
+PREPARE REMOVEFILE Contents/Resources/7/7text0
+PREPARE REMOVEFILE Contents/Resources/7/7text1
+PREPARE REMOVEDIR Contents/Resources/7/
+PREPARE REMOVEDIR Contents/Resources/6/
+PREPARE REMOVEFILE Contents/Resources/5/5text1
+PREPARE REMOVEFILE Contents/Resources/5/5text0
+PREPARE REMOVEFILE Contents/Resources/5/5test.exe
+PREPARE REMOVEFILE Contents/Resources/5/5text0
+PREPARE REMOVEFILE Contents/Resources/5/5text1
+PREPARE REMOVEDIR Contents/Resources/5/
+PREPARE REMOVEFILE Contents/Resources/4/4text1
+PREPARE REMOVEFILE Contents/Resources/4/4text0
+PREPARE REMOVEDIR Contents/Resources/4/
+PREPARE REMOVEFILE Contents/Resources/3/3text1
+PREPARE REMOVEFILE Contents/Resources/3/3text0
+PREPARE REMOVEDIR Contents/Resources/1/10/
+PREPARE REMOVEDIR Contents/Resources/1/
+EXECUTE ADD Contents/Resources/searchplugins/searchpluginstext0
+EXECUTE PATCH Contents/Resources/searchplugins/searchpluginspng1.png
+EXECUTE PATCH Contents/Resources/searchplugins/searchpluginspng0.png
+EXECUTE ADD Contents/Resources/precomplete
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+EXECUTE PATCH Contents/Resources/0/0exe0.exe
+LoadSourceFile: destination file size 776 does not match expected size 79872
+LoadSourceFile failed
+### execution failed
+FINISH ADD Contents/Resources/searchplugins/searchpluginstext0
+FINISH PATCH Contents/Resources/searchplugins/searchpluginspng1.png
+FINISH PATCH Contents/Resources/searchplugins/searchpluginspng0.png
+FINISH ADD Contents/Resources/precomplete
+FINISH ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+backup_restore: backup file doesn't exist: Contents/Resources/distribution/extensions/extensions1/extensions1text0.moz-backup
+FINISH PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+FINISH PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+FINISH ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+FINISH PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+FINISH PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+FINISH PATCH Contents/Resources/0/0exe0.exe
+backup_restore: backup file doesn't exist: Contents/Resources/0/0exe0.exe.moz-backup
+FINISH ADD Contents/Resources/0/00/00text0
+backup_restore: backup file doesn't exist: Contents/Resources/0/00/00text0.moz-backup
+FINISH PATCH Contents/Resources/0/00/00png0.png
+backup_restore: backup file doesn't exist: Contents/Resources/0/00/00png0.png.moz-backup
+FINISH PATCH Contents/MacOS/exe0.exe
+backup_restore: backup file doesn't exist: Contents/MacOS/exe0.exe.moz-backup
+FINISH ADD Contents/Resources/2/20/20text0
+backup_restore: backup file doesn't exist: Contents/Resources/2/20/20text0.moz-backup
+FINISH ADD Contents/Resources/2/20/20png0.png
+backup_restore: backup file doesn't exist: Contents/Resources/2/20/20png0.png.moz-backup
+FINISH ADD Contents/Resources/0/00/00text2
+backup_restore: backup file doesn't exist: Contents/Resources/0/00/00text2.moz-backup
+FINISH REMOVEFILE Contents/Resources/1/10/10text0
+backup_restore: backup file doesn't exist: Contents/Resources/1/10/10text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/0/00/00text1
+backup_restore: backup file doesn't exist: Contents/Resources/0/00/00text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/9/97/970/97xtext0
+backup_restore: backup file doesn't exist: Contents/Resources/9/97/970/97xtext0.moz-backup
+FINISH REMOVEFILE Contents/Resources/9/97/970/97xtext1
+backup_restore: backup file doesn't exist: Contents/Resources/9/97/970/97xtext1.moz-backup
+FINISH REMOVEFILE Contents/Resources/9/97/971/97xtext0
+backup_restore: backup file doesn't exist: Contents/Resources/9/97/971/97xtext0.moz-backup
+FINISH REMOVEFILE Contents/Resources/9/97/971/97xtext1
+backup_restore: backup file doesn't exist: Contents/Resources/9/97/971/97xtext1.moz-backup
+FINISH REMOVEFILE Contents/Resources/9/96/96text0
+backup_restore: backup file doesn't exist: Contents/Resources/9/96/96text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/9/96/96text1
+backup_restore: backup file doesn't exist: Contents/Resources/9/96/96text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/8/87/870/87xtext0
+backup_restore: backup file doesn't exist: Contents/Resources/8/87/870/87xtext0.moz-backup
+FINISH REMOVEFILE Contents/Resources/8/87/870/87xtext1
+backup_restore: backup file doesn't exist: Contents/Resources/8/87/870/87xtext1.moz-backup
+FINISH REMOVEFILE Contents/Resources/8/87/871/87xtext0
+backup_restore: backup file doesn't exist: Contents/Resources/8/87/871/87xtext0.moz-backup
+FINISH REMOVEFILE Contents/Resources/8/87/871/87xtext1
+backup_restore: backup file doesn't exist: Contents/Resources/8/87/871/87xtext1.moz-backup
+FINISH REMOVEFILE Contents/Resources/8/86/86text0
+backup_restore: backup file doesn't exist: Contents/Resources/8/86/86text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/8/86/86text1
+backup_restore: backup file doesn't exist: Contents/Resources/8/86/86text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/70/7xtest.exe
+backup_restore: backup file doesn't exist: Contents/Resources/7/70/7xtest.exe.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/70/7xtext0
+backup_restore: backup file doesn't exist: Contents/Resources/7/70/7xtext0.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/70/7xtext1
+backup_restore: backup file doesn't exist: Contents/Resources/7/70/7xtext1.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/71/7xtest.exe
+backup_restore: backup file doesn't exist: Contents/Resources/7/71/7xtest.exe.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/71/7xtext0
+backup_restore: backup file doesn't exist: Contents/Resources/7/71/7xtext0.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/71/7xtext1
+backup_restore: backup file doesn't exist: Contents/Resources/7/71/7xtext1.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/7text0
+backup_restore: backup file doesn't exist: Contents/Resources/7/7text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/7/7text1
+backup_restore: backup file doesn't exist: Contents/Resources/7/7text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/5/5text1
+backup_restore: backup file doesn't exist: Contents/Resources/5/5text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/5/5text0
+backup_restore: backup file doesn't exist: Contents/Resources/5/5text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/5/5test.exe
+backup_restore: backup file doesn't exist: Contents/Resources/5/5test.exe.moz-backup
+FINISH REMOVEFILE Contents/Resources/5/5text0
+backup_restore: backup file doesn't exist: Contents/Resources/5/5text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/5/5text1
+backup_restore: backup file doesn't exist: Contents/Resources/5/5text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/4/4text1
+backup_restore: backup file doesn't exist: Contents/Resources/4/4text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/4/4text0
+backup_restore: backup file doesn't exist: Contents/Resources/4/4text0.moz-backup
+FINISH REMOVEFILE Contents/Resources/3/3text1
+backup_restore: backup file doesn't exist: Contents/Resources/3/3text1.moz-backup
+FINISH REMOVEFILE Contents/Resources/3/3text0
+backup_restore: backup file doesn't exist: Contents/Resources/3/3text0.moz-backup
+failed: 2
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/partial_log_failure_win b/toolkit/mozapps/update/tests/data/partial_log_failure_win
new file mode 100644
index 0000000000..e3d683dc19
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_log_failure_win
@@ -0,0 +1,192 @@
+UPDATE TYPE partial
+PREPARE ADD searchplugins/searchpluginstext0
+PREPARE PATCH searchplugins/searchpluginspng1.png
+PREPARE PATCH searchplugins/searchpluginspng0.png
+PREPARE ADD precomplete
+PREPARE PATCH exe0.exe
+PREPARE ADD distribution/extensions/extensions1/extensions1text0
+PREPARE PATCH distribution/extensions/extensions1/extensions1png1.png
+PREPARE PATCH distribution/extensions/extensions1/extensions1png0.png
+PREPARE ADD distribution/extensions/extensions0/extensions0text0
+PREPARE PATCH distribution/extensions/extensions0/extensions0png1.png
+PREPARE PATCH distribution/extensions/extensions0/extensions0png0.png
+PREPARE PATCH 0/0exe0.exe
+PREPARE ADD 0/00/00text0
+PREPARE PATCH 0/00/00png0.png
+PREPARE ADD 2/20/20text0
+PREPARE ADD 2/20/20png0.png
+PREPARE ADD 0/00/00text2
+PREPARE REMOVEFILE 1/10/10text0
+PREPARE REMOVEFILE 0/00/00text1
+PREPARE REMOVEDIR 9/99/
+PREPARE REMOVEDIR 9/99/
+PREPARE REMOVEDIR 9/98/
+PREPARE REMOVEFILE 9/97/970/97xtext0
+PREPARE REMOVEFILE 9/97/970/97xtext1
+PREPARE REMOVEDIR 9/97/970/
+PREPARE REMOVEFILE 9/97/971/97xtext0
+PREPARE REMOVEFILE 9/97/971/97xtext1
+PREPARE REMOVEDIR 9/97/971/
+PREPARE REMOVEDIR 9/97/
+PREPARE REMOVEFILE 9/96/96text0
+PREPARE REMOVEFILE 9/96/96text1
+PREPARE REMOVEDIR 9/96/
+PREPARE REMOVEDIR 9/95/
+PREPARE REMOVEDIR 9/95/
+PREPARE REMOVEDIR 9/94/
+PREPARE REMOVEDIR 9/94/
+PREPARE REMOVEDIR 9/93/
+PREPARE REMOVEDIR 9/92/
+PREPARE REMOVEDIR 9/91/
+PREPARE REMOVEDIR 9/90/
+PREPARE REMOVEDIR 9/90/
+PREPARE REMOVEDIR 8/89/
+PREPARE REMOVEDIR 8/89/
+PREPARE REMOVEDIR 8/88/
+PREPARE REMOVEFILE 8/87/870/87xtext0
+PREPARE REMOVEFILE 8/87/870/87xtext1
+PREPARE REMOVEDIR 8/87/870/
+PREPARE REMOVEFILE 8/87/871/87xtext0
+PREPARE REMOVEFILE 8/87/871/87xtext1
+PREPARE REMOVEDIR 8/87/871/
+PREPARE REMOVEDIR 8/87/
+PREPARE REMOVEFILE 8/86/86text0
+PREPARE REMOVEFILE 8/86/86text1
+PREPARE REMOVEDIR 8/86/
+PREPARE REMOVEDIR 8/85/
+PREPARE REMOVEDIR 8/85/
+PREPARE REMOVEDIR 8/84/
+PREPARE REMOVEDIR 8/84/
+PREPARE REMOVEDIR 8/83/
+PREPARE REMOVEDIR 8/82/
+PREPARE REMOVEDIR 8/81/
+PREPARE REMOVEDIR 8/80/
+PREPARE REMOVEDIR 8/80/
+PREPARE REMOVEFILE 7/70/7xtest.exe
+PREPARE REMOVEFILE 7/70/7xtext0
+PREPARE REMOVEFILE 7/70/7xtext1
+PREPARE REMOVEDIR 7/70/
+PREPARE REMOVEFILE 7/71/7xtest.exe
+PREPARE REMOVEFILE 7/71/7xtext0
+PREPARE REMOVEFILE 7/71/7xtext1
+PREPARE REMOVEDIR 7/71/
+PREPARE REMOVEFILE 7/7text0
+PREPARE REMOVEFILE 7/7text1
+PREPARE REMOVEDIR 7/
+PREPARE REMOVEDIR 6/
+PREPARE REMOVEFILE 5/5text1
+PREPARE REMOVEFILE 5/5text0
+PREPARE REMOVEFILE 5/5test.exe
+PREPARE REMOVEFILE 5/5text0
+PREPARE REMOVEFILE 5/5text1
+PREPARE REMOVEDIR 5/
+PREPARE REMOVEFILE 4/4text1
+PREPARE REMOVEFILE 4/4text0
+PREPARE REMOVEDIR 4/
+PREPARE REMOVEFILE 3/3text1
+PREPARE REMOVEFILE 3/3text0
+PREPARE REMOVEDIR 1/10/
+PREPARE REMOVEDIR 1/
+EXECUTE ADD searchplugins/searchpluginstext0
+EXECUTE PATCH searchplugins/searchpluginspng1.png
+EXECUTE PATCH searchplugins/searchpluginspng0.png
+EXECUTE ADD precomplete
+EXECUTE PATCH exe0.exe
+EXECUTE ADD distribution/extensions/extensions1/extensions1text0
+EXECUTE PATCH distribution/extensions/extensions1/extensions1png1.png
+EXECUTE PATCH distribution/extensions/extensions1/extensions1png0.png
+EXECUTE ADD distribution/extensions/extensions0/extensions0text0
+EXECUTE PATCH distribution/extensions/extensions0/extensions0png1.png
+EXECUTE PATCH distribution/extensions/extensions0/extensions0png0.png
+EXECUTE PATCH 0/0exe0.exe
+LoadSourceFile: destination file size 776 does not match expected size 79872
+LoadSourceFile failed
+### execution failed
+FINISH ADD searchplugins/searchpluginstext0
+FINISH PATCH searchplugins/searchpluginspng1.png
+FINISH PATCH searchplugins/searchpluginspng0.png
+FINISH ADD precomplete
+FINISH PATCH exe0.exe
+FINISH ADD distribution/extensions/extensions1/extensions1text0
+backup_restore: backup file doesn't exist: distribution/extensions/extensions1/extensions1text0.moz-backup
+FINISH PATCH distribution/extensions/extensions1/extensions1png1.png
+FINISH PATCH distribution/extensions/extensions1/extensions1png0.png
+FINISH ADD distribution/extensions/extensions0/extensions0text0
+FINISH PATCH distribution/extensions/extensions0/extensions0png1.png
+FINISH PATCH distribution/extensions/extensions0/extensions0png0.png
+FINISH PATCH 0/0exe0.exe
+backup_restore: backup file doesn't exist: 0/0exe0.exe.moz-backup
+FINISH ADD 0/00/00text0
+backup_restore: backup file doesn't exist: 0/00/00text0.moz-backup
+FINISH PATCH 0/00/00png0.png
+backup_restore: backup file doesn't exist: 0/00/00png0.png.moz-backup
+FINISH ADD 2/20/20text0
+backup_restore: backup file doesn't exist: 2/20/20text0.moz-backup
+FINISH ADD 2/20/20png0.png
+backup_restore: backup file doesn't exist: 2/20/20png0.png.moz-backup
+FINISH ADD 0/00/00text2
+backup_restore: backup file doesn't exist: 0/00/00text2.moz-backup
+FINISH REMOVEFILE 1/10/10text0
+backup_restore: backup file doesn't exist: 1/10/10text0.moz-backup
+FINISH REMOVEFILE 0/00/00text1
+backup_restore: backup file doesn't exist: 0/00/00text1.moz-backup
+FINISH REMOVEFILE 9/97/970/97xtext0
+backup_restore: backup file doesn't exist: 9/97/970/97xtext0.moz-backup
+FINISH REMOVEFILE 9/97/970/97xtext1
+backup_restore: backup file doesn't exist: 9/97/970/97xtext1.moz-backup
+FINISH REMOVEFILE 9/97/971/97xtext0
+backup_restore: backup file doesn't exist: 9/97/971/97xtext0.moz-backup
+FINISH REMOVEFILE 9/97/971/97xtext1
+backup_restore: backup file doesn't exist: 9/97/971/97xtext1.moz-backup
+FINISH REMOVEFILE 9/96/96text0
+backup_restore: backup file doesn't exist: 9/96/96text0.moz-backup
+FINISH REMOVEFILE 9/96/96text1
+backup_restore: backup file doesn't exist: 9/96/96text1.moz-backup
+FINISH REMOVEFILE 8/87/870/87xtext0
+backup_restore: backup file doesn't exist: 8/87/870/87xtext0.moz-backup
+FINISH REMOVEFILE 8/87/870/87xtext1
+backup_restore: backup file doesn't exist: 8/87/870/87xtext1.moz-backup
+FINISH REMOVEFILE 8/87/871/87xtext0
+backup_restore: backup file doesn't exist: 8/87/871/87xtext0.moz-backup
+FINISH REMOVEFILE 8/87/871/87xtext1
+backup_restore: backup file doesn't exist: 8/87/871/87xtext1.moz-backup
+FINISH REMOVEFILE 8/86/86text0
+backup_restore: backup file doesn't exist: 8/86/86text0.moz-backup
+FINISH REMOVEFILE 8/86/86text1
+backup_restore: backup file doesn't exist: 8/86/86text1.moz-backup
+FINISH REMOVEFILE 7/70/7xtest.exe
+backup_restore: backup file doesn't exist: 7/70/7xtest.exe.moz-backup
+FINISH REMOVEFILE 7/70/7xtext0
+backup_restore: backup file doesn't exist: 7/70/7xtext0.moz-backup
+FINISH REMOVEFILE 7/70/7xtext1
+backup_restore: backup file doesn't exist: 7/70/7xtext1.moz-backup
+FINISH REMOVEFILE 7/71/7xtest.exe
+backup_restore: backup file doesn't exist: 7/71/7xtest.exe.moz-backup
+FINISH REMOVEFILE 7/71/7xtext0
+backup_restore: backup file doesn't exist: 7/71/7xtext0.moz-backup
+FINISH REMOVEFILE 7/71/7xtext1
+backup_restore: backup file doesn't exist: 7/71/7xtext1.moz-backup
+FINISH REMOVEFILE 7/7text0
+backup_restore: backup file doesn't exist: 7/7text0.moz-backup
+FINISH REMOVEFILE 7/7text1
+backup_restore: backup file doesn't exist: 7/7text1.moz-backup
+FINISH REMOVEFILE 5/5text1
+backup_restore: backup file doesn't exist: 5/5text1.moz-backup
+FINISH REMOVEFILE 5/5text0
+backup_restore: backup file doesn't exist: 5/5text0.moz-backup
+FINISH REMOVEFILE 5/5test.exe
+backup_restore: backup file doesn't exist: 5/5test.exe.moz-backup
+FINISH REMOVEFILE 5/5text0
+backup_restore: backup file doesn't exist: 5/5text0.moz-backup
+FINISH REMOVEFILE 5/5text1
+backup_restore: backup file doesn't exist: 5/5text1.moz-backup
+FINISH REMOVEFILE 4/4text1
+backup_restore: backup file doesn't exist: 4/4text1.moz-backup
+FINISH REMOVEFILE 4/4text0
+backup_restore: backup file doesn't exist: 4/4text0.moz-backup
+FINISH REMOVEFILE 3/3text1
+backup_restore: backup file doesn't exist: 3/3text1.moz-backup
+FINISH REMOVEFILE 3/3text0
+backup_restore: backup file doesn't exist: 3/3text0.moz-backup
+failed: 2
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/partial_log_success_mac b/toolkit/mozapps/update/tests/data/partial_log_success_mac
new file mode 100644
index 0000000000..fb5272ad2c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_log_success_mac
@@ -0,0 +1,279 @@
+UPDATE TYPE partial
+PREPARE ADD Contents/Resources/searchplugins/searchpluginstext0
+PREPARE PATCH Contents/Resources/searchplugins/searchpluginspng1.png
+PREPARE PATCH Contents/Resources/searchplugins/searchpluginspng0.png
+PREPARE ADD Contents/Resources/precomplete
+PREPARE ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+PREPARE ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+PREPARE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+PREPARE PATCH Contents/Resources/0/0exe0.exe
+PREPARE ADD Contents/Resources/0/00/00text0
+PREPARE PATCH Contents/Resources/0/00/00png0.png
+PREPARE PATCH Contents/MacOS/exe0.exe
+PREPARE ADD Contents/Resources/2/20/20text0
+PREPARE ADD Contents/Resources/2/20/20png0.png
+PREPARE ADD Contents/Resources/0/00/00text2
+PREPARE REMOVEFILE Contents/Resources/1/10/10text0
+PREPARE REMOVEFILE Contents/Resources/0/00/00text1
+PREPARE REMOVEDIR Contents/Resources/9/99/
+PREPARE REMOVEDIR Contents/Resources/9/99/
+PREPARE REMOVEDIR Contents/Resources/9/98/
+PREPARE REMOVEFILE Contents/Resources/9/97/970/97xtext0
+PREPARE REMOVEFILE Contents/Resources/9/97/970/97xtext1
+PREPARE REMOVEDIR Contents/Resources/9/97/970/
+PREPARE REMOVEFILE Contents/Resources/9/97/971/97xtext0
+PREPARE REMOVEFILE Contents/Resources/9/97/971/97xtext1
+PREPARE REMOVEDIR Contents/Resources/9/97/971/
+PREPARE REMOVEDIR Contents/Resources/9/97/
+PREPARE REMOVEFILE Contents/Resources/9/96/96text0
+PREPARE REMOVEFILE Contents/Resources/9/96/96text1
+PREPARE REMOVEDIR Contents/Resources/9/96/
+PREPARE REMOVEDIR Contents/Resources/9/95/
+PREPARE REMOVEDIR Contents/Resources/9/95/
+PREPARE REMOVEDIR Contents/Resources/9/94/
+PREPARE REMOVEDIR Contents/Resources/9/94/
+PREPARE REMOVEDIR Contents/Resources/9/93/
+PREPARE REMOVEDIR Contents/Resources/9/92/
+PREPARE REMOVEDIR Contents/Resources/9/91/
+PREPARE REMOVEDIR Contents/Resources/9/90/
+PREPARE REMOVEDIR Contents/Resources/9/90/
+PREPARE REMOVEDIR Contents/Resources/8/89/
+PREPARE REMOVEDIR Contents/Resources/8/89/
+PREPARE REMOVEDIR Contents/Resources/8/88/
+PREPARE REMOVEFILE Contents/Resources/8/87/870/87xtext0
+PREPARE REMOVEFILE Contents/Resources/8/87/870/87xtext1
+PREPARE REMOVEDIR Contents/Resources/8/87/870/
+PREPARE REMOVEFILE Contents/Resources/8/87/871/87xtext0
+PREPARE REMOVEFILE Contents/Resources/8/87/871/87xtext1
+PREPARE REMOVEDIR Contents/Resources/8/87/871/
+PREPARE REMOVEDIR Contents/Resources/8/87/
+PREPARE REMOVEFILE Contents/Resources/8/86/86text0
+PREPARE REMOVEFILE Contents/Resources/8/86/86text1
+PREPARE REMOVEDIR Contents/Resources/8/86/
+PREPARE REMOVEDIR Contents/Resources/8/85/
+PREPARE REMOVEDIR Contents/Resources/8/85/
+PREPARE REMOVEDIR Contents/Resources/8/84/
+PREPARE REMOVEDIR Contents/Resources/8/84/
+PREPARE REMOVEDIR Contents/Resources/8/83/
+PREPARE REMOVEDIR Contents/Resources/8/82/
+PREPARE REMOVEDIR Contents/Resources/8/81/
+PREPARE REMOVEDIR Contents/Resources/8/80/
+PREPARE REMOVEDIR Contents/Resources/8/80/
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtest.exe
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtext0
+PREPARE REMOVEFILE Contents/Resources/7/70/7xtext1
+PREPARE REMOVEDIR Contents/Resources/7/70/
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtest.exe
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtext0
+PREPARE REMOVEFILE Contents/Resources/7/71/7xtext1
+PREPARE REMOVEDIR Contents/Resources/7/71/
+PREPARE REMOVEFILE Contents/Resources/7/7text0
+PREPARE REMOVEFILE Contents/Resources/7/7text1
+PREPARE REMOVEDIR Contents/Resources/7/
+PREPARE REMOVEDIR Contents/Resources/6/
+PREPARE REMOVEFILE Contents/Resources/5/5text1
+PREPARE REMOVEFILE Contents/Resources/5/5text0
+PREPARE REMOVEFILE Contents/Resources/5/5test.exe
+PREPARE REMOVEFILE Contents/Resources/5/5text0
+PREPARE REMOVEFILE Contents/Resources/5/5text1
+PREPARE REMOVEDIR Contents/Resources/5/
+PREPARE REMOVEFILE Contents/Resources/4/4text1
+PREPARE REMOVEFILE Contents/Resources/4/4text0
+PREPARE REMOVEDIR Contents/Resources/4/
+PREPARE REMOVEFILE Contents/Resources/3/3text1
+PREPARE REMOVEFILE Contents/Resources/3/3text0
+PREPARE REMOVEDIR Contents/Resources/1/10/
+PREPARE REMOVEDIR Contents/Resources/1/
+EXECUTE ADD Contents/Resources/searchplugins/searchpluginstext0
+EXECUTE PATCH Contents/Resources/searchplugins/searchpluginspng1.png
+EXECUTE PATCH Contents/Resources/searchplugins/searchpluginspng0.png
+EXECUTE ADD Contents/Resources/precomplete
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+EXECUTE ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+EXECUTE PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+EXECUTE PATCH Contents/Resources/0/0exe0.exe
+EXECUTE ADD Contents/Resources/0/00/00text0
+EXECUTE PATCH Contents/Resources/0/00/00png0.png
+EXECUTE PATCH Contents/MacOS/exe0.exe
+EXECUTE ADD Contents/Resources/2/20/20text0
+EXECUTE ADD Contents/Resources/2/20/20png0.png
+EXECUTE ADD Contents/Resources/0/00/00text2
+EXECUTE REMOVEFILE Contents/Resources/1/10/10text0
+EXECUTE REMOVEFILE Contents/Resources/0/00/00text1
+EXECUTE REMOVEDIR Contents/Resources/9/99/
+EXECUTE REMOVEDIR Contents/Resources/9/99/
+EXECUTE REMOVEDIR Contents/Resources/9/98/
+EXECUTE REMOVEFILE Contents/Resources/9/97/970/97xtext0
+EXECUTE REMOVEFILE Contents/Resources/9/97/970/97xtext1
+EXECUTE REMOVEDIR Contents/Resources/9/97/970/
+EXECUTE REMOVEFILE Contents/Resources/9/97/971/97xtext0
+EXECUTE REMOVEFILE Contents/Resources/9/97/971/97xtext1
+EXECUTE REMOVEDIR Contents/Resources/9/97/971/
+EXECUTE REMOVEDIR Contents/Resources/9/97/
+EXECUTE REMOVEFILE Contents/Resources/9/96/96text0
+EXECUTE REMOVEFILE Contents/Resources/9/96/96text1
+EXECUTE REMOVEDIR Contents/Resources/9/96/
+EXECUTE REMOVEDIR Contents/Resources/9/95/
+EXECUTE REMOVEDIR Contents/Resources/9/95/
+EXECUTE REMOVEDIR Contents/Resources/9/94/
+EXECUTE REMOVEDIR Contents/Resources/9/94/
+EXECUTE REMOVEDIR Contents/Resources/9/93/
+EXECUTE REMOVEDIR Contents/Resources/9/92/
+EXECUTE REMOVEDIR Contents/Resources/9/91/
+EXECUTE REMOVEDIR Contents/Resources/9/90/
+EXECUTE REMOVEDIR Contents/Resources/9/90/
+EXECUTE REMOVEDIR Contents/Resources/8/89/
+EXECUTE REMOVEDIR Contents/Resources/8/89/
+EXECUTE REMOVEDIR Contents/Resources/8/88/
+EXECUTE REMOVEFILE Contents/Resources/8/87/870/87xtext0
+EXECUTE REMOVEFILE Contents/Resources/8/87/870/87xtext1
+EXECUTE REMOVEDIR Contents/Resources/8/87/870/
+EXECUTE REMOVEFILE Contents/Resources/8/87/871/87xtext0
+EXECUTE REMOVEFILE Contents/Resources/8/87/871/87xtext1
+EXECUTE REMOVEDIR Contents/Resources/8/87/871/
+EXECUTE REMOVEDIR Contents/Resources/8/87/
+EXECUTE REMOVEFILE Contents/Resources/8/86/86text0
+EXECUTE REMOVEFILE Contents/Resources/8/86/86text1
+EXECUTE REMOVEDIR Contents/Resources/8/86/
+EXECUTE REMOVEDIR Contents/Resources/8/85/
+EXECUTE REMOVEDIR Contents/Resources/8/85/
+EXECUTE REMOVEDIR Contents/Resources/8/84/
+EXECUTE REMOVEDIR Contents/Resources/8/84/
+EXECUTE REMOVEDIR Contents/Resources/8/83/
+EXECUTE REMOVEDIR Contents/Resources/8/82/
+EXECUTE REMOVEDIR Contents/Resources/8/81/
+EXECUTE REMOVEDIR Contents/Resources/8/80/
+EXECUTE REMOVEDIR Contents/Resources/8/80/
+EXECUTE REMOVEFILE Contents/Resources/7/70/7xtest.exe
+EXECUTE REMOVEFILE Contents/Resources/7/70/7xtext0
+EXECUTE REMOVEFILE Contents/Resources/7/70/7xtext1
+EXECUTE REMOVEDIR Contents/Resources/7/70/
+EXECUTE REMOVEFILE Contents/Resources/7/71/7xtest.exe
+EXECUTE REMOVEFILE Contents/Resources/7/71/7xtext0
+EXECUTE REMOVEFILE Contents/Resources/7/71/7xtext1
+EXECUTE REMOVEDIR Contents/Resources/7/71/
+EXECUTE REMOVEFILE Contents/Resources/7/7text0
+EXECUTE REMOVEFILE Contents/Resources/7/7text1
+EXECUTE REMOVEDIR Contents/Resources/7/
+EXECUTE REMOVEDIR Contents/Resources/6/
+EXECUTE REMOVEFILE Contents/Resources/5/5text1
+EXECUTE REMOVEFILE Contents/Resources/5/5text0
+EXECUTE REMOVEFILE Contents/Resources/5/5test.exe
+EXECUTE REMOVEFILE Contents/Resources/5/5text0
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEFILE Contents/Resources/5/5text1
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEDIR Contents/Resources/5/
+EXECUTE REMOVEFILE Contents/Resources/4/4text1
+EXECUTE REMOVEFILE Contents/Resources/4/4text0
+EXECUTE REMOVEDIR Contents/Resources/4/
+EXECUTE REMOVEFILE Contents/Resources/3/3text1
+EXECUTE REMOVEFILE Contents/Resources/3/3text0
+EXECUTE REMOVEDIR Contents/Resources/1/10/
+EXECUTE REMOVEDIR Contents/Resources/1/
+FINISH ADD Contents/Resources/searchplugins/searchpluginstext0
+FINISH PATCH Contents/Resources/searchplugins/searchpluginspng1.png
+FINISH PATCH Contents/Resources/searchplugins/searchpluginspng0.png
+FINISH ADD Contents/Resources/precomplete
+FINISH ADD Contents/Resources/distribution/extensions/extensions1/extensions1text0
+FINISH PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png1.png
+FINISH PATCH Contents/Resources/distribution/extensions/extensions1/extensions1png0.png
+FINISH ADD Contents/Resources/distribution/extensions/extensions0/extensions0text0
+FINISH PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png1.png
+FINISH PATCH Contents/Resources/distribution/extensions/extensions0/extensions0png0.png
+FINISH PATCH Contents/Resources/0/0exe0.exe
+FINISH ADD Contents/Resources/0/00/00text0
+FINISH PATCH Contents/Resources/0/00/00png0.png
+FINISH PATCH Contents/MacOS/exe0.exe
+FINISH ADD Contents/Resources/2/20/20text0
+FINISH ADD Contents/Resources/2/20/20png0.png
+FINISH ADD Contents/Resources/0/00/00text2
+FINISH REMOVEFILE Contents/Resources/1/10/10text0
+FINISH REMOVEFILE Contents/Resources/0/00/00text1
+FINISH REMOVEDIR Contents/Resources/9/99/
+FINISH REMOVEDIR Contents/Resources/9/99/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/9/98/
+FINISH REMOVEFILE Contents/Resources/9/97/970/97xtext0
+FINISH REMOVEFILE Contents/Resources/9/97/970/97xtext1
+FINISH REMOVEDIR Contents/Resources/9/97/970/
+FINISH REMOVEFILE Contents/Resources/9/97/971/97xtext0
+FINISH REMOVEFILE Contents/Resources/9/97/971/97xtext1
+FINISH REMOVEDIR Contents/Resources/9/97/971/
+FINISH REMOVEDIR Contents/Resources/9/97/
+FINISH REMOVEFILE Contents/Resources/9/96/96text0
+FINISH REMOVEFILE Contents/Resources/9/96/96text1
+FINISH REMOVEDIR Contents/Resources/9/96/
+FINISH REMOVEDIR Contents/Resources/9/95/
+FINISH REMOVEDIR Contents/Resources/9/95/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/9/94/
+FINISH REMOVEDIR Contents/Resources/9/94/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/9/93/
+FINISH REMOVEDIR Contents/Resources/9/92/
+removing directory: Contents/Resources/9/92/, rv: 0
+FINISH REMOVEDIR Contents/Resources/9/91/
+removing directory: Contents/Resources/9/91/, rv: 0
+FINISH REMOVEDIR Contents/Resources/9/90/
+FINISH REMOVEDIR Contents/Resources/9/90/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/89/
+FINISH REMOVEDIR Contents/Resources/8/89/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/88/
+FINISH REMOVEFILE Contents/Resources/8/87/870/87xtext0
+FINISH REMOVEFILE Contents/Resources/8/87/870/87xtext1
+FINISH REMOVEDIR Contents/Resources/8/87/870/
+FINISH REMOVEFILE Contents/Resources/8/87/871/87xtext0
+FINISH REMOVEFILE Contents/Resources/8/87/871/87xtext1
+FINISH REMOVEDIR Contents/Resources/8/87/871/
+FINISH REMOVEDIR Contents/Resources/8/87/
+FINISH REMOVEFILE Contents/Resources/8/86/86text0
+FINISH REMOVEFILE Contents/Resources/8/86/86text1
+FINISH REMOVEDIR Contents/Resources/8/86/
+FINISH REMOVEDIR Contents/Resources/8/85/
+FINISH REMOVEDIR Contents/Resources/8/85/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/84/
+FINISH REMOVEDIR Contents/Resources/8/84/
+directory no longer exists; skipping
+FINISH REMOVEDIR Contents/Resources/8/83/
+FINISH REMOVEDIR Contents/Resources/8/82/
+removing directory: Contents/Resources/8/82/, rv: 0
+FINISH REMOVEDIR Contents/Resources/8/81/
+removing directory: Contents/Resources/8/81/, rv: 0
+FINISH REMOVEDIR Contents/Resources/8/80/
+FINISH REMOVEDIR Contents/Resources/8/80/
+directory no longer exists; skipping
+FINISH REMOVEFILE Contents/Resources/7/70/7xtest.exe
+FINISH REMOVEFILE Contents/Resources/7/70/7xtext0
+FINISH REMOVEFILE Contents/Resources/7/70/7xtext1
+FINISH REMOVEDIR Contents/Resources/7/70/
+FINISH REMOVEFILE Contents/Resources/7/71/7xtest.exe
+FINISH REMOVEFILE Contents/Resources/7/71/7xtext0
+FINISH REMOVEFILE Contents/Resources/7/71/7xtext1
+FINISH REMOVEDIR Contents/Resources/7/71/
+FINISH REMOVEFILE Contents/Resources/7/7text0
+FINISH REMOVEFILE Contents/Resources/7/7text1
+FINISH REMOVEDIR Contents/Resources/7/
+FINISH REMOVEDIR Contents/Resources/6/
+FINISH REMOVEFILE Contents/Resources/5/5text1
+FINISH REMOVEFILE Contents/Resources/5/5text0
+FINISH REMOVEFILE Contents/Resources/5/5test.exe
+FINISH REMOVEDIR Contents/Resources/5/
+FINISH REMOVEFILE Contents/Resources/4/4text1
+FINISH REMOVEFILE Contents/Resources/4/4text0
+FINISH REMOVEDIR Contents/Resources/4/
+FINISH REMOVEFILE Contents/Resources/3/3text1
+FINISH REMOVEFILE Contents/Resources/3/3text0
+FINISH REMOVEDIR Contents/Resources/1/10/
+FINISH REMOVEDIR Contents/Resources/1/
+succeeded
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/partial_log_success_win b/toolkit/mozapps/update/tests/data/partial_log_success_win
new file mode 100644
index 0000000000..1f5c4b3b49
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_log_success_win
@@ -0,0 +1,279 @@
+UPDATE TYPE partial
+PREPARE ADD searchplugins/searchpluginstext0
+PREPARE PATCH searchplugins/searchpluginspng1.png
+PREPARE PATCH searchplugins/searchpluginspng0.png
+PREPARE ADD precomplete
+PREPARE PATCH exe0.exe
+PREPARE ADD distribution/extensions/extensions1/extensions1text0
+PREPARE PATCH distribution/extensions/extensions1/extensions1png1.png
+PREPARE PATCH distribution/extensions/extensions1/extensions1png0.png
+PREPARE ADD distribution/extensions/extensions0/extensions0text0
+PREPARE PATCH distribution/extensions/extensions0/extensions0png1.png
+PREPARE PATCH distribution/extensions/extensions0/extensions0png0.png
+PREPARE PATCH 0/0exe0.exe
+PREPARE ADD 0/00/00text0
+PREPARE PATCH 0/00/00png0.png
+PREPARE ADD 2/20/20text0
+PREPARE ADD 2/20/20png0.png
+PREPARE ADD 0/00/00text2
+PREPARE REMOVEFILE 1/10/10text0
+PREPARE REMOVEFILE 0/00/00text1
+PREPARE REMOVEDIR 9/99/
+PREPARE REMOVEDIR 9/99/
+PREPARE REMOVEDIR 9/98/
+PREPARE REMOVEFILE 9/97/970/97xtext0
+PREPARE REMOVEFILE 9/97/970/97xtext1
+PREPARE REMOVEDIR 9/97/970/
+PREPARE REMOVEFILE 9/97/971/97xtext0
+PREPARE REMOVEFILE 9/97/971/97xtext1
+PREPARE REMOVEDIR 9/97/971/
+PREPARE REMOVEDIR 9/97/
+PREPARE REMOVEFILE 9/96/96text0
+PREPARE REMOVEFILE 9/96/96text1
+PREPARE REMOVEDIR 9/96/
+PREPARE REMOVEDIR 9/95/
+PREPARE REMOVEDIR 9/95/
+PREPARE REMOVEDIR 9/94/
+PREPARE REMOVEDIR 9/94/
+PREPARE REMOVEDIR 9/93/
+PREPARE REMOVEDIR 9/92/
+PREPARE REMOVEDIR 9/91/
+PREPARE REMOVEDIR 9/90/
+PREPARE REMOVEDIR 9/90/
+PREPARE REMOVEDIR 8/89/
+PREPARE REMOVEDIR 8/89/
+PREPARE REMOVEDIR 8/88/
+PREPARE REMOVEFILE 8/87/870/87xtext0
+PREPARE REMOVEFILE 8/87/870/87xtext1
+PREPARE REMOVEDIR 8/87/870/
+PREPARE REMOVEFILE 8/87/871/87xtext0
+PREPARE REMOVEFILE 8/87/871/87xtext1
+PREPARE REMOVEDIR 8/87/871/
+PREPARE REMOVEDIR 8/87/
+PREPARE REMOVEFILE 8/86/86text0
+PREPARE REMOVEFILE 8/86/86text1
+PREPARE REMOVEDIR 8/86/
+PREPARE REMOVEDIR 8/85/
+PREPARE REMOVEDIR 8/85/
+PREPARE REMOVEDIR 8/84/
+PREPARE REMOVEDIR 8/84/
+PREPARE REMOVEDIR 8/83/
+PREPARE REMOVEDIR 8/82/
+PREPARE REMOVEDIR 8/81/
+PREPARE REMOVEDIR 8/80/
+PREPARE REMOVEDIR 8/80/
+PREPARE REMOVEFILE 7/70/7xtest.exe
+PREPARE REMOVEFILE 7/70/7xtext0
+PREPARE REMOVEFILE 7/70/7xtext1
+PREPARE REMOVEDIR 7/70/
+PREPARE REMOVEFILE 7/71/7xtest.exe
+PREPARE REMOVEFILE 7/71/7xtext0
+PREPARE REMOVEFILE 7/71/7xtext1
+PREPARE REMOVEDIR 7/71/
+PREPARE REMOVEFILE 7/7text0
+PREPARE REMOVEFILE 7/7text1
+PREPARE REMOVEDIR 7/
+PREPARE REMOVEDIR 6/
+PREPARE REMOVEFILE 5/5text1
+PREPARE REMOVEFILE 5/5text0
+PREPARE REMOVEFILE 5/5test.exe
+PREPARE REMOVEFILE 5/5text0
+PREPARE REMOVEFILE 5/5text1
+PREPARE REMOVEDIR 5/
+PREPARE REMOVEFILE 4/4text1
+PREPARE REMOVEFILE 4/4text0
+PREPARE REMOVEDIR 4/
+PREPARE REMOVEFILE 3/3text1
+PREPARE REMOVEFILE 3/3text0
+PREPARE REMOVEDIR 1/10/
+PREPARE REMOVEDIR 1/
+EXECUTE ADD searchplugins/searchpluginstext0
+EXECUTE PATCH searchplugins/searchpluginspng1.png
+EXECUTE PATCH searchplugins/searchpluginspng0.png
+EXECUTE ADD precomplete
+EXECUTE PATCH exe0.exe
+EXECUTE ADD distribution/extensions/extensions1/extensions1text0
+EXECUTE PATCH distribution/extensions/extensions1/extensions1png1.png
+EXECUTE PATCH distribution/extensions/extensions1/extensions1png0.png
+EXECUTE ADD distribution/extensions/extensions0/extensions0text0
+EXECUTE PATCH distribution/extensions/extensions0/extensions0png1.png
+EXECUTE PATCH distribution/extensions/extensions0/extensions0png0.png
+EXECUTE PATCH 0/0exe0.exe
+EXECUTE ADD 0/00/00text0
+EXECUTE PATCH 0/00/00png0.png
+EXECUTE ADD 2/20/20text0
+EXECUTE ADD 2/20/20png0.png
+EXECUTE ADD 0/00/00text2
+EXECUTE REMOVEFILE 1/10/10text0
+EXECUTE REMOVEFILE 0/00/00text1
+EXECUTE REMOVEDIR 9/99/
+EXECUTE REMOVEDIR 9/99/
+EXECUTE REMOVEDIR 9/98/
+EXECUTE REMOVEFILE 9/97/970/97xtext0
+EXECUTE REMOVEFILE 9/97/970/97xtext1
+EXECUTE REMOVEDIR 9/97/970/
+EXECUTE REMOVEFILE 9/97/971/97xtext0
+EXECUTE REMOVEFILE 9/97/971/97xtext1
+EXECUTE REMOVEDIR 9/97/971/
+EXECUTE REMOVEDIR 9/97/
+EXECUTE REMOVEFILE 9/96/96text0
+EXECUTE REMOVEFILE 9/96/96text1
+EXECUTE REMOVEDIR 9/96/
+EXECUTE REMOVEDIR 9/95/
+EXECUTE REMOVEDIR 9/95/
+EXECUTE REMOVEDIR 9/94/
+EXECUTE REMOVEDIR 9/94/
+EXECUTE REMOVEDIR 9/93/
+EXECUTE REMOVEDIR 9/92/
+EXECUTE REMOVEDIR 9/91/
+EXECUTE REMOVEDIR 9/90/
+EXECUTE REMOVEDIR 9/90/
+EXECUTE REMOVEDIR 8/89/
+EXECUTE REMOVEDIR 8/89/
+EXECUTE REMOVEDIR 8/88/
+EXECUTE REMOVEFILE 8/87/870/87xtext0
+EXECUTE REMOVEFILE 8/87/870/87xtext1
+EXECUTE REMOVEDIR 8/87/870/
+EXECUTE REMOVEFILE 8/87/871/87xtext0
+EXECUTE REMOVEFILE 8/87/871/87xtext1
+EXECUTE REMOVEDIR 8/87/871/
+EXECUTE REMOVEDIR 8/87/
+EXECUTE REMOVEFILE 8/86/86text0
+EXECUTE REMOVEFILE 8/86/86text1
+EXECUTE REMOVEDIR 8/86/
+EXECUTE REMOVEDIR 8/85/
+EXECUTE REMOVEDIR 8/85/
+EXECUTE REMOVEDIR 8/84/
+EXECUTE REMOVEDIR 8/84/
+EXECUTE REMOVEDIR 8/83/
+EXECUTE REMOVEDIR 8/82/
+EXECUTE REMOVEDIR 8/81/
+EXECUTE REMOVEDIR 8/80/
+EXECUTE REMOVEDIR 8/80/
+EXECUTE REMOVEFILE 7/70/7xtest.exe
+EXECUTE REMOVEFILE 7/70/7xtext0
+EXECUTE REMOVEFILE 7/70/7xtext1
+EXECUTE REMOVEDIR 7/70/
+EXECUTE REMOVEFILE 7/71/7xtest.exe
+EXECUTE REMOVEFILE 7/71/7xtext0
+EXECUTE REMOVEFILE 7/71/7xtext1
+EXECUTE REMOVEDIR 7/71/
+EXECUTE REMOVEFILE 7/7text0
+EXECUTE REMOVEFILE 7/7text1
+EXECUTE REMOVEDIR 7/
+EXECUTE REMOVEDIR 6/
+EXECUTE REMOVEFILE 5/5text1
+EXECUTE REMOVEFILE 5/5text0
+EXECUTE REMOVEFILE 5/5test.exe
+EXECUTE REMOVEFILE 5/5text0
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEFILE 5/5text1
+file cannot be removed because it does not exist; skipping
+EXECUTE REMOVEDIR 5/
+EXECUTE REMOVEFILE 4/4text1
+EXECUTE REMOVEFILE 4/4text0
+EXECUTE REMOVEDIR 4/
+EXECUTE REMOVEFILE 3/3text1
+EXECUTE REMOVEFILE 3/3text0
+EXECUTE REMOVEDIR 1/10/
+EXECUTE REMOVEDIR 1/
+FINISH ADD searchplugins/searchpluginstext0
+FINISH PATCH searchplugins/searchpluginspng1.png
+FINISH PATCH searchplugins/searchpluginspng0.png
+FINISH ADD precomplete
+FINISH PATCH exe0.exe
+FINISH ADD distribution/extensions/extensions1/extensions1text0
+FINISH PATCH distribution/extensions/extensions1/extensions1png1.png
+FINISH PATCH distribution/extensions/extensions1/extensions1png0.png
+FINISH ADD distribution/extensions/extensions0/extensions0text0
+FINISH PATCH distribution/extensions/extensions0/extensions0png1.png
+FINISH PATCH distribution/extensions/extensions0/extensions0png0.png
+FINISH PATCH 0/0exe0.exe
+FINISH ADD 0/00/00text0
+FINISH PATCH 0/00/00png0.png
+FINISH ADD 2/20/20text0
+FINISH ADD 2/20/20png0.png
+FINISH ADD 0/00/00text2
+FINISH REMOVEFILE 1/10/10text0
+FINISH REMOVEFILE 0/00/00text1
+FINISH REMOVEDIR 9/99/
+FINISH REMOVEDIR 9/99/
+directory no longer exists; skipping
+FINISH REMOVEDIR 9/98/
+FINISH REMOVEFILE 9/97/970/97xtext0
+FINISH REMOVEFILE 9/97/970/97xtext1
+FINISH REMOVEDIR 9/97/970/
+FINISH REMOVEFILE 9/97/971/97xtext0
+FINISH REMOVEFILE 9/97/971/97xtext1
+FINISH REMOVEDIR 9/97/971/
+FINISH REMOVEDIR 9/97/
+FINISH REMOVEFILE 9/96/96text0
+FINISH REMOVEFILE 9/96/96text1
+FINISH REMOVEDIR 9/96/
+FINISH REMOVEDIR 9/95/
+FINISH REMOVEDIR 9/95/
+directory no longer exists; skipping
+FINISH REMOVEDIR 9/94/
+FINISH REMOVEDIR 9/94/
+directory no longer exists; skipping
+FINISH REMOVEDIR 9/93/
+FINISH REMOVEDIR 9/92/
+removing directory: 9/92/, rv: 0
+FINISH REMOVEDIR 9/91/
+removing directory: 9/91/, rv: 0
+FINISH REMOVEDIR 9/90/
+FINISH REMOVEDIR 9/90/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/89/
+FINISH REMOVEDIR 8/89/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/88/
+FINISH REMOVEFILE 8/87/870/87xtext0
+FINISH REMOVEFILE 8/87/870/87xtext1
+FINISH REMOVEDIR 8/87/870/
+FINISH REMOVEFILE 8/87/871/87xtext0
+FINISH REMOVEFILE 8/87/871/87xtext1
+FINISH REMOVEDIR 8/87/871/
+FINISH REMOVEDIR 8/87/
+FINISH REMOVEFILE 8/86/86text0
+FINISH REMOVEFILE 8/86/86text1
+FINISH REMOVEDIR 8/86/
+FINISH REMOVEDIR 8/85/
+FINISH REMOVEDIR 8/85/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/84/
+FINISH REMOVEDIR 8/84/
+directory no longer exists; skipping
+FINISH REMOVEDIR 8/83/
+FINISH REMOVEDIR 8/82/
+removing directory: 8/82/, rv: 0
+FINISH REMOVEDIR 8/81/
+removing directory: 8/81/, rv: 0
+FINISH REMOVEDIR 8/80/
+FINISH REMOVEDIR 8/80/
+directory no longer exists; skipping
+FINISH REMOVEFILE 7/70/7xtest.exe
+FINISH REMOVEFILE 7/70/7xtext0
+FINISH REMOVEFILE 7/70/7xtext1
+FINISH REMOVEDIR 7/70/
+FINISH REMOVEFILE 7/71/7xtest.exe
+FINISH REMOVEFILE 7/71/7xtext0
+FINISH REMOVEFILE 7/71/7xtext1
+FINISH REMOVEDIR 7/71/
+FINISH REMOVEFILE 7/7text0
+FINISH REMOVEFILE 7/7text1
+FINISH REMOVEDIR 7/
+FINISH REMOVEDIR 6/
+FINISH REMOVEFILE 5/5text1
+FINISH REMOVEFILE 5/5text0
+FINISH REMOVEFILE 5/5test.exe
+FINISH REMOVEDIR 5/
+FINISH REMOVEFILE 4/4text1
+FINISH REMOVEFILE 4/4text0
+FINISH REMOVEDIR 4/
+FINISH REMOVEFILE 3/3text1
+FINISH REMOVEFILE 3/3text0
+FINISH REMOVEDIR 1/10/
+FINISH REMOVEDIR 1/
+succeeded
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/partial_mac.mar b/toolkit/mozapps/update/tests/data/partial_mac.mar
new file mode 100644
index 0000000000..bcc04b9939
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_mac.mar
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/partial_precomplete b/toolkit/mozapps/update/tests/data/partial_precomplete
new file mode 100644
index 0000000000..3ec201463a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_precomplete
@@ -0,0 +1,19 @@
+remove "searchplugins/searchpluginstext0"
+remove "searchplugins/searchpluginspng1.png"
+remove "searchplugins/searchpluginspng0.png"
+remove "removed-files"
+remove "precomplete"
+remove "exe0.exe"
+remove "2/20/20text0"
+remove "2/20/20png0.png"
+remove "0/0exe0.exe"
+remove "0/00/00text2"
+remove "0/00/00text0"
+remove "0/00/00png0.png"
+rmdir "searchplugins/"
+rmdir "defaults/pref/"
+rmdir "defaults/"
+rmdir "2/20/"
+rmdir "2/"
+rmdir "0/00/"
+rmdir "0/"
diff --git a/toolkit/mozapps/update/tests/data/partial_precomplete_mac b/toolkit/mozapps/update/tests/data/partial_precomplete_mac
new file mode 100644
index 0000000000..c65b6e4e38
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_precomplete_mac
@@ -0,0 +1,22 @@
+remove "Contents/Resources/searchplugins/searchpluginstext0"
+remove "Contents/Resources/searchplugins/searchpluginspng1.png"
+remove "Contents/Resources/searchplugins/searchpluginspng0.png"
+remove "Contents/Resources/removed-files"
+remove "Contents/Resources/precomplete"
+remove "Contents/Resources/2/20/20text0"
+remove "Contents/Resources/2/20/20png0.png"
+remove "Contents/Resources/0/0exe0.exe"
+remove "Contents/Resources/0/00/00text2"
+remove "Contents/Resources/0/00/00text0"
+remove "Contents/Resources/0/00/00png0.png"
+remove "Contents/MacOS/exe0.exe"
+rmdir "Contents/Resources/searchplugins/"
+rmdir "Contents/Resources/defaults/pref/"
+rmdir "Contents/Resources/defaults/"
+rmdir "Contents/Resources/2/20/"
+rmdir "Contents/Resources/2/"
+rmdir "Contents/Resources/0/00/"
+rmdir "Contents/Resources/0/"
+rmdir "Contents/Resources/"
+rmdir "Contents/MacOS/"
+rmdir "Contents/"
diff --git a/toolkit/mozapps/update/tests/data/partial_removed-files b/toolkit/mozapps/update/tests/data/partial_removed-files
new file mode 100644
index 0000000000..881311b82c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_removed-files
@@ -0,0 +1,41 @@
+a/b/text0
+a/b/text1
+a/b/3/3text0
+a/b/3/3text1
+a/b/4/4exe0.exe
+a/b/4/4text0
+a/b/4/4text1
+a/b/4/
+a/b/5/5text0
+a/b/5/5text1
+a/b/5/*
+a/b/6/
+a/b/7/*
+a/b/8/80/
+a/b/8/81/
+a/b/8/82/
+a/b/8/83/
+a/b/8/84/
+a/b/8/85/*
+a/b/8/86/*
+a/b/8/87/*
+a/b/8/88/*
+a/b/8/89/*
+a/b/8/80/
+a/b/8/84/*
+a/b/8/85/*
+a/b/8/89/
+a/b/9/90/
+a/b/9/91/
+a/b/9/92/
+a/b/9/93/
+a/b/9/94/
+a/b/9/95/*
+a/b/9/96/*
+a/b/9/97/*
+a/b/9/98/*
+a/b/9/99/*
+a/b/9/90/
+a/b/9/94/*
+a/b/9/95/*
+a/b/9/99/
diff --git a/toolkit/mozapps/update/tests/data/partial_removed-files_mac b/toolkit/mozapps/update/tests/data/partial_removed-files_mac
new file mode 100644
index 0000000000..955dc5b340
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_removed-files_mac
@@ -0,0 +1,41 @@
+Contents/Resources/text0
+Contents/Resources/text1
+Contents/Resources/3/3text0
+Contents/Resources/3/3text1
+Contents/Resources/4/exe0.exe
+Contents/Resources/4/4text0
+Contents/Resources/4/4text1
+Contents/Resources/4/
+Contents/Resources/5/5text0
+Contents/Resources/5/5text1
+Contents/Resources/5/*
+Contents/Resources/6/
+Contents/Resources/7/*
+Contents/Resources/8/80/
+Contents/Resources/8/81/
+Contents/Resources/8/82/
+Contents/Resources/8/83/
+Contents/Resources/8/84/
+Contents/Resources/8/85/*
+Contents/Resources/8/86/*
+Contents/Resources/8/87/*
+Contents/Resources/8/88/*
+Contents/Resources/8/89/*
+Contents/Resources/8/80/
+Contents/Resources/8/84/*
+Contents/Resources/8/85/*
+Contents/Resources/8/89/
+Contents/Resources/9/90/
+Contents/Resources/9/91/
+Contents/Resources/9/92/
+Contents/Resources/9/93/
+Contents/Resources/9/94/
+Contents/Resources/9/95/*
+Contents/Resources/9/96/*
+Contents/Resources/9/97/*
+Contents/Resources/9/98/*
+Contents/Resources/9/99/*
+Contents/Resources/9/90/
+Contents/Resources/9/94/*
+Contents/Resources/9/95/*
+Contents/Resources/9/99/
diff --git a/toolkit/mozapps/update/tests/data/partial_update_manifest b/toolkit/mozapps/update/tests/data/partial_update_manifest
new file mode 100644
index 0000000000..8d4e60ed25
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/partial_update_manifest
@@ -0,0 +1,63 @@
+type "partial"
+add "precomplete"
+add "a/b/searchplugins/searchpluginstext0"
+patch-if "a/b/searchplugins/searchpluginspng1.png" "a/b/searchplugins/searchpluginspng1.png.patch" "a/b/searchplugins/searchpluginspng1.png"
+patch-if "a/b/searchplugins/searchpluginspng0.png" "a/b/searchplugins/searchpluginspng0.png.patch" "a/b/searchplugins/searchpluginspng0.png"
+add-if "a/b/extensions/extensions1" "a/b/extensions/extensions1/extensions1text0"
+patch-if "a/b/extensions/extensions1" "a/b/extensions/extensions1/extensions1png1.png.patch" "a/b/extensions/extensions1/extensions1png1.png"
+patch-if "a/b/extensions/extensions1" "a/b/extensions/extensions1/extensions1png0.png.patch" "a/b/extensions/extensions1/extensions1png0.png"
+add-if "a/b/extensions/extensions0" "a/b/extensions/extensions0/extensions0text0"
+patch-if "a/b/extensions/extensions0" "a/b/extensions/extensions0/extensions0png1.png.patch" "a/b/extensions/extensions0/extensions0png1.png"
+patch-if "a/b/extensions/extensions0" "a/b/extensions/extensions0/extensions0png0.png.patch" "a/b/extensions/extensions0/extensions0png0.png"
+patch "a/b/exe0.exe.patch" "a/b/exe0.exe"
+patch "a/b/0/0exe0.exe.patch" "a/b/0/0exe0.exe"
+add "a/b/0/00/00text0"
+patch "a/b/0/00/00png0.png.patch" "a/b/0/00/00png0.png"
+add "a/b/2/20/20text0"
+add "a/b/2/20/20png0.png"
+add "a/b/0/00/00text2"
+remove "a/b/1/10/10text0"
+remove "a/b/0/00/00text1"
+remove "a/b/text1"
+remove "a/b/text0"
+rmrfdir "a/b/9/99/"
+rmdir "a/b/9/99/"
+rmrfdir "a/b/9/98/"
+rmrfdir "a/b/9/97/"
+rmrfdir "a/b/9/96/"
+rmrfdir "a/b/9/95/"
+rmrfdir "a/b/9/95/"
+rmrfdir "a/b/9/94/"
+rmdir "a/b/9/94/"
+rmdir "a/b/9/93/"
+rmdir "a/b/9/92/"
+rmdir "a/b/9/91/"
+rmdir "a/b/9/90/"
+rmdir "a/b/9/90/"
+rmrfdir "a/b/8/89/"
+rmdir "a/b/8/89/"
+rmrfdir "a/b/8/88/"
+rmrfdir "a/b/8/87/"
+rmrfdir "a/b/8/86/"
+rmrfdir "a/b/8/85/"
+rmrfdir "a/b/8/85/"
+rmrfdir "a/b/8/84/"
+rmdir "a/b/8/84/"
+rmdir "a/b/8/83/"
+rmdir "a/b/8/82/"
+rmdir "a/b/8/81/"
+rmdir "a/b/8/80/"
+rmdir "a/b/8/80/"
+rmrfdir "a/b/7/"
+rmdir "a/b/6/"
+remove "a/b/5/5text1"
+remove "a/b/5/5text0"
+rmrfdir "a/b/5/"
+remove "a/b/4/4text1"
+remove "a/b/4/4text0"
+remove "a/b/4/4exe0.exe"
+rmdir "a/b/4/"
+remove "a/b/3/3text1"
+remove "a/b/3/3text0"
+rmdir "a/b/1/10/"
+rmdir "a/b/1/"
diff --git a/toolkit/mozapps/update/tests/data/replace_log_success b/toolkit/mozapps/update/tests/data/replace_log_success
new file mode 100644
index 0000000000..323f1db41e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/replace_log_success
@@ -0,0 +1,6 @@
+Performing a replace request
+rename_file: proceeding to rename the directory
+rename_file: proceeding to rename the directory
+Now, remove the tmpDir
+succeeded
+calling QuitProgressUI
diff --git a/toolkit/mozapps/update/tests/data/shared.js b/toolkit/mozapps/update/tests/data/shared.js
new file mode 100644
index 0000000000..fc3d358586
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/shared.js
@@ -0,0 +1,933 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Shared code for xpcshell, mochitests-chrome, and mochitest-browser-chrome. */
+
+// Definitions needed to run eslint on this file.
+/* global AppConstants, DATA_URI_SPEC, LOG_FUNCTION */
+/* global Services, URL_HOST, TestUtils */
+
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+ ctypes: "resource://gre/modules/ctypes.sys.mjs",
+});
+
+const PREF_APP_UPDATE_AUTO = "app.update.auto";
+const PREF_APP_UPDATE_BACKGROUNDERRORS = "app.update.backgroundErrors";
+const PREF_APP_UPDATE_BACKGROUNDMAXERRORS = "app.update.backgroundMaxErrors";
+const PREF_APP_UPDATE_BADGEWAITTIME = "app.update.badgeWaitTime";
+const PREF_APP_UPDATE_BITS_ENABLED = "app.update.BITS.enabled";
+const PREF_APP_UPDATE_CANCELATIONS = "app.update.cancelations";
+const PREF_APP_UPDATE_CHANNEL = "app.update.channel";
+const PREF_APP_UPDATE_DOWNLOAD_MAXATTEMPTS = "app.update.download.maxAttempts";
+const PREF_APP_UPDATE_DOWNLOAD_ATTEMPTS = "app.update.download.attempts";
+const PREF_APP_UPDATE_DISABLEDFORTESTING = "app.update.disabledForTesting";
+const PREF_APP_UPDATE_INTERVAL = "app.update.interval";
+const PREF_APP_UPDATE_LASTUPDATETIME =
+ "app.update.lastUpdateTime.background-update-timer";
+const PREF_APP_UPDATE_LOG = "app.update.log";
+const PREF_APP_UPDATE_NOTIFYDURINGDOWNLOAD = "app.update.notifyDuringDownload";
+const PREF_APP_UPDATE_PROMPTWAITTIME = "app.update.promptWaitTime";
+const PREF_APP_UPDATE_RETRYTIMEOUT = "app.update.socket.retryTimeout";
+const PREF_APP_UPDATE_SERVICE_ENABLED = "app.update.service.enabled";
+const PREF_APP_UPDATE_SOCKET_MAXERRORS = "app.update.socket.maxErrors";
+const PREF_APP_UPDATE_STAGING_ENABLED = "app.update.staging.enabled";
+const PREF_APP_UPDATE_UNSUPPORTED_URL = "app.update.unsupported.url";
+const PREF_APP_UPDATE_URL_DETAILS = "app.update.url.details";
+const PREF_APP_UPDATE_URL_MANUAL = "app.update.url.manual";
+const PREF_APP_UPDATE_LANGPACK_ENABLED = "app.update.langpack.enabled";
+
+const PREFBRANCH_APP_PARTNER = "app.partner.";
+const PREF_DISTRIBUTION_ID = "distribution.id";
+const PREF_DISTRIBUTION_VERSION = "distribution.version";
+
+const CONFIG_APP_UPDATE_AUTO = "app.update.auto";
+
+const NS_APP_PROFILE_DIR_STARTUP = "ProfDS";
+const NS_APP_USER_PROFILE_50_DIR = "ProfD";
+const NS_GRE_BIN_DIR = "GreBinD";
+const NS_GRE_DIR = "GreD";
+const NS_XPCOM_CURRENT_PROCESS_DIR = "XCurProcD";
+const XRE_EXECUTABLE_FILE = "XREExeF";
+const XRE_OLD_UPDATE_ROOT_DIR = "OldUpdRootD";
+const XRE_UPDATE_ROOT_DIR = "UpdRootD";
+
+const DIR_PATCH = "0";
+const DIR_TOBEDELETED = "tobedeleted";
+const DIR_UPDATES = "updates";
+const DIR_UPDATED =
+ AppConstants.platform == "macosx" ? "Updated.app" : "updated";
+const DIR_DOWNLOADING = "downloading";
+
+const FILE_ACTIVE_UPDATE_XML = "active-update.xml";
+const FILE_ACTIVE_UPDATE_XML_TMP = "active-update.xml.tmp";
+const FILE_APPLICATION_INI = "application.ini";
+const FILE_BACKUP_UPDATE_CONFIG_JSON = "backup-update-config.json";
+const FILE_BACKUP_UPDATE_LOG = "backup-update.log";
+const FILE_BACKUP_UPDATE_ELEVATED_LOG = "backup-update-elevated.log";
+const FILE_BT_RESULT = "bt.result";
+const FILE_LAST_UPDATE_LOG = "last-update.log";
+const FILE_LAST_UPDATE_ELEVATED_LOG = "last-update-elevated.log";
+const FILE_PRECOMPLETE = "precomplete";
+const FILE_PRECOMPLETE_BAK = "precomplete.bak";
+const FILE_UPDATE_CONFIG_JSON = "update-config.json";
+const FILE_UPDATE_LOG = "update.log";
+const FILE_UPDATE_ELEVATED_LOG = "update-elevated.log";
+const FILE_UPDATE_MAR = "update.mar";
+const FILE_UPDATE_SETTINGS_INI = "update-settings.ini";
+const FILE_UPDATE_SETTINGS_INI_BAK = "update-settings.ini.bak";
+const FILE_UPDATE_STATUS = "update.status";
+const FILE_UPDATE_TEST = "update.test";
+const FILE_UPDATE_VERSION = "update.version";
+const FILE_UPDATER_INI = "updater.ini";
+const FILE_UPDATES_XML = "updates.xml";
+const FILE_UPDATES_XML_TMP = "updates.xml.tmp";
+
+const UPDATE_SETTINGS_CONTENTS =
+ "[Settings]\nACCEPTED_MAR_CHANNEL_IDS=xpcshell-test\n";
+const PRECOMPLETE_CONTENTS = 'rmdir "nonexistent_dir/"\n';
+
+const PR_RDWR = 0x04;
+const PR_CREATE_FILE = 0x08;
+const PR_TRUNCATE = 0x20;
+
+var gChannel;
+var gDebugTest = false;
+
+/* import-globals-from sharedUpdateXML.js */
+Services.scriptloader.loadSubScript(DATA_URI_SPEC + "sharedUpdateXML.js", this);
+
+const PERMS_FILE = FileUtils.PERMS_FILE;
+const PERMS_DIRECTORY = FileUtils.PERMS_DIRECTORY;
+
+const MODE_WRONLY = FileUtils.MODE_WRONLY;
+const MODE_CREATE = FileUtils.MODE_CREATE;
+const MODE_APPEND = FileUtils.MODE_APPEND;
+const MODE_TRUNCATE = FileUtils.MODE_TRUNCATE;
+
+const URI_UPDATES_PROPERTIES =
+ "chrome://mozapps/locale/update/updates.properties";
+const gUpdateBundle = Services.strings.createBundle(URI_UPDATES_PROPERTIES);
+
+ChromeUtils.defineLazyGetter(this, "gAUS", function test_gAUS() {
+ return Cc["@mozilla.org/updates/update-service;1"]
+ .getService(Ci.nsIApplicationUpdateService)
+ .QueryInterface(Ci.nsITimerCallback)
+ .QueryInterface(Ci.nsIObserver);
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gUpdateManager",
+ "@mozilla.org/updates/update-manager;1",
+ "nsIUpdateManager"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gUpdateChecker",
+ "@mozilla.org/updates/update-checker;1",
+ "nsIUpdateChecker"
+);
+
+ChromeUtils.defineLazyGetter(this, "gDefaultPrefBranch", function test_gDPB() {
+ return Services.prefs.getDefaultBranch(null);
+});
+
+ChromeUtils.defineLazyGetter(this, "gPrefRoot", function test_gPR() {
+ return Services.prefs.getBranch(null);
+});
+
+/**
+ * Waits for the specified topic and (optionally) status.
+ *
+ * @param topic
+ * String representing the topic to wait for.
+ * @param status (optional)
+ * A string representing the status on said topic to wait for.
+ * @return A promise which will resolve the first time an event occurs on the
+ * specified topic, and (optionally) with the specified status.
+ */
+function waitForEvent(topic, status = null) {
+ return new Promise(resolve =>
+ Services.obs.addObserver(
+ {
+ observe(subject, innerTopic, innerStatus) {
+ if (!status || status == innerStatus) {
+ Services.obs.removeObserver(this, topic);
+ resolve(innerStatus);
+ }
+ },
+ },
+ topic
+ )
+ );
+}
+
+/* Triggers post-update processing */
+function testPostUpdateProcessing() {
+ gAUS.observe(null, "test-post-update-processing", "");
+}
+
+/* Initializes the update service stub */
+function initUpdateServiceStub() {
+ Cc["@mozilla.org/updates/update-service-stub;1"].createInstance(
+ Ci.nsISupports
+ );
+}
+
+/**
+ * Reloads the update xml files.
+ *
+ * @param skipFiles (optional)
+ * If true, the update xml files will not be read and the metadata will
+ * be reset. If false (the default), the update xml files will be read
+ * to populate the update metadata.
+ */
+function reloadUpdateManagerData(skipFiles = false) {
+ let observeData = skipFiles ? "skip-files" : "";
+ gUpdateManager
+ .QueryInterface(Ci.nsIObserver)
+ .observe(null, "um-reload-update-data", observeData);
+}
+
+const observer = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed" && aData == PREF_APP_UPDATE_CHANNEL) {
+ let channel = gDefaultPrefBranch.getCharPref(PREF_APP_UPDATE_CHANNEL);
+ if (channel != gChannel) {
+ debugDump("Changing channel from " + channel + " to " + gChannel);
+ gDefaultPrefBranch.setCharPref(PREF_APP_UPDATE_CHANNEL, gChannel);
+ }
+ }
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+};
+
+/**
+ * Sets the app.update.channel preference.
+ *
+ * @param aChannel
+ * The update channel.
+ */
+function setUpdateChannel(aChannel) {
+ gChannel = aChannel;
+ debugDump(
+ "setting default pref " + PREF_APP_UPDATE_CHANNEL + " to " + gChannel
+ );
+ gDefaultPrefBranch.setCharPref(PREF_APP_UPDATE_CHANNEL, gChannel);
+ gPrefRoot.addObserver(PREF_APP_UPDATE_CHANNEL, observer);
+}
+
+/**
+ * Sets the effective update url.
+ *
+ * @param aURL
+ * The update url. If not specified 'URL_HOST + "/update.xml"' will be
+ * used.
+ */
+function setUpdateURL(aURL) {
+ let url = aURL ? aURL : URL_HOST + "/update.xml";
+ debugDump("setting update URL to " + url);
+
+ // The Update URL is stored in appinfo. We can replace this process's appinfo
+ // directly, but that will affect only this process. Luckily, the update URL
+ // is only ever read from the update process. This means that replacing
+ // Services.appinfo is sufficient and we don't need to worry about registering
+ // a replacement factory or anything like that.
+ let origAppInfo = Services.appinfo;
+ registerCleanupFunction(() => {
+ Services.appinfo = origAppInfo;
+ });
+
+ // Override the appinfo object with an object that exposes all of the same
+ // properties overriding just the updateURL.
+ let mockAppInfo = Object.create(origAppInfo, {
+ updateURL: {
+ configurable: true,
+ enumerable: true,
+ writable: false,
+ value: url,
+ },
+ });
+
+ Services.appinfo = mockAppInfo;
+}
+
+/**
+ * Writes the updates specified to either the active-update.xml or the
+ * updates.xml.
+ *
+ * @param aContent
+ * The updates represented as a string to write to the XML file.
+ * @param isActiveUpdate
+ * If true this will write to the active-update.xml otherwise it will
+ * write to the updates.xml file.
+ */
+function writeUpdatesToXMLFile(aContent, aIsActiveUpdate) {
+ let file = getUpdateDirFile(
+ aIsActiveUpdate ? FILE_ACTIVE_UPDATE_XML : FILE_UPDATES_XML
+ );
+ writeFile(file, aContent);
+}
+
+/**
+ * Writes the current update operation/state to a file in the patch
+ * directory, indicating to the patching system that operations need
+ * to be performed.
+ *
+ * @param aStatus
+ * The status value to write.
+ */
+function writeStatusFile(aStatus) {
+ let file = getUpdateDirFile(FILE_UPDATE_STATUS);
+ writeFile(file, aStatus + "\n");
+}
+
+/**
+ * Writes the current update version to a file in the patch directory,
+ * indicating to the patching system the version of the update.
+ *
+ * @param aVersion
+ * The version value to write.
+ */
+function writeVersionFile(aVersion) {
+ let file = getUpdateDirFile(FILE_UPDATE_VERSION);
+ writeFile(file, aVersion + "\n");
+}
+
+/**
+ * Writes text to a file. This will replace existing text if the file exists
+ * and create the file if it doesn't exist.
+ *
+ * @param aFile
+ * The file to write to. Will be created if it doesn't exist.
+ * @param aText
+ * The text to write to the file. If there is existing text it will be
+ * replaced.
+ */
+function writeFile(aFile, aText) {
+ let fos = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ if (!aFile.exists()) {
+ aFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ }
+ fos.init(aFile, MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE, PERMS_FILE, 0);
+ fos.write(aText, aText.length);
+ fos.close();
+}
+
+/**
+ * Reads the current update operation/state in the status file in the patch
+ * directory including the error code if it is present.
+ *
+ * @return The status value.
+ */
+function readStatusFile() {
+ let file = getUpdateDirFile(FILE_UPDATE_STATUS);
+ if (!file.exists()) {
+ debugDump("update status file does not exists! Path: " + file.path);
+ return STATE_NONE;
+ }
+ return readFile(file).split("\n")[0];
+}
+
+/**
+ * Reads the current update operation/state in the status file in the patch
+ * directory without the error code if it is present.
+ *
+ * @return The state value.
+ */
+function readStatusState() {
+ return readStatusFile().split(": ")[0];
+}
+
+/**
+ * Reads the current update operation/state in the status file in the patch
+ * directory with the error code.
+ *
+ * @return The state value.
+ */
+function readStatusFailedCode() {
+ return readStatusFile().split(": ")[1];
+}
+
+/**
+ * Returns whether or not applying the current update resulted in an error
+ * verifying binary transparency information.
+ *
+ * @return true if there was an error result and false otherwise
+ */
+function updateHasBinaryTransparencyErrorResult() {
+ let file = getUpdateDirFile(FILE_BT_RESULT);
+ return file.exists();
+}
+
+/**
+ * Reads text from a file and returns the string.
+ *
+ * @param aFile
+ * The file to read from.
+ * @return The string of text read from the file.
+ */
+function readFile(aFile) {
+ let fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ if (!aFile.exists()) {
+ return null;
+ }
+ // Specifying -1 for ioFlags will open the file with the default of PR_RDONLY.
+ // Specifying -1 for perm will open the file with the default of 0.
+ fis.init(aFile, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF);
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sis.init(fis);
+ let text = sis.read(sis.available());
+ sis.close();
+ return text;
+}
+
+/* Returns human readable status text from the updates.properties bundle */
+function getStatusText(aErrCode) {
+ return getString("check_error-" + aErrCode);
+}
+
+/* Returns a string from the updates.properties bundle */
+function getString(aName) {
+ try {
+ return gUpdateBundle.GetStringFromName(aName);
+ } catch (e) {}
+ return null;
+}
+
+/**
+ * Gets the file extension for an nsIFile.
+ *
+ * @param aFile
+ * The file to get the file extension for.
+ * @return The file extension.
+ */
+function getFileExtension(aFile) {
+ return Services.io.newFileURI(aFile).QueryInterface(Ci.nsIURL).fileExtension;
+}
+
+/**
+ * Gets the specified update file or directory.
+ *
+ * @param aLogLeafName
+ * The leafName of the file or directory to get.
+ * @param aWhichDir
+ * Since we started having a separate patch directory and downloading
+ * directory, there are now files with the same name that can be in
+ * either directory. This argument is optional and defaults to the
+ * patch directory for historical reasons. But if it is specified as
+ * DIR_DOWNLOADING, this function will provide the version of the file
+ * in the downloading directory. For files that aren't in the patch
+ * directory or the downloading directory, this value is ignored.
+ * @return nsIFile for the file or directory.
+ */
+function getUpdateDirFile(aLeafName, aWhichDir = null) {
+ let file = Services.dirsvc.get(XRE_UPDATE_ROOT_DIR, Ci.nsIFile);
+ switch (aLeafName) {
+ case undefined:
+ return file;
+ case DIR_UPDATES:
+ case FILE_ACTIVE_UPDATE_XML:
+ case FILE_ACTIVE_UPDATE_XML_TMP:
+ case FILE_UPDATE_CONFIG_JSON:
+ case FILE_BACKUP_UPDATE_CONFIG_JSON:
+ case FILE_UPDATE_TEST:
+ case FILE_UPDATES_XML:
+ case FILE_UPDATES_XML_TMP:
+ file.append(aLeafName);
+ return file;
+ case DIR_PATCH:
+ case DIR_DOWNLOADING:
+ case FILE_BACKUP_UPDATE_LOG:
+ case FILE_BACKUP_UPDATE_ELEVATED_LOG:
+ case FILE_LAST_UPDATE_LOG:
+ case FILE_LAST_UPDATE_ELEVATED_LOG:
+ file.append(DIR_UPDATES);
+ file.append(aLeafName);
+ return file;
+ case FILE_BT_RESULT:
+ case FILE_UPDATE_LOG:
+ case FILE_UPDATE_ELEVATED_LOG:
+ case FILE_UPDATE_MAR:
+ case FILE_UPDATE_STATUS:
+ case FILE_UPDATE_VERSION:
+ case FILE_UPDATER_INI:
+ file.append(DIR_UPDATES);
+ if (aWhichDir == DIR_DOWNLOADING) {
+ file.append(DIR_DOWNLOADING);
+ } else {
+ file.append(DIR_PATCH);
+ }
+ file.append(aLeafName);
+ return file;
+ }
+
+ throw new Error(
+ "The leafName specified is not handled by this function, " +
+ "leafName: " +
+ aLeafName
+ );
+}
+
+/**
+ * Helper function for getting the nsIFile for a file in the directory where the
+ * update will be staged.
+ *
+ * The files for the update are located two directories below the stage
+ * directory since Mac OS X sets the last modified time for the root directory
+ * to the current time and if the update changes any files in the root directory
+ * then it wouldn't be possible to test (bug 600098).
+ *
+ * @param aRelPath (optional)
+ * The relative path to the file or directory to get from the root of
+ * the stage directory. If not specified the stage directory will be
+ * returned.
+ * @return The nsIFile for the file in the directory where the update will be
+ * staged.
+ */
+function getStageDirFile(aRelPath) {
+ let file;
+ if (AppConstants.platform == "macosx") {
+ file = getUpdateDirFile(DIR_PATCH);
+ } else {
+ file = getGREBinDir();
+ }
+ file.append(DIR_UPDATED);
+ if (aRelPath) {
+ let pathParts = aRelPath.split("/");
+ for (let i = 0; i < pathParts.length; i++) {
+ if (pathParts[i]) {
+ file.append(pathParts[i]);
+ }
+ }
+ }
+ return file;
+}
+
+/**
+ * Removes the update files that typically need to be removed by tests without
+ * removing the directories since removing the directories has caused issues
+ * when running tests with --verify and recursively removes the stage directory.
+ *
+ * @param aRemoveLogFiles
+ * When true the update log files will also be removed. This allows
+ * for the inspection of the log files while troubleshooting tests.
+ */
+function removeUpdateFiles(aRemoveLogFiles) {
+ let files = [
+ [FILE_ACTIVE_UPDATE_XML],
+ [FILE_UPDATES_XML],
+ [FILE_BT_RESULT],
+ [FILE_UPDATE_STATUS],
+ [FILE_UPDATE_VERSION],
+ [FILE_UPDATE_MAR],
+ [FILE_UPDATE_MAR, DIR_DOWNLOADING],
+ [FILE_UPDATER_INI],
+ ];
+
+ if (aRemoveLogFiles) {
+ files = files.concat([
+ [FILE_BACKUP_UPDATE_LOG],
+ [FILE_LAST_UPDATE_LOG],
+ [FILE_UPDATE_LOG],
+ [FILE_BACKUP_UPDATE_ELEVATED_LOG],
+ [FILE_LAST_UPDATE_ELEVATED_LOG],
+ [FILE_UPDATE_ELEVATED_LOG],
+ ]);
+ }
+
+ for (let i = 0; i < files.length; i++) {
+ let file = getUpdateDirFile.apply(null, files[i]);
+ try {
+ if (file.exists()) {
+ file.remove(false);
+ }
+ } catch (e) {
+ logTestInfo(
+ "Unable to remove file. Path: " + file.path + ", Exception: " + e
+ );
+ }
+ }
+
+ let stageDir = getStageDirFile();
+ if (stageDir.exists()) {
+ try {
+ removeDirRecursive(stageDir);
+ } catch (e) {
+ logTestInfo(
+ "Unable to remove directory. Path: " +
+ stageDir.path +
+ ", Exception: " +
+ e
+ );
+ }
+ }
+}
+
+/**
+ * Deletes a directory and its children. First it tries nsIFile::Remove(true).
+ * If that fails it will fall back to recursing, setting the appropriate
+ * permissions, and deleting the current entry.
+ *
+ * @param aDir
+ * nsIFile for the directory to be deleted.
+ */
+function removeDirRecursive(aDir) {
+ if (!aDir.exists()) {
+ return;
+ }
+
+ if (!aDir.isDirectory()) {
+ throw new Error("Only a directory can be passed to this funtion!");
+ }
+
+ try {
+ debugDump("attempting to remove directory. Path: " + aDir.path);
+ aDir.remove(true);
+ return;
+ } catch (e) {
+ logTestInfo("non-fatal error removing directory. Exception: " + e);
+ }
+
+ let dirEntries = aDir.directoryEntries;
+ while (dirEntries.hasMoreElements()) {
+ let entry = dirEntries.nextFile;
+
+ if (entry.isDirectory()) {
+ removeDirRecursive(entry);
+ } else {
+ entry.permissions = PERMS_FILE;
+ try {
+ debugDump("attempting to remove file. Path: " + entry.path);
+ entry.remove(false);
+ } catch (e) {
+ logTestInfo("error removing file. Exception: " + e);
+ throw e;
+ }
+ }
+ }
+
+ aDir.permissions = PERMS_DIRECTORY;
+ try {
+ debugDump("attempting to remove directory. Path: " + aDir.path);
+ aDir.remove(true);
+ } catch (e) {
+ logTestInfo("error removing directory. Exception: " + e);
+ throw e;
+ }
+}
+
+/**
+ * Returns the directory for the currently running process. This is used to
+ * clean up after the tests and to locate the active-update.xml and updates.xml
+ * files.
+ *
+ * @return nsIFile for the current process directory.
+ */
+function getCurrentProcessDir() {
+ return Services.dirsvc.get(NS_XPCOM_CURRENT_PROCESS_DIR, Ci.nsIFile);
+}
+
+/**
+ * Returns the Gecko Runtime Engine directory where files other than executable
+ * binaries are located. On Mac OS X this will be <bundle>/Contents/Resources/
+ * and the installation directory on all other platforms.
+ *
+ * @return nsIFile for the Gecko Runtime Engine directory.
+ */
+function getGREDir() {
+ return Services.dirsvc.get(NS_GRE_DIR, Ci.nsIFile);
+}
+
+/**
+ * Returns the Gecko Runtime Engine Binary directory where the executable
+ * binaries are located such as the updater binary (Windows and Linux) or
+ * updater package (Mac OS X). On Mac OS X this will be
+ * <bundle>/Contents/MacOS/ and the installation directory on all other
+ * platforms.
+ *
+ * @return nsIFile for the Gecko Runtime Engine Binary directory.
+ */
+function getGREBinDir() {
+ return Services.dirsvc.get(NS_GRE_BIN_DIR, Ci.nsIFile);
+}
+
+/**
+ * Gets the unique mutex name for the installation.
+ *
+ * @return Global mutex path.
+ * @throws If the function is called on a platform other than Windows.
+ */
+function getPerInstallationMutexName() {
+ if (AppConstants.platform != "win") {
+ throw new Error("Windows only function called by a different platform!");
+ }
+
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(hasher.SHA1);
+
+ let exeFile = Services.dirsvc.get(XRE_EXECUTABLE_FILE, Ci.nsIFile);
+ let data = new TextEncoder().encode(exeFile.path.toLowerCase());
+
+ hasher.update(data, data.length);
+ return "Global\\MozillaUpdateMutex-" + hasher.finish(true);
+}
+
+/**
+ * Closes a Win32 handle.
+ *
+ * @param aHandle
+ * The handle to close.
+ * @throws If the function is called on a platform other than Windows.
+ */
+function closeHandle(aHandle) {
+ if (AppConstants.platform != "win") {
+ throw new Error("Windows only function called by a different platform!");
+ }
+
+ let lib = ctypes.open("kernel32.dll");
+ let CloseHandle = lib.declare(
+ "CloseHandle",
+ ctypes.winapi_abi,
+ ctypes.int32_t /* success */,
+ ctypes.void_t.ptr
+ ); /* handle */
+ CloseHandle(aHandle);
+ lib.close();
+}
+
+/**
+ * Creates a mutex.
+ *
+ * @param aName
+ * The name for the mutex.
+ * @return The Win32 handle to the mutex.
+ * @throws If the function is called on a platform other than Windows.
+ */
+function createMutex(aName) {
+ if (AppConstants.platform != "win") {
+ throw new Error("Windows only function called by a different platform!");
+ }
+
+ const INITIAL_OWN = 1;
+ const ERROR_ALREADY_EXISTS = 0xb7;
+ let lib = ctypes.open("kernel32.dll");
+ let CreateMutexW = lib.declare(
+ "CreateMutexW",
+ ctypes.winapi_abi,
+ ctypes.void_t.ptr /* return handle */,
+ ctypes.void_t.ptr /* security attributes */,
+ ctypes.int32_t /* initial owner */,
+ ctypes.char16_t.ptr
+ ); /* name */
+
+ let handle = CreateMutexW(null, INITIAL_OWN, aName);
+ lib.close();
+ let alreadyExists = ctypes.winLastError == ERROR_ALREADY_EXISTS;
+ if (handle && !handle.isNull() && alreadyExists) {
+ closeHandle(handle);
+ handle = null;
+ }
+
+ if (handle && handle.isNull()) {
+ handle = null;
+ }
+
+ return handle;
+}
+
+/**
+ * Synchronously writes the value of the app.update.auto setting to the update
+ * configuration file on Windows or to a user preference on other platforms.
+ * When the value passed to this function is null or undefined it will remove
+ * the configuration file on Windows or the user preference on other platforms.
+ *
+ * @param aEnabled
+ * Possible values are true, false, null, and undefined. When true or
+ * false this value will be written for app.update.auto in the update
+ * configuration file on Windows or to the user preference on other
+ * platforms. When null or undefined the update configuration file will
+ * be removed on Windows or the user preference will be removed on other
+ * platforms.
+ */
+function setAppUpdateAutoSync(aEnabled) {
+ if (AppConstants.platform == "win") {
+ let file = getUpdateDirFile(FILE_UPDATE_CONFIG_JSON);
+ if (aEnabled === undefined || aEnabled === null) {
+ if (file.exists()) {
+ file.remove(false);
+ }
+ } else {
+ writeFile(
+ file,
+ '{"' + CONFIG_APP_UPDATE_AUTO + '":' + aEnabled.toString() + "}"
+ );
+ }
+ } else if (aEnabled === undefined || aEnabled === null) {
+ if (Services.prefs.prefHasUserValue(PREF_APP_UPDATE_AUTO)) {
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_AUTO);
+ }
+ } else {
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_AUTO, aEnabled);
+ }
+}
+
+/**
+ * Logs TEST-INFO messages.
+ *
+ * @param aText
+ * The text to log.
+ * @param aCaller (optional)
+ * An optional Components.stack.caller. If not specified
+ * Components.stack.caller will be used.
+ */
+function logTestInfo(aText, aCaller) {
+ let caller = aCaller ? aCaller : Components.stack.caller;
+ let now = new Date();
+ let hh = now.getHours();
+ let mm = now.getMinutes();
+ let ss = now.getSeconds();
+ let ms = now.getMilliseconds();
+ let time =
+ (hh < 10 ? "0" + hh : hh) +
+ ":" +
+ (mm < 10 ? "0" + mm : mm) +
+ ":" +
+ (ss < 10 ? "0" + ss : ss) +
+ ":";
+ if (ms < 10) {
+ time += "00";
+ } else if (ms < 100) {
+ time += "0";
+ }
+ time += ms;
+ let msg =
+ time +
+ " | TEST-INFO | " +
+ caller.filename +
+ " | [" +
+ caller.name +
+ " : " +
+ caller.lineNumber +
+ "] " +
+ aText;
+ LOG_FUNCTION(msg);
+}
+
+/**
+ * Logs TEST-INFO messages when gDebugTest evaluates to true.
+ *
+ * @param aText
+ * The text to log.
+ * @param aCaller (optional)
+ * An optional Components.stack.caller. If not specified
+ * Components.stack.caller will be used.
+ */
+function debugDump(aText, aCaller) {
+ if (gDebugTest) {
+ let caller = aCaller ? aCaller : Components.stack.caller;
+ logTestInfo(aText, caller);
+ }
+}
+
+/**
+ * Creates the continue file used to signal that update staging or the mock http
+ * server should continue. The delay this creates allows the tests to verify the
+ * user interfaces before they auto advance to other phases of an update. The
+ * continue file for staging will be deleted by the test updater and the
+ * continue file for the update check and update download requests will be
+ * deleted by the test http server handler implemented in app_update.sjs. The
+ * test returns a promise so the test can wait on the deletion of the continue
+ * file when necessary. If the continue file still exists at the end of a test
+ * it will be removed to prevent it from affecting tests that run after the test
+ * that created it.
+ *
+ * @param leafName
+ * The leafName of the file to create. This should be one of the
+ * folowing constants that are defined in testConstants.js:
+ * CONTINUE_CHECK
+ * CONTINUE_DOWNLOAD
+ * CONTINUE_STAGING
+ * @return Promise
+ * Resolves when the file is deleted or if the file is not deleted when
+ * the check for the file's existence times out. If the file isn't
+ * deleted before the check for the file's existence times out it will
+ * be deleted when the test ends so it doesn't affect tests that run
+ * after the test that created the continue file.
+ * @throws If the file already exists.
+ */
+async function continueFileHandler(leafName) {
+ // The total time to wait with 300 retries and the default interval of 100 is
+ // approximately 30 seconds.
+ let interval = 100;
+ let retries = 300;
+ let continueFile;
+ if (leafName == CONTINUE_STAGING) {
+ // The total time to wait with 600 retries and an interval of 200 is
+ // approximately 120 seconds.
+ interval = 200;
+ retries = 600;
+ continueFile = getGREBinDir();
+ if (AppConstants.platform == "macosx") {
+ continueFile = continueFile.parent.parent;
+ }
+ continueFile.append(leafName);
+ } else {
+ continueFile = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ let continuePath = REL_PATH_DATA + leafName;
+ let continuePathParts = continuePath.split("/");
+ for (let i = 0; i < continuePathParts.length; ++i) {
+ continueFile.append(continuePathParts[i]);
+ }
+ }
+ if (continueFile.exists()) {
+ logTestInfo(
+ "The continue file should not exist, path: " + continueFile.path
+ );
+ continueFile.remove(false);
+ }
+ debugDump("Creating continue file, path: " + continueFile.path);
+ continueFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ // If for whatever reason the continue file hasn't been removed when a test
+ // has finished remove it during cleanup so it doesn't affect tests that run
+ // after the test that created it.
+ registerCleanupFunction(() => {
+ if (continueFile.exists()) {
+ logTestInfo(
+ "Removing continue file during test cleanup, path: " + continueFile.path
+ );
+ continueFile.remove(false);
+ }
+ });
+ return TestUtils.waitForCondition(
+ () => !continueFile.exists(),
+ "Waiting for file to be deleted, path: " + continueFile.path,
+ interval,
+ retries
+ ).catch(e => {
+ logTestInfo(
+ "Continue file was not removed after checking " +
+ retries +
+ " times, path: " +
+ continueFile.path
+ );
+ });
+}
diff --git a/toolkit/mozapps/update/tests/data/sharedUpdateXML.js b/toolkit/mozapps/update/tests/data/sharedUpdateXML.js
new file mode 100644
index 0000000000..acff4aec3f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/sharedUpdateXML.js
@@ -0,0 +1,417 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Shared code for xpcshell, mochitests-chrome, mochitest-browser-chrome, and
+ * SJS server-side scripts for the test http server.
+ */
+
+/**
+ * Helper functions for creating xml strings used by application update tests.
+ */
+
+/* import-globals-from ../browser/testConstants.js */
+
+/* global Services, UpdateUtils, gURLData */
+
+const FILE_SIMPLE_MAR = "simple.mar";
+const SIZE_SIMPLE_MAR = "1419";
+
+const STATE_NONE = "null";
+const STATE_DOWNLOADING = "downloading";
+const STATE_PENDING = "pending";
+const STATE_PENDING_SVC = "pending-service";
+const STATE_PENDING_ELEVATE = "pending-elevate";
+const STATE_APPLYING = "applying";
+const STATE_APPLIED = "applied";
+const STATE_APPLIED_SVC = "applied-service";
+const STATE_SUCCEEDED = "succeeded";
+const STATE_DOWNLOAD_FAILED = "download-failed";
+const STATE_FAILED = "failed";
+
+const LOADSOURCE_ERROR_WRONG_SIZE = 2;
+const CRC_ERROR = 4;
+const READ_ERROR = 6;
+const WRITE_ERROR = 7;
+const MAR_CHANNEL_MISMATCH_ERROR = 22;
+const VERSION_DOWNGRADE_ERROR = 23;
+const UPDATE_SETTINGS_FILE_CHANNEL = 38;
+const SERVICE_COULD_NOT_COPY_UPDATER = 49;
+const SERVICE_INVALID_APPLYTO_DIR_STAGED_ERROR = 52;
+const SERVICE_INVALID_APPLYTO_DIR_ERROR = 54;
+const SERVICE_INVALID_INSTALL_DIR_PATH_ERROR = 55;
+const SERVICE_INVALID_WORKING_DIR_PATH_ERROR = 56;
+const INVALID_APPLYTO_DIR_STAGED_ERROR = 72;
+const INVALID_APPLYTO_DIR_ERROR = 74;
+const INVALID_INSTALL_DIR_PATH_ERROR = 75;
+const INVALID_WORKING_DIR_PATH_ERROR = 76;
+const INVALID_CALLBACK_PATH_ERROR = 77;
+const INVALID_CALLBACK_DIR_ERROR = 78;
+
+// Error codes 80 through 99 are reserved for nsUpdateService.js and are not
+// defined in common/updatererrors.h
+const ERR_OLDER_VERSION_OR_SAME_BUILD = 90;
+const ERR_UPDATE_STATE_NONE = 91;
+const ERR_CHANNEL_CHANGE = 92;
+
+const WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION = 106;
+
+const STATE_FAILED_DELIMETER = ": ";
+
+const STATE_FAILED_LOADSOURCE_ERROR_WRONG_SIZE =
+ STATE_FAILED + STATE_FAILED_DELIMETER + LOADSOURCE_ERROR_WRONG_SIZE;
+const STATE_FAILED_CRC_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + CRC_ERROR;
+const STATE_FAILED_READ_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + READ_ERROR;
+const STATE_FAILED_WRITE_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + WRITE_ERROR;
+const STATE_FAILED_MAR_CHANNEL_MISMATCH_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + MAR_CHANNEL_MISMATCH_ERROR;
+const STATE_FAILED_VERSION_DOWNGRADE_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + VERSION_DOWNGRADE_ERROR;
+const STATE_FAILED_UPDATE_SETTINGS_FILE_CHANNEL =
+ STATE_FAILED + STATE_FAILED_DELIMETER + UPDATE_SETTINGS_FILE_CHANNEL;
+const STATE_FAILED_SERVICE_COULD_NOT_COPY_UPDATER =
+ STATE_FAILED + STATE_FAILED_DELIMETER + SERVICE_COULD_NOT_COPY_UPDATER;
+const STATE_FAILED_SERVICE_INVALID_APPLYTO_DIR_STAGED_ERROR =
+ STATE_FAILED +
+ STATE_FAILED_DELIMETER +
+ SERVICE_INVALID_APPLYTO_DIR_STAGED_ERROR;
+const STATE_FAILED_SERVICE_INVALID_APPLYTO_DIR_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + SERVICE_INVALID_APPLYTO_DIR_ERROR;
+const STATE_FAILED_SERVICE_INVALID_INSTALL_DIR_PATH_ERROR =
+ STATE_FAILED +
+ STATE_FAILED_DELIMETER +
+ SERVICE_INVALID_INSTALL_DIR_PATH_ERROR;
+const STATE_FAILED_SERVICE_INVALID_WORKING_DIR_PATH_ERROR =
+ STATE_FAILED +
+ STATE_FAILED_DELIMETER +
+ SERVICE_INVALID_WORKING_DIR_PATH_ERROR;
+const STATE_FAILED_INVALID_APPLYTO_DIR_STAGED_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + INVALID_APPLYTO_DIR_STAGED_ERROR;
+const STATE_FAILED_INVALID_APPLYTO_DIR_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + INVALID_APPLYTO_DIR_ERROR;
+const STATE_FAILED_INVALID_INSTALL_DIR_PATH_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + INVALID_INSTALL_DIR_PATH_ERROR;
+const STATE_FAILED_INVALID_WORKING_DIR_PATH_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + INVALID_WORKING_DIR_PATH_ERROR;
+const STATE_FAILED_INVALID_CALLBACK_PATH_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + INVALID_CALLBACK_PATH_ERROR;
+const STATE_FAILED_INVALID_CALLBACK_DIR_ERROR =
+ STATE_FAILED + STATE_FAILED_DELIMETER + INVALID_CALLBACK_DIR_ERROR;
+const STATE_FAILED_WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION =
+ STATE_FAILED +
+ STATE_FAILED_DELIMETER +
+ WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION;
+
+const DEFAULT_UPDATE_VERSION = "999999.0";
+
+/**
+ * Constructs a string representing a remote update xml file.
+ *
+ * @param aUpdates
+ * The string representing the update elements.
+ * @return The string representing a remote update xml file.
+ */
+function getRemoteUpdatesXMLString(aUpdates) {
+ return '<?xml version="1.0"?><updates>' + aUpdates + "</updates>";
+}
+
+/**
+ * Constructs a string representing an update element for a remote update xml
+ * file. See getUpdateString for parameter information not provided below.
+ *
+ * @param aUpdateProps
+ * An object containing non default test values for an nsIUpdate.
+ * See updateProps names below for possible object names.
+ * @param aPatches
+ * String representing the application update patches.
+ * @return The string representing an update element for an update xml file.
+ */
+function getRemoteUpdateString(aUpdateProps, aPatches) {
+ const updateProps = {
+ appVersion: DEFAULT_UPDATE_VERSION,
+ buildID: "20080811053724",
+ custom1: null,
+ custom2: null,
+ detailsURL: URL_HTTP_UPDATE_SJS + "?uiURL=DETAILS",
+ displayVersion: null,
+ name: "App Update Test",
+ promptWaitTime: null,
+ type: "major",
+ };
+
+ for (let name in aUpdateProps) {
+ updateProps[name] = aUpdateProps[name];
+ }
+
+ // To test that text nodes are handled properly the string returned contains
+ // spaces and newlines.
+ return getUpdateString(updateProps) + ">\n " + aPatches + "\n</update>\n";
+}
+
+/**
+ * Constructs a string representing a patch element for a remote update xml
+ * file. See getPatchString for parameter information not provided below.
+ *
+ * @param aPatchProps (optional)
+ * An object containing non default test values for an nsIUpdatePatch.
+ * See patchProps below for possible object names.
+ * @return The string representing a patch element for a remote update xml file.
+ */
+function getRemotePatchString(aPatchProps) {
+ const patchProps = {
+ type: "complete",
+ _url: null,
+ get url() {
+ if (this._url) {
+ return this._url;
+ }
+ if (gURLData) {
+ return gURLData + FILE_SIMPLE_MAR;
+ }
+ return null;
+ },
+ set url(val) {
+ this._url = val;
+ },
+ custom1: null,
+ custom2: null,
+ size: SIZE_SIMPLE_MAR,
+ };
+
+ for (let name in aPatchProps) {
+ patchProps[name] = aPatchProps[name];
+ }
+
+ return getPatchString(patchProps) + "/>";
+}
+
+/**
+ * Constructs a string representing a local update xml file.
+ *
+ * @param aUpdates
+ * The string representing the update elements.
+ * @return The string representing a local update xml file.
+ */
+function getLocalUpdatesXMLString(aUpdates) {
+ if (!aUpdates || aUpdates == "") {
+ return '<updates xmlns="http://www.mozilla.org/2005/app-update"/>';
+ }
+ return (
+ '<updates xmlns="http://www.mozilla.org/2005/app-update">' +
+ aUpdates +
+ "</updates>"
+ );
+}
+
+/**
+ * Constructs a string representing an update element for a local update xml
+ * file. See getUpdateString for parameter information not provided below.
+ *
+ * @param aUpdateProps
+ * An object containing non default test values for an nsIUpdate.
+ * See updateProps names below for possible object names.
+ * @param aPatches
+ * String representing the application update patches.
+ * @return The string representing an update element for an update xml file.
+ */
+function getLocalUpdateString(aUpdateProps, aPatches) {
+ const updateProps = {
+ _appVersion: null,
+ get appVersion() {
+ if (this._appVersion) {
+ return this._appVersion;
+ }
+ if (Services && Services.appinfo && Services.appinfo.version) {
+ return Services.appinfo.version;
+ }
+ return DEFAULT_UPDATE_VERSION;
+ },
+ set appVersion(val) {
+ this._appVersion = val;
+ },
+ buildID: "20080811053724",
+ channel: UpdateUtils ? UpdateUtils.getUpdateChannel() : "default",
+ custom1: null,
+ custom2: null,
+ detailsURL: URL_HTTP_UPDATE_SJS + "?uiURL=DETAILS",
+ displayVersion: null,
+ foregroundDownload: "true",
+ installDate: "1238441400314",
+ isCompleteUpdate: "true",
+ name: "App Update Test",
+ previousAppVersion: null,
+ promptWaitTime: null,
+ serviceURL: "http://test_service/",
+ statusText: "Install Pending",
+ type: "major",
+ };
+
+ for (let name in aUpdateProps) {
+ updateProps[name] = aUpdateProps[name];
+ }
+
+ let checkInterval = updateProps.checkInterval
+ ? 'checkInterval="' + updateProps.checkInterval + '" '
+ : "";
+ let channel = 'channel="' + updateProps.channel + '" ';
+ let isCompleteUpdate =
+ 'isCompleteUpdate="' + updateProps.isCompleteUpdate + '" ';
+ let foregroundDownload = updateProps.foregroundDownload
+ ? 'foregroundDownload="' + updateProps.foregroundDownload + '" '
+ : "";
+ let installDate = 'installDate="' + updateProps.installDate + '" ';
+ let previousAppVersion = updateProps.previousAppVersion
+ ? 'previousAppVersion="' + updateProps.previousAppVersion + '" '
+ : "";
+ let statusText = updateProps.statusText
+ ? 'statusText="' + updateProps.statusText + '" '
+ : "";
+ let serviceURL = 'serviceURL="' + updateProps.serviceURL + '">';
+
+ return (
+ getUpdateString(updateProps) +
+ " " +
+ checkInterval +
+ channel +
+ isCompleteUpdate +
+ foregroundDownload +
+ installDate +
+ previousAppVersion +
+ statusText +
+ serviceURL +
+ aPatches +
+ "</update>"
+ );
+}
+
+/**
+ * Constructs a string representing a patch element for a local update xml file.
+ * See getPatchString for parameter information not provided below.
+ *
+ * @param aPatchProps (optional)
+ * An object containing non default test values for an nsIUpdatePatch.
+ * See patchProps below for possible object names.
+ * @return The string representing a patch element for a local update xml file.
+ */
+function getLocalPatchString(aPatchProps) {
+ const patchProps = {
+ type: "complete",
+ url: gURLData + FILE_SIMPLE_MAR,
+ size: SIZE_SIMPLE_MAR,
+ custom1: null,
+ custom2: null,
+ selected: "true",
+ state: STATE_SUCCEEDED,
+ };
+
+ for (let name in aPatchProps) {
+ patchProps[name] = aPatchProps[name];
+ }
+
+ let selected = 'selected="' + patchProps.selected + '" ';
+ let state = 'state="' + patchProps.state + '"/>';
+ return getPatchString(patchProps) + " " + selected + state;
+}
+
+/**
+ * Constructs a string representing an update element for a remote update xml
+ * file.
+ *
+ * @param aUpdateProps (optional)
+ * An object containing non default test values for an nsIUpdate.
+ * See the aUpdateProps property names below for possible object names.
+ * @return The string representing an update element for an update xml file.
+ */
+function getUpdateString(aUpdateProps) {
+ let type = 'type="' + aUpdateProps.type + '" ';
+ let name = 'name="' + aUpdateProps.name + '" ';
+ let displayVersion = aUpdateProps.displayVersion
+ ? 'displayVersion="' + aUpdateProps.displayVersion + '" '
+ : "";
+ let appVersion = 'appVersion="' + aUpdateProps.appVersion + '" ';
+ // Not specifying a detailsURL will cause a leak due to bug 470244
+ let detailsURL = 'detailsURL="' + aUpdateProps.detailsURL + '" ';
+ let promptWaitTime = aUpdateProps.promptWaitTime
+ ? 'promptWaitTime="' + aUpdateProps.promptWaitTime + '" '
+ : "";
+ let disableBITS = aUpdateProps.disableBITS
+ ? 'disableBITS="' + aUpdateProps.disableBITS + '" '
+ : "";
+ let disableBackgroundUpdates = aUpdateProps.disableBackgroundUpdates
+ ? 'disableBackgroundUpdates="' +
+ aUpdateProps.disableBackgroundUpdates +
+ '" '
+ : "";
+ let custom1 = aUpdateProps.custom1 ? aUpdateProps.custom1 + " " : "";
+ let custom2 = aUpdateProps.custom2 ? aUpdateProps.custom2 + " " : "";
+ let buildID = 'buildID="' + aUpdateProps.buildID + '"';
+
+ return (
+ "<update " +
+ type +
+ name +
+ displayVersion +
+ appVersion +
+ detailsURL +
+ promptWaitTime +
+ disableBITS +
+ disableBackgroundUpdates +
+ custom1 +
+ custom2 +
+ buildID
+ );
+}
+
+/**
+ * Constructs a string representing a patch element for an update xml file.
+ *
+ * @param aPatchProps (optional)
+ * An object containing non default test values for an nsIUpdatePatch.
+ * See the patchProps property names below for possible object names.
+ * @return The string representing a patch element for an update xml file.
+ */
+function getPatchString(aPatchProps) {
+ let type = 'type="' + aPatchProps.type + '" ';
+ let url = 'URL="' + aPatchProps.url + '" ';
+ let size = 'size="' + aPatchProps.size + '"';
+ let custom1 = aPatchProps.custom1 ? aPatchProps.custom1 + " " : "";
+ let custom2 = aPatchProps.custom2 ? aPatchProps.custom2 + " " : "";
+ return "<patch " + type + url + custom1 + custom2 + size;
+}
+
+/**
+ * Reads the binary contents of a file and returns it as a string.
+ *
+ * @param aFile
+ * The file to read from.
+ * @return The contents of the file as a string.
+ */
+function readFileBytes(aFile) {
+ let fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ // Specifying -1 for ioFlags will open the file with the default of PR_RDONLY.
+ // Specifying -1 for perm will open the file with the default of 0.
+ fis.init(aFile, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF);
+ let bis = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ bis.setInputStream(fis);
+ let data = [];
+ let count = fis.available();
+ while (count > 0) {
+ let bytes = bis.readByteArray(Math.min(65535, count));
+ data.push(String.fromCharCode.apply(null, bytes));
+ count -= bytes.length;
+ if (!bytes.length) {
+ throw new Error("Nothing read from input stream!");
+ }
+ }
+ data = data.join("");
+ fis.close();
+ return data.toString();
+}
diff --git a/toolkit/mozapps/update/tests/data/simple.mar b/toolkit/mozapps/update/tests/data/simple.mar
new file mode 100644
index 0000000000..fd635b46bd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/simple.mar
Binary files differ
diff --git a/toolkit/mozapps/update/tests/data/syncManagerTestChild.js b/toolkit/mozapps/update/tests/data/syncManagerTestChild.js
new file mode 100644
index 0000000000..1f52554e7c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/syncManagerTestChild.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This is the script that runs in the child xpcshell process for the test
+// unit_aus_update/updateSyncManager.js.
+// The main thing this script does is override the child's directory service
+// so that it ends up with the same fake binary path that the parent test runner
+// has opened its update lock with.
+// This requires that we have already been passed a constant on our command
+// line which contains the relevant fake binary path, which is called:
+/* global customExePath */
+
+print("child process is running");
+
+// This function is copied from xpcshellUtilsAUS.js so that we can have our
+// xpcshell subprocess call it without having to load that whole file, because
+// it turns out that needs a bunch of infrastructure that normally the testing
+// framework would provide, and that also requires a bunch of setup, and it's
+// just not worth all that. This is a cut down version that only includes the
+// directory provider functionality that the subprocess really needs.
+function adjustGeneralPaths() {
+ let dirProvider = {
+ getFile: function AGP_DP_getFile(aProp, aPersistent) {
+ // Set the value of persistent to false so when this directory provider is
+ // unregistered it will revert back to the original provider.
+ aPersistent.value = false;
+ // The sync manager only needs XREExeF, so that's all we provide.
+ if (aProp == "XREExeF") {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(customExePath);
+ return file;
+ }
+ return null;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ let ds = Services.dirsvc.QueryInterface(Ci.nsIDirectoryService);
+ ds.QueryInterface(Ci.nsIProperties).undefine("XREExeF");
+ ds.registerProvider(dirProvider);
+
+ // Now that we've overridden the directory provider, the name of the update
+ // lock needs to be changed to match the overridden path.
+ let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ syncManager.resetLock();
+}
+
+adjustGeneralPaths();
+
+// Wait a few seconds for the parent to do what it needs to do, then exit.
+print("child process should now have the lock; will exit in 5 seconds");
+simulateNoScriptActivity(5);
+print("child process exiting now");
diff --git a/toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js b/toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js
new file mode 100644
index 0000000000..90880d96d9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#filter substitution
+
+/* Preprocessed constants used by xpcshell tests */
+
+// MOZ_APP_VENDOR is optional.
+#ifdef MOZ_APP_VENDOR
+const MOZ_APP_VENDOR = "@MOZ_APP_VENDOR@";
+#else
+const MOZ_APP_VENDOR = "";
+#endif
+
+// MOZ_APP_BASENAME is not optional for tests.
+const MOZ_APP_BASENAME = "@MOZ_APP_BASENAME@";
+
+#ifdef MOZ_VERIFY_MAR_SIGNATURE
+const MOZ_VERIFY_MAR_SIGNATURE = true;
+#else
+const MOZ_VERIFY_MAR_SIGNATURE = false;
+#endif
+
+#ifdef DISABLE_UPDATER_AUTHENTICODE_CHECK
+ const IS_AUTHENTICODE_CHECK_ENABLED = false;
+#else
+ const IS_AUTHENTICODE_CHECK_ENABLED = true;
+#endif
diff --git a/toolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js b/toolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js
new file mode 100644
index 0000000000..e88a4418cb
--- /dev/null
+++ b/toolkit/mozapps/update/tests/data/xpcshellUtilsAUS.js
@@ -0,0 +1,4881 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Test log warnings that happen before the test has started
+ * "Couldn't get the user appdata directory. Crash events may not be produced."
+ * in nsExceptionHandler.cpp (possibly bug 619104)
+ *
+ * Test log warnings that happen after the test has finished
+ * "OOPDeinit() without successful OOPInit()" in nsExceptionHandler.cpp
+ * (bug 619104)
+ * "XPCOM objects created/destroyed from static ctor/dtor" in nsTraceRefcnt.cpp
+ * (possibly bug 457479)
+ *
+ * Other warnings printed to the test logs
+ * "site security information will not be persisted" in
+ * nsSiteSecurityService.cpp and the error in nsSystemInfo.cpp preceding this
+ * error are due to not having a profile when running some of the xpcshell
+ * tests. Since most xpcshell tests also log these errors these tests don't
+ * call do_get_profile unless necessary for the test.
+ * "!mMainThread" in nsThreadManager.cpp are due to using timers and it might be
+ * possible to fix some or all of these in the test itself.
+ * "NS_FAILED(rv)" in nsThreadUtils.cpp are due to using timers and it might be
+ * possible to fix some or all of these in the test itself.
+ */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+ updateAppInfo: "resource://testing-common/AppInfo.sys.mjs",
+});
+
+const Cm = Components.manager;
+
+/* global MOZ_APP_VENDOR, MOZ_APP_BASENAME */
+/* global MOZ_VERIFY_MAR_SIGNATURE, IS_AUTHENTICODE_CHECK_ENABLED */
+load("../data/xpcshellConstantsPP.js");
+
+const DIR_MACOS = AppConstants.platform == "macosx" ? "Contents/MacOS/" : "";
+const DIR_RESOURCES =
+ AppConstants.platform == "macosx" ? "Contents/Resources/" : "";
+const TEST_FILE_SUFFIX = AppConstants.platform == "macosx" ? "_mac" : "";
+const FILE_COMPLETE_MAR = "complete" + TEST_FILE_SUFFIX + ".mar";
+const FILE_PARTIAL_MAR = "partial" + TEST_FILE_SUFFIX + ".mar";
+const FILE_COMPLETE_PRECOMPLETE = "complete_precomplete" + TEST_FILE_SUFFIX;
+const FILE_PARTIAL_PRECOMPLETE = "partial_precomplete" + TEST_FILE_SUFFIX;
+const FILE_COMPLETE_REMOVEDFILES = "complete_removed-files" + TEST_FILE_SUFFIX;
+const FILE_PARTIAL_REMOVEDFILES = "partial_removed-files" + TEST_FILE_SUFFIX;
+const FILE_UPDATE_IN_PROGRESS_LOCK = "updated.update_in_progress.lock";
+const COMPARE_LOG_SUFFIX = "_" + mozinfo.os;
+const LOG_COMPLETE_SUCCESS = "complete_log_success" + COMPARE_LOG_SUFFIX;
+const LOG_PARTIAL_SUCCESS = "partial_log_success" + COMPARE_LOG_SUFFIX;
+const LOG_PARTIAL_FAILURE = "partial_log_failure" + COMPARE_LOG_SUFFIX;
+const LOG_REPLACE_SUCCESS = "replace_log_success";
+const MAC_APP_XATTR_KEY = "com.apple.application-instance";
+const MAC_APP_XATTR_VALUE = "dlsource%3Dmozillaci";
+
+const USE_EXECV = AppConstants.platform == "linux";
+
+const URL_HOST = "http://localhost";
+
+const APP_INFO_NAME = "XPCShell";
+const APP_INFO_VENDOR = "Mozilla";
+
+const APP_BIN_SUFFIX =
+ AppConstants.platform == "linux" ? "-bin" : mozinfo.bin_suffix;
+const FILE_APP_BIN = AppConstants.MOZ_APP_NAME + APP_BIN_SUFFIX;
+const FILE_COMPLETE_EXE = "complete.exe";
+const FILE_HELPER_BIN = "TestAUSHelper" + mozinfo.bin_suffix;
+const FILE_MAINTENANCE_SERVICE_BIN = "maintenanceservice.exe";
+const FILE_MAINTENANCE_SERVICE_INSTALLER_BIN =
+ "maintenanceservice_installer.exe";
+const FILE_OLD_VERSION_MAR = "old_version.mar";
+const FILE_PARTIAL_EXE = "partial.exe";
+const FILE_UPDATER_BIN = "updater" + mozinfo.bin_suffix;
+
+const PERFORMING_STAGED_UPDATE = "Performing a staged update";
+const CALL_QUIT = "calling QuitProgressUI";
+const ERR_UPDATE_IN_PROGRESS = "Update already in progress! Exiting";
+const ERR_RENAME_FILE = "rename_file: failed to rename file";
+const ERR_ENSURE_COPY = "ensure_copy: failed to copy the file";
+const ERR_UNABLE_OPEN_DEST = "unable to open destination file";
+const ERR_BACKUP_DISCARD = "backup_discard: unable to remove";
+const ERR_MOVE_DESTDIR_7 = "Moving destDir to tmpDir failed, err: 7";
+const ERR_BACKUP_CREATE_7 = "backup_create failed: 7";
+const ERR_LOADSOURCEFILE_FAILED = "LoadSourceFile failed";
+const ERR_PARENT_PID_PERSISTS =
+ "The parent process didn't exit! Continuing with update.";
+const ERR_BGTASK_EXCLUSIVE =
+ "failed to exclusively open executable file from background task: ";
+
+const LOG_SVC_SUCCESSFUL_LAUNCH = "Process was started... waiting on result.";
+const LOG_SVC_UNSUCCESSFUL_LAUNCH =
+ "The install directory path is not valid for this application.";
+
+// Typical end of a message when calling assert
+const MSG_SHOULD_EQUAL = " should equal the expected value";
+const MSG_SHOULD_EXIST = "the file or directory should exist";
+const MSG_SHOULD_NOT_EXIST = "the file or directory should not exist";
+
+// Time in seconds the helper application should sleep before exiting. The
+// helper can also be made to exit by writing |finish| to its input file.
+const HELPER_SLEEP_TIMEOUT = 180;
+
+// How many of do_timeout calls using FILE_IN_USE_TIMEOUT_MS to wait before the
+// test is aborted.
+const FILE_IN_USE_TIMEOUT_MS = 1000;
+
+const PIPE_TO_NULL =
+ AppConstants.platform == "win" ? ">nul" : "> /dev/null 2>&1";
+
+const LOG_FUNCTION = info;
+
+const gHTTPHandlerPath = "updates.xml";
+
+var gIsServiceTest;
+var gTestID;
+
+// This default value will be overridden when using the http server.
+var gURLData = URL_HOST + "/";
+var gTestserver;
+var gUpdateCheckCount = 0;
+
+var gIncrementalDownloadErrorType;
+
+var gResponseBody;
+
+var gProcess;
+var gAppTimer;
+var gHandle;
+
+var gGREDirOrig;
+var gGREBinDirOrig;
+
+var gPIDPersistProcess;
+
+// Variables are used instead of contants so tests can override these values if
+// necessary.
+var gCallbackBinFile = "callback_app" + mozinfo.bin_suffix;
+var gCallbackArgs = ["./", "callback.log", "Test Arg 2", "Test Arg 3"];
+var gPostUpdateBinFile = "postup_app" + mozinfo.bin_suffix;
+
+var gTimeoutRuns = 0;
+
+// Environment related globals
+var gShouldResetEnv = undefined;
+var gAddedEnvXRENoWindowsCrashDialog = false;
+var gEnvXPCOMDebugBreak;
+var gEnvXPCOMMemLeakLog;
+var gEnvForceServiceFallback = false;
+
+const URL_HTTP_UPDATE_SJS = "http://test_details/";
+const DATA_URI_SPEC = Services.io.newFileURI(do_get_file("", false)).spec;
+
+/* import-globals-from shared.js */
+load("shared.js");
+
+// Set to true to log additional information for debugging. To log additional
+// information for individual tests set gDebugTest to false here and to true in
+// the test's onload function.
+gDebugTest = true;
+
+// Setting gDebugTestLog to true will create log files for the tests in
+// <objdir>/_tests/xpcshell/toolkit/mozapps/update/tests/<testdir>/ except for
+// the service tests since they run sequentially. This can help when debugging
+// failures for the tests that intermittently fail when they run in parallel.
+// Never set gDebugTestLog to true except when running tests locally.
+var gDebugTestLog = false;
+// An empty array for gTestsToLog will log most of the output of all of the
+// update tests except for the service tests. To only log specific tests add the
+// test file name without the file extension to the array below.
+var gTestsToLog = [];
+var gRealDump;
+var gFOS;
+
+var gTestFiles = [];
+var gTestDirs = [];
+
+// Common files for both successful and failed updates.
+var gTestFilesCommon = [
+ {
+ description: "Should never change",
+ fileName: FILE_UPDATE_SETTINGS_INI,
+ relPathDir: DIR_RESOURCES,
+ originalContents: UPDATE_SETTINGS_CONTENTS,
+ compareContents: UPDATE_SETTINGS_CONTENTS,
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o767,
+ comparePerms: 0o767,
+ },
+ {
+ description: "Should never change",
+ fileName: "channel-prefs.js",
+ relPathDir: DIR_RESOURCES + "defaults/pref/",
+ originalContents: "ShouldNotBeReplaced\n",
+ compareContents: "ShouldNotBeReplaced\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o767,
+ comparePerms: 0o767,
+ },
+];
+
+// Files for a complete successful update. This can be used for a complete
+// failed update by calling setTestFilesAndDirsForFailure.
+var gTestFilesCompleteSuccess = [
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "precomplete",
+ relPathDir: DIR_RESOURCES,
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_PARTIAL_PRECOMPLETE,
+ compareFile: FILE_COMPLETE_PRECOMPLETE,
+ originalPerms: 0o666,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "searchpluginstext0",
+ relPathDir: DIR_RESOURCES + "searchplugins/",
+ originalContents: "ToBeReplacedWithFromComplete\n",
+ compareContents: "FromComplete\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o775,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "searchpluginspng1.png",
+ relPathDir: DIR_RESOURCES + "searchplugins/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: null,
+ compareFile: "complete.png",
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "searchpluginspng0.png",
+ relPathDir: DIR_RESOURCES + "searchplugins/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "partial.png",
+ compareFile: "complete.png",
+ originalPerms: 0o666,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "removed-files",
+ relPathDir: DIR_RESOURCES,
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_PARTIAL_REMOVEDFILES,
+ compareFile: FILE_COMPLETE_REMOVEDFILES,
+ originalPerms: 0o666,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions1text0",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions1/",
+ originalContents: null,
+ compareContents: "FromComplete\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions1png1.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions1/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "partial.png",
+ compareFile: "complete.png",
+ originalPerms: 0o666,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions1png0.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions1/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: null,
+ compareFile: "complete.png",
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions0text0",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions0/",
+ originalContents: "ToBeReplacedWithFromComplete\n",
+ compareContents: "FromComplete\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions0png1.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions0/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: null,
+ compareFile: "complete.png",
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions0png0.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions0/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: null,
+ compareFile: "complete.png",
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "exe0.exe",
+ relPathDir: DIR_MACOS,
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_HELPER_BIN,
+ compareFile: FILE_COMPLETE_EXE,
+ originalPerms: 0o777,
+ comparePerms: 0o755,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "10text0",
+ relPathDir: DIR_RESOURCES + "1/10/",
+ originalContents: "ToBeReplacedWithFromComplete\n",
+ compareContents: "FromComplete\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o767,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "0exe0.exe",
+ relPathDir: DIR_RESOURCES + "0/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_HELPER_BIN,
+ compareFile: FILE_COMPLETE_EXE,
+ originalPerms: 0o777,
+ comparePerms: 0o755,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "00text1",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: "ToBeReplacedWithFromComplete\n",
+ compareContents: "FromComplete\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o677,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "00text0",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: "ToBeReplacedWithFromComplete\n",
+ compareContents: "FromComplete\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o775,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "00png0.png",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: null,
+ compareFile: "complete.png",
+ originalPerms: 0o776,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Removed by precomplete (remove)",
+ fileName: "20text0",
+ relPathDir: DIR_RESOURCES + "2/20/",
+ originalContents: "ToBeDeleted\n",
+ compareContents: null,
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: null,
+ },
+ {
+ description: "Removed by precomplete (remove)",
+ fileName: "20png0.png",
+ relPathDir: DIR_RESOURCES + "2/20/",
+ originalContents: "ToBeDeleted\n",
+ compareContents: null,
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: null,
+ },
+];
+
+// Concatenate the common files to the end of the array.
+gTestFilesCompleteSuccess = gTestFilesCompleteSuccess.concat(gTestFilesCommon);
+
+// Files for a partial successful update. This can be used for a partial failed
+// update by calling setTestFilesAndDirsForFailure.
+var gTestFilesPartialSuccess = [
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "precomplete",
+ relPathDir: DIR_RESOURCES,
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_COMPLETE_PRECOMPLETE,
+ compareFile: FILE_PARTIAL_PRECOMPLETE,
+ originalPerms: 0o666,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "searchpluginstext0",
+ relPathDir: DIR_RESOURCES + "searchplugins/",
+ originalContents: "ToBeReplacedWithFromPartial\n",
+ compareContents: "FromPartial\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o775,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Patched by update.manifest if the file exists (patch-if)",
+ fileName: "searchpluginspng1.png",
+ relPathDir: DIR_RESOURCES + "searchplugins/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ },
+ {
+ description: "Patched by update.manifest if the file exists (patch-if)",
+ fileName: "searchpluginspng0.png",
+ relPathDir: DIR_RESOURCES + "searchplugins/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions1text0",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions1/",
+ originalContents: null,
+ compareContents: "FromPartial\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Patched by update.manifest if the parent directory exists (patch-if)",
+ fileName: "extensions1png1.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions1/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ },
+ {
+ description:
+ "Patched by update.manifest if the parent directory exists (patch-if)",
+ fileName: "extensions1png0.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions1/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ },
+ {
+ description:
+ "Added by update.manifest if the parent directory exists (add-if)",
+ fileName: "extensions0text0",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions0/",
+ originalContents: "ToBeReplacedWithFromPartial\n",
+ compareContents: "FromPartial\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o644,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Patched by update.manifest if the parent directory exists (patch-if)",
+ fileName: "extensions0png1.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions0/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o644,
+ comparePerms: 0o644,
+ },
+ {
+ description:
+ "Patched by update.manifest if the parent directory exists (patch-if)",
+ fileName: "extensions0png0.png",
+ relPathDir: DIR_RESOURCES + "distribution/extensions/extensions0/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o644,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Patched by update.manifest (patch)",
+ fileName: "exe0.exe",
+ relPathDir: DIR_MACOS,
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_COMPLETE_EXE,
+ compareFile: FILE_PARTIAL_EXE,
+ originalPerms: 0o755,
+ comparePerms: 0o755,
+ },
+ {
+ description: "Patched by update.manifest (patch)",
+ fileName: "0exe0.exe",
+ relPathDir: DIR_RESOURCES + "0/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: FILE_COMPLETE_EXE,
+ compareFile: FILE_PARTIAL_EXE,
+ originalPerms: 0o755,
+ comparePerms: 0o755,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "00text0",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: "ToBeReplacedWithFromPartial\n",
+ compareContents: "FromPartial\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o644,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Patched by update.manifest (patch)",
+ fileName: "00png0.png",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: "complete.png",
+ compareFile: "partial.png",
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "20text0",
+ relPathDir: DIR_RESOURCES + "2/20/",
+ originalContents: null,
+ compareContents: "FromPartial\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "20png0.png",
+ relPathDir: DIR_RESOURCES + "2/20/",
+ originalContents: null,
+ compareContents: null,
+ originalFile: null,
+ compareFile: "partial.png",
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Added by update.manifest (add)",
+ fileName: "00text2",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: null,
+ compareContents: "FromPartial\n",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: 0o644,
+ },
+ {
+ description: "Removed by update.manifest (remove)",
+ fileName: "10text0",
+ relPathDir: DIR_RESOURCES + "1/10/",
+ originalContents: "ToBeDeleted\n",
+ compareContents: null,
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: null,
+ },
+ {
+ description: "Removed by update.manifest (remove)",
+ fileName: "00text1",
+ relPathDir: DIR_RESOURCES + "0/00/",
+ originalContents: "ToBeDeleted\n",
+ compareContents: null,
+ originalFile: null,
+ compareFile: null,
+ originalPerms: null,
+ comparePerms: null,
+ },
+];
+
+// Concatenate the common files to the end of the array.
+gTestFilesPartialSuccess = gTestFilesPartialSuccess.concat(gTestFilesCommon);
+
+var gTestDirsCommon = [
+ {
+ relPathDir: DIR_RESOURCES + "3/",
+ dirRemoved: false,
+ files: ["3text0", "3text1"],
+ filesRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "4/",
+ dirRemoved: true,
+ files: ["4text0", "4text1"],
+ filesRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "5/",
+ dirRemoved: true,
+ files: ["5test.exe", "5text0", "5text1"],
+ filesRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "6/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "7/",
+ dirRemoved: true,
+ files: ["7text0", "7text1"],
+ subDirs: ["70/", "71/"],
+ subDirFiles: ["7xtest.exe", "7xtext0", "7xtext1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/",
+ dirRemoved: false,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/80/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/81/",
+ dirRemoved: false,
+ files: ["81text0", "81text1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/82/",
+ dirRemoved: false,
+ subDirs: ["820/", "821/"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/83/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/84/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/85/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/86/",
+ dirRemoved: true,
+ files: ["86text0", "86text1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/87/",
+ dirRemoved: true,
+ subDirs: ["870/", "871/"],
+ subDirFiles: ["87xtext0", "87xtext1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/88/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "8/89/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/90/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/91/",
+ dirRemoved: false,
+ files: ["91text0", "91text1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/92/",
+ dirRemoved: false,
+ subDirs: ["920/", "921/"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/93/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/94/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/95/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/96/",
+ dirRemoved: true,
+ files: ["96text0", "96text1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/97/",
+ dirRemoved: true,
+ subDirs: ["970/", "971/"],
+ subDirFiles: ["97xtext0", "97xtext1"],
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/98/",
+ dirRemoved: true,
+ },
+ {
+ relPathDir: DIR_RESOURCES + "9/99/",
+ dirRemoved: true,
+ },
+ {
+ description:
+ "Silences 'WARNING: Failed to resolve XUL App Dir.' in debug builds",
+ relPathDir: DIR_RESOURCES + "browser",
+ dirRemoved: false,
+ },
+];
+
+// Directories for a complete successful update. This array can be used for a
+// complete failed update by calling setTestFilesAndDirsForFailure.
+var gTestDirsCompleteSuccess = [
+ {
+ description: "Removed by precomplete (rmdir)",
+ relPathDir: DIR_RESOURCES + "2/20/",
+ dirRemoved: true,
+ },
+ {
+ description: "Removed by precomplete (rmdir)",
+ relPathDir: DIR_RESOURCES + "2/",
+ dirRemoved: true,
+ },
+];
+
+// Concatenate the common files to the beginning of the array.
+gTestDirsCompleteSuccess = gTestDirsCommon.concat(gTestDirsCompleteSuccess);
+
+// Directories for a partial successful update. This array can be used for a
+// partial failed update by calling setTestFilesAndDirsForFailure.
+var gTestDirsPartialSuccess = [
+ {
+ description: "Removed by update.manifest (rmdir)",
+ relPathDir: DIR_RESOURCES + "1/10/",
+ dirRemoved: true,
+ },
+ {
+ description: "Removed by update.manifest (rmdir)",
+ relPathDir: DIR_RESOURCES + "1/",
+ dirRemoved: true,
+ },
+];
+
+// Concatenate the common files to the beginning of the array.
+gTestDirsPartialSuccess = gTestDirsCommon.concat(gTestDirsPartialSuccess);
+
+/**
+ * Helper function for setting up the test environment.
+ *
+ * @param aAppUpdateAutoEnabled
+ * See setAppUpdateAutoSync in shared.js for details.
+ * @param aAllowBits
+ * If true, allow update downloads via the Windows BITS service.
+ * If false, this download mechanism will not be used.
+ */
+function setupTestCommon(aAppUpdateAutoEnabled = false, aAllowBits = false) {
+ debugDump("start - general test setup");
+
+ Assert.strictEqual(
+ gTestID,
+ undefined,
+ "gTestID should be 'undefined' (setupTestCommon should " +
+ "only be called once)"
+ );
+
+ let caller = Components.stack.caller;
+ gTestID = caller.filename.toString().split("/").pop().split(".")[0];
+
+ if (gDebugTestLog && !gIsServiceTest) {
+ if (!gTestsToLog.length || gTestsToLog.includes(gTestID)) {
+ let logFile = do_get_file(gTestID + ".log", true);
+ if (!logFile.exists()) {
+ logFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ }
+ gFOS = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ gFOS.init(logFile, MODE_WRONLY | MODE_APPEND, PERMS_FILE, 0);
+
+ gRealDump = dump;
+ dump = dumpOverride;
+ }
+ }
+
+ createAppInfo("xpcshell@tests.mozilla.org", APP_INFO_NAME, "1.0", "2.0");
+
+ if (gIsServiceTest && !shouldRunServiceTest()) {
+ return false;
+ }
+
+ do_test_pending();
+
+ setDefaultPrefs();
+
+ gGREDirOrig = getGREDir();
+ gGREBinDirOrig = getGREBinDir();
+
+ let applyDir = getApplyDirFile().parent;
+
+ // Try to remove the directory used to apply updates and the updates directory
+ // on platforms other than Windows. This is non-fatal for the test since if
+ // this fails a different directory will be used.
+ if (applyDir.exists()) {
+ debugDump("attempting to remove directory. Path: " + applyDir.path);
+ try {
+ removeDirRecursive(applyDir);
+ } catch (e) {
+ logTestInfo(
+ "non-fatal error removing directory. Path: " +
+ applyDir.path +
+ ", Exception: " +
+ e
+ );
+ // When the application doesn't exit properly it can cause the test to
+ // fail again on the second run with an NS_ERROR_FILE_ACCESS_DENIED error
+ // along with no useful information in the test log. To prevent this use
+ // a different directory for the test when it isn't possible to remove the
+ // existing test directory (bug 1294196).
+ gTestID += "_new";
+ logTestInfo(
+ "using a new directory for the test by changing gTestID " +
+ "since there is an existing test directory that can't be " +
+ "removed, gTestID: " +
+ gTestID
+ );
+ }
+ }
+
+ if (AppConstants.platform == "win") {
+ Services.prefs.setBoolPref(
+ PREF_APP_UPDATE_SERVICE_ENABLED,
+ !!gIsServiceTest
+ );
+ }
+
+ if (gIsServiceTest) {
+ let exts = ["id", "log", "status"];
+ for (let i = 0; i < exts.length; ++i) {
+ let file = getSecureOutputFile(exts[i]);
+ if (file.exists()) {
+ try {
+ file.remove(false);
+ } catch (e) {}
+ }
+ }
+ }
+
+ adjustGeneralPaths();
+ createWorldWritableAppUpdateDir();
+
+ // Logged once here instead of in the mock directory provider to lessen test
+ // log spam.
+ debugDump("Updates Directory (UpdRootD) Path: " + getMockUpdRootD().path);
+
+ // This prevents a warning about not being able to find the greprefs.js file
+ // from being logged.
+ let grePrefsFile = getGREDir();
+ if (!grePrefsFile.exists()) {
+ grePrefsFile.create(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
+ }
+ grePrefsFile.append("greprefs.js");
+ if (!grePrefsFile.exists()) {
+ grePrefsFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ }
+
+ // The name of the update lock needs to be changed to match the path
+ // overridden in adjustGeneralPaths() above. Wait until now to reset
+ // because the GRE dir now exists, which may cause the "install
+ // path" to be normalized differently now that it can be resolved.
+ debugDump("resetting update lock");
+ let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ syncManager.resetLock();
+
+ // Remove the updates directory on Windows and Mac OS X which is located
+ // outside of the application directory after the call to adjustGeneralPaths
+ // has set it up. Since the test hasn't ran yet and the directory shouldn't
+ // exist this is non-fatal for the test.
+ if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
+ let updatesDir = getMockUpdRootD();
+ if (updatesDir.exists()) {
+ debugDump("attempting to remove directory. Path: " + updatesDir.path);
+ try {
+ removeDirRecursive(updatesDir);
+ } catch (e) {
+ logTestInfo(
+ "non-fatal error removing directory. Path: " +
+ updatesDir.path +
+ ", Exception: " +
+ e
+ );
+ }
+ }
+ }
+
+ setAppUpdateAutoSync(aAppUpdateAutoEnabled);
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_BITS_ENABLED, aAllowBits);
+
+ debugDump("finish - general test setup");
+ return true;
+}
+
+/**
+ * Nulls out the most commonly used global vars used by tests to prevent leaks
+ * as needed and attempts to restore the system to its original state.
+ */
+function cleanupTestCommon() {
+ debugDump("start - general test cleanup");
+
+ if (gChannel) {
+ gPrefRoot.removeObserver(PREF_APP_UPDATE_CHANNEL, observer);
+ }
+
+ gTestserver = null;
+
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "linux") {
+ // This will delete the launch script if it exists.
+ getLaunchScript();
+ }
+
+ if (gIsServiceTest) {
+ let exts = ["id", "log", "status"];
+ for (let i = 0; i < exts.length; ++i) {
+ let file = getSecureOutputFile(exts[i]);
+ if (file.exists()) {
+ try {
+ file.remove(false);
+ } catch (e) {}
+ }
+ }
+ }
+
+ if (AppConstants.platform == "win" && MOZ_APP_BASENAME) {
+ let appDir = getApplyDirFile();
+ let vendor = MOZ_APP_VENDOR ? MOZ_APP_VENDOR : "Mozilla";
+ const REG_PATH =
+ "SOFTWARE\\" + vendor + "\\" + MOZ_APP_BASENAME + "\\TaskBarIDs";
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ key.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ REG_PATH,
+ Ci.nsIWindowsRegKey.ACCESS_ALL
+ );
+ if (key.hasValue(appDir.path)) {
+ key.removeValue(appDir.path);
+ }
+ } catch (e) {}
+ try {
+ key.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ REG_PATH,
+ Ci.nsIWindowsRegKey.ACCESS_ALL
+ );
+ if (key.hasValue(appDir.path)) {
+ key.removeValue(appDir.path);
+ }
+ } catch (e) {}
+ }
+
+ // The updates directory is located outside of the application directory and
+ // needs to be removed on Windows and Mac OS X.
+ if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
+ let updatesDir = getMockUpdRootD();
+ // Try to remove the directory used to apply updates. Since the test has
+ // already finished this is non-fatal for the test.
+ if (updatesDir.exists()) {
+ debugDump("attempting to remove directory. Path: " + updatesDir.path);
+ try {
+ removeDirRecursive(updatesDir);
+ } catch (e) {
+ logTestInfo(
+ "non-fatal error removing directory. Path: " +
+ updatesDir.path +
+ ", Exception: " +
+ e
+ );
+ }
+ if (AppConstants.platform == "macosx") {
+ let updatesRootDir = gUpdatesRootDir.clone();
+ while (updatesRootDir.path != updatesDir.path) {
+ if (updatesDir.exists()) {
+ debugDump(
+ "attempting to remove directory. Path: " + updatesDir.path
+ );
+ try {
+ // Try to remove the directory without the recursive flag set
+ // since the top level directory has already had its contents
+ // removed and the parent directory might still be used by a
+ // different test.
+ updatesDir.remove(false);
+ } catch (e) {
+ logTestInfo(
+ "non-fatal error removing directory. Path: " +
+ updatesDir.path +
+ ", Exception: " +
+ e
+ );
+ if (e == Cr.NS_ERROR_FILE_DIR_NOT_EMPTY) {
+ break;
+ }
+ }
+ }
+ updatesDir = updatesDir.parent;
+ }
+ }
+ }
+ }
+
+ let applyDir = getApplyDirFile().parent;
+
+ // Try to remove the directory used to apply updates. Since the test has
+ // already finished this is non-fatal for the test.
+ if (applyDir.exists()) {
+ debugDump("attempting to remove directory. Path: " + applyDir.path);
+ try {
+ removeDirRecursive(applyDir);
+ } catch (e) {
+ logTestInfo(
+ "non-fatal error removing directory. Path: " +
+ applyDir.path +
+ ", Exception: " +
+ e
+ );
+ }
+ }
+
+ resetEnvironment();
+ Services.prefs.clearUserPref(PREF_APP_UPDATE_BITS_ENABLED);
+
+ debugDump("finish - general test cleanup");
+
+ if (gRealDump) {
+ dump = gRealDump;
+ gRealDump = null;
+ }
+
+ if (gFOS) {
+ gFOS.close();
+ }
+}
+
+/**
+ * Helper function to store the log output of calls to dump in a variable so the
+ * values can be written to a file for a parallel run of a test and printed to
+ * the log file when the test runs synchronously.
+ */
+function dumpOverride(aText) {
+ gFOS.write(aText, aText.length);
+ gRealDump(aText);
+}
+
+/**
+ * Helper function that calls do_test_finished that tracks whether a parallel
+ * run of a test passed when it runs synchronously so the log output can be
+ * inspected.
+ */
+function doTestFinish() {
+ if (gDebugTest) {
+ // This prevents do_print errors from being printed by the xpcshell test
+ // harness due to nsUpdateService.js logging to the console when the
+ // app.update.log preference is true.
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_LOG, false);
+ gAUS.observe(null, "nsPref:changed", PREF_APP_UPDATE_LOG);
+ }
+
+ reloadUpdateManagerData(true);
+
+ // Call app update's observe method passing quit-application to test that the
+ // shutdown of app update runs without throwing or leaking. The observer
+ // method is used directly instead of calling notifyObservers so components
+ // outside of the scope of this test don't assert and thereby cause app update
+ // tests to fail.
+ gAUS.observe(null, "quit-application", "");
+
+ executeSoon(do_test_finished);
+}
+
+/**
+ * Sets the most commonly used preferences used by tests
+ */
+function setDefaultPrefs() {
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, false);
+ if (gDebugTest) {
+ // Enable Update logging
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_LOG, true);
+ } else {
+ // Some apps set this preference to true by default
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_LOG, false);
+ }
+}
+
+/**
+ * Helper function for updater binary tests that sets the appropriate values
+ * to check for update failures.
+ */
+function setTestFilesAndDirsForFailure() {
+ gTestFiles.forEach(function STFADFF_Files(aTestFile) {
+ aTestFile.compareContents = aTestFile.originalContents;
+ aTestFile.compareFile = aTestFile.originalFile;
+ aTestFile.comparePerms = aTestFile.originalPerms;
+ });
+
+ gTestDirs.forEach(function STFADFF_Dirs(aTestDir) {
+ aTestDir.dirRemoved = false;
+ if (aTestDir.filesRemoved) {
+ aTestDir.filesRemoved = false;
+ }
+ });
+}
+
+/**
+ * Helper function for updater binary tests that prevents the distribution
+ * directory files from being created.
+ */
+function preventDistributionFiles() {
+ gTestFiles = gTestFiles.filter(function (aTestFile) {
+ return !aTestFile.relPathDir.includes("distribution/");
+ });
+
+ gTestDirs = gTestDirs.filter(function (aTestDir) {
+ return !aTestDir.relPathDir.includes("distribution/");
+ });
+}
+
+/**
+ * On Mac OS X this sets the last modified time for the app bundle directory to
+ * a date in the past to test that the last modified time is updated when an
+ * update has been successfully applied (bug 600098).
+ */
+function setAppBundleModTime() {
+ if (AppConstants.platform != "macosx") {
+ return;
+ }
+ let now = Date.now();
+ let yesterday = now - 1000 * 60 * 60 * 24;
+ let applyToDir = getApplyDirFile();
+ applyToDir.lastModifiedTime = yesterday;
+}
+
+/**
+ * On Mac OS X this checks that the last modified time for the app bundle
+ * directory has been updated when an update has been successfully applied
+ * (bug 600098).
+ */
+function checkAppBundleModTime() {
+ if (AppConstants.platform != "macosx") {
+ return;
+ }
+ // All we care about is that the last modified time has changed so that Mac OS
+ // X Launch Services invalidates its cache so the test allows up to one minute
+ // difference in the last modified time.
+ const MAC_MAX_TIME_DIFFERENCE = 60000;
+ let now = Date.now();
+ let applyToDir = getApplyDirFile();
+ let timeDiff = Math.abs(applyToDir.lastModifiedTime - now);
+ Assert.ok(
+ timeDiff < MAC_MAX_TIME_DIFFERENCE,
+ "the last modified time on the apply to directory should " +
+ "change after a successful update"
+ );
+}
+
+/**
+ * Performs Update Manager checks to verify that the update metadata is correct
+ * and that it is the same after the update xml files are reloaded.
+ *
+ * @param aStatusFileState
+ * The expected state of the status file.
+ * @param aHasActiveUpdate
+ * Should there be an active update.
+ * @param aUpdateStatusState
+ * The expected update's status state.
+ * @param aUpdateErrCode
+ * The expected update's error code.
+ * @param aUpdateCount
+ * The update history's update count.
+ */
+function checkUpdateManager(
+ aStatusFileState,
+ aHasActiveUpdate,
+ aUpdateStatusState,
+ aUpdateErrCode,
+ aUpdateCount
+) {
+ let activeUpdate =
+ aUpdateStatusState == STATE_DOWNLOADING
+ ? gUpdateManager.downloadingUpdate
+ : gUpdateManager.readyUpdate;
+ Assert.equal(
+ readStatusState(),
+ aStatusFileState,
+ "the status file state" + MSG_SHOULD_EQUAL
+ );
+ let msgTags = [" after startup ", " after a file reload "];
+ for (let i = 0; i < msgTags.length; ++i) {
+ logTestInfo(
+ "checking Update Manager updates" + msgTags[i] + "is performed"
+ );
+ if (aHasActiveUpdate) {
+ Assert.ok(
+ !!activeUpdate,
+ msgTags[i] + "the active update should be defined"
+ );
+ } else {
+ Assert.ok(
+ !activeUpdate,
+ msgTags[i] + "the active update should not be defined"
+ );
+ }
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ aUpdateCount,
+ msgTags[i] + "the update manager updateCount attribute" + MSG_SHOULD_EQUAL
+ );
+ if (aUpdateCount > 0) {
+ let update = gUpdateManager.getUpdateAt(0);
+ Assert.equal(
+ update.state,
+ aUpdateStatusState,
+ msgTags[i] + "the first update state" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.errorCode,
+ aUpdateErrCode,
+ msgTags[i] + "the first update errorCode" + MSG_SHOULD_EQUAL
+ );
+ }
+ if (i != msgTags.length - 1) {
+ reloadUpdateManagerData();
+ }
+ }
+}
+
+/**
+ * Waits until the update files exist or not based on the parameters specified
+ * when calling this function or the default values if the parameters are not
+ * specified. This is necessary due to the update xml files being written
+ * asynchronously by nsIUpdateManager.
+ *
+ * @param aActiveUpdateExists (optional)
+ * Whether the active-update.xml file should exist (default is false).
+ * @param aUpdatesExists (optional)
+ * Whether the updates.xml file should exist (default is true).
+ */
+async function waitForUpdateXMLFiles(
+ aActiveUpdateExists = false,
+ aUpdatesExists = true
+) {
+ function areFilesStabilized() {
+ let file = getUpdateDirFile(FILE_ACTIVE_UPDATE_XML_TMP);
+ if (file.exists()) {
+ debugDump("file exists, Path: " + file.path);
+ return false;
+ }
+ file = getUpdateDirFile(FILE_UPDATES_XML_TMP);
+ if (file.exists()) {
+ debugDump("file exists, Path: " + file.path);
+ return false;
+ }
+ file = getUpdateDirFile(FILE_ACTIVE_UPDATE_XML);
+ if (file.exists() != aActiveUpdateExists) {
+ debugDump(
+ "file exists should equal: " +
+ aActiveUpdateExists +
+ ", Path: " +
+ file.path
+ );
+ return false;
+ }
+ file = getUpdateDirFile(FILE_UPDATES_XML);
+ if (file.exists() != aUpdatesExists) {
+ debugDump(
+ "file exists should equal: " +
+ aActiveUpdateExists +
+ ", Path: " +
+ file.path
+ );
+ return false;
+ }
+ return true;
+ }
+
+ await TestUtils.waitForCondition(
+ () => areFilesStabilized(),
+ "Waiting for update xml files to stabilize"
+ );
+}
+
+/**
+ * On Mac OS X and Windows this checks if the post update '.running' file exists
+ * to determine if the post update binary was launched.
+ *
+ * @param aShouldExist
+ * Whether the post update '.running' file should exist.
+ */
+function checkPostUpdateRunningFile(aShouldExist) {
+ if (AppConstants.platform == "linux") {
+ return;
+ }
+ let postUpdateRunningFile = getPostUpdateFile(".running");
+ if (aShouldExist) {
+ Assert.ok(
+ postUpdateRunningFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(postUpdateRunningFile.path)
+ );
+ } else {
+ Assert.ok(
+ !postUpdateRunningFile.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(postUpdateRunningFile.path)
+ );
+ }
+}
+
+/**
+ * Initializes the most commonly used settings and creates an instance of the
+ * update service stub.
+ */
+function standardInit() {
+ // Initialize the update service stub component
+ initUpdateServiceStub();
+}
+
+/**
+ * Helper function for getting the application version from the application.ini
+ * file. This will look in both the GRE and the application directories for the
+ * application.ini file.
+ *
+ * @return The version string from the application.ini file.
+ */
+function getAppVersion() {
+ // Read the application.ini and use its application version.
+ let iniFile = gGREDirOrig.clone();
+ iniFile.append(FILE_APPLICATION_INI);
+ if (!iniFile.exists()) {
+ iniFile = gGREBinDirOrig.clone();
+ iniFile.append(FILE_APPLICATION_INI);
+ }
+ Assert.ok(iniFile.exists(), MSG_SHOULD_EXIST + getMsgPath(iniFile.path));
+ let iniParser = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]
+ .getService(Ci.nsIINIParserFactory)
+ .createINIParser(iniFile);
+ return iniParser.getString("App", "Version");
+}
+
+/**
+ * Helper function for getting the path to the directory where the
+ * application binary is located (e.g. <test_file_leafname>/dir.app/).
+ *
+ * Note: The dir.app subdirectory under <test_file_leafname> is needed for
+ * platforms other than Mac OS X so the tests can run in parallel due to
+ * update staging creating a lock file named moz_update_in_progress.lock in
+ * the parent directory of the installation directory.
+ * Note: For service tests with IS_AUTHENTICODE_CHECK_ENABLED we use an absolute
+ * path inside Program Files because the service itself will refuse to
+ * update an installation not located in Program Files.
+ *
+ * @return The path to the directory where application binary is located.
+ */
+function getApplyDirPath() {
+ if (gIsServiceTest && IS_AUTHENTICODE_CHECK_ENABLED) {
+ let dir = getMaintSvcDir();
+ dir.append(gTestID);
+ dir.append("dir.app");
+ return dir.path;
+ }
+ return gTestID + "/dir.app/";
+}
+
+/**
+ * Helper function for getting the nsIFile for a file in the directory where the
+ * update will be applied.
+ *
+ * The files for the update are located two directories below the apply to
+ * directory since Mac OS X sets the last modified time for the root directory
+ * to the current time and if the update changes any files in the root directory
+ * then it wouldn't be possible to test (bug 600098).
+ *
+ * @param aRelPath (optional)
+ * The relative path to the file or directory to get from the root of
+ * the test's directory. If not specified the test's directory will be
+ * returned.
+ * @return The nsIFile for the file in the directory where the update will be
+ * applied.
+ */
+function getApplyDirFile(aRelPath) {
+ // do_get_file only supports relative paths, but under these conditions we
+ // need to use an absolute path in Program Files instead.
+ if (gIsServiceTest && IS_AUTHENTICODE_CHECK_ENABLED) {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(getApplyDirPath());
+ if (aRelPath) {
+ if (aRelPath == "..") {
+ file = file.parent;
+ } else {
+ aRelPath = aRelPath.replace(/\//g, "\\");
+ file.appendRelativePath(aRelPath);
+ }
+ }
+ return file;
+ }
+ let relpath = getApplyDirPath() + (aRelPath ? aRelPath : "");
+ return do_get_file(relpath, true);
+}
+
+/**
+ * Helper function for getting the relative path to the directory where the
+ * test data files are located.
+ *
+ * @return The relative path to the directory where the test data files are
+ * located.
+ */
+function getTestDirPath() {
+ return "../data/";
+}
+
+/**
+ * Helper function for getting the nsIFile for a file in the test data
+ * directory.
+ *
+ * @param aRelPath (optional)
+ * The relative path to the file or directory to get from the root of
+ * the test's data directory. If not specified the test's data
+ * directory will be returned.
+ * @param aAllowNonExists (optional)
+ * Whether or not to throw an error if the path exists.
+ * If not specified, then false is used.
+ * @return The nsIFile for the file in the test data directory.
+ * @throws If the file or directory does not exist.
+ */
+function getTestDirFile(aRelPath, aAllowNonExists) {
+ let relpath = getTestDirPath() + (aRelPath ? aRelPath : "");
+ return do_get_file(relpath, !!aAllowNonExists);
+}
+
+/**
+ * Helper function for getting the nsIFile for the maintenance service
+ * directory on Windows.
+ *
+ * @return The nsIFile for the maintenance service directory.
+ * @throws If called from a platform other than Windows.
+ */
+function getMaintSvcDir() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ const CSIDL_PROGRAM_FILES = 0x26;
+ const CSIDL_PROGRAM_FILESX86 = 0x2a;
+ // This will return an empty string on our Win XP build systems.
+ let maintSvcDir = getSpecialFolderDir(CSIDL_PROGRAM_FILESX86);
+ if (maintSvcDir) {
+ maintSvcDir.append("Mozilla Maintenance Service");
+ debugDump(
+ "using CSIDL_PROGRAM_FILESX86 - maintenance service install " +
+ "directory path: " +
+ maintSvcDir.path
+ );
+ }
+ if (!maintSvcDir || !maintSvcDir.exists()) {
+ maintSvcDir = getSpecialFolderDir(CSIDL_PROGRAM_FILES);
+ if (maintSvcDir) {
+ maintSvcDir.append("Mozilla Maintenance Service");
+ debugDump(
+ "using CSIDL_PROGRAM_FILES - maintenance service install " +
+ "directory path: " +
+ maintSvcDir.path
+ );
+ }
+ }
+ if (!maintSvcDir) {
+ do_throw("Unable to find the maintenance service install directory");
+ }
+
+ return maintSvcDir;
+}
+
+/**
+ * Reads the current update operation/state in the status file in the secure
+ * update log directory.
+ *
+ * @return The status value.
+ */
+function readSecureStatusFile() {
+ let file = getSecureOutputFile("status");
+ if (!file.exists()) {
+ debugDump("update status file does not exist, path: " + file.path);
+ return STATE_NONE;
+ }
+ return readFile(file).split("\n")[0];
+}
+
+/**
+ * Get an nsIFile for a file in the secure update log directory. The file name
+ * is always the value of gTestID and the file extension is specified by the
+ * aFileExt parameter.
+ *
+ * @param aFileExt
+ * The file extension.
+ * @return The nsIFile of the secure update file.
+ */
+function getSecureOutputFile(aFileExt) {
+ let file = getMaintSvcDir();
+ file.append("UpdateLogs");
+ file.append(gTestID + "." + aFileExt);
+ return file;
+}
+
+/**
+ * Get the nsIFile for a Windows special folder determined by the CSIDL
+ * passed.
+ *
+ * @param aCSIDL
+ * The CSIDL for the Windows special folder.
+ * @return The nsIFile for the Windows special folder.
+ * @throws If called from a platform other than Windows.
+ */
+function getSpecialFolderDir(aCSIDL) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ let lib = ctypes.open("shell32");
+ let SHGetSpecialFolderPath = lib.declare(
+ "SHGetSpecialFolderPathW",
+ ctypes.winapi_abi,
+ ctypes.bool /* bool(return) */,
+ ctypes.int32_t /* HWND hwndOwner */,
+ ctypes.char16_t.ptr /* LPTSTR lpszPath */,
+ ctypes.int32_t /* int csidl */,
+ ctypes.bool /* BOOL fCreate */
+ );
+
+ let aryPath = ctypes.char16_t.array()(260);
+ let rv = SHGetSpecialFolderPath(0, aryPath, aCSIDL, false);
+ if (!rv) {
+ do_throw(
+ "SHGetSpecialFolderPath failed to retrieve " +
+ aCSIDL +
+ " with Win32 error " +
+ ctypes.winLastError
+ );
+ }
+ lib.close();
+
+ let path = aryPath.readString(); // Convert the c-string to js-string
+ if (!path) {
+ return null;
+ }
+ debugDump("SHGetSpecialFolderPath returned path: " + path);
+ let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ dir.initWithPath(path);
+ return dir;
+}
+
+ChromeUtils.defineLazyGetter(
+ this,
+ "gInstallDirPathHash",
+ function test_gIDPH() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ if (!MOZ_APP_BASENAME) {
+ return null;
+ }
+
+ let vendor = MOZ_APP_VENDOR ? MOZ_APP_VENDOR : "Mozilla";
+ let appDir = getApplyDirFile();
+
+ const REG_PATH =
+ "SOFTWARE\\" + vendor + "\\" + MOZ_APP_BASENAME + "\\TaskBarIDs";
+ let regKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ regKey.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ REG_PATH,
+ Ci.nsIWindowsRegKey.ACCESS_ALL
+ );
+ regKey.writeStringValue(appDir.path, gTestID);
+ return gTestID;
+ } catch (e) {}
+
+ try {
+ regKey.create(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ REG_PATH,
+ Ci.nsIWindowsRegKey.ACCESS_ALL
+ );
+ regKey.writeStringValue(appDir.path, gTestID);
+ return gTestID;
+ } catch (e) {
+ logTestInfo(
+ "failed to create registry value. Registry Path: " +
+ REG_PATH +
+ ", Value Name: " +
+ appDir.path +
+ ", Value Data: " +
+ gTestID +
+ ", Exception " +
+ e
+ );
+ do_throw(
+ "Unable to write HKLM or HKCU TaskBarIDs registry value, key path: " +
+ REG_PATH
+ );
+ }
+ return null;
+ }
+);
+
+ChromeUtils.defineLazyGetter(this, "gLocalAppDataDir", function test_gLADD() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ const CSIDL_LOCAL_APPDATA = 0x1c;
+ return getSpecialFolderDir(CSIDL_LOCAL_APPDATA);
+});
+
+ChromeUtils.defineLazyGetter(this, "gCommonAppDataDir", function test_gCDD() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ const CSIDL_COMMON_APPDATA = 0x0023;
+ return getSpecialFolderDir(CSIDL_COMMON_APPDATA);
+});
+
+ChromeUtils.defineLazyGetter(this, "gProgFilesDir", function test_gPFD() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ const CSIDL_PROGRAM_FILES = 0x26;
+ return getSpecialFolderDir(CSIDL_PROGRAM_FILES);
+});
+
+/**
+ * Helper function for getting the update root directory used by the tests. This
+ * returns the same directory as returned by nsXREDirProvider::GetUpdateRootDir
+ * in nsXREDirProvider.cpp so an application will be able to find the update
+ * when running a test that launches the application.
+ *
+ * The aGetOldLocation argument performs the same function that the argument
+ * with the same name in nsXREDirProvider::GetUpdateRootDir performs. If true,
+ * the old (pre-migration) update directory is returned.
+ */
+function getMockUpdRootD(aGetOldLocation = false) {
+ if (AppConstants.platform == "win") {
+ return getMockUpdRootDWin(aGetOldLocation);
+ }
+
+ if (AppConstants.platform == "macosx") {
+ return getMockUpdRootDMac();
+ }
+
+ return getApplyDirFile(DIR_MACOS);
+}
+
+/**
+ * Helper function for getting the update root directory used by the tests. This
+ * returns the same directory as returned by nsXREDirProvider::GetUpdateRootDir
+ * in nsXREDirProvider.cpp so an application will be able to find the update
+ * when running a test that launches the application.
+ *
+ * @throws If called from a platform other than Windows.
+ */
+function getMockUpdRootDWin(aGetOldLocation) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ let relPathUpdates = "";
+ let dataDirectory = gCommonAppDataDir.clone();
+ if (aGetOldLocation) {
+ relPathUpdates += "Mozilla";
+ } else {
+ relPathUpdates += "Mozilla-1de4eec8-1241-4177-a864-e594e8d1fb38";
+ }
+
+ relPathUpdates += "\\" + DIR_UPDATES + "\\" + gInstallDirPathHash;
+ let updatesDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ updatesDir.initWithPath(dataDirectory.path + "\\" + relPathUpdates);
+ return updatesDir;
+}
+
+function createWorldWritableAppUpdateDir() {
+ // This function is only necessary in Windows
+ if (AppConstants.platform == "win") {
+ let installDir = Services.dirsvc.get(
+ XRE_EXECUTABLE_FILE,
+ Ci.nsIFile
+ ).parent;
+ let exitValue = runTestHelperSync(["create-update-dir", installDir.path]);
+ Assert.equal(exitValue, 0, "The helper process exit value should be 0");
+ }
+}
+
+ChromeUtils.defineLazyGetter(this, "gUpdatesRootDir", function test_gURD() {
+ if (AppConstants.platform != "macosx") {
+ do_throw("Mac OS X only function called by a different platform!");
+ }
+
+ let dir = Services.dirsvc.get("ULibDir", Ci.nsIFile);
+ dir.append("Caches");
+ if (MOZ_APP_VENDOR || MOZ_APP_BASENAME) {
+ dir.append(MOZ_APP_VENDOR ? MOZ_APP_VENDOR : MOZ_APP_BASENAME);
+ } else {
+ dir.append("Mozilla");
+ }
+ dir.append(DIR_UPDATES);
+ return dir;
+});
+
+/**
+ * Helper function for getting the update root directory used by the tests. This
+ * returns the same directory as returned by nsXREDirProvider::GetUpdateRootDir
+ * in nsXREDirProvider.cpp so an application will be able to find the update
+ * when running a test that launches the application.
+ */
+function getMockUpdRootDMac() {
+ if (AppConstants.platform != "macosx") {
+ do_throw("Mac OS X only function called by a different platform!");
+ }
+
+ let appDir = Services.dirsvc.get(XRE_EXECUTABLE_FILE, Ci.nsIFile).parent
+ .parent.parent;
+ let appDirPath = appDir.path;
+ appDirPath = appDirPath.substr(0, appDirPath.length - 4);
+
+ let pathUpdates = gUpdatesRootDir.path + appDirPath;
+ let updatesDir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ updatesDir.initWithPath(pathUpdates);
+ return updatesDir;
+}
+
+/**
+ * Creates an update in progress lock file in the specified directory on
+ * Windows.
+ *
+ * @param aDir
+ * The nsIFile for the directory where the lock file should be created.
+ * @throws If called from a platform other than Windows.
+ */
+function createUpdateInProgressLockFile(aDir) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ let file = aDir.clone();
+ file.append(FILE_UPDATE_IN_PROGRESS_LOCK);
+ file.create(file.NORMAL_FILE_TYPE, 0o444);
+ file.QueryInterface(Ci.nsILocalFileWin);
+ file.readOnly = true;
+ Assert.ok(file.exists(), MSG_SHOULD_EXIST + getMsgPath(file.path));
+ Assert.ok(!file.isWritable(), "the lock file should not be writeable");
+}
+
+/**
+ * Removes an update in progress lock file in the specified directory on
+ * Windows.
+ *
+ * @param aDir
+ * The nsIFile for the directory where the lock file is located.
+ * @throws If called from a platform other than Windows.
+ */
+function removeUpdateInProgressLockFile(aDir) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ let file = aDir.clone();
+ file.append(FILE_UPDATE_IN_PROGRESS_LOCK);
+ file.QueryInterface(Ci.nsILocalFileWin);
+ file.readOnly = false;
+ file.remove(false);
+ Assert.ok(!file.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(file.path));
+}
+
+/**
+ * Copies the test updater to the GRE binary directory and returns the nsIFile
+ * for the copied test updater.
+ *
+ * @return nsIFIle for the copied test updater.
+ */
+function copyTestUpdaterToBinDir() {
+ let updaterLeafName =
+ AppConstants.platform == "macosx" ? "updater.app" : FILE_UPDATER_BIN;
+ let testUpdater = getTestDirFile(updaterLeafName);
+ let updater = getGREBinDir();
+ updater.append(updaterLeafName);
+ if (!updater.exists()) {
+ testUpdater.copyToFollowingLinks(updater.parent, updaterLeafName);
+ }
+ if (AppConstants.platform == "macosx") {
+ updater.append("Contents");
+ updater.append("MacOS");
+ updater.append("org.mozilla.updater");
+ }
+ return updater;
+}
+
+/**
+ * Logs the contents of an update log and for maintenance service tests this
+ * will log the contents of the latest maintenanceservice.log.
+ *
+ * @param aLogLeafName
+ * The leaf name of the update log.
+ */
+function logUpdateLog(aLogLeafName) {
+ let updateLog = getUpdateDirFile(aLogLeafName);
+ if (updateLog.exists()) {
+ // xpcshell tests won't display the entire contents so log each line.
+ let updateLogContents = readFileBytes(updateLog).replace(/\r\n/g, "\n");
+ updateLogContents = removeTimeStamps(updateLogContents);
+ updateLogContents = replaceLogPaths(updateLogContents);
+ let aryLogContents = updateLogContents.split("\n");
+ logTestInfo("contents of " + updateLog.path + ":");
+ aryLogContents.forEach(function LU_ULC_FE(aLine) {
+ logTestInfo(aLine);
+ });
+ } else {
+ logTestInfo("update log doesn't exist, path: " + updateLog.path);
+ }
+
+ if (gIsServiceTest) {
+ let secureStatus = readSecureStatusFile();
+ logTestInfo("secure update status: " + secureStatus);
+
+ updateLog = getSecureOutputFile("log");
+ if (updateLog.exists()) {
+ // xpcshell tests won't display the entire contents so log each line.
+ let updateLogContents = readFileBytes(updateLog).replace(/\r\n/g, "\n");
+ updateLogContents = removeTimeStamps(updateLogContents);
+ updateLogContents = replaceLogPaths(updateLogContents);
+ let aryLogContents = updateLogContents.split("\n");
+ logTestInfo("contents of " + updateLog.path + ":");
+ aryLogContents.forEach(function LU_SULC_FE(aLine) {
+ logTestInfo(aLine);
+ });
+ } else {
+ logTestInfo("secure update log doesn't exist, path: " + updateLog.path);
+ }
+
+ let serviceLog = getMaintSvcDir();
+ serviceLog.append("logs");
+ serviceLog.append("maintenanceservice.log");
+ if (serviceLog.exists()) {
+ // xpcshell tests won't display the entire contents so log each line.
+ let serviceLogContents = readFileBytes(serviceLog).replace(/\r\n/g, "\n");
+ serviceLogContents = replaceLogPaths(serviceLogContents);
+ let aryLogContents = serviceLogContents.split("\n");
+ logTestInfo("contents of " + serviceLog.path + ":");
+ aryLogContents.forEach(function LU_MSLC_FE(aLine) {
+ logTestInfo(aLine);
+ });
+ } else {
+ logTestInfo(
+ "maintenance service log doesn't exist, path: " + serviceLog.path
+ );
+ }
+ }
+}
+
+/**
+ * Gets the maintenance service log contents.
+ */
+function readServiceLogFile() {
+ let file = getMaintSvcDir();
+ file.append("logs");
+ file.append("maintenanceservice.log");
+ return readFile(file);
+}
+
+/**
+ * Launches the updater binary to apply an update for updater tests.
+ *
+ * @param aExpectedStatus
+ * The expected value of update.status when the update finishes. For
+ * service tests passing STATE_PENDING or STATE_APPLIED will change the
+ * value to STATE_PENDING_SVC and STATE_APPLIED_SVC respectively.
+ * @param aSwitchApp
+ * If true the update should switch the application with an updated
+ * staged application and if false the update should be applied to the
+ * installed application.
+ * @param aExpectedExitValue
+ * The expected exit value from the updater binary for non-service
+ * tests.
+ * @param aCheckSvcLog
+ * Whether the service log should be checked for service tests.
+ * @param aPatchDirPath (optional)
+ * When specified the patch directory path to use for invalid argument
+ * tests otherwise the normal path will be used.
+ * @param aInstallDirPath (optional)
+ * When specified the install directory path to use for invalid
+ * argument tests otherwise the normal path will be used.
+ * @param aApplyToDirPath (optional)
+ * When specified the apply to / working directory path to use for
+ * invalid argument tests otherwise the normal path will be used.
+ * @param aCallbackPath (optional)
+ * When specified the callback path to use for invalid argument tests
+ * otherwise the normal path will be used.
+ */
+function runUpdate(
+ aExpectedStatus,
+ aSwitchApp,
+ aExpectedExitValue,
+ aCheckSvcLog,
+ aPatchDirPath,
+ aInstallDirPath,
+ aApplyToDirPath,
+ aCallbackPath
+) {
+ let isInvalidArgTest =
+ !!aPatchDirPath ||
+ !!aInstallDirPath ||
+ !!aApplyToDirPath ||
+ !!aCallbackPath;
+
+ let svcOriginalLog;
+ if (gIsServiceTest) {
+ copyFileToTestAppDir(FILE_MAINTENANCE_SERVICE_BIN, false);
+ copyFileToTestAppDir(FILE_MAINTENANCE_SERVICE_INSTALLER_BIN, false);
+ if (aCheckSvcLog) {
+ svcOriginalLog = readServiceLogFile();
+ }
+ }
+
+ let pid = 0;
+ if (gPIDPersistProcess) {
+ pid = gPIDPersistProcess.pid;
+ Services.env.set("MOZ_TEST_SHORTER_WAIT_PID", "1");
+ }
+
+ let updateBin = copyTestUpdaterToBinDir();
+ Assert.ok(updateBin.exists(), MSG_SHOULD_EXIST + getMsgPath(updateBin.path));
+
+ let updatesDirPath = aPatchDirPath || getUpdateDirFile(DIR_PATCH).path;
+ let installDirPath = aInstallDirPath || getApplyDirFile().path;
+ let applyToDirPath = aApplyToDirPath || getApplyDirFile().path;
+ let stageDirPath = aApplyToDirPath || getStageDirFile().path;
+
+ let callbackApp = getApplyDirFile(DIR_RESOURCES + gCallbackBinFile);
+ Assert.ok(
+ callbackApp.exists(),
+ MSG_SHOULD_EXIST + ", path: " + callbackApp.path
+ );
+ callbackApp.permissions = PERMS_DIRECTORY;
+
+ setAppBundleModTime();
+
+ let args = [updatesDirPath, installDirPath];
+ if (aSwitchApp) {
+ args[2] = stageDirPath;
+ args[3] = pid + "/replace";
+ } else {
+ args[2] = applyToDirPath;
+ args[3] = pid;
+ }
+
+ let launchBin = gIsServiceTest && isInvalidArgTest ? callbackApp : updateBin;
+
+ if (!isInvalidArgTest) {
+ args = args.concat([callbackApp.parent.path, callbackApp.path]);
+ args = args.concat(gCallbackArgs);
+ } else if (gIsServiceTest) {
+ args = ["launch-service", updateBin.path].concat(args);
+ } else if (aCallbackPath) {
+ args = args.concat([callbackApp.parent.path, aCallbackPath]);
+ }
+
+ debugDump("launching the program: " + launchBin.path + " " + args.join(" "));
+
+ if (aSwitchApp && !isInvalidArgTest) {
+ // We want to set the env vars again
+ gShouldResetEnv = undefined;
+ }
+
+ setEnvironment();
+
+ let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
+ process.init(launchBin);
+ process.run(true, args, args.length);
+
+ resetEnvironment();
+
+ if (gPIDPersistProcess) {
+ Services.env.set("MOZ_TEST_SHORTER_WAIT_PID", "");
+ }
+
+ let status = readStatusFile();
+ if (
+ (!gIsServiceTest && process.exitValue != aExpectedExitValue) ||
+ (status != aExpectedStatus && !gIsServiceTest && !isInvalidArgTest)
+ ) {
+ if (process.exitValue != aExpectedExitValue) {
+ logTestInfo(
+ "updater exited with unexpected value! Got: " +
+ process.exitValue +
+ ", Expected: " +
+ aExpectedExitValue
+ );
+ }
+ if (status != aExpectedStatus) {
+ logTestInfo(
+ "update status is not the expected status! Got: " +
+ status +
+ ", Expected: " +
+ aExpectedStatus
+ );
+ }
+ logUpdateLog(FILE_LAST_UPDATE_LOG);
+ }
+
+ if (gIsServiceTest && isInvalidArgTest) {
+ let secureStatus = readSecureStatusFile();
+ if (secureStatus != STATE_NONE) {
+ status = secureStatus;
+ }
+ }
+
+ if (!gIsServiceTest) {
+ Assert.equal(
+ process.exitValue,
+ aExpectedExitValue,
+ "the process exit value" + MSG_SHOULD_EQUAL
+ );
+ }
+
+ if (status != aExpectedStatus) {
+ logUpdateLog(FILE_UPDATE_LOG);
+ }
+ Assert.equal(status, aExpectedStatus, "the update status" + MSG_SHOULD_EQUAL);
+
+ Assert.ok(
+ !updateHasBinaryTransparencyErrorResult(),
+ "binary transparency is not being processed for now"
+ );
+
+ if (gIsServiceTest && aCheckSvcLog) {
+ let contents = readServiceLogFile();
+ Assert.notEqual(
+ contents,
+ svcOriginalLog,
+ "the contents of the maintenanceservice.log should not " +
+ "be the same as the original contents"
+ );
+ if (gEnvForceServiceFallback) {
+ // If we are forcing the service to fail and fall back to update without
+ // the service, the service log should reflect that we failed in that way.
+ Assert.ok(
+ contents.includes(LOG_SVC_UNSUCCESSFUL_LAUNCH),
+ "the contents of the maintenanceservice.log should " +
+ "contain the unsuccessful launch string"
+ );
+ } else if (!isInvalidArgTest) {
+ Assert.notEqual(
+ contents.indexOf(LOG_SVC_SUCCESSFUL_LAUNCH),
+ -1,
+ "the contents of the maintenanceservice.log should " +
+ "contain the successful launch string"
+ );
+ }
+ }
+}
+
+/**
+ * Launches the helper binary synchronously with the specified arguments for
+ * updater tests.
+ *
+ * @param aArgs
+ * The arguments to pass to the helper binary.
+ * @return the process exit value returned by the helper binary.
+ */
+function runTestHelperSync(aArgs) {
+ let helperBin = getTestDirFile(FILE_HELPER_BIN);
+ let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
+ process.init(helperBin);
+ debugDump("Running " + helperBin.path + " " + aArgs.join(" "));
+ process.run(true, aArgs, aArgs.length);
+ return process.exitValue;
+}
+
+/**
+ * Creates a symlink for updater tests.
+ */
+function createSymlink() {
+ let args = [
+ "setup-symlink",
+ "moz-foo",
+ "moz-bar",
+ "target",
+ getApplyDirFile().path + "/" + DIR_RESOURCES + "link",
+ ];
+ let exitValue = runTestHelperSync(args);
+ Assert.equal(exitValue, 0, "the helper process exit value should be 0");
+ let file = getApplyDirFile(DIR_RESOURCES + "link");
+ Assert.ok(file.exists(), MSG_SHOULD_EXIST + ", path: " + file.path);
+ file.permissions = 0o666;
+ args = [
+ "setup-symlink",
+ "moz-foo2",
+ "moz-bar2",
+ "target2",
+ getApplyDirFile().path + "/" + DIR_RESOURCES + "link2",
+ "change-perm",
+ ];
+ exitValue = runTestHelperSync(args);
+ Assert.equal(exitValue, 0, "the helper process exit value should be 0");
+}
+
+/**
+ * Removes a symlink for updater tests.
+ */
+function removeSymlink() {
+ let args = [
+ "remove-symlink",
+ "moz-foo",
+ "moz-bar",
+ "target",
+ getApplyDirFile().path + "/" + DIR_RESOURCES + "link",
+ ];
+ let exitValue = runTestHelperSync(args);
+ Assert.equal(exitValue, 0, "the helper process exit value should be 0");
+ args = [
+ "remove-symlink",
+ "moz-foo2",
+ "moz-bar2",
+ "target2",
+ getApplyDirFile().path + "/" + DIR_RESOURCES + "link2",
+ ];
+ exitValue = runTestHelperSync(args);
+ Assert.equal(exitValue, 0, "the helper process exit value should be 0");
+}
+
+/**
+ * Checks a symlink for updater tests.
+ */
+function checkSymlink() {
+ let args = [
+ "check-symlink",
+ getApplyDirFile().path + "/" + DIR_RESOURCES + "link",
+ ];
+ let exitValue = runTestHelperSync(args);
+ Assert.equal(exitValue, 0, "the helper process exit value should be 0");
+}
+
+/**
+ * Sets the active update and related information for updater tests.
+ */
+function setupActiveUpdate() {
+ let pendingState = gIsServiceTest ? STATE_PENDING_SVC : STATE_PENDING;
+ let patchProps = { state: pendingState };
+ let patches = getLocalPatchString(patchProps);
+ let updates = getLocalUpdateString({}, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeVersionFile(DEFAULT_UPDATE_VERSION);
+ writeStatusFile(pendingState);
+ reloadUpdateManagerData();
+ Assert.ok(!!gUpdateManager.readyUpdate, "the ready update should be defined");
+}
+
+/**
+ * Stages an update using nsIUpdateProcessor:processUpdate for updater tests.
+ *
+ * @param aStateAfterStage
+ * The expected update state after the update has been staged.
+ * @param aCheckSvcLog
+ * Whether the service log should be checked for service tests.
+ * @param aUpdateRemoved (optional)
+ * Whether the update is removed after staging. This can happen when
+ * a staging failure occurs.
+ */
+async function stageUpdate(
+ aStateAfterStage,
+ aCheckSvcLog,
+ aUpdateRemoved = false
+) {
+ debugDump("start - attempting to stage update");
+
+ let svcLogOriginalContents;
+ if (gIsServiceTest && aCheckSvcLog) {
+ svcLogOriginalContents = readServiceLogFile();
+ }
+
+ setAppBundleModTime();
+ setEnvironment();
+ try {
+ // Stage the update.
+ Cc["@mozilla.org/updates/update-processor;1"]
+ .createInstance(Ci.nsIUpdateProcessor)
+ .processUpdate();
+ } catch (e) {
+ Assert.ok(
+ false,
+ "error thrown while calling processUpdate, Exception: " + e
+ );
+ }
+ await waitForEvent("update-staged", aStateAfterStage);
+ resetEnvironment();
+
+ if (AppConstants.platform == "win") {
+ if (gIsServiceTest) {
+ waitForServiceStop(false);
+ } else {
+ let updater = getApplyDirFile(FILE_UPDATER_BIN);
+ await TestUtils.waitForCondition(
+ () => !isFileInUse(updater),
+ "Waiting for the file tp not be in use, Path: " + updater.path
+ );
+ }
+ }
+
+ if (!aUpdateRemoved) {
+ Assert.equal(
+ readStatusState(),
+ aStateAfterStage,
+ "the status file state" + MSG_SHOULD_EQUAL
+ );
+
+ Assert.equal(
+ gUpdateManager.readyUpdate.state,
+ aStateAfterStage,
+ "the update state" + MSG_SHOULD_EQUAL
+ );
+ }
+
+ let log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ let stageDir = getStageDirFile();
+ if (
+ aStateAfterStage == STATE_APPLIED ||
+ aStateAfterStage == STATE_APPLIED_SVC
+ ) {
+ Assert.ok(stageDir.exists(), MSG_SHOULD_EXIST + getMsgPath(stageDir.path));
+ } else {
+ Assert.ok(
+ !stageDir.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(stageDir.path)
+ );
+ }
+
+ if (gIsServiceTest && aCheckSvcLog) {
+ let contents = readServiceLogFile();
+ Assert.notEqual(
+ contents,
+ svcLogOriginalContents,
+ "the contents of the maintenanceservice.log should not " +
+ "be the same as the original contents"
+ );
+ Assert.notEqual(
+ contents.indexOf(LOG_SVC_SUCCESSFUL_LAUNCH),
+ -1,
+ "the contents of the maintenanceservice.log should " +
+ "contain the successful launch string"
+ );
+ }
+
+ debugDump("finish - attempting to stage update");
+}
+
+/**
+ * Helper function to check whether the maintenance service updater tests should
+ * run. See bug 711660 for more details.
+ *
+ * @return true if the test should run and false if it shouldn't.
+ * @throws If called from a platform other than Windows.
+ */
+function shouldRunServiceTest() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ let binDir = getGREBinDir();
+ let updaterBin = binDir.clone();
+ updaterBin.append(FILE_UPDATER_BIN);
+ Assert.ok(
+ updaterBin.exists(),
+ MSG_SHOULD_EXIST + ", leafName: " + updaterBin.leafName
+ );
+
+ let updaterBinPath = updaterBin.path;
+ if (/ /.test(updaterBinPath)) {
+ updaterBinPath = '"' + updaterBinPath + '"';
+ }
+
+ let isBinSigned = isBinarySigned(updaterBinPath);
+
+ const REG_PATH =
+ "SOFTWARE\\Mozilla\\MaintenanceService\\" +
+ "3932ecacee736d366d6436db0f55bce4";
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ key.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+ REG_PATH,
+ Ci.nsIWindowsRegKey.ACCESS_READ | key.WOW64_64
+ );
+ } catch (e) {
+ // The build system could sign the files and not have the test registry key
+ // in which case we should fail the test if the updater binary is signed so
+ // the build system can be fixed by adding the registry key.
+ if (IS_AUTHENTICODE_CHECK_ENABLED) {
+ Assert.ok(
+ !isBinSigned,
+ "the updater.exe binary should not be signed when the test " +
+ "registry key doesn't exist (if it is, build system " +
+ "configuration bug?)"
+ );
+ }
+
+ logTestInfo(
+ "this test can only run on the buildbot build system at this time"
+ );
+ return false;
+ }
+
+ // Check to make sure the service is installed
+ let args = ["wait-for-service-stop", "MozillaMaintenance", "10"];
+ let exitValue = runTestHelperSync(args);
+ Assert.notEqual(
+ exitValue,
+ 0xee,
+ "the maintenance service should be " +
+ "installed (if not, build system configuration bug?)"
+ );
+
+ if (IS_AUTHENTICODE_CHECK_ENABLED) {
+ // The test registry key exists and IS_AUTHENTICODE_CHECK_ENABLED is true
+ // so the binaries should be signed. To run the test locally
+ // DISABLE_UPDATER_AUTHENTICODE_CHECK can be defined.
+ Assert.ok(
+ isBinSigned,
+ "the updater.exe binary should be signed (if not, build system " +
+ "configuration bug?)"
+ );
+ }
+
+ // In case the machine is running an old maintenance service or if it
+ // is not installed, and permissions exist to install it. Then install
+ // the newer bin that we have since all of the other checks passed.
+ return attemptServiceInstall();
+}
+
+/**
+ * Helper function to check whether the a binary is signed.
+ *
+ * @param aBinPath
+ * The path to the file to check if it is signed.
+ * @return true if the file is signed and false if it isn't.
+ */
+function isBinarySigned(aBinPath) {
+ let args = ["check-signature", aBinPath];
+ let exitValue = runTestHelperSync(args);
+ if (exitValue != 0) {
+ logTestInfo(
+ "binary is not signed. " +
+ FILE_HELPER_BIN +
+ " returned " +
+ exitValue +
+ " for file " +
+ aBinPath
+ );
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Helper function for setting up the application files required to launch the
+ * application for the updater tests by either copying or creating symlinks to
+ * the files.
+ *
+ * @param options.requiresOmnijar when true, copy or symlink omnijars as well.
+ * This may be required to launch the updated application and have non-trivial
+ * functionality available.
+ */
+function setupAppFiles({ requiresOmnijar = false } = {}) {
+ debugDump(
+ "start - copying or creating symlinks to application files " +
+ "for the test"
+ );
+
+ let destDir = getApplyDirFile();
+ if (!destDir.exists()) {
+ try {
+ destDir.create(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
+ } catch (e) {
+ logTestInfo(
+ "unable to create directory! Path: " +
+ destDir.path +
+ ", Exception: " +
+ e
+ );
+ do_throw(e);
+ }
+ }
+
+ // Required files for the application or the test that aren't listed in the
+ // dependentlibs.list file.
+ let appFiles = [
+ { relPath: FILE_APP_BIN, inGreDir: false },
+ { relPath: FILE_APPLICATION_INI, inGreDir: true },
+ { relPath: "dependentlibs.list", inGreDir: true },
+ ];
+
+ if (requiresOmnijar) {
+ appFiles.push({ relPath: AppConstants.OMNIJAR_NAME, inGreDir: true });
+
+ if (AppConstants.MOZ_BUILD_APP == "browser") {
+ // Only Firefox uses an app-specific omnijar.
+ appFiles.push({
+ relPath: "browser/" + AppConstants.OMNIJAR_NAME,
+ inGreDir: true,
+ });
+ }
+ }
+
+ // On Linux the updater.png must also be copied and libsoftokn3.so must be
+ // symlinked or copied.
+ if (AppConstants.platform == "linux") {
+ appFiles.push(
+ { relPath: "icons/updater.png", inGreDir: true },
+ { relPath: "libsoftokn3.so", inGreDir: true }
+ );
+ }
+
+ // Read the dependent libs file leafnames from the dependentlibs.list file
+ // into the array.
+ let deplibsFile = gGREDirOrig.clone();
+ deplibsFile.append("dependentlibs.list");
+ let fis = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fis.init(deplibsFile, 0x01, 0o444, Ci.nsIFileInputStream.CLOSE_ON_EOF);
+ fis.QueryInterface(Ci.nsILineInputStream);
+
+ let hasMore;
+ let line = {};
+ do {
+ hasMore = fis.readLine(line);
+ appFiles.push({ relPath: line.value, inGreDir: false });
+ } while (hasMore);
+
+ fis.close();
+
+ appFiles.forEach(function CMAF_FLN_FE(aAppFile) {
+ copyFileToTestAppDir(aAppFile.relPath, aAppFile.inGreDir);
+ });
+
+ copyTestUpdaterToBinDir();
+
+ debugDump(
+ "finish - copying or creating symlinks to application files " +
+ "for the test"
+ );
+}
+
+/**
+ * Copies the specified files from the dist/bin directory into the test's
+ * application directory.
+ *
+ * @param aFileRelPath
+ * The relative path to the source and the destination of the file to
+ * copy.
+ * @param aInGreDir
+ * Whether the file is located in the GRE directory which is
+ * <bundle>/Contents/Resources on Mac OS X and is the installation
+ * directory on all other platforms. If false the file must be in the
+ * GRE Binary directory which is <bundle>/Contents/MacOS on Mac OS X
+ * and is the installation directory on on all other platforms.
+ */
+function copyFileToTestAppDir(aFileRelPath, aInGreDir) {
+ // gGREDirOrig and gGREBinDirOrig must always be cloned when changing its
+ // properties
+ let srcFile = aInGreDir ? gGREDirOrig.clone() : gGREBinDirOrig.clone();
+ let destFile = aInGreDir ? getGREDir() : getGREBinDir();
+ let fileRelPath = aFileRelPath;
+ let pathParts = fileRelPath.split("/");
+ for (let i = 0; i < pathParts.length; i++) {
+ if (pathParts[i]) {
+ srcFile.append(pathParts[i]);
+ destFile.append(pathParts[i]);
+ }
+ }
+
+ if (AppConstants.platform == "macosx" && !srcFile.exists()) {
+ debugDump(
+ "unable to copy file since it doesn't exist! Checking if " +
+ fileRelPath +
+ ".app exists. Path: " +
+ srcFile.path
+ );
+ // gGREDirOrig and gGREBinDirOrig must always be cloned when changing its
+ // properties
+ srcFile = aInGreDir ? gGREDirOrig.clone() : gGREBinDirOrig.clone();
+ destFile = aInGreDir ? getGREDir() : getGREBinDir();
+ for (let i = 0; i < pathParts.length; i++) {
+ if (pathParts[i]) {
+ srcFile.append(
+ pathParts[i] + (pathParts.length - 1 == i ? ".app" : "")
+ );
+ destFile.append(
+ pathParts[i] + (pathParts.length - 1 == i ? ".app" : "")
+ );
+ }
+ }
+ fileRelPath = fileRelPath + ".app";
+ }
+ Assert.ok(
+ srcFile.exists(),
+ MSG_SHOULD_EXIST + ", leafName: " + srcFile.leafName
+ );
+
+ // Symlink libraries. Note that the XUL library on Mac OS X doesn't have a
+ // file extension and shouldSymlink will always be false on Windows.
+ let shouldSymlink =
+ pathParts[pathParts.length - 1] == "XUL" ||
+ fileRelPath.substr(fileRelPath.length - 3) == ".so" ||
+ fileRelPath.substr(fileRelPath.length - 6) == ".dylib";
+ if (!shouldSymlink) {
+ if (!destFile.exists()) {
+ try {
+ srcFile.copyToFollowingLinks(destFile.parent, destFile.leafName);
+ } catch (e) {
+ // Just in case it is partially copied
+ if (destFile.exists()) {
+ try {
+ destFile.remove(true);
+ } catch (ex) {
+ logTestInfo(
+ "unable to remove file that failed to copy! Path: " +
+ destFile.path +
+ ", Exception: " +
+ ex
+ );
+ }
+ }
+ do_throw(
+ "Unable to copy file! Path: " + srcFile.path + ", Exception: " + e
+ );
+ }
+ }
+ } else {
+ try {
+ if (destFile.exists()) {
+ destFile.remove(false);
+ }
+ let ln = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ ln.initWithPath("/bin/ln");
+ let process = Cc["@mozilla.org/process/util;1"].createInstance(
+ Ci.nsIProcess
+ );
+ process.init(ln);
+ let args = ["-s", srcFile.path, destFile.path];
+ process.run(true, args, args.length);
+ Assert.ok(
+ destFile.isSymlink(),
+ destFile.leafName + " should be a symlink"
+ );
+ } catch (e) {
+ do_throw(
+ "Unable to create symlink for file! Path: " +
+ srcFile.path +
+ ", Exception: " +
+ e
+ );
+ }
+ }
+}
+
+/**
+ * Attempts to upgrade the maintenance service if permissions are allowed.
+ * This is useful for XP where we have permission to upgrade in case an
+ * older service installer exists. Also if the user manually installed into
+ * a unprivileged location.
+ *
+ * @return true if the installed service is from this build. If the installed
+ * service is not from this build the test will fail instead of
+ * returning false.
+ * @throws If called from a platform other than Windows.
+ */
+function attemptServiceInstall() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ let maintSvcDir = getMaintSvcDir();
+ Assert.ok(
+ maintSvcDir.exists(),
+ MSG_SHOULD_EXIST + ", leafName: " + maintSvcDir.leafName
+ );
+ let oldMaintSvcBin = maintSvcDir.clone();
+ oldMaintSvcBin.append(FILE_MAINTENANCE_SERVICE_BIN);
+ Assert.ok(
+ oldMaintSvcBin.exists(),
+ MSG_SHOULD_EXIST + ", leafName: " + oldMaintSvcBin.leafName
+ );
+ let buildMaintSvcBin = getGREBinDir();
+ buildMaintSvcBin.append(FILE_MAINTENANCE_SERVICE_BIN);
+ if (readFileBytes(oldMaintSvcBin) == readFileBytes(buildMaintSvcBin)) {
+ debugDump(
+ "installed maintenance service binary is the same as the " +
+ "build's maintenance service binary"
+ );
+ return true;
+ }
+ let backupMaintSvcBin = maintSvcDir.clone();
+ backupMaintSvcBin.append(FILE_MAINTENANCE_SERVICE_BIN + ".backup");
+ try {
+ if (backupMaintSvcBin.exists()) {
+ backupMaintSvcBin.remove(false);
+ }
+ oldMaintSvcBin.moveTo(
+ maintSvcDir,
+ FILE_MAINTENANCE_SERVICE_BIN + ".backup"
+ );
+ buildMaintSvcBin.copyTo(maintSvcDir, FILE_MAINTENANCE_SERVICE_BIN);
+ backupMaintSvcBin.remove(false);
+ } catch (e) {
+ // Restore the original file in case the moveTo was successful.
+ if (backupMaintSvcBin.exists()) {
+ oldMaintSvcBin = maintSvcDir.clone();
+ oldMaintSvcBin.append(FILE_MAINTENANCE_SERVICE_BIN);
+ if (!oldMaintSvcBin.exists()) {
+ backupMaintSvcBin.moveTo(maintSvcDir, FILE_MAINTENANCE_SERVICE_BIN);
+ }
+ }
+ Assert.ok(
+ false,
+ "should be able copy the test maintenance service to " +
+ "the maintenance service directory (if not, build system " +
+ "configuration bug?), path: " +
+ maintSvcDir.path
+ );
+ }
+
+ return true;
+}
+
+/**
+ * Waits for the applications that are launched by the maintenance service to
+ * stop.
+ *
+ * @throws If called from a platform other than Windows.
+ */
+function waitServiceApps() {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ // maintenanceservice_installer.exe is started async during updates.
+ waitForApplicationStop("maintenanceservice_installer.exe");
+ // maintenanceservice_tmp.exe is started async from the service installer.
+ waitForApplicationStop("maintenanceservice_tmp.exe");
+ // In case the SCM thinks the service is stopped, but process still exists.
+ waitForApplicationStop("maintenanceservice.exe");
+}
+
+/**
+ * Waits for the maintenance service to stop.
+ *
+ * @throws If called from a platform other than Windows.
+ */
+function waitForServiceStop(aFailTest) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ waitServiceApps();
+ debugDump("waiting for the maintenance service to stop if necessary");
+ // Use the helper bin to ensure the service is stopped. If not stopped, then
+ // wait for the service to stop (at most 120 seconds).
+ let args = ["wait-for-service-stop", "MozillaMaintenance", "120"];
+ let exitValue = runTestHelperSync(args);
+ Assert.notEqual(exitValue, 0xee, "the maintenance service should exist");
+ if (exitValue != 0) {
+ if (aFailTest) {
+ Assert.ok(
+ false,
+ "the maintenance service should stop, process exit " +
+ "value: " +
+ exitValue
+ );
+ }
+ logTestInfo(
+ "maintenance service did not stop which may cause test " +
+ "failures later, process exit value: " +
+ exitValue
+ );
+ } else {
+ debugDump("service stopped");
+ }
+ waitServiceApps();
+}
+
+/**
+ * Waits for the specified application to stop.
+ *
+ * @param aApplication
+ * The application binary name to wait until it has stopped.
+ * @throws If called from a platform other than Windows.
+ */
+function waitForApplicationStop(aApplication) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ debugDump("waiting for " + aApplication + " to stop if necessary");
+ // Use the helper bin to ensure the application is stopped. If not stopped,
+ // then wait for it to stop (at most 120 seconds).
+ let args = ["wait-for-application-exit", aApplication, "120"];
+ let exitValue = runTestHelperSync(args);
+ Assert.equal(
+ exitValue,
+ 0,
+ "the process should have stopped, process name: " + aApplication
+ );
+}
+
+/**
+ * Gets the platform specific shell binary that is launched using nsIProcess and
+ * in turn launches a binary used for the test (e.g. application, updater,
+ * etc.). A shell is used so debug console output can be redirected to a file so
+ * it doesn't end up in the test log.
+ *
+ * @return nsIFile for the shell binary to launch using nsIProcess.
+ */
+function getLaunchBin() {
+ let launchBin;
+ if (AppConstants.platform == "win") {
+ launchBin = Services.dirsvc.get("WinD", Ci.nsIFile);
+ launchBin.append("System32");
+ launchBin.append("cmd.exe");
+ } else {
+ launchBin = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ launchBin.initWithPath("/bin/sh");
+ }
+ Assert.ok(launchBin.exists(), MSG_SHOULD_EXIST + getMsgPath(launchBin.path));
+
+ return launchBin;
+}
+
+/**
+ * Locks a Windows directory.
+ *
+ * @param aDirPath
+ * The test file object that describes the file to make in use.
+ * @throws If called from a platform other than Windows.
+ */
+function lockDirectory(aDirPath) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ debugDump("start - locking installation directory");
+ const LPCWSTR = ctypes.char16_t.ptr;
+ const DWORD = ctypes.uint32_t;
+ const LPVOID = ctypes.voidptr_t;
+ const GENERIC_READ = 0x80000000;
+ const FILE_SHARE_READ = 1;
+ const FILE_SHARE_WRITE = 2;
+ const OPEN_EXISTING = 3;
+ const FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
+ const INVALID_HANDLE_VALUE = LPVOID(0xffffffff);
+ let kernel32 = ctypes.open("kernel32");
+ let CreateFile = kernel32.declare(
+ "CreateFileW",
+ ctypes.winapi_abi,
+ LPVOID,
+ LPCWSTR,
+ DWORD,
+ DWORD,
+ LPVOID,
+ DWORD,
+ DWORD,
+ LPVOID
+ );
+ gHandle = CreateFile(
+ aDirPath,
+ GENERIC_READ,
+ FILE_SHARE_READ | FILE_SHARE_WRITE,
+ LPVOID(0),
+ OPEN_EXISTING,
+ FILE_FLAG_BACKUP_SEMANTICS,
+ LPVOID(0)
+ );
+ Assert.notEqual(
+ gHandle.toString(),
+ INVALID_HANDLE_VALUE.toString(),
+ "the handle should not equal INVALID_HANDLE_VALUE"
+ );
+ kernel32.close();
+ debugDump("finish - locking installation directory");
+}
+
+/**
+ * Launches the test helper binary to make it in use for updater tests.
+ *
+ * @param aRelPath
+ * The relative path in the apply to directory for the helper binary.
+ * @param aCopyTestHelper
+ * Whether to copy the test helper binary to the relative path in the
+ * apply to directory.
+ */
+async function runHelperFileInUse(aRelPath, aCopyTestHelper) {
+ debugDump("aRelPath: " + aRelPath);
+ // Launch an existing file so it is in use during the update.
+ let helperBin = getTestDirFile(FILE_HELPER_BIN);
+ let fileInUseBin = getApplyDirFile(aRelPath);
+ if (aCopyTestHelper) {
+ if (fileInUseBin.exists()) {
+ fileInUseBin.remove(false);
+ }
+ helperBin.copyTo(fileInUseBin.parent, fileInUseBin.leafName);
+ }
+ fileInUseBin.permissions = PERMS_DIRECTORY;
+ let args = [
+ getApplyDirPath() + DIR_RESOURCES,
+ "input",
+ "output",
+ "-s",
+ HELPER_SLEEP_TIMEOUT,
+ ];
+ let fileInUseProcess = Cc["@mozilla.org/process/util;1"].createInstance(
+ Ci.nsIProcess
+ );
+ fileInUseProcess.init(fileInUseBin);
+ fileInUseProcess.run(false, args, args.length);
+
+ await waitForHelperSleep();
+}
+
+/**
+ * Launches the test helper binary to provide a pid that is in use for updater
+ * tests.
+ *
+ * @param aRelPath
+ * The relative path in the apply to directory for the helper binary.
+ * @param aCopyTestHelper
+ * Whether to copy the test helper binary to the relative path in the
+ * apply to directory.
+ */
+async function runHelperPIDPersists(aRelPath, aCopyTestHelper) {
+ debugDump("aRelPath: " + aRelPath);
+ // Launch an existing file so it is in use during the update.
+ let helperBin = getTestDirFile(FILE_HELPER_BIN);
+ let pidPersistsBin = getApplyDirFile(aRelPath);
+ if (aCopyTestHelper) {
+ if (pidPersistsBin.exists()) {
+ pidPersistsBin.remove(false);
+ }
+ helperBin.copyTo(pidPersistsBin.parent, pidPersistsBin.leafName);
+ }
+ pidPersistsBin.permissions = PERMS_DIRECTORY;
+ let args = [
+ getApplyDirPath() + DIR_RESOURCES,
+ "input",
+ "output",
+ "-s",
+ HELPER_SLEEP_TIMEOUT,
+ ];
+ gPIDPersistProcess = Cc["@mozilla.org/process/util;1"].createInstance(
+ Ci.nsIProcess
+ );
+ gPIDPersistProcess.init(pidPersistsBin);
+ gPIDPersistProcess.run(false, args, args.length);
+
+ await waitForHelperSleep();
+ await TestUtils.waitForCondition(
+ () => !!gPIDPersistProcess.pid,
+ "Waiting for the process pid"
+ );
+}
+
+/**
+ * Launches the test helper binary and locks a file specified on the command
+ * line for updater tests.
+ *
+ * @param aTestFile
+ * The test file object that describes the file to lock.
+ */
+async function runHelperLockFile(aTestFile) {
+ // Exclusively lock an existing file so it is in use during the update.
+ let helperBin = getTestDirFile(FILE_HELPER_BIN);
+ let helperDestDir = getApplyDirFile(DIR_RESOURCES);
+ helperBin.copyTo(helperDestDir, FILE_HELPER_BIN);
+ helperBin = getApplyDirFile(DIR_RESOURCES + FILE_HELPER_BIN);
+ // Strip off the first two directories so the path has to be from the helper's
+ // working directory.
+ let lockFileRelPath = aTestFile.relPathDir.split("/");
+ if (AppConstants.platform == "macosx") {
+ lockFileRelPath = lockFileRelPath.slice(2);
+ }
+ lockFileRelPath = lockFileRelPath.join("/") + "/" + aTestFile.fileName;
+ let args = [
+ getApplyDirPath() + DIR_RESOURCES,
+ "input",
+ "output",
+ "-s",
+ HELPER_SLEEP_TIMEOUT,
+ lockFileRelPath,
+ ];
+ let helperProcess = Cc["@mozilla.org/process/util;1"].createInstance(
+ Ci.nsIProcess
+ );
+ helperProcess.init(helperBin);
+ helperProcess.run(false, args, args.length);
+
+ await waitForHelperSleep();
+}
+
+/**
+ * Helper function that waits until the helper has completed its operations.
+ */
+async function waitForHelperSleep() {
+ // Give the lock file process time to lock the file before updating otherwise
+ // this test can fail intermittently on Windows debug builds.
+ let file = getApplyDirFile(DIR_RESOURCES + "output");
+ await TestUtils.waitForCondition(
+ () => file.exists(),
+ "Waiting for file to exist, path: " + file.path
+ );
+
+ let expectedContents = "sleeping\n";
+ await TestUtils.waitForCondition(
+ () => readFile(file) == expectedContents,
+ "Waiting for expected file contents: " + expectedContents
+ );
+
+ await TestUtils.waitForCondition(() => {
+ try {
+ file.remove(false);
+ } catch (e) {
+ debugDump(
+ "failed to remove file. Path: " + file.path + ", Exception: " + e
+ );
+ }
+ return !file.exists();
+ }, "Waiting for file to be removed, Path: " + file.path);
+}
+
+/**
+ * Helper function to tell the helper to finish and exit its sleep state.
+ */
+async function waitForHelperExit() {
+ let file = getApplyDirFile(DIR_RESOURCES + "input");
+ writeFile(file, "finish\n");
+
+ // Give the lock file process time to lock the file before updating otherwise
+ // this test can fail intermittently on Windows debug builds.
+ file = getApplyDirFile(DIR_RESOURCES + "output");
+ await TestUtils.waitForCondition(
+ () => file.exists(),
+ "Waiting for file to exist, Path: " + file.path
+ );
+
+ let expectedContents = "finished\n";
+ await TestUtils.waitForCondition(
+ () => readFile(file) == expectedContents,
+ "Waiting for expected file contents: " + expectedContents
+ );
+
+ // Give the lock file process time to unlock the file before deleting the
+ // input and output files.
+ await TestUtils.waitForCondition(() => {
+ try {
+ file.remove(false);
+ } catch (e) {
+ debugDump(
+ "failed to remove file. Path: " + file.path + ", Exception: " + e
+ );
+ }
+ return !file.exists();
+ }, "Waiting for file to be removed, Path: " + file.path);
+
+ file = getApplyDirFile(DIR_RESOURCES + "input");
+ await TestUtils.waitForCondition(() => {
+ try {
+ file.remove(false);
+ } catch (e) {
+ debugDump(
+ "failed to remove file. Path: " + file.path + ", Exception: " + e
+ );
+ }
+ return !file.exists();
+ }, "Waiting for file to be removed, Path: " + file.path);
+}
+
+/**
+ * Helper function for updater binary tests that creates the files and
+ * directories used by the test.
+ *
+ * @param aMarFile
+ * The mar file for the update test.
+ * @param aPostUpdateAsync
+ * When null the updater.ini is not created otherwise this parameter
+ * is passed to createUpdaterINI.
+ * @param aPostUpdateExeRelPathPrefix
+ * When aPostUpdateAsync null this value is ignored otherwise it is
+ * passed to createUpdaterINI.
+ * @param aSetupActiveUpdate
+ * Whether to setup the active update.
+ *
+ * @param options.requiresOmnijar
+ * When true, copy or symlink omnijars as well. This may be required
+ * to launch the updated application and have non-trivial functionality
+ * available.
+ */
+async function setupUpdaterTest(
+ aMarFile,
+ aPostUpdateAsync,
+ aPostUpdateExeRelPathPrefix = "",
+ aSetupActiveUpdate = true,
+ { requiresOmnijar = false } = {}
+) {
+ debugDump("start - updater test setup");
+ let updatesPatchDir = getUpdateDirFile(DIR_PATCH);
+ if (!updatesPatchDir.exists()) {
+ updatesPatchDir.create(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
+ }
+ // Copy the mar that will be applied
+ let mar = getTestDirFile(aMarFile);
+ mar.copyToFollowingLinks(updatesPatchDir, FILE_UPDATE_MAR);
+
+ let helperBin = getTestDirFile(FILE_HELPER_BIN);
+ helperBin.permissions = PERMS_DIRECTORY;
+ let afterApplyBinDir = getApplyDirFile(DIR_RESOURCES);
+ helperBin.copyToFollowingLinks(afterApplyBinDir, gCallbackBinFile);
+ helperBin.copyToFollowingLinks(afterApplyBinDir, gPostUpdateBinFile);
+
+ gTestFiles.forEach(function SUT_TF_FE(aTestFile) {
+ debugDump("start - setup test file: " + aTestFile.fileName);
+ if (aTestFile.originalFile || aTestFile.originalContents) {
+ let testDir = getApplyDirFile(aTestFile.relPathDir);
+ // Somehow these create calls are failing with FILE_ALREADY_EXISTS even
+ // after checking .exists() first, so we just catch the exception.
+ try {
+ testDir.create(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw e;
+ }
+ }
+
+ let testFile;
+ if (aTestFile.originalFile) {
+ testFile = getTestDirFile(aTestFile.originalFile);
+ testFile.copyToFollowingLinks(testDir, aTestFile.fileName);
+ testFile = getApplyDirFile(aTestFile.relPathDir + aTestFile.fileName);
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + ", path: " + testFile.path
+ );
+ } else {
+ testFile = getApplyDirFile(aTestFile.relPathDir + aTestFile.fileName);
+ writeFile(testFile, aTestFile.originalContents);
+ }
+
+ // Skip these tests on Windows since chmod doesn't really set permissions
+ // on Windows.
+ if (AppConstants.platform != "win" && aTestFile.originalPerms) {
+ testFile.permissions = aTestFile.originalPerms;
+ // Store the actual permissions on the file for reference later after
+ // setting the permissions.
+ if (!aTestFile.comparePerms) {
+ aTestFile.comparePerms = testFile.permissions;
+ }
+ }
+ }
+ debugDump("finish - setup test file: " + aTestFile.fileName);
+ });
+
+ // Set a similar extended attribute on the `.app` directory as we see in
+ // the wild. We will verify that it is preserved at the end of tests.
+ if (AppConstants.platform == "macosx") {
+ await IOUtils.setMacXAttr(
+ getApplyDirFile().path,
+ MAC_APP_XATTR_KEY,
+ new TextEncoder().encode(MAC_APP_XATTR_VALUE)
+ );
+ }
+ // Add the test directory that will be updated for a successful update or left
+ // in the initial state for a failed update.
+ gTestDirs.forEach(function SUT_TD_FE(aTestDir) {
+ debugDump("start - setup test directory: " + aTestDir.relPathDir);
+ let testDir = getApplyDirFile(aTestDir.relPathDir);
+ // Somehow these create calls are failing with FILE_ALREADY_EXISTS even
+ // after checking .exists() first, so we just catch the exception.
+ try {
+ testDir.create(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw e;
+ }
+ }
+
+ if (aTestDir.files) {
+ aTestDir.files.forEach(function SUT_TD_F_FE(aTestFile) {
+ let testFile = getApplyDirFile(aTestDir.relPathDir + aTestFile);
+ if (!testFile.exists()) {
+ testFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ }
+ });
+ }
+
+ if (aTestDir.subDirs) {
+ aTestDir.subDirs.forEach(function SUT_TD_SD_FE(aSubDir) {
+ let testSubDir = getApplyDirFile(aTestDir.relPathDir + aSubDir);
+ if (!testSubDir.exists()) {
+ testSubDir.create(Ci.nsIFile.DIRECTORY_TYPE, PERMS_DIRECTORY);
+ }
+
+ if (aTestDir.subDirFiles) {
+ aTestDir.subDirFiles.forEach(function SUT_TD_SDF_FE(aTestFile) {
+ let testFile = getApplyDirFile(
+ aTestDir.relPathDir + aSubDir + aTestFile
+ );
+ if (!testFile.exists()) {
+ testFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ }
+ });
+ }
+ });
+ }
+ debugDump("finish - setup test directory: " + aTestDir.relPathDir);
+ });
+
+ if (aSetupActiveUpdate) {
+ setupActiveUpdate();
+ }
+
+ if (aPostUpdateAsync !== null) {
+ createUpdaterINI(aPostUpdateAsync, aPostUpdateExeRelPathPrefix);
+ }
+
+ await TestUtils.waitForCondition(() => {
+ try {
+ setupAppFiles({ requiresOmnijar });
+ return true;
+ } catch (e) {
+ logTestInfo("exception when calling setupAppFiles, Exception: " + e);
+ }
+ return false;
+ }, "Waiting to setup app files");
+
+ debugDump("finish - updater test setup");
+}
+
+/**
+ * Helper function for updater binary tests that creates the updater.ini
+ * file.
+ *
+ * @param aIsExeAsync
+ * True or undefined if the post update process should be async. If
+ * undefined ExeAsync will not be added to the updater.ini file in
+ * order to test the default launch behavior which is async.
+ * @param aExeRelPathPrefix
+ * A string to prefix the ExeRelPath values in the updater.ini.
+ */
+function createUpdaterINI(aIsExeAsync, aExeRelPathPrefix) {
+ let exeArg = "ExeArg=post-update-async\n";
+ let exeAsync = "";
+ if (aIsExeAsync !== undefined) {
+ if (aIsExeAsync) {
+ exeAsync = "ExeAsync=true\n";
+ } else {
+ exeArg = "ExeArg=post-update-sync\n";
+ exeAsync = "ExeAsync=false\n";
+ }
+ }
+
+ if (AppConstants.platform == "win" && aExeRelPathPrefix) {
+ aExeRelPathPrefix = aExeRelPathPrefix.replace("/", "\\");
+ }
+
+ let exeRelPathMac =
+ "ExeRelPath=" +
+ aExeRelPathPrefix +
+ DIR_RESOURCES +
+ gPostUpdateBinFile +
+ "\n";
+ let exeRelPathWin =
+ "ExeRelPath=" + aExeRelPathPrefix + gPostUpdateBinFile + "\n";
+ let updaterIniContents =
+ "[Strings]\n" +
+ "Title=Update Test\n" +
+ "Info=Running update test " +
+ gTestID +
+ "\n\n" +
+ "[PostUpdateMac]\n" +
+ exeRelPathMac +
+ exeArg +
+ exeAsync +
+ "\n" +
+ "[PostUpdateWin]\n" +
+ exeRelPathWin +
+ exeArg +
+ exeAsync;
+ let updaterIni = getApplyDirFile(DIR_RESOURCES + FILE_UPDATER_INI);
+ writeFile(updaterIni, updaterIniContents);
+}
+
+/**
+ * Gets the message log path used for assert checks to lessen the length printed
+ * to the log file.
+ *
+ * @param aPath
+ * The path to shorten for the log file.
+ * @return the message including the shortened path for the log file.
+ */
+function getMsgPath(aPath) {
+ return ", path: " + replaceLogPaths(aPath);
+}
+
+/**
+ * Helper function that replaces the common part of paths in the update log's
+ * contents with <test_dir_path> for paths to the the test directory and
+ * <update_dir_path> for paths to the update directory. This is needed since
+ * Assert.equal will truncate what it prints to the xpcshell log file.
+ *
+ * @param aLogContents
+ * The update log file's contents.
+ * @return the log contents with the paths replaced.
+ */
+function replaceLogPaths(aLogContents) {
+ let logContents = aLogContents;
+ // Remove the majority of the path up to the test directory. This is needed
+ // since Assert.equal won't print long strings to the test logs.
+ let testDirPath = getApplyDirFile().parent.path;
+ if (AppConstants.platform == "win") {
+ // Replace \\ with \\\\ so the regexp works.
+ testDirPath = testDirPath.replace(/\\/g, "\\\\");
+ }
+ logContents = logContents.replace(
+ new RegExp(testDirPath, "g"),
+ "<test_dir_path>/" + gTestID
+ );
+ let updatesDirPath = getMockUpdRootD().path;
+ if (AppConstants.platform == "win") {
+ // Replace \\ with \\\\ so the regexp works.
+ updatesDirPath = updatesDirPath.replace(/\\/g, "\\\\");
+ }
+ logContents = logContents.replace(
+ new RegExp(updatesDirPath, "g"),
+ "<update_dir_path>/" + gTestID
+ );
+ if (AppConstants.platform == "win") {
+ // Replace \ with /
+ logContents = logContents.replace(/\\/g, "/");
+ }
+ return logContents;
+}
+
+/**
+ * Helper function that removes the timestamps in the update log
+ *
+ * @param aLogContents
+ * The update log file's contents.
+ * @return the log contents without timestamps
+ */
+function removeTimeStamps(aLogContents) {
+ return aLogContents.replace(
+ /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{4}: /gm,
+ ""
+ );
+}
+
+/**
+ * Helper function that gets the contents of the last update log.
+ *
+ * It used to be that we only kept one copy of the updater log. This would be
+ * the copy created by the elevated updater, if it was run. If it wasn't run,
+ * then then it would be the only copy (created by the unelevated updater).
+ * Now we keep both of these files. These tests were written assuming that
+ * this unelevated updater log would be overwritten if the updater ran
+ * elevated. Since that is no longer true, we can get the correct log intended
+ * by these tests by always just trying for the elevated version first and, if
+ * that doesn't exist, getting the unelevated version.
+ * This works better than checking `gIsServiceTest` because some service tests
+ * intentionally run bits of the test without elevation.
+ */
+function getLogFileContents() {
+ let updateLog = getUpdateDirFile(FILE_LAST_UPDATE_ELEVATED_LOG);
+ let updateLogContents;
+ try {
+ updateLogContents = readFileBytes(updateLog);
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
+ throw ex;
+ }
+ updateLog = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ updateLogContents = readFileBytes(updateLog);
+ }
+ return updateLogContents;
+}
+
+/**
+ * Helper function for updater binary tests for verifying the contents of the
+ * update log after a successful update.
+ * Requires that the compare file have all the correct log lines in the correct
+ * order, but it is not an error for extra lines to be present in the test file.
+ *
+ * @param aCompareLogFile
+ * The log file to compare the update log with.
+ * @param aStaged
+ * If the update log file is for a staged update.
+ * @param aReplace
+ * If the update log file is for a replace update.
+ * @param aExcludeDistDir
+ * Removes lines containing the distribution directory from the log
+ * file to compare the update log with.
+ */
+function checkUpdateLogContents(
+ aCompareLogFile,
+ aStaged = false,
+ aReplace = false,
+ aExcludeDistDir = false
+) {
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "linux") {
+ // The order that files are returned when enumerating the file system on
+ // Linux and Mac is not deterministic so skip checking the logs.
+ return;
+ }
+
+ let updateLogContents = getLogFileContents();
+
+ // Remove leading timestamps
+ updateLogContents = removeTimeStamps(updateLogContents);
+
+ // The channel-prefs.js is defined in gTestFilesCommon which will always be
+ // located to the end of gTestFiles when it is present.
+ if (
+ gTestFiles.length > 1 &&
+ gTestFiles[gTestFiles.length - 1].fileName == "channel-prefs.js" &&
+ !gTestFiles[gTestFiles.length - 1].originalContents
+ ) {
+ updateLogContents = updateLogContents.replace(/.*defaults\/.*/g, "");
+ }
+
+ if (
+ gTestFiles.length > 2 &&
+ gTestFiles[gTestFiles.length - 2].fileName == FILE_UPDATE_SETTINGS_INI &&
+ !gTestFiles[gTestFiles.length - 2].originalContents
+ ) {
+ updateLogContents = updateLogContents.replace(
+ /.*update-settings.ini.*/g,
+ ""
+ );
+ }
+
+ // Skip the source/destination lines since they contain absolute paths.
+ // These could be changed to relative paths using <test_dir_path> and
+ // <update_dir_path>
+ updateLogContents = updateLogContents.replace(/PATCH DIRECTORY.*/g, "");
+ updateLogContents = updateLogContents.replace(
+ /INSTALLATION DIRECTORY.*/g,
+ ""
+ );
+ updateLogContents = updateLogContents.replace(/WORKING DIRECTORY.*/g, "");
+ // Skip lines that log failed attempts to open the callback executable.
+ updateLogContents = updateLogContents.replace(
+ /NS_main: callback app file .*/g,
+ ""
+ );
+ // Remove carriage returns.
+ updateLogContents = updateLogContents.replace(/\r/g, "");
+
+ if (AppConstants.platform == "win") {
+ // The FindFile results when enumerating the filesystem on Windows is not
+ // determistic so the results matching the following need to be fixed.
+ let re = new RegExp(
+ // eslint-disable-next-line no-control-regex
+ "([^\n]* 7/7text1[^\n]*)\n([^\n]* 7/7text0[^\n]*)\n",
+ "g"
+ );
+ updateLogContents = updateLogContents.replace(re, "$2\n$1\n");
+ }
+
+ if (aReplace) {
+ // Remove the lines which contain absolute paths
+ updateLogContents = updateLogContents.replace(/^Begin moving.*$/gm, "");
+ updateLogContents = updateLogContents.replace(
+ /^ensure_remove: failed to remove file: .*$/gm,
+ ""
+ );
+ updateLogContents = updateLogContents.replace(
+ /^ensure_remove_recursive: unable to remove directory: .*$/gm,
+ ""
+ );
+ updateLogContents = updateLogContents.replace(
+ /^Removing tmpDir failed, err: -1$/gm,
+ ""
+ );
+ updateLogContents = updateLogContents.replace(
+ /^remove_recursive_on_reboot: .*$/gm,
+ ""
+ );
+ // Replace requests will retry renaming the installation directory 10 times
+ // when there are files still in use. The following will remove the
+ // additional entries from the log file when this happens so the log check
+ // passes.
+ let re = new RegExp(
+ ERR_RENAME_FILE +
+ "[^\n]*\n" +
+ "PerformReplaceRequest: destDir rename[^\n]*\n" +
+ "rename_file: proceeding to rename the directory\n",
+ "g"
+ );
+ updateLogContents = updateLogContents.replace(re, "");
+ }
+
+ // Replace error codes since they are different on each platform.
+ updateLogContents = updateLogContents.replace(/, err:.*\n/g, "\n");
+ // Replace to make the log parsing happy.
+ updateLogContents = updateLogContents.replace(/non-fatal error /g, "");
+ // Remove consecutive newlines
+ updateLogContents = updateLogContents.replace(/\n+/g, "\n");
+ // Remove leading and trailing newlines
+ updateLogContents = updateLogContents.replace(/^\n|\n$/g, "");
+ // Replace the log paths with <test_dir_path> and <update_dir_path>
+ updateLogContents = replaceLogPaths(updateLogContents);
+
+ let compareLogContents = "";
+ if (aCompareLogFile) {
+ compareLogContents = readFileBytes(getTestDirFile(aCompareLogFile));
+ }
+
+ if (aStaged) {
+ compareLogContents = PERFORMING_STAGED_UPDATE + "\n" + compareLogContents;
+ }
+
+ // Remove leading timestamps
+ compareLogContents = removeTimeStamps(compareLogContents);
+
+ // The channel-prefs.js is defined in gTestFilesCommon which will always be
+ // located to the end of gTestFiles.
+ if (
+ gTestFiles.length > 1 &&
+ gTestFiles[gTestFiles.length - 1].fileName == "channel-prefs.js" &&
+ !gTestFiles[gTestFiles.length - 1].originalContents
+ ) {
+ compareLogContents = compareLogContents.replace(/.*defaults\/.*/g, "");
+ }
+
+ if (
+ gTestFiles.length > 2 &&
+ gTestFiles[gTestFiles.length - 2].fileName == FILE_UPDATE_SETTINGS_INI &&
+ !gTestFiles[gTestFiles.length - 2].originalContents
+ ) {
+ compareLogContents = compareLogContents.replace(
+ /.*update-settings.ini.*/g,
+ ""
+ );
+ }
+
+ if (aExcludeDistDir) {
+ compareLogContents = compareLogContents.replace(/.*distribution\/.*/g, "");
+ }
+
+ // Remove leading and trailing newlines
+ compareLogContents = compareLogContents.replace(/\n+/g, "\n");
+ // Remove leading and trailing newlines
+ compareLogContents = compareLogContents.replace(/^\n|\n$/g, "");
+
+ // Compare line by line, skipping non-matching lines that may be in the update
+ // log so that these tests don't start failing just because we add a new log
+ // message to the updater.
+ let compareLogContentsArray = compareLogContents.split("\n");
+ let updateLogContentsArray = updateLogContents.split("\n");
+ while (updateLogContentsArray.length && compareLogContentsArray.length) {
+ if (updateLogContentsArray[0] == compareLogContentsArray[0]) {
+ compareLogContentsArray.shift();
+ }
+ updateLogContentsArray.shift();
+ }
+
+ // Don't write the contents of the file to the log to reduce log spam
+ // unless there is a failure.
+ if (!compareLogContentsArray.length) {
+ Assert.ok(true, "the update log contents" + MSG_SHOULD_EQUAL);
+ } else {
+ Assert.ok(
+ false,
+ `the update log is missing the line: '${compareLogContentsArray[0]}'`
+ );
+ }
+}
+
+/**
+ * Helper function to check if the update log contains a string.
+ *
+ * @param aCheckString
+ * The string to check if the update log contains.
+ */
+function checkUpdateLogContains(aCheckString) {
+ let updateLogContents = getLogFileContents();
+ updateLogContents = updateLogContents.replace(/\r\n/g, "\n");
+ updateLogContents = removeTimeStamps(updateLogContents);
+ updateLogContents = replaceLogPaths(updateLogContents);
+
+ // Compare line by line, skipping non-matching lines that may be in the update
+ // log so that these tests don't start failing just because we add a new log
+ // message to the updater.
+ let isFirstCompareLine = true;
+ let compareLogContentsArray = aCheckString.split("\n");
+ let updateLogContentsArray = updateLogContents.split("\n");
+ while (updateLogContentsArray.length && compareLogContentsArray.length) {
+ let isLastCompareLine = compareLogContentsArray.length == 1;
+ if (isFirstCompareLine && isLastCompareLine) {
+ if (updateLogContentsArray[0].includes(compareLogContentsArray[0])) {
+ compareLogContentsArray.shift();
+ isFirstCompareLine = false;
+ }
+ } else if (isFirstCompareLine) {
+ if (updateLogContentsArray[0].endsWith(compareLogContentsArray[0])) {
+ compareLogContentsArray.shift();
+ isFirstCompareLine = false;
+ }
+ } else if (isLastCompareLine) {
+ if (updateLogContentsArray[0].startsWith(compareLogContentsArray[0])) {
+ compareLogContentsArray.shift();
+ }
+ } else if (updateLogContentsArray[0] == compareLogContentsArray[0]) {
+ compareLogContentsArray.shift();
+ }
+ updateLogContentsArray.shift();
+ }
+
+ if (!compareLogContentsArray.length) {
+ Assert.ok(true, "the update log contents" + MSG_SHOULD_EQUAL);
+ } else {
+ Assert.ok(
+ false,
+ `the update log is missing the line: '${compareLogContentsArray[0]}'`
+ );
+ }
+}
+
+/**
+ * Helper function for updater binary tests for verifying the state of files and
+ * directories after a successful update.
+ *
+ * @param aGetFileFunc
+ * The function used to get the files in the directory to be checked.
+ * @param aStageDirExists
+ * If true the staging directory will be tested for existence and if
+ * false the staging directory will be tested for non-existence.
+ * @param aToBeDeletedDirExists
+ * On Windows, if true the tobedeleted directory will be tested for
+ * existence and if false the tobedeleted directory will be tested for
+ * non-existence. On all othere platforms it will be tested for
+ * non-existence.
+ */
+function checkFilesAfterUpdateSuccess(
+ aGetFileFunc,
+ aStageDirExists = false,
+ aToBeDeletedDirExists = false
+) {
+ debugDump("testing contents of files after a successful update");
+ gTestFiles.forEach(function CFAUS_TF_FE(aTestFile) {
+ let testFile = aGetFileFunc(
+ aTestFile.relPathDir + aTestFile.fileName,
+ true
+ );
+ debugDump("testing file: " + testFile.path);
+ if (aTestFile.compareFile || aTestFile.compareContents) {
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testFile.path)
+ );
+
+ // Skip these tests on Windows since chmod doesn't really set permissions
+ // on Windows.
+ if (AppConstants.platform != "win" && aTestFile.comparePerms) {
+ // Check if the permssions as set in the complete mar file are correct.
+ Assert.equal(
+ "0o" + (testFile.permissions & 0xfff).toString(8),
+ "0o" + (aTestFile.comparePerms & 0xfff).toString(8),
+ "the file permissions" + MSG_SHOULD_EQUAL
+ );
+ }
+
+ let fileContents1 = readFileBytes(testFile);
+ let fileContents2 = aTestFile.compareFile
+ ? readFileBytes(getTestDirFile(aTestFile.compareFile))
+ : aTestFile.compareContents;
+ // Don't write the contents of the file to the log to reduce log spam
+ // unless there is a failure.
+ if (fileContents1 == fileContents2) {
+ Assert.ok(true, "the file contents" + MSG_SHOULD_EQUAL);
+ } else {
+ Assert.equal(
+ fileContents1,
+ fileContents2,
+ "the file contents" + MSG_SHOULD_EQUAL
+ );
+ }
+ } else {
+ Assert.ok(
+ !testFile.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(testFile.path)
+ );
+ }
+ });
+
+ debugDump(
+ "testing operations specified in removed-files were performed " +
+ "after a successful update"
+ );
+ gTestDirs.forEach(function CFAUS_TD_FE(aTestDir) {
+ let testDir = aGetFileFunc(aTestDir.relPathDir, true);
+ debugDump("testing directory: " + testDir.path);
+ if (aTestDir.dirRemoved) {
+ Assert.ok(
+ !testDir.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(testDir.path)
+ );
+ } else {
+ Assert.ok(testDir.exists(), MSG_SHOULD_EXIST + getMsgPath(testDir.path));
+
+ if (aTestDir.files) {
+ aTestDir.files.forEach(function CFAUS_TD_F_FE(aTestFile) {
+ let testFile = aGetFileFunc(aTestDir.relPathDir + aTestFile, true);
+ if (aTestDir.filesRemoved) {
+ Assert.ok(
+ !testFile.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(testFile.path)
+ );
+ } else {
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testFile.path)
+ );
+ }
+ });
+ }
+
+ if (aTestDir.subDirs) {
+ aTestDir.subDirs.forEach(function CFAUS_TD_SD_FE(aSubDir) {
+ let testSubDir = aGetFileFunc(aTestDir.relPathDir + aSubDir, true);
+ Assert.ok(
+ testSubDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testSubDir.path)
+ );
+ if (aTestDir.subDirFiles) {
+ aTestDir.subDirFiles.forEach(function CFAUS_TD_SDF_FE(aTestFile) {
+ let testFile = aGetFileFunc(
+ aTestDir.relPathDir + aSubDir + aTestFile,
+ true
+ );
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testFile.path)
+ );
+ });
+ }
+ });
+ }
+ }
+ });
+
+ if (AppConstants.platform == "macosx") {
+ debugDump("testing that xattrs were preserved after a successful update");
+ IOUtils.getMacXAttr(getApplyDirFile().path, MAC_APP_XATTR_KEY).then(
+ bytes => {
+ Assert.equal(
+ new TextDecoder().decode(bytes),
+ MAC_APP_XATTR_VALUE,
+ "xattr value changed"
+ );
+ },
+ reason => {
+ Assert.fail(MAC_APP_XATTR_KEY + " xattr is missing!");
+ }
+ );
+ }
+
+ checkFilesAfterUpdateCommon(aStageDirExists, aToBeDeletedDirExists);
+}
+
+/**
+ * Helper function for updater binary tests for verifying the state of files and
+ * directories after a failed update.
+ *
+ * @param aGetFileFunc
+ * The function used to get the files in the directory to be checked.
+ * @param aStageDirExists
+ * If true the staging directory will be tested for existence and if
+ * false the staging directory will be tested for non-existence.
+ * @param aToBeDeletedDirExists
+ * On Windows, if true the tobedeleted directory will be tested for
+ * existence and if false the tobedeleted directory will be tested for
+ * non-existence. On all othere platforms it will be tested for
+ * non-existence.
+ */
+function checkFilesAfterUpdateFailure(
+ aGetFileFunc,
+ aStageDirExists = false,
+ aToBeDeletedDirExists = false
+) {
+ debugDump("testing contents of files after a failed update");
+ gTestFiles.forEach(function CFAUF_TF_FE(aTestFile) {
+ let testFile = aGetFileFunc(
+ aTestFile.relPathDir + aTestFile.fileName,
+ true
+ );
+ debugDump("testing file: " + testFile.path);
+ if (aTestFile.compareFile || aTestFile.compareContents) {
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testFile.path)
+ );
+
+ // Skip these tests on Windows since chmod doesn't really set permissions
+ // on Windows.
+ if (AppConstants.platform != "win" && aTestFile.comparePerms) {
+ // Check the original permssions are retained on the file.
+ Assert.equal(
+ testFile.permissions & 0xfff,
+ aTestFile.comparePerms & 0xfff,
+ "the file permissions" + MSG_SHOULD_EQUAL
+ );
+ }
+
+ let fileContents1 = readFileBytes(testFile);
+ let fileContents2 = aTestFile.compareFile
+ ? readFileBytes(getTestDirFile(aTestFile.compareFile))
+ : aTestFile.compareContents;
+ // Don't write the contents of the file to the log to reduce log spam
+ // unless there is a failure.
+ if (fileContents1 == fileContents2) {
+ Assert.ok(true, "the file contents" + MSG_SHOULD_EQUAL);
+ } else {
+ Assert.equal(
+ fileContents1,
+ fileContents2,
+ "the file contents" + MSG_SHOULD_EQUAL
+ );
+ }
+ } else {
+ Assert.ok(
+ !testFile.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(testFile.path)
+ );
+ }
+ });
+
+ debugDump(
+ "testing operations specified in removed-files were not " +
+ "performed after a failed update"
+ );
+ gTestDirs.forEach(function CFAUF_TD_FE(aTestDir) {
+ let testDir = aGetFileFunc(aTestDir.relPathDir, true);
+ Assert.ok(testDir.exists(), MSG_SHOULD_EXIST + getMsgPath(testDir.path));
+
+ if (aTestDir.files) {
+ aTestDir.files.forEach(function CFAUS_TD_F_FE(aTestFile) {
+ let testFile = aGetFileFunc(aTestDir.relPathDir + aTestFile, true);
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testFile.path)
+ );
+ });
+ }
+
+ if (aTestDir.subDirs) {
+ aTestDir.subDirs.forEach(function CFAUS_TD_SD_FE(aSubDir) {
+ let testSubDir = aGetFileFunc(aTestDir.relPathDir + aSubDir, true);
+ Assert.ok(
+ testSubDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testSubDir.path)
+ );
+ if (aTestDir.subDirFiles) {
+ aTestDir.subDirFiles.forEach(function CFAUS_TD_SDF_FE(aTestFile) {
+ let testFile = aGetFileFunc(
+ aTestDir.relPathDir + aSubDir + aTestFile,
+ true
+ );
+ Assert.ok(
+ testFile.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(testFile.path)
+ );
+ });
+ }
+ });
+ }
+ });
+
+ checkFilesAfterUpdateCommon(aStageDirExists, aToBeDeletedDirExists);
+}
+
+/**
+ * Helper function for updater binary tests for verifying the state of common
+ * files and directories after a successful or failed update.
+ *
+ * @param aStageDirExists
+ * If true the staging directory will be tested for existence and if
+ * false the staging directory will be tested for non-existence.
+ * @param aToBeDeletedDirExists
+ * On Windows, if true the tobedeleted directory will be tested for
+ * existence and if false the tobedeleted directory will be tested for
+ * non-existence. On all othere platforms it will be tested for
+ * non-existence.
+ */
+function checkFilesAfterUpdateCommon(aStageDirExists, aToBeDeletedDirExists) {
+ debugDump("testing extra directories");
+ let stageDir = getStageDirFile();
+ if (aStageDirExists) {
+ Assert.ok(stageDir.exists(), MSG_SHOULD_EXIST + getMsgPath(stageDir.path));
+ } else {
+ Assert.ok(
+ !stageDir.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(stageDir.path)
+ );
+ }
+
+ let toBeDeletedDirExists =
+ AppConstants.platform == "win" ? aToBeDeletedDirExists : false;
+ let toBeDeletedDir = getApplyDirFile(DIR_TOBEDELETED);
+ if (toBeDeletedDirExists) {
+ Assert.ok(
+ toBeDeletedDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(toBeDeletedDir.path)
+ );
+ } else {
+ Assert.ok(
+ !toBeDeletedDir.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(toBeDeletedDir.path)
+ );
+ }
+
+ let updatingDir = getApplyDirFile("updating");
+ Assert.ok(
+ !updatingDir.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(updatingDir.path)
+ );
+
+ if (stageDir.exists()) {
+ updatingDir = stageDir.clone();
+ updatingDir.append("updating");
+ Assert.ok(
+ !updatingDir.exists(),
+ MSG_SHOULD_NOT_EXIST + getMsgPath(updatingDir.path)
+ );
+ }
+
+ debugDump(
+ "testing backup files should not be left behind in the " +
+ "application directory"
+ );
+ let applyToDir = getApplyDirFile();
+ checkFilesInDirRecursive(applyToDir, checkForBackupFiles);
+
+ if (stageDir.exists()) {
+ debugDump(
+ "testing backup files should not be left behind in the " +
+ "staging directory"
+ );
+ checkFilesInDirRecursive(stageDir, checkForBackupFiles);
+ }
+}
+
+/**
+ * Helper function for updater binary tests for verifying the contents of the
+ * updater callback application log which should contain the arguments passed to
+ * the callback application.
+ *
+ * @param appLaunchLog (optional)
+ * The application log nsIFile to verify. Defaults to the second
+ * parameter passed to the callback executable (in the apply directory).
+ */
+function checkCallbackLog(
+ appLaunchLog = getApplyDirFile(DIR_RESOURCES + gCallbackArgs[1])
+) {
+ if (!appLaunchLog.exists()) {
+ // Uses do_timeout instead of do_execute_soon to lessen log spew.
+ do_timeout(FILE_IN_USE_TIMEOUT_MS, checkCallbackLog);
+ return;
+ }
+
+ let expectedLogContents = gCallbackArgs.join("\n") + "\n";
+ let logContents = readFile(appLaunchLog);
+ // It is possible for the log file contents check to occur before the log file
+ // contents are completely written so wait until the contents are the expected
+ // value. If the contents are never the expected value then the test will
+ // fail by timing out after gTimeoutRuns is greater than MAX_TIMEOUT_RUNS or
+ // the test harness times out the test.
+ const MAX_TIMEOUT_RUNS = 20000;
+ if (logContents != expectedLogContents) {
+ gTimeoutRuns++;
+ if (gTimeoutRuns > MAX_TIMEOUT_RUNS) {
+ logTestInfo("callback log contents are not correct");
+ // This file doesn't contain full paths so there is no need to call
+ // replaceLogPaths.
+ let aryLog = logContents.split("\n");
+ let aryCompare = expectedLogContents.split("\n");
+ // Pushing an empty string to both arrays makes it so either array's length
+ // can be used in the for loop below without going out of bounds.
+ aryLog.push("");
+ aryCompare.push("");
+ // xpcshell tests won't display the entire contents so log the incorrect
+ // line.
+ for (let i = 0; i < aryLog.length; ++i) {
+ if (aryLog[i] != aryCompare[i]) {
+ logTestInfo(
+ "the first incorrect line in the callback log is: " + aryLog[i]
+ );
+ Assert.equal(
+ aryLog[i],
+ aryCompare[i],
+ "the callback log contents" + MSG_SHOULD_EQUAL
+ );
+ }
+ }
+ // This should never happen!
+ do_throw("Unable to find incorrect callback log contents!");
+ }
+ // Uses do_timeout instead of do_execute_soon to lessen log spew.
+ do_timeout(FILE_IN_USE_TIMEOUT_MS, checkCallbackLog);
+ return;
+ }
+ Assert.ok(true, "the callback log contents" + MSG_SHOULD_EQUAL);
+
+ waitForFilesInUse();
+}
+
+/**
+ * Helper function for updater binary tests for getting the log and running
+ * files created by the test helper binary file when called with the post-update
+ * command line argument.
+ *
+ * @param aSuffix
+ * The string to append to the post update test helper binary path.
+ */
+function getPostUpdateFile(aSuffix) {
+ return getApplyDirFile(DIR_RESOURCES + gPostUpdateBinFile + aSuffix);
+}
+
+/**
+ * Checks the contents of the updater post update binary log. When completed
+ * checkPostUpdateAppLogFinished will be called.
+ */
+async function checkPostUpdateAppLog() {
+ // Only Mac OS X and Windows support post update.
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "win") {
+ let file = getPostUpdateFile(".log");
+ await TestUtils.waitForCondition(
+ () => file.exists(),
+ "Waiting for file to exist, path: " + file.path
+ );
+
+ let expectedContents = "post-update\n";
+ await TestUtils.waitForCondition(
+ () => readFile(file) == expectedContents,
+ "Waiting for expected file contents: " + expectedContents
+ );
+ }
+}
+
+/**
+ * Helper function to check if a file is in use on Windows by making a copy of
+ * a file and attempting to delete the original file. If the deletion is
+ * successful the copy of the original file is renamed to the original file's
+ * name and if the deletion is not successful the copy of the original file is
+ * deleted.
+ *
+ * @param aFile
+ * An nsIFile for the file to be checked if it is in use.
+ * @return true if the file can't be deleted and false otherwise.
+ * @throws If called from a platform other than Windows.
+ */
+function isFileInUse(aFile) {
+ if (AppConstants.platform != "win") {
+ do_throw("Windows only function called by a different platform!");
+ }
+
+ if (!aFile.exists()) {
+ debugDump("file does not exist, path: " + aFile.path);
+ return false;
+ }
+
+ let fileBak = aFile.parent;
+ fileBak.append(aFile.leafName + ".bak");
+ try {
+ if (fileBak.exists()) {
+ fileBak.remove(false);
+ }
+ aFile.copyTo(aFile.parent, fileBak.leafName);
+ aFile.remove(false);
+ fileBak.moveTo(aFile.parent, aFile.leafName);
+ debugDump("file is not in use, path: " + aFile.path);
+ return false;
+ } catch (e) {
+ debugDump("file in use, path: " + aFile.path + ", Exception: " + e);
+ try {
+ if (fileBak.exists()) {
+ fileBak.remove(false);
+ }
+ } catch (ex) {
+ logTestInfo(
+ "unable to remove backup file, path: " +
+ fileBak.path +
+ ", Exception: " +
+ ex
+ );
+ }
+ }
+ return true;
+}
+
+/**
+ * Waits until files that are in use that break tests are no longer in use and
+ * then calls doTestFinish to end the test.
+ */
+function waitForFilesInUse() {
+ if (AppConstants.platform == "win") {
+ let fileNames = [
+ FILE_APP_BIN,
+ FILE_UPDATER_BIN,
+ FILE_MAINTENANCE_SERVICE_INSTALLER_BIN,
+ ];
+ for (let i = 0; i < fileNames.length; ++i) {
+ let file = getApplyDirFile(fileNames[i]);
+ if (isFileInUse(file)) {
+ do_timeout(FILE_IN_USE_TIMEOUT_MS, waitForFilesInUse);
+ return;
+ }
+ }
+ }
+
+ debugDump("calling doTestFinish");
+ doTestFinish();
+}
+
+/**
+ * Helper function for updater binary tests for verifying there are no update
+ * backup files left behind after an update.
+ *
+ * @param aFile
+ * An nsIFile to check if it has moz-backup for its extension.
+ */
+function checkForBackupFiles(aFile) {
+ Assert.notEqual(
+ getFileExtension(aFile),
+ "moz-backup",
+ "the file's extension should not equal moz-backup" + getMsgPath(aFile.path)
+ );
+}
+
+/**
+ * Helper function for updater binary tests for recursively enumerating a
+ * directory and calling a callback function with the file as a parameter for
+ * each file found.
+ *
+ * @param aDir
+ * A nsIFile for the directory to be deleted
+ * @param aCallback
+ * A callback function that will be called with the file as a
+ * parameter for each file found.
+ */
+function checkFilesInDirRecursive(aDir, aCallback) {
+ if (!aDir.exists()) {
+ do_throw("Directory must exist!");
+ }
+
+ let dirEntries = aDir.directoryEntries;
+ while (dirEntries.hasMoreElements()) {
+ let entry = dirEntries.nextFile;
+
+ if (entry.exists()) {
+ if (entry.isDirectory()) {
+ checkFilesInDirRecursive(entry, aCallback);
+ } else {
+ aCallback(entry);
+ }
+ }
+ }
+}
+
+/**
+ * Waits for an update check request to complete and asserts that the results
+ * are as-expected.
+ *
+ * @param aSuccess
+ * Whether the update check succeeds or not. If aSuccess is true then
+ * the check should succeed and if aSuccess is false then the check
+ * should fail.
+ * @param aExpectedValues
+ * An object with common values to check.
+ * @return A promise which will resolve with the nsIUpdateCheckResult object
+ * once the update check is complete.
+ */
+async function waitForUpdateCheck(aSuccess, aExpectedValues = {}) {
+ let check = gUpdateChecker.checkForUpdates(gUpdateChecker.FOREGROUND_CHECK);
+ let result = await check.result;
+ Assert.ok(result.checksAllowed, "We should be able to check for updates");
+ Assert.equal(
+ result.succeeded,
+ aSuccess,
+ "the update check should " + (aSuccess ? "succeed" : "error")
+ );
+ if (aExpectedValues.updateCount) {
+ Assert.equal(
+ aExpectedValues.updateCount,
+ result.updates.length,
+ "the update count" + MSG_SHOULD_EQUAL
+ );
+ }
+ if (aExpectedValues.url) {
+ Assert.equal(
+ aExpectedValues.url,
+ result.request.channel.originalURI.spec,
+ "the url" + MSG_SHOULD_EQUAL
+ );
+ }
+ return result;
+}
+
+/**
+ * Downloads an update and waits for the download onStopRequest.
+ *
+ * @param aUpdates
+ * An array of updates to select from to download an update.
+ * @param aUpdateCount
+ * The number of updates in the aUpdates array.
+ * @param aExpectedStatus
+ * The download onStopRequest expected status.
+ * @return A promise which will resolve the first time the update download
+ * onStopRequest occurs and returns the arguments from onStopRequest.
+ */
+async function waitForUpdateDownload(aUpdates, aExpectedStatus) {
+ let bestUpdate = gAUS.selectUpdate(aUpdates);
+ let success = await gAUS.downloadUpdate(bestUpdate, false);
+ if (!success) {
+ do_throw("nsIApplicationUpdateService:downloadUpdate returned " + success);
+ }
+ return new Promise(resolve =>
+ gAUS.addDownloadListener({
+ onStartRequest: aRequest => {},
+ onProgress: (aRequest, aContext, aProgress, aMaxProgress) => {},
+ onStatus: (aRequest, aStatus, aStatusText) => {},
+ onStopRequest(request, status) {
+ gAUS.removeDownloadListener(this);
+ Assert.equal(
+ aExpectedStatus,
+ status,
+ "the download status" + MSG_SHOULD_EQUAL
+ );
+ resolve(request, status);
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsIProgressEventSink",
+ ]),
+ })
+ );
+}
+
+/**
+ * Helper for starting the http server used by the tests
+ */
+function start_httpserver() {
+ let dir = getTestDirFile();
+ debugDump("http server directory path: " + dir.path);
+
+ if (!dir.isDirectory()) {
+ do_throw(
+ "A file instead of a directory was specified for HttpServer " +
+ "registerDirectory! Path: " +
+ dir.path
+ );
+ }
+
+ let { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+ );
+ gTestserver = new HttpServer();
+ gTestserver.registerDirectory("/", dir);
+ gTestserver.registerPathHandler("/" + gHTTPHandlerPath, pathHandler);
+ gTestserver.start(-1);
+ let testserverPort = gTestserver.identity.primaryPort;
+ // eslint-disable-next-line no-global-assign
+ gURLData = URL_HOST + ":" + testserverPort + "/";
+ debugDump("http server port = " + testserverPort);
+}
+
+/**
+ * Custom path handler for the http server
+ *
+ * @param aMetadata
+ * The http metadata for the request.
+ * @param aResponse
+ * The http response for the request.
+ */
+function pathHandler(aMetadata, aResponse) {
+ gUpdateCheckCount += 1;
+ aResponse.setHeader("Content-Type", "text/xml", false);
+ aResponse.setStatusLine(aMetadata.httpVersion, 200, "OK");
+ aResponse.bodyOutputStream.write(gResponseBody, gResponseBody.length);
+}
+
+/**
+ * Helper for stopping the http server used by the tests
+ *
+ * @param aCallback
+ * The callback to call after stopping the http server.
+ */
+function stop_httpserver(aCallback) {
+ Assert.ok(!!aCallback, "the aCallback parameter should be defined");
+ gTestserver.stop(aCallback);
+}
+
+/**
+ * Creates an nsIXULAppInfo
+ *
+ * @param aID
+ * The ID of the test application
+ * @param aName
+ * A name for the test application
+ * @param aVersion
+ * The version of the application
+ * @param aPlatformVersion
+ * The gecko version of the application
+ */
+function createAppInfo(aID, aName, aVersion, aPlatformVersion) {
+ updateAppInfo({
+ vendor: APP_INFO_VENDOR,
+ name: aName,
+ ID: aID,
+ version: aVersion,
+ appBuildID: "2007010101",
+ platformVersion: aPlatformVersion,
+ platformBuildID: "2007010101",
+ inSafeMode: false,
+ logConsoleErrors: true,
+ OS: "XPCShell",
+ XPCOMABI: "noarch-spidermonkey",
+ });
+}
+
+/**
+ * Returns the platform specific arguments used by nsIProcess when launching
+ * the application.
+ *
+ * @param aExtraArgs (optional)
+ * An array of extra arguments to append to the default arguments.
+ * @return an array of arguments to be passed to nsIProcess.
+ *
+ * Note: a shell is necessary to pipe the application's console output which
+ * would otherwise pollute the xpcshell log.
+ *
+ * Command line arguments used when launching the application:
+ * -no-remote prevents shell integration from being affected by an existing
+ * application process.
+ * -test-process-updates makes the application exit after being relaunched by
+ * the updater.
+ * the platform specific string defined by PIPE_TO_NULL to output both stdout
+ * and stderr to null. This is needed to prevent output from the application
+ * from ending up in the xpchsell log.
+ */
+function getProcessArgs(aExtraArgs) {
+ if (!aExtraArgs) {
+ aExtraArgs = [];
+ }
+
+ let appBin = getApplyDirFile(DIR_MACOS + FILE_APP_BIN);
+ Assert.ok(appBin.exists(), MSG_SHOULD_EXIST + ", path: " + appBin.path);
+ let appBinPath = appBin.path;
+
+ // The profile must be specified for the tests that launch the application to
+ // run locally when the profiles.ini and installs.ini files already exist.
+ // We can't use getApplyDirFile to find the profile path because on Windows
+ // for service tests that would place the profile inside Program Files, and
+ // this test script has permission to write in Program Files, but the
+ // application may drop those permissions. So for Windows service tests we
+ // override that path with the per-test temp directory that xpcshell provides,
+ // which should be user writable.
+ let profileDir = appBin.parent.parent;
+ if (gIsServiceTest && IS_AUTHENTICODE_CHECK_ENABLED) {
+ profileDir = do_get_tempdir();
+ }
+ profileDir.append("profile");
+ let profilePath = profileDir.path;
+
+ let args;
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "linux") {
+ let launchScript = getLaunchScript();
+ // Precreate the script with executable permissions
+ launchScript.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_DIRECTORY);
+
+ let scriptContents = "#! /bin/sh\n";
+ scriptContents += "export XRE_PROFILE_PATH=" + profilePath + "\n";
+ scriptContents +=
+ appBinPath +
+ " -no-remote -test-process-updates " +
+ aExtraArgs.join(" ") +
+ " " +
+ PIPE_TO_NULL;
+ writeFile(launchScript, scriptContents);
+ debugDump(
+ "created " + launchScript.path + " containing:\n" + scriptContents
+ );
+ args = [launchScript.path];
+ } else {
+ args = [
+ "/D",
+ "/Q",
+ "/C",
+ appBinPath,
+ "-profile",
+ profilePath,
+ "-no-remote",
+ "-test-process-updates",
+ "-wait-for-browser",
+ ]
+ .concat(aExtraArgs)
+ .concat([PIPE_TO_NULL]);
+ }
+ return args;
+}
+
+/**
+ * Gets a file path for the application to dump its arguments into. This is used
+ * to verify that a callback application is launched.
+ *
+ * @return the file for the application to dump its arguments into.
+ */
+function getAppArgsLogPath() {
+ let appArgsLog = do_get_file("/" + gTestID + "_app_args_log", true);
+ if (appArgsLog.exists()) {
+ appArgsLog.remove(false);
+ }
+ let appArgsLogPath = appArgsLog.path;
+ if (/ /.test(appArgsLogPath)) {
+ appArgsLogPath = '"' + appArgsLogPath + '"';
+ }
+ return appArgsLogPath;
+}
+
+/**
+ * Gets the nsIFile reference for the shell script to launch the application. If
+ * the file exists it will be removed by this function.
+ *
+ * @return the nsIFile for the shell script to launch the application.
+ */
+function getLaunchScript() {
+ let launchScript = do_get_file("/" + gTestID + "_launch.sh", true);
+ if (launchScript.exists()) {
+ launchScript.remove(false);
+ }
+ return launchScript;
+}
+
+/**
+ * Makes GreD, XREExeF, and UpdRootD point to unique file system locations so
+ * xpcshell tests can run in parallel and to keep the environment clean.
+ */
+function adjustGeneralPaths() {
+ let dirProvider = {
+ getFile: function AGP_DP_getFile(aProp, aPersistent) {
+ // Set the value of persistent to false so when this directory provider is
+ // unregistered it will revert back to the original provider.
+ aPersistent.value = false;
+ switch (aProp) {
+ case NS_GRE_DIR:
+ return getApplyDirFile(DIR_RESOURCES);
+ case NS_GRE_BIN_DIR:
+ return getApplyDirFile(DIR_MACOS);
+ case XRE_EXECUTABLE_FILE:
+ return getApplyDirFile(DIR_MACOS + FILE_APP_BIN);
+ case XRE_UPDATE_ROOT_DIR:
+ return getMockUpdRootD();
+ case XRE_OLD_UPDATE_ROOT_DIR:
+ return getMockUpdRootD(true);
+ }
+ return null;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ let ds = Services.dirsvc.QueryInterface(Ci.nsIDirectoryService);
+ ds.QueryInterface(Ci.nsIProperties).undefine(NS_GRE_DIR);
+ ds.QueryInterface(Ci.nsIProperties).undefine(NS_GRE_BIN_DIR);
+ ds.QueryInterface(Ci.nsIProperties).undefine(XRE_EXECUTABLE_FILE);
+ ds.registerProvider(dirProvider);
+ registerCleanupFunction(function AGP_cleanup() {
+ debugDump("start - unregistering directory provider");
+
+ if (gAppTimer) {
+ debugDump("start - cancel app timer");
+ gAppTimer.cancel();
+ gAppTimer = null;
+ debugDump("finish - cancel app timer");
+ }
+
+ if (gProcess && gProcess.isRunning) {
+ debugDump("start - kill process");
+ try {
+ gProcess.kill();
+ } catch (e) {
+ debugDump("kill process failed, Exception: " + e);
+ }
+ gProcess = null;
+ debugDump("finish - kill process");
+ }
+
+ if (gPIDPersistProcess && gPIDPersistProcess.isRunning) {
+ debugDump("start - kill pid persist process");
+ try {
+ gPIDPersistProcess.kill();
+ } catch (e) {
+ debugDump("kill pid persist process failed, Exception: " + e);
+ }
+ gPIDPersistProcess = null;
+ debugDump("finish - kill pid persist process");
+ }
+
+ if (gHandle) {
+ try {
+ debugDump("start - closing handle");
+ let kernel32 = ctypes.open("kernel32");
+ let CloseHandle = kernel32.declare(
+ "CloseHandle",
+ ctypes.winapi_abi,
+ ctypes.bool /* return*/,
+ ctypes.voidptr_t /* handle*/
+ );
+ if (!CloseHandle(gHandle)) {
+ debugDump("call to CloseHandle failed");
+ }
+ kernel32.close();
+ gHandle = null;
+ debugDump("finish - closing handle");
+ } catch (e) {
+ debugDump("call to CloseHandle failed, Exception: " + e);
+ }
+ }
+
+ ds.unregisterProvider(dirProvider);
+ cleanupTestCommon();
+
+ // Now that our provider is unregistered, reset the lock a second time so
+ // that we know the lock we're interested in gets released (xpcshell
+ // doesn't always run a proper XPCOM shutdown sequence, which is where that
+ // would normally be happening).
+ let syncManager = Cc[
+ "@mozilla.org/updates/update-sync-manager;1"
+ ].getService(Ci.nsIUpdateSyncManager);
+ syncManager.resetLock();
+
+ debugDump("finish - unregistering directory provider");
+ });
+}
+
+/**
+ * The timer callback to kill the process if it takes too long.
+ */
+const gAppTimerCallback = {
+ notify: function TC_notify(aTimer) {
+ gAppTimer = null;
+ if (gProcess.isRunning) {
+ logTestInfo("attempting to kill process");
+ gProcess.kill();
+ }
+ Assert.ok(false, "launch application timer expired");
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsITimerCallback"]),
+};
+
+/**
+ * Launches an application to apply an update.
+ *
+ * @param aExpectedStatus
+ * The expected value of update.status when the update finishes.
+ */
+async function runUpdateUsingApp(aExpectedStatus) {
+ debugDump("start - launching application to apply update");
+
+ // The maximum number of milliseconds the process that is launched can run
+ // before the test will try to kill it.
+ const APP_TIMER_TIMEOUT = 120000;
+ let launchBin = getLaunchBin();
+ let args = getProcessArgs();
+ debugDump("launching " + launchBin.path + " " + args.join(" "));
+
+ gProcess = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
+ gProcess.init(launchBin);
+
+ gAppTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ gAppTimer.initWithCallback(
+ gAppTimerCallback,
+ APP_TIMER_TIMEOUT,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ setEnvironment();
+
+ debugDump("launching application");
+ gProcess.run(true, args, args.length);
+ debugDump("launched application exited");
+
+ resetEnvironment();
+
+ if (AppConstants.platform == "win") {
+ waitForApplicationStop(FILE_UPDATER_BIN);
+ }
+
+ let file = getUpdateDirFile(FILE_UPDATE_STATUS);
+ await TestUtils.waitForCondition(
+ () => file.exists(),
+ "Waiting for file to exist, path: " + file.path
+ );
+
+ await TestUtils.waitForCondition(
+ () => readStatusFile() == aExpectedStatus,
+ "Waiting for expected status file contents: " + aExpectedStatus
+ ).catch(e => {
+ // Instead of throwing let the check below fail the test so the status
+ // file's contents are logged.
+ logTestInfo(e);
+ });
+ Assert.equal(
+ readStatusFile(),
+ aExpectedStatus,
+ "the status file state" + MSG_SHOULD_EQUAL
+ );
+
+ // Don't check for an update log when the code in nsUpdateDriver.cpp skips
+ // updating.
+ if (
+ aExpectedStatus != STATE_PENDING &&
+ aExpectedStatus != STATE_PENDING_SVC &&
+ aExpectedStatus != STATE_APPLIED &&
+ aExpectedStatus != STATE_APPLIED_SVC
+ ) {
+ // Don't proceed until the update log has been created.
+ file = getUpdateDirFile(FILE_UPDATE_LOG);
+ await TestUtils.waitForCondition(
+ () => file.exists(),
+ "Waiting for file to exist, path: " + file.path
+ );
+ }
+
+ debugDump("finish - launching application to apply update");
+}
+
+/* This Mock incremental downloader is used to verify that connection interrupts
+ * work correctly in updater code. The implementation of the mock incremental
+ * downloader is very simple, it simply copies the file to the destination
+ * location.
+ */
+function initMockIncrementalDownload() {
+ const INC_CONTRACT_ID = "@mozilla.org/network/incremental-download;1";
+ let incrementalDownloadCID = MockRegistrar.register(
+ INC_CONTRACT_ID,
+ IncrementalDownload
+ );
+ registerCleanupFunction(() => {
+ MockRegistrar.unregister(incrementalDownloadCID);
+ });
+}
+
+function IncrementalDownload() {
+ this.wrappedJSObject = this;
+}
+
+IncrementalDownload.prototype = {
+ /* nsIIncrementalDownload */
+ init(uri, file, chunkSize, intervalInSeconds) {
+ this._destination = file;
+ this._URI = uri;
+ this._finalURI = uri;
+ },
+
+ start(observer, ctxt) {
+ // Do the actual operation async to give a chance for observers to add
+ // themselves.
+ Services.tm.dispatchToMainThread(() => {
+ this._observer = observer.QueryInterface(Ci.nsIRequestObserver);
+ this._ctxt = ctxt;
+ this._observer.onStartRequest(this);
+ let mar = getTestDirFile(FILE_SIMPLE_MAR);
+ mar.copyTo(this._destination.parent, this._destination.leafName);
+ let status = Cr.NS_OK;
+ switch (gIncrementalDownloadErrorType++) {
+ case 0:
+ status = Cr.NS_ERROR_NET_RESET;
+ break;
+ case 1:
+ status = Cr.NS_ERROR_CONNECTION_REFUSED;
+ break;
+ case 2:
+ status = Cr.NS_ERROR_NET_RESET;
+ break;
+ case 3:
+ status = Cr.NS_OK;
+ break;
+ case 4:
+ status = Cr.NS_ERROR_OFFLINE;
+ // After we report offline, we want to eventually show offline
+ // status being changed to online.
+ Services.tm.dispatchToMainThread(function () {
+ Services.obs.notifyObservers(
+ gAUS,
+ "network:offline-status-changed",
+ "online"
+ );
+ });
+ break;
+ }
+ this._observer.onStopRequest(this, status);
+ });
+ },
+
+ get URI() {
+ return this._URI;
+ },
+
+ get currentSize() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get destination() {
+ return this._destination;
+ },
+
+ get finalURI() {
+ return this._finalURI;
+ },
+
+ get totalSize() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ /* nsIRequest */
+ cancel(aStatus) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ suspend() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ isPending() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ _loadFlags: 0,
+ get loadFlags() {
+ return this._loadFlags;
+ },
+ set loadFlags(val) {
+ this._loadFlags = val;
+ },
+
+ _loadGroup: null,
+ get loadGroup() {
+ return this._loadGroup;
+ },
+ set loadGroup(val) {
+ this._loadGroup = val;
+ },
+
+ _name: "",
+ get name() {
+ return this._name;
+ },
+
+ _status: 0,
+ get status() {
+ return this._status;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIIncrementalDownload"]),
+};
+
+/**
+ * Sets the environment that will be used by the application process when it is
+ * launched.
+ */
+function setEnvironment() {
+ if (AppConstants.platform == "win") {
+ // The tests use nsIProcess to launch the updater and it is simpler to just
+ // set an environment variable and have the test updater set the current
+ // working directory than it is to set the current working directory in the
+ // test itself.
+ Services.env.set("CURWORKDIRPATH", getApplyDirFile().path);
+ }
+
+ // Prevent setting the environment more than once.
+ if (gShouldResetEnv !== undefined) {
+ return;
+ }
+
+ gShouldResetEnv = true;
+
+ if (
+ AppConstants.platform == "win" &&
+ !Services.env.exists("XRE_NO_WINDOWS_CRASH_DIALOG")
+ ) {
+ gAddedEnvXRENoWindowsCrashDialog = true;
+ debugDump(
+ "setting the XRE_NO_WINDOWS_CRASH_DIALOG environment " +
+ "variable to 1... previously it didn't exist"
+ );
+ Services.env.set("XRE_NO_WINDOWS_CRASH_DIALOG", "1");
+ }
+
+ if (Services.env.exists("XPCOM_MEM_LEAK_LOG")) {
+ gEnvXPCOMMemLeakLog = Services.env.get("XPCOM_MEM_LEAK_LOG");
+ debugDump(
+ "removing the XPCOM_MEM_LEAK_LOG environment variable... " +
+ "previous value " +
+ gEnvXPCOMMemLeakLog
+ );
+ Services.env.set("XPCOM_MEM_LEAK_LOG", "");
+ }
+
+ if (Services.env.exists("XPCOM_DEBUG_BREAK")) {
+ gEnvXPCOMDebugBreak = Services.env.get("XPCOM_DEBUG_BREAK");
+ debugDump(
+ "setting the XPCOM_DEBUG_BREAK environment variable to " +
+ "warn... previous value " +
+ gEnvXPCOMDebugBreak
+ );
+ } else {
+ debugDump(
+ "setting the XPCOM_DEBUG_BREAK environment variable to " +
+ "warn... previously it didn't exist"
+ );
+ }
+
+ Services.env.set("XPCOM_DEBUG_BREAK", "warn");
+
+ if (gEnvForceServiceFallback) {
+ // This env variable forces the updater to use the service in an invalid
+ // way, so that it has to fall back to updating without the service.
+ debugDump("setting MOZ_FORCE_SERVICE_FALLBACK environment variable to 1");
+ Services.env.set("MOZ_FORCE_SERVICE_FALLBACK", "1");
+ } else if (gIsServiceTest) {
+ debugDump("setting MOZ_NO_SERVICE_FALLBACK environment variable to 1");
+ Services.env.set("MOZ_NO_SERVICE_FALLBACK", "1");
+ }
+}
+
+/**
+ * Sets the environment back to the original values after launching the
+ * application.
+ */
+function resetEnvironment() {
+ // Prevent resetting the environment more than once.
+ if (gShouldResetEnv !== true) {
+ return;
+ }
+
+ gShouldResetEnv = false;
+
+ if (gEnvXPCOMMemLeakLog) {
+ debugDump(
+ "setting the XPCOM_MEM_LEAK_LOG environment variable back to " +
+ gEnvXPCOMMemLeakLog
+ );
+ Services.env.set("XPCOM_MEM_LEAK_LOG", gEnvXPCOMMemLeakLog);
+ }
+
+ if (gEnvXPCOMDebugBreak) {
+ debugDump(
+ "setting the XPCOM_DEBUG_BREAK environment variable back to " +
+ gEnvXPCOMDebugBreak
+ );
+ Services.env.set("XPCOM_DEBUG_BREAK", gEnvXPCOMDebugBreak);
+ } else if (Services.env.exists("XPCOM_DEBUG_BREAK")) {
+ debugDump("clearing the XPCOM_DEBUG_BREAK environment variable");
+ Services.env.set("XPCOM_DEBUG_BREAK", "");
+ }
+
+ if (AppConstants.platform == "win" && gAddedEnvXRENoWindowsCrashDialog) {
+ debugDump("removing the XRE_NO_WINDOWS_CRASH_DIALOG environment variable");
+ Services.env.set("XRE_NO_WINDOWS_CRASH_DIALOG", "");
+ }
+
+ if (gEnvForceServiceFallback) {
+ debugDump("removing MOZ_FORCE_SERVICE_FALLBACK environment variable");
+ Services.env.set("MOZ_FORCE_SERVICE_FALLBACK", "");
+ } else if (gIsServiceTest) {
+ debugDump("removing MOZ_NO_SERVICE_FALLBACK environment variable");
+ Services.env.set("MOZ_NO_SERVICE_FALLBACK", "");
+ }
+}
diff --git a/toolkit/mozapps/update/tests/diff_base_service.bash b/toolkit/mozapps/update/tests/diff_base_service.bash
new file mode 100644
index 0000000000..bf2338666d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/diff_base_service.bash
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+
+# Compare the common files in unit_base_updater and unit_service_updater.
+
+while read LINE1 && read LINE2 && read BLANK; do
+ diff -U 3 "unit_base_updater/$LINE1" "unit_service_updater/$LINE2"
+done <<END
+invalidArgInstallDirPathTooLongFailure.js
+invalidArgInstallDirPathTooLongFailureSvc.js
+
+invalidArgInstallDirPathTraversalFailure.js
+invalidArgInstallDirPathTraversalFailureSvc.js
+
+invalidArgInstallWorkingDirPathNotSameFailure_win.js
+invalidArgInstallWorkingDirPathNotSameFailureSvc_win.js
+
+invalidArgPatchDirPathTraversalFailure.js
+invalidArgPatchDirPathTraversalFailureSvc.js
+
+invalidArgStageDirNotInInstallDirFailure_win.js
+invalidArgStageDirNotInInstallDirFailureSvc_win.js
+
+invalidArgWorkingDirPathLocalUNCFailure_win.js
+invalidArgWorkingDirPathLocalUNCFailureSvc_win.js
+
+invalidArgWorkingDirPathRelativeFailure.js
+invalidArgWorkingDirPathRelativeFailureSvc.js
+
+marAppApplyDirLockedStageFailure_win.js
+marAppApplyDirLockedStageFailureSvc_win.js
+
+marAppApplyUpdateAppBinInUseStageSuccess_win.js
+marAppApplyUpdateAppBinInUseStageSuccessSvc_win.js
+
+marAppApplyUpdateStageSuccess.js
+marAppApplyUpdateStageSuccessSvc.js
+
+marAppApplyUpdateSuccess.js
+marAppApplyUpdateSuccessSvc.js
+
+marAppInUseBackgroundTaskFailure_win.js
+marAppInUseBackgroundTaskFailureSvc_win.js
+
+marAppInUseStageFailureComplete_win.js
+marAppInUseStageFailureCompleteSvc_win.js
+
+marAppInUseSuccessComplete.js
+marAppInUseSuccessCompleteSvc.js
+
+marCallbackAppStageSuccessComplete_win.js
+marCallbackAppStageSuccessCompleteSvc_win.js
+
+marCallbackAppStageSuccessPartial_win.js
+marCallbackAppStageSuccessPartialSvc_win.js
+
+marCallbackAppSuccessComplete_win.js
+marCallbackAppSuccessCompleteSvc_win.js
+
+marCallbackAppSuccessPartial_win.js
+marCallbackAppSuccessPartialSvc_win.js
+
+marFailurePartial.js
+marFailurePartialSvc.js
+
+marFileInUseStageFailureComplete_win.js
+marFileInUseStageFailureCompleteSvc_win.js
+
+marFileInUseStageFailurePartial_win.js
+marFileInUseStageFailurePartialSvc_win.js
+
+marFileInUseSuccessComplete_win.js
+marFileInUseSuccessCompleteSvc_win.js
+
+marFileInUseSuccessPartial_win.js
+marFileInUseSuccessPartialSvc_win.js
+
+marFileLockedFailureComplete_win.js
+marFileLockedFailureCompleteSvc_win.js
+
+marFileLockedFailurePartial_win.js
+marFileLockedFailurePartialSvc_win.js
+
+marFileLockedStageFailureComplete_win.js
+marFileLockedStageFailureCompleteSvc_win.js
+
+marFileLockedStageFailurePartial_win.js
+marFileLockedStageFailurePartialSvc_win.js
+
+marRMRFDirFileInUseStageFailureComplete_win.js
+marRMRFDirFileInUseStageFailureCompleteSvc_win.js
+
+marRMRFDirFileInUseStageFailurePartial_win.js
+marRMRFDirFileInUseStageFailurePartialSvc_win.js
+
+marRMRFDirFileInUseSuccessComplete_win.js
+marRMRFDirFileInUseSuccessCompleteSvc_win.js
+
+marRMRFDirFileInUseSuccessPartial_win.js
+marRMRFDirFileInUseSuccessPartialSvc_win.js
+
+marStageFailurePartial.js
+marStageFailurePartialSvc.js
+
+marStageSuccessComplete.js
+marStageSuccessCompleteSvc.js
+
+marStageSuccessPartial.js
+marStageSuccessPartialSvc.js
+
+marSuccessComplete.js
+marSuccessCompleteSvc.js
+
+marSuccessPartial.js
+marSuccessPartialSvc.js
+
+END
diff --git a/toolkit/mozapps/update/tests/marionette/marionette.toml b/toolkit/mozapps/update/tests/marionette/marionette.toml
new file mode 100644
index 0000000000..5487eb3938
--- /dev/null
+++ b/toolkit/mozapps/update/tests/marionette/marionette.toml
@@ -0,0 +1,5 @@
+[DEFAULT]
+
+["test_no_window_update_restart.py"]
+run-if = ["os == 'mac'"]
+reason = "Test of a mac only feature"
diff --git a/toolkit/mozapps/update/tests/marionette/test_no_window_update_restart.py b/toolkit/mozapps/update/tests/marionette/test_no_window_update_restart.py
new file mode 100644
index 0000000000..a8495064ef
--- /dev/null
+++ b/toolkit/mozapps/update/tests/marionette/test_no_window_update_restart.py
@@ -0,0 +1,255 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+# This test is not written in a very modular way because update tests are generally not done with
+# Marionette, so this is sort of a one-off. We can't successfully just load all the actual setup
+# code that the normal update tests use, so this test basically just copies the bits that it needs.
+# The reason that this is a Marionette test at all is that, even if we stub out the quit/restart
+# call, we need no windows to be open to test the relevant functionality, but xpcshell doesn't do
+# windows at all and mochitest has a test runner window that Firefox recognizes, but mustn't close
+# during testing.
+
+from marionette_driver import Wait, errors
+from marionette_harness import MarionetteTestCase
+
+
+class TestNoWindowUpdateRestart(MarionetteTestCase):
+ def setUp(self):
+ super(TestNoWindowUpdateRestart, self).setUp()
+
+ self.marionette.delete_session()
+ self.marionette.start_session({"moz:windowless": True})
+ # See Bug 1777956
+ window = self.marionette.window_handles[0]
+ self.marionette.switch_to_window(window)
+
+ # Every part of this test ought to run in the chrome context.
+ self.marionette.set_context(self.marionette.CONTEXT_CHROME)
+ self.setUpBrowser()
+ self.origDisabledForTesting = self.marionette.get_pref(
+ "app.update.disabledForTesting"
+ )
+ self.resetUpdate()
+
+ def setUpBrowser(self):
+ self.origAppUpdateAuto = self.marionette.execute_async_script(
+ """
+ let [resolve] = arguments;
+
+ (async () => {
+ Services.prefs.setIntPref("app.update.download.attempts", 0);
+ Services.prefs.setIntPref("app.update.download.maxAttempts", 0);
+ Services.prefs.setBoolPref("app.update.staging.enabled", false);
+ Services.prefs.setBoolPref("app.update.noWindowAutoRestart.enabled", true);
+ Services.prefs.setIntPref("app.update.noWindowAutoRestart.delayMs", 1000);
+ Services.prefs.clearUserPref("testing.no_window_update_restart.silent_restart_env");
+
+ let { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+ );
+ let origAppUpdateAuto = await UpdateUtils.getAppUpdateAutoEnabled();
+ await UpdateUtils.setAppUpdateAutoEnabled(true);
+
+ // Prevent the update sync manager from thinking there are two instances running
+ let exePath = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+ let dirProvider = {
+ getFile: function AGP_DP_getFile(aProp, aPersistent) {
+ aPersistent.value = false;
+ switch (aProp) {
+ case "XREExeF":
+ exePath.append("browser-test");
+ return exePath;
+ }
+ return null;
+ },
+ QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]),
+ };
+ let ds = Services.dirsvc.QueryInterface(Ci.nsIDirectoryService);
+ ds.QueryInterface(Ci.nsIProperties).undefine("XREExeF");
+ ds.registerProvider(dirProvider);
+ let gSyncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ gSyncManager.resetLock();
+ ds.unregisterProvider(dirProvider);
+
+ return origAppUpdateAuto;
+ })().then(resolve);
+ """
+ )
+
+ def tearDown(self):
+ self.tearDownBrowser()
+ self.resetUpdate()
+
+ # Reset context to the default.
+ self.marionette.set_context(self.marionette.CONTEXT_CONTENT)
+
+ super(TestNoWindowUpdateRestart, self).tearDown()
+
+ def tearDownBrowser(self):
+ self.marionette.execute_async_script(
+ """
+ let [origAppUpdateAuto, origDisabledForTesting, resolve] = arguments;
+ (async () => {
+ Services.prefs.setBoolPref("app.update.disabledForTesting", origDisabledForTesting);
+ Services.prefs.clearUserPref("app.update.download.attempts");
+ Services.prefs.clearUserPref("app.update.download.maxAttempts");
+ Services.prefs.clearUserPref("app.update.staging.enabled");
+ Services.prefs.clearUserPref("app.update.noWindowAutoRestart.enabled");
+ Services.prefs.clearUserPref("app.update.noWindowAutoRestart.delayMs");
+ Services.prefs.clearUserPref("testing.no_window_update_restart.silent_restart_env");
+
+ let { UpdateUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+ );
+ await UpdateUtils.setAppUpdateAutoEnabled(origAppUpdateAuto);
+ })().then(resolve);
+ """,
+ script_args=(self.origAppUpdateAuto, self.origDisabledForTesting),
+ )
+
+ def test_update_on_last_window_close(self):
+ # By preparing an update and closing all windows, we activate the No
+ # Window Update Restart feature (see Bug 1720742) which causes Firefox
+ # to restart to install updates.
+ self.marionette.restart(
+ callback=self.prepare_update_and_close_all_windows, in_app=True
+ )
+
+ # Firefox should come back without any windows (i.e. silently).
+ with self.assertRaises(errors.TimeoutException):
+ wait = Wait(
+ self.marionette,
+ ignored_exceptions=errors.NoSuchWindowException,
+ timeout=5,
+ )
+ wait.until(lambda _: self.marionette.window_handles)
+
+ # Reset the browser and active WebDriver session
+ self.marionette.restart(in_app=True)
+ self.marionette.delete_session()
+ self.marionette.start_session()
+ self.marionette.set_context(self.marionette.CONTEXT_CHROME)
+
+ quit_flags_correct = self.marionette.get_pref(
+ "testing.no_window_update_restart.silent_restart_env"
+ )
+ self.assertTrue(quit_flags_correct)
+
+ # Normally, the update status file would have been removed at this point by Post Update
+ # Processing. But restarting resets app.update.disabledForTesting, which causes that to be
+ # skipped, allowing us to look at the update status file directly.
+ update_status_path = self.marionette.execute_script(
+ """
+ let statusFile = FileUtils.getDir("UpdRootD", ["updates", "0"]);
+ statusFile.append("update.status");
+ return statusFile.path;
+ """
+ )
+ with open(update_status_path, "r") as f:
+ # If Firefox was built with "--enable-unverified-updates" (or presumably if we tested
+ # with an actual, signed update), the update should succeed. Otherwise, it will fail
+ # with CERT_VERIFY_ERROR (error code 19). Unfortunately, there is no good way to tell
+ # which of those situations we are in. Luckily, it doesn't matter, because we aren't
+ # trying to test whether the update applied successfully, just whether the
+ # "No Window Update Restart" feature works.
+ self.assertIn(f.read().strip(), ["succeeded", "failed: 19"])
+
+ def resetUpdate(self):
+ self.marionette.execute_script(
+ """
+ let UM = Cc["@mozilla.org/updates/update-manager;1"].getService(Ci.nsIUpdateManager);
+ UM.QueryInterface(Ci.nsIObserver).observe(null, "um-reload-update-data", "skip-files");
+
+ let { UpdateListener } = ChromeUtils.import("resource://gre/modules/UpdateListener.jsm");
+ UpdateListener.reset();
+
+ let { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+ );
+ AppMenuNotifications.removeNotification(/.*/);
+
+ // Remove old update files so that they don't interfere with tests.
+ let rootUpdateDir = Services.dirsvc.get("UpdRootD", Ci.nsIFile);
+ let updateDir = rootUpdateDir.clone();
+ updateDir.append("updates");
+ let patchDir = updateDir.clone();
+ patchDir.append("0");
+
+ let filesToRemove = [];
+ let addFileToRemove = (dir, filename) => {
+ let file = dir.clone();
+ file.append(filename);
+ filesToRemove.push(file);
+ };
+
+ addFileToRemove(rootUpdateDir, "active-update.xml");
+ addFileToRemove(rootUpdateDir, "updates.xml");
+ addFileToRemove(patchDir, "bt.result");
+ addFileToRemove(patchDir, "update.status");
+ addFileToRemove(patchDir, "update.version");
+ addFileToRemove(patchDir, "update.mar");
+ addFileToRemove(patchDir, "updater.ini");
+ addFileToRemove(updateDir, "backup-update.log");
+ addFileToRemove(updateDir, "last-update.log");
+ addFileToRemove(patchDir, "update.log");
+
+ for (const file of filesToRemove) {
+ try {
+ if (file.exists()) {
+ file.remove(false);
+ }
+ } catch (e) {
+ console.warn("Unable to remove file. Path: '" + file.path + "', Exception: " + e);
+ }
+ }
+ """
+ )
+
+ def prepare_update_and_close_all_windows(self):
+ self.marionette.execute_async_script(
+ """
+ let [updateURLString, resolve] = arguments;
+
+ (async () => {
+ let updateDownloadedPromise = new Promise(innerResolve => {
+ Services.obs.addObserver(function callback() {
+ Services.obs.removeObserver(callback, "update-downloaded");
+ innerResolve();
+ }, "update-downloaded");
+ });
+
+ // Set the update URL to the one that was passed in.
+ let mockAppInfo = Object.create(Services.appinfo, {
+ updateURL: {
+ configurable: true,
+ enumerable: true,
+ writable: false,
+ value: updateURLString,
+ },
+ });
+ Services.appinfo = mockAppInfo;
+
+ // We aren't going to flip this until after the URL is set because the test fails
+ // if we hit the real update server.
+ Services.prefs.setBoolPref("app.update.disabledForTesting", false);
+
+ let aus = Cc["@mozilla.org/updates/update-service;1"]
+ .getService(Ci.nsIApplicationUpdateService);
+ aus.checkForBackgroundUpdates();
+
+ await updateDownloadedPromise;
+
+ Services.obs.addObserver((aSubject, aTopic, aData) => {
+ let silent_restart = Services.env.get("MOZ_APP_SILENT_START") == 1 && Services.env.get("MOZ_APP_RESTART") == 1;
+ Services.prefs.setBoolPref("testing.no_window_update_restart.silent_restart_env", silent_restart);
+ }, "quit-application-granted");
+
+ for (const win of Services.wm.getEnumerator("navigator:browser")) {
+ win.close();
+ }
+ })().then(resolve);
+ """,
+ script_args=(self.marionette.absolute_url("update.xml"),),
+ )
diff --git a/toolkit/mozapps/update/tests/moz.build b/toolkit/mozapps/update/tests/moz.build
new file mode 100644
index 0000000000..389a5c14b4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/moz.build
@@ -0,0 +1,118 @@
+# -*- 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/.
+
+FINAL_TARGET = "_tests/xpcshell/toolkit/mozapps/update/tests/data"
+
+if not CONFIG["MOZ_SUITE"]:
+ BROWSER_CHROME_MANIFESTS += [
+ "browser/browser.toml",
+ "browser/manual_app_update_only/browser.toml",
+ ]
+ if CONFIG["MOZ_BITS_DOWNLOAD"]:
+ BROWSER_CHROME_MANIFESTS += ["browser/browser.bits.toml"]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "unit_aus_update/xpcshell.toml",
+ "unit_base_updater/xpcshell.toml",
+]
+
+if CONFIG["MOZ_MAINTENANCE_SERVICE"]:
+ XPCSHELL_TESTS_MANIFESTS += ["unit_service_updater/xpcshell.toml"]
+
+if CONFIG["MOZ_BUILD_APP"] == "browser" and CONFIG["MOZ_UPDATE_AGENT"]:
+ XPCSHELL_TESTS_MANIFESTS += ["unit_background_update/xpcshell.toml"]
+
+SimplePrograms(
+ [
+ "TestAUSHelper",
+ "TestAUSReadStrings",
+ ]
+)
+
+LOCAL_INCLUDES += [
+ "/toolkit/mozapps/update",
+ "/toolkit/mozapps/update/common",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ OS_LIBS += [
+ "shlwapi",
+ "user32",
+ "uuid",
+ ]
+
+USE_LIBS += [
+ "updatecommon",
+]
+
+for var in ("MOZ_APP_VENDOR", "MOZ_APP_BASENAME"):
+ DEFINES[var] = CONFIG[var]
+
+DEFINES["NS_NO_XPCOM"] = True
+
+DisableStlWrapping()
+
+if CONFIG["MOZ_MAINTENANCE_SERVICE"]:
+ DEFINES["MOZ_MAINTENANCE_SERVICE"] = CONFIG["MOZ_MAINTENANCE_SERVICE"]
+
+if CONFIG["DISABLE_UPDATER_AUTHENTICODE_CHECK"]:
+ DEFINES["DISABLE_UPDATER_AUTHENTICODE_CHECK"] = True
+
+if CONFIG["CC_TYPE"] == "clang-cl":
+ WIN32_EXE_LDFLAGS += ["-ENTRY:wmainCRTStartup"]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ DEFINES["UNICODE"] = True
+ DEFINES["_UNICODE"] = True
+ USE_STATIC_LIBS = True
+ if CONFIG["CC_TYPE"] in ("clang", "gcc"):
+ WIN32_EXE_LDFLAGS += ["-municode"]
+
+TEST_HARNESS_FILES.testing.mochitest.browser.toolkit.mozapps.update.tests.browser += [
+ "data/simple.mar",
+]
+
+FINAL_TARGET_FILES += [
+ "data/complete.exe",
+ "data/complete.mar",
+ "data/complete.png",
+ "data/complete_log_success_mac",
+ "data/complete_log_success_win",
+ "data/complete_mac.mar",
+ "data/complete_precomplete",
+ "data/complete_precomplete_mac",
+ "data/complete_removed-files",
+ "data/complete_removed-files_mac",
+ "data/complete_update_manifest",
+ "data/old_version.mar",
+ "data/partial.exe",
+ "data/partial.mar",
+ "data/partial.png",
+ "data/partial_log_failure_mac",
+ "data/partial_log_failure_win",
+ "data/partial_log_success_mac",
+ "data/partial_log_success_win",
+ "data/partial_mac.mar",
+ "data/partial_precomplete",
+ "data/partial_precomplete_mac",
+ "data/partial_removed-files",
+ "data/partial_removed-files_mac",
+ "data/partial_update_manifest",
+ "data/replace_log_success",
+ "data/simple.mar",
+ "data/syncManagerTestChild.js",
+ "TestAUSReadStrings1.ini",
+ "TestAUSReadStrings2.ini",
+ "TestAUSReadStrings3.ini",
+ "TestAUSReadStrings4.ini",
+]
+
+FINAL_TARGET_PP_FILES += [
+ "data/xpcshellConstantsPP.js",
+]
+
+with Files("browser/browser_telemetry_updatePing_*_ready.js"):
+ BUG_COMPONENT = ("Toolkit", "Telemetry")
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/ausReadStrings.js b/toolkit/mozapps/update/tests/unit_aus_update/ausReadStrings.js
new file mode 100644
index 0000000000..ed331f5ebc
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/ausReadStrings.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const BIN_DIR =
+ AppConstants.platform == "win" ? "test_bug473417-ó" : "test_bug473417";
+const BIN_EXE = "TestAUSReadStrings" + mozinfo.bin_suffix;
+const tempdir = do_get_tempdir();
+
+function run_test() {
+ let workdir = tempdir.clone();
+ workdir.append(BIN_DIR);
+
+ let paths = [
+ BIN_EXE,
+ "TestAUSReadStrings1.ini",
+ "TestAUSReadStrings2.ini",
+ "TestAUSReadStrings3.ini",
+ "TestAUSReadStrings4.ini",
+ ];
+ for (let i = 0; i < paths.length; i++) {
+ let file = do_get_file("../data/" + paths[i]);
+ file.copyTo(workdir, null);
+ }
+
+ let readStrings = workdir.clone();
+ readStrings.append(BIN_EXE);
+
+ let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
+ process.init(readStrings);
+ process.run(true, [], 0);
+ Assert.equal(process.exitValue, 0);
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/backgroundUpdateTaskInternalUpdater.js b/toolkit/mozapps/update/tests/unit_aus_update/backgroundUpdateTaskInternalUpdater.js
new file mode 100644
index 0000000000..329b8edbea
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/backgroundUpdateTaskInternalUpdater.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 test ensures that we don't resume an update download with the internal
+ * downloader when we are running background updates. Normally, the background
+ * update task won't even run if we can't use BITS. But it is possible for us to
+ * fall back from BITS to the internal downloader. Background update should
+ * prevent this fallback and just abort.
+ *
+ * But interactive Firefox allows that fallback. And once the internal
+ * download has started, the background update task must leave that download
+ * untouched and allow it to finish.
+ */
+
+var TEST_MAR_CONTENTS = "Arbitrary MAR contents";
+
+add_task(async function setup() {
+ setupTestCommon();
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ setUpdateChannel("test_channel");
+
+ // Pretend that this is a background task.
+ const bts = Cc["@mozilla.org/backgroundtasks;1"].getService(
+ Ci.nsIBackgroundTasks
+ );
+ bts.overrideBackgroundTaskNameForTesting("test-task");
+
+ // No need for cleanup needed for changing update files. These will be cleaned
+ // up by removeUpdateFiles.
+ const downloadingMarFile = getUpdateDirFile(FILE_UPDATE_MAR, DIR_DOWNLOADING);
+ await IOUtils.writeUTF8(downloadingMarFile.path, TEST_MAR_CONTENTS);
+
+ writeStatusFile(STATE_DOWNLOADING);
+
+ let patchProps = {
+ state: STATE_DOWNLOADING,
+ bitsResult: Cr.NS_ERROR_FAILURE,
+ };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { appVersion: "1.0" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+});
+
+add_task(async function backgroundUpdate() {
+ let patches = getRemotePatchString({});
+ let updateString = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+
+ let { updates } = await waitForUpdateCheck(true);
+ let bestUpdate = gAUS.selectUpdate(updates);
+ let success = await gAUS.downloadUpdate(bestUpdate, false);
+ Assert.equal(
+ success,
+ false,
+ "We should not attempt to download an update in the background when an " +
+ "internal update download is already in progress."
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_DOWNLOADING,
+ "Background update during an internally downloading update should not " +
+ "change update status"
+ );
+ const downloadingMarFile = getUpdateDirFile(FILE_UPDATE_MAR, DIR_DOWNLOADING);
+ Assert.ok(
+ await IOUtils.exists(downloadingMarFile.path),
+ "Downloading MAR should still exist"
+ );
+ Assert.equal(
+ await IOUtils.readUTF8(downloadingMarFile.path),
+ TEST_MAR_CONTENTS,
+ "Downloading MAR should not have been modified"
+ );
+});
+
+add_task(async function finish() {
+ stop_httpserver(doTestFinish);
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/canCheckForAndCanApplyUpdates.js b/toolkit/mozapps/update/tests/unit_aus_update/canCheckForAndCanApplyUpdates.js
new file mode 100644
index 0000000000..b3dcb72d14
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/canCheckForAndCanApplyUpdates.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+function run_test() {
+ setupTestCommon();
+
+ // Verify write access to the custom app dir
+ debugDump("testing write access to the application directory");
+ let testFile = getCurrentProcessDir();
+ testFile.append("update_write_access_test");
+ testFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, PERMS_FILE);
+ Assert.ok(testFile.exists(), MSG_SHOULD_EXIST);
+ testFile.remove(false);
+ Assert.ok(!testFile.exists(), MSG_SHOULD_NOT_EXIST);
+
+ if (AppConstants.platform == "win") {
+ // Create a mutex to prevent being able to check for or apply updates.
+ debugDump("attempting to create mutex");
+ let handle = createMutex(getPerInstallationMutexName());
+ Assert.ok(!!handle, "the update mutex should have been created");
+
+ // Check if available updates cannot be checked for when there is a mutex
+ // for this installation.
+ Assert.ok(
+ !gAUS.canCheckForUpdates,
+ "should not be able to check for " +
+ "updates when there is an update mutex"
+ );
+
+ // Check if updates cannot be applied when there is a mutex for this
+ // installation.
+ Assert.ok(
+ !gAUS.canApplyUpdates,
+ "should not be able to apply updates when there is an update mutex"
+ );
+
+ debugDump("destroying mutex");
+ closeHandle(handle);
+ }
+
+ // Check if available updates can be checked for
+ Assert.ok(gAUS.canCheckForUpdates, "should be able to check for updates");
+ // Check if updates can be applied
+ Assert.ok(gAUS.canApplyUpdates, "should be able to apply updates");
+
+ if (AppConstants.platform == "win") {
+ // Attempt to create a mutex when application update has already created one
+ // with the same name.
+ debugDump("attempting to create mutex");
+ let handle = createMutex(getPerInstallationMutexName());
+
+ Assert.ok(
+ !handle,
+ "should not be able to create the update mutex when " +
+ "the application has created the update mutex"
+ );
+ }
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForDifferentChannel.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForDifferentChannel.js
new file mode 100644
index 0000000000..33df63e7e7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForDifferentChannel.js
@@ -0,0 +1,60 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing removal of an active update for a channel that is not " +
+ "valid due to switching channels (Bug 486275)."
+ );
+
+ let patchProps = { state: STATE_DOWNLOADING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { appVersion: "1.0" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_DOWNLOADING);
+
+ setUpdateChannel("original_channel");
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ let update = gUpdateManager.getUpdateAt(0);
+ Assert.equal(
+ update.state,
+ STATE_FAILED,
+ "the first update state" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.errorCode,
+ ERR_CHANNEL_CHANGE,
+ "the first update errorCode" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.statusText,
+ getString("statusFailed"),
+ "the first update statusText " + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ let statusFile = getUpdateDirFile(FILE_UPDATE_STATUS);
+ Assert.ok(!statusFile.exists(), MSG_SHOULD_NOT_EXIST);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForOlderAppVersion.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForOlderAppVersion.js
new file mode 100644
index 0000000000..3c9f7d1c2e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForOlderAppVersion.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing cleanup of an update download in progress for an " +
+ "older version of the application on startup (Bug 485624)"
+ );
+
+ let patchProps = { state: STATE_DOWNLOADING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { appVersion: "0.9" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_DOWNLOADING);
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ let update = gUpdateManager.getUpdateAt(0);
+ Assert.equal(
+ update.state,
+ STATE_FAILED,
+ "the first update state" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.errorCode,
+ ERR_OLDER_VERSION_OR_SAME_BUILD,
+ "the first update errorCode" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.statusText,
+ getString("statusFailed"),
+ "the first update statusText " + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ let statusFile = getUpdateDirFile(FILE_UPDATE_STATUS);
+ Assert.ok(!statusFile.exists(), MSG_SHOULD_NOT_EXIST);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForSameVersionAndBuildID.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForSameVersionAndBuildID.js
new file mode 100644
index 0000000000..0eb1b6c22e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingForSameVersionAndBuildID.js
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing removal of an update download in progress for the " +
+ "same version of the application with the same application " +
+ "build id on startup (Bug 536547)"
+ );
+
+ let patchProps = { state: STATE_DOWNLOADING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { appVersion: "1.0", buildID: "2007010101" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_DOWNLOADING);
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ let update = gUpdateManager.getUpdateAt(0);
+ Assert.equal(
+ update.state,
+ STATE_FAILED,
+ "the first update state" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.errorCode,
+ ERR_OLDER_VERSION_OR_SAME_BUILD,
+ "the first update errorCode" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.statusText,
+ getString("statusFailed"),
+ "the first update statusText " + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ let statusFile = getUpdateDirFile(FILE_UPDATE_STATUS);
+ Assert.ok(!statusFile.exists(), MSG_SHOULD_NOT_EXIST);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingIncorrectStatus.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingIncorrectStatus.js
new file mode 100644
index 0000000000..8468ca453c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupDownloadingIncorrectStatus.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing update cleanup when reading the status file returns " +
+ "STATUS_NONE and the update xml has an update with " +
+ "STATE_DOWNLOADING (Bug 539717)."
+ );
+
+ let patchProps = { state: STATE_DOWNLOADING };
+ let patches = getLocalPatchString(patchProps);
+ let updates = getLocalUpdateString({}, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_NONE);
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ let update = gUpdateManager.getUpdateAt(0);
+ Assert.equal(
+ update.state,
+ STATE_FAILED,
+ "the first update state" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.errorCode,
+ ERR_UPDATE_STATE_NONE,
+ "the first update errorCode" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.statusText,
+ getString("statusFailed"),
+ "the first update statusText " + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ let statusFile = getUpdateDirFile(FILE_UPDATE_STATUS);
+ Assert.ok(!statusFile.exists(), MSG_SHOULD_NOT_EXIST);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupPendingVersionFileIncorrectStatus.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupPendingVersionFileIncorrectStatus.js
new file mode 100644
index 0000000000..274f029150
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupPendingVersionFileIncorrectStatus.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing update cleanup when reading the status file returns " +
+ "STATUS_NONE, the version file is for a newer version, and the " +
+ "update xml has an update with STATE_PENDING (Bug 601701)."
+ );
+
+ let patchProps = { state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updates = getLocalUpdateString({}, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeVersionFile("99.9");
+
+ // Check that there are no active updates first so the updates directory is
+ // cleaned up by the UpdateManager before the remaining tests.
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ let update = gUpdateManager.getUpdateAt(0);
+ Assert.equal(
+ update.state,
+ STATE_FAILED,
+ "the first update state" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.errorCode,
+ ERR_UPDATE_STATE_NONE,
+ "the first update errorCode" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.statusText,
+ getString("statusFailed"),
+ "the first update statusText " + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ let versionFile = getUpdateDirFile(FILE_UPDATE_VERSION);
+ Assert.ok(!versionFile.exists(), MSG_SHOULD_NOT_EXIST);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogMove.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogMove.js
new file mode 100644
index 0000000000..c05b4bfd73
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogMove.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump("testing that the update.log is moved after a successful update");
+
+ Services.prefs.setIntPref(PREF_APP_UPDATE_CANCELATIONS, 5);
+
+ let patchProps = { state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updates = getLocalUpdateString({}, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_SUCCEEDED);
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ writeFile(log, "Last Update Log");
+ log = getUpdateDirFile(FILE_UPDATE_ELEVATED_LOG);
+ writeFile(log, "Last Update Elevated Log");
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let cancelations = Services.prefs.getIntPref(PREF_APP_UPDATE_CANCELATIONS, 0);
+ Assert.equal(
+ cancelations,
+ 0,
+ "the " + PREF_APP_UPDATE_CANCELATIONS + " preference " + MSG_SHOULD_EQUAL
+ );
+
+ log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ log = getUpdateDirFile(FILE_UPDATE_ELEVATED_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Last Update Log",
+ "the last update log contents" + MSG_SHOULD_EQUAL
+ );
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_ELEVATED_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Last Update Elevated Log",
+ "the last update elevated log contents" + MSG_SHOULD_EQUAL
+ );
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_ELEVATED_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogsFIFO.js b/toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogsFIFO.js
new file mode 100644
index 0000000000..c93644bc0e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/cleanupSuccessLogsFIFO.js
@@ -0,0 +1,226 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * Creates the specified log files and makes sure that they are rotated properly
+ * when we successfully update and cleanup the old update files.
+ *
+ * We test various starting combinations of logs because we want to make sure
+ * that, for example, when "update.log" exists but "update-elevated.log" does
+ * not, that we still move "last-update-elevated.log" to
+ * "backup-update-elevated.log". Allowing the files to be mismatched makes them
+ * much less useful.
+ *
+ * When running this, either `createUpdateLog` or `createUpdateElevatedLog`
+ * should be `true` because otherwise it doesn't make sense that an update just
+ * ran successfully.
+ *
+ * @param createUpdateLog
+ * If `true`, "update.log" will be created at the start of the test.
+ * @param createLastUpdateLog
+ * If `true`, "last-update.log" will be created at the start of the test.
+ * @param createBackupUpdateLog
+ * If `true`, "backup-update.log" will be created at the start of the
+ * test.
+ * @param createUpdateElevatedLog
+ * If `true`, "update-elevated.log" will be created at the start of the
+ * test.
+ * @param createLastUpdateElevatedLog
+ * If `true`, "last-update-elevated.log" will be created at the start of
+ * the test.
+ * @param createBackupUpdateElevatedLog
+ * If `true`, "backup-update-elevated.log" will be created at the start
+ * of the test.
+ */
+async function testCleanupSuccessLogsFIFO(
+ createUpdateLog,
+ createLastUpdateLog,
+ createBackupUpdateLog,
+ createUpdateElevatedLog,
+ createLastUpdateElevatedLog,
+ createBackupUpdateElevatedLog
+) {
+ logTestInfo(
+ `createUpdateLog=${createUpdateLog} ` +
+ `createLastUpdateLog=${createLastUpdateLog} ` +
+ `createBackupUpdateLog=${createBackupUpdateLog} ` +
+ `createUpdateElevatedLog=${createUpdateElevatedLog} ` +
+ `createLastUpdateElevatedLog=${createLastUpdateElevatedLog} ` +
+ `createBackupUpdateElevatedLog=${createBackupUpdateElevatedLog}`
+ );
+
+ let patchProps = { state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updates = getLocalUpdateString({}, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_SUCCEEDED);
+
+ const createOrDeleteFile = (shouldCreate, filename, contents) => {
+ let log = getUpdateDirFile(filename);
+ if (shouldCreate) {
+ writeFile(log, contents);
+ } else {
+ try {
+ log.remove(false);
+ } catch (ex) {
+ if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
+ throw ex;
+ }
+ }
+ }
+ };
+
+ createOrDeleteFile(
+ createLastUpdateLog,
+ FILE_LAST_UPDATE_LOG,
+ "Backup Update Log"
+ );
+ createOrDeleteFile(
+ createBackupUpdateLog,
+ FILE_BACKUP_UPDATE_LOG,
+ "To Be Deleted Backup Update Log"
+ );
+ createOrDeleteFile(createUpdateLog, FILE_UPDATE_LOG, "Last Update Log");
+ createOrDeleteFile(
+ createLastUpdateElevatedLog,
+ FILE_LAST_UPDATE_ELEVATED_LOG,
+ "Backup Update Elevated Log"
+ );
+ createOrDeleteFile(
+ createBackupUpdateElevatedLog,
+ FILE_BACKUP_UPDATE_ELEVATED_LOG,
+ "To Be Deleted Backup Update Elevated Log"
+ );
+ createOrDeleteFile(
+ createUpdateElevatedLog,
+ FILE_UPDATE_ELEVATED_LOG,
+ "Last Update Elevated Log"
+ );
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ log = getUpdateDirFile(FILE_UPDATE_ELEVATED_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ if (createUpdateLog) {
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Last Update Log",
+ "the last update log contents" + MSG_SHOULD_EQUAL
+ );
+ } else {
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+ }
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_ELEVATED_LOG);
+ if (createUpdateElevatedLog) {
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Last Update Elevated Log",
+ "the last update log contents" + MSG_SHOULD_EQUAL
+ );
+ } else {
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+ }
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ if (createLastUpdateLog) {
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Backup Update Log",
+ "the backup update log contents" + MSG_SHOULD_EQUAL
+ );
+ } else if (!createLastUpdateElevatedLog && createBackupUpdateLog) {
+ // This isn't really a conventional FIFO. We don't shift the old backup logs
+ // out if, for some reason the last pair of logs didn't exist.
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "To Be Deleted Backup Update Log",
+ "the backup update log contents" + MSG_SHOULD_EQUAL
+ );
+ } else {
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+ }
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_ELEVATED_LOG);
+ if (createLastUpdateElevatedLog) {
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Backup Update Elevated Log",
+ "the backup update log contents" + MSG_SHOULD_EQUAL
+ );
+ } else if (!createLastUpdateLog && createBackupUpdateElevatedLog) {
+ // This isn't really a conventional FIFO. We don't shift the old backup logs
+ // out if, for some reason the last pair of logs didn't exist.
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "To Be Deleted Backup Update Elevated Log",
+ "the backup update log contents" + MSG_SHOULD_EQUAL
+ );
+ } else {
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+ }
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ // Clean up so this function can run again.
+ reloadUpdateManagerData(true);
+}
+
+async function run_test() {
+ debugDump("testing update logs are first in first out deleted");
+ setupTestCommon();
+
+ // This runs a bunch of tests (I think 48 of them: 2^6 - 2^4), but each test
+ // is fairly simple and doesn't do a lot of waiting. So it seems unlikely that
+ // it will exceed its timeout.
+ for (const createUpdateLog of [true, false]) {
+ for (const createUpdateElevatedLog of [true, false]) {
+ if (!createUpdateLog && !createUpdateElevatedLog) {
+ continue;
+ }
+ for (const createLastUpdateLog of [true, false]) {
+ for (const createLastUpdateElevatedLog of [true, false]) {
+ for (const createBackupUpdateLog of [true, false]) {
+ for (const createBackupUpdateElevatedLog of [true, false]) {
+ await testCleanupSuccessLogsFIFO(
+ createUpdateLog,
+ createLastUpdateLog,
+ createBackupUpdateLog,
+ createUpdateElevatedLog,
+ createLastUpdateElevatedLog,
+ createBackupUpdateElevatedLog
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesBackgroundTask.js b/toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesBackgroundTask.js
new file mode 100644
index 0000000000..4dcf559563
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesBackgroundTask.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+/**
+ * This test verifies that when Balrog advertises that an update should not
+ * be downloaded in the background, it is not.
+ */
+
+function setup() {
+ setupTestCommon();
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ setUpdateChannel("test_channel");
+
+ // Pretend that this is a background task.
+ const bts = Cc["@mozilla.org/backgroundtasks;1"].getService(
+ Ci.nsIBackgroundTasks
+ );
+ bts.overrideBackgroundTaskNameForTesting("test-task");
+}
+setup();
+
+add_task(async function disableBackgroundUpdatesBackgroundTask() {
+ let patches = getRemotePatchString({});
+ let updateString = getRemoteUpdateString(
+ { disableBackgroundUpdates: "true" },
+ patches
+ );
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+
+ let { updates } = await waitForUpdateCheck(true);
+ let bestUpdate = gAUS.selectUpdate(updates);
+ let success = await gAUS.downloadUpdate(bestUpdate, false);
+ Assert.equal(
+ success,
+ false,
+ "Update should not download when disableBackgroundUpdates is specified " +
+ "and we are in background task mode."
+ );
+});
+
+add_task(async function finish() {
+ stop_httpserver(doTestFinish);
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesNonBackgroundTask.js b/toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesNonBackgroundTask.js
new file mode 100644
index 0000000000..2f4eec25ff
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/disableBackgroundUpdatesNonBackgroundTask.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 test verifies that when Balrog advertises that an update should not
+ * be downloaded in the background, but we are not running in the background,
+ * the advertisement does not have any effect.
+ */
+
+function setup() {
+ setupTestCommon();
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ setUpdateChannel("test_channel");
+}
+setup();
+
+add_task(async function disableBackgroundUpdatesBackgroundTask() {
+ let patches = getRemotePatchString({});
+ let updateString = getRemoteUpdateString(
+ { disableBackgroundUpdates: "true" },
+ patches
+ );
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+
+ let { updates } = await waitForUpdateCheck(true);
+
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 3;
+
+ // This will assert that the download completes successfully.
+ await waitForUpdateDownload(updates, Cr.NS_OK);
+});
+
+add_task(async function finish() {
+ stop_httpserver(doTestFinish);
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedNoRecovery.js b/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedNoRecovery.js
new file mode 100644
index 0000000000..15dd39ce0a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedNoRecovery.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+ debugDump("testing mar download with interrupted recovery count exceeded");
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false);
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 0;
+ Services.prefs.setIntPref(PREF_APP_UPDATE_SOCKET_MAXERRORS, 2);
+ Services.prefs.setIntPref(PREF_APP_UPDATE_RETRYTIMEOUT, 0);
+ let patches = getRemotePatchString({});
+ let updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 1 }).then(async aArgs => {
+ await waitForUpdateDownload(aArgs.updates, Cr.NS_ERROR_NET_RESET);
+ });
+ stop_httpserver(doTestFinish);
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedOffline.js b/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedOffline.js
new file mode 100644
index 0000000000..2c54e058d2
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedOffline.js
@@ -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/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+ debugDump("testing mar download when offline");
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false);
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 4;
+ let patches = getRemotePatchString({});
+ let updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 1 }).then(async aArgs => {
+ await waitForUpdateDownload(aArgs.updates, Cr.NS_OK);
+ });
+ stop_httpserver(doTestFinish);
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedRecovery.js b/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedRecovery.js
new file mode 100644
index 0000000000..1ed40f9a2a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/downloadInterruptedRecovery.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/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+ debugDump("testing mar mar download interrupted recovery");
+ // This test assumes speculative connections enabled.
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", 6);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("network.http.speculative-parallel-limit");
+ });
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false);
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 0;
+ let patches = getRemotePatchString({});
+ let updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 1 }).then(async aArgs => {
+ await waitForUpdateDownload(aArgs.updates, Cr.NS_OK);
+ });
+ stop_httpserver(doTestFinish);
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/downloadResumeForSameAppVersion.js b/toolkit/mozapps/update/tests/unit_aus_update/downloadResumeForSameAppVersion.js
new file mode 100644
index 0000000000..8a386513f3
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/downloadResumeForSameAppVersion.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing resuming an update download in progress for the same " +
+ "version of the application on startup (Bug 485624)"
+ );
+
+ let patchProps = { state: STATE_DOWNLOADING };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { appVersion: "1.0" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_DOWNLOADING);
+
+ standardInit();
+
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 0,
+ "the update manager updateCount attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ gUpdateManager.downloadingUpdate.state,
+ STATE_DOWNLOADING,
+ "the update manager activeUpdate state attribute" + MSG_SHOULD_EQUAL
+ );
+
+ // Cancel the download early to prevent it writing the update xml files during
+ // shutdown.
+ await gAUS.stopDownload();
+ executeSoon(doTestFinish);
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/ensureExperimentToRolloutTransitionPerformed.js b/toolkit/mozapps/update/tests/unit_aus_update/ensureExperimentToRolloutTransitionPerformed.js
new file mode 100644
index 0000000000..86bc75a7d6
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/ensureExperimentToRolloutTransitionPerformed.js
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ BackgroundUpdate: "resource://gre/modules/BackgroundUpdate.sys.mjs",
+});
+
+const transitionPerformedPref = "app.update.background.rolledout";
+const backgroundUpdateEnabledPref = "app.update.background.enabled";
+const defaultPrefValue =
+ UpdateUtils.PER_INSTALLATION_PREFS[backgroundUpdateEnabledPref].defaultValue;
+
+async function testTransition(options) {
+ Services.prefs.clearUserPref(transitionPerformedPref);
+ await UpdateUtils.writeUpdateConfigSetting(
+ backgroundUpdateEnabledPref,
+ options.initialDefaultValue,
+ { setDefaultOnly: true }
+ );
+ await UpdateUtils.writeUpdateConfigSetting(
+ backgroundUpdateEnabledPref,
+ options.initialUserValue
+ );
+ BackgroundUpdate.ensureExperimentToRolloutTransitionPerformed();
+ Assert.equal(
+ await UpdateUtils.readUpdateConfigSetting(backgroundUpdateEnabledPref),
+ options.expectedPostTransitionValue,
+ "Post transition option value does not match the expected value"
+ );
+
+ // Make sure that we only do the transition once.
+
+ // If we change the default value, then change the user value to the same
+ // thing, we will end up with only a default value and no saved user value.
+ // This allows us to ensure that we read the default value back out, if it is
+ // changed.
+ await UpdateUtils.writeUpdateConfigSetting(
+ backgroundUpdateEnabledPref,
+ !defaultPrefValue,
+ { setDefaultOnly: true }
+ );
+ await UpdateUtils.writeUpdateConfigSetting(
+ backgroundUpdateEnabledPref,
+ !defaultPrefValue
+ );
+ BackgroundUpdate.ensureExperimentToRolloutTransitionPerformed();
+ Assert.equal(
+ await UpdateUtils.readUpdateConfigSetting(backgroundUpdateEnabledPref),
+ !defaultPrefValue,
+ "Transition should not change the pref value if it already ran"
+ );
+}
+
+async function run_test() {
+ setupTestCommon(null);
+ standardInit();
+ // The setup functions we use for update testing typically allow for update.
+ // But we are just testing preferences here. We don't want anything to
+ // actually attempt to update. Also, because we are messing with the pref
+ // system itself in this test, we want to make sure to use a pref outside of
+ // that system to disable update.
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, true);
+
+ const originalBackgroundUpdateEnabled =
+ await UpdateUtils.readUpdateConfigSetting(backgroundUpdateEnabledPref);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref(transitionPerformedPref);
+ await UpdateUtils.writeUpdateConfigSetting(
+ backgroundUpdateEnabledPref,
+ originalBackgroundUpdateEnabled
+ );
+ await UpdateUtils.writeUpdateConfigSetting(
+ backgroundUpdateEnabledPref,
+ defaultPrefValue,
+ { setDefaultOnly: true }
+ );
+ });
+
+ await testTransition({
+ initialDefaultValue: true,
+ initialUserValue: true,
+ expectedPostTransitionValue: true,
+ });
+
+ // Make sure we don't interfere with a user's choice to turn the feature off.
+ await testTransition({
+ initialDefaultValue: true,
+ initialUserValue: false,
+ expectedPostTransitionValue: false,
+ });
+
+ // In this case, there effectively is no user value since the user value
+ // equals the default value. So the effective value should change after
+ // the transition switches the default.
+ await testTransition({
+ initialDefaultValue: false,
+ initialUserValue: false,
+ expectedPostTransitionValue: true,
+ });
+
+ await testTransition({
+ initialDefaultValue: false,
+ initialUserValue: true,
+ expectedPostTransitionValue: true,
+ });
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/head_update.js b/toolkit/mozapps/update/tests/unit_aus_update/head_update.js
new file mode 100644
index 0000000000..3cfde06015
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/head_update.js
@@ -0,0 +1,8 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const IS_SERVICE_TEST = false;
+
+/* import-globals-from ../data/xpcshellUtilsAUS.js */
+load("xpcshellUtilsAUS.js");
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/languagePackUpdates.js b/toolkit/mozapps/update/tests/unit_aus_update/languagePackUpdates.js
new file mode 100644
index 0000000000..0ae35ef77a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/languagePackUpdates.js
@@ -0,0 +1,291 @@
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { getAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+const { XPIExports } = ChromeUtils.importESModule(
+ "resource://gre/modules/addons/XPIExports.sys.mjs"
+);
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+AddonTestUtils.init(this);
+setupTestCommon();
+AddonTestUtils.appInfo = getAppInfo();
+start_httpserver();
+setUpdateURL(gURLData + gHTTPHandlerPath);
+setUpdateChannel("test_channel");
+Services.prefs.setBoolPref(PREF_APP_UPDATE_LANGPACK_ENABLED, true);
+
+/**
+ * Checks for updates and waits for the update to download.
+ */
+async function downloadUpdate() {
+ let patches = getRemotePatchString({});
+ let updateString = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+
+ let { updates } = await waitForUpdateCheck(true);
+
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 3;
+
+ await waitForUpdateDownload(updates, Cr.NS_OK);
+}
+
+/**
+ * Returns a promise that will resolve when the add-ons manager attempts to
+ * stage langpack updates. The returned object contains the appVersion and
+ * platformVersion parameters as well as resolve and reject functions to
+ * complete the mocked langpack update.
+ */
+function mockLangpackUpdate() {
+ let stagingCall = Promise.withResolvers();
+ XPIExports.XPIInstall.stageLangpacksForAppUpdate = (
+ appVersion,
+ platformVersion
+ ) => {
+ let result = Promise.withResolvers();
+ stagingCall.resolve({
+ appVersion,
+ platformVersion,
+ resolve: result.resolve,
+ reject: result.reject,
+ });
+
+ return result.promise;
+ };
+
+ return stagingCall.promise;
+}
+
+add_setup(async function () {
+ // Thunderbird doesn't have one or more of the probes used in this test.
+ // Ensure the data is collected anyway.
+ Services.prefs.setBoolPref(
+ "toolkit.telemetry.testing.overrideProductsCheck",
+ true
+ );
+
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function testLangpackUpdateSuccess() {
+ let histogram = TelemetryTestUtils.getAndClearHistogram(
+ "UPDATE_LANGPACK_OVERTIME"
+ );
+
+ let updateDownloadNotified = false;
+ let notified = waitForEvent("update-downloaded").then(
+ () => (updateDownloadNotified = true)
+ );
+
+ let stagingCall = mockLangpackUpdate();
+
+ await downloadUpdate();
+
+ // We have to wait for UpdateService's onStopRequest to run far enough that
+ // the notification will have been sent if the language pack update completed.
+ await TestUtils.waitForCondition(() => readStatusFile() == "pending");
+
+ Assert.ok(
+ !updateDownloadNotified,
+ "Should not have seen the notification yet."
+ );
+
+ let { appVersion, platformVersion, resolve } = await stagingCall;
+ Assert.equal(
+ appVersion,
+ DEFAULT_UPDATE_VERSION,
+ "Should see the right app version"
+ );
+ Assert.equal(
+ platformVersion,
+ DEFAULT_UPDATE_VERSION,
+ "Should see the right platform version"
+ );
+
+ resolve();
+
+ await notified;
+
+ // Because we resolved the lang pack call after the download completed a value
+ // should have been recorded in telemetry.
+ let snapshot = histogram.snapshot();
+ Assert.ok(
+ !Object.values(snapshot.values).every(val => val == 0),
+ "Should have recorded a time"
+ );
+
+ // Reload the update manager so that we can download the same update again
+ reloadUpdateManagerData(true);
+});
+
+add_task(async function testLangpackUpdateFails() {
+ let updateDownloadNotified = false;
+ let notified = waitForEvent("update-downloaded").then(
+ () => (updateDownloadNotified = true)
+ );
+
+ let stagingCall = mockLangpackUpdate();
+
+ await downloadUpdate();
+
+ // We have to wait for UpdateService's onStopRequest to run far enough that
+ // the notification will have been sent if the language pack update completed.
+ await TestUtils.waitForCondition(() => readStatusFile() == "pending");
+
+ Assert.ok(
+ !updateDownloadNotified,
+ "Should not have seen the notification yet."
+ );
+
+ let { appVersion, platformVersion, reject } = await stagingCall;
+ Assert.equal(
+ appVersion,
+ DEFAULT_UPDATE_VERSION,
+ "Should see the right app version"
+ );
+ Assert.equal(
+ platformVersion,
+ DEFAULT_UPDATE_VERSION,
+ "Should see the right platform version"
+ );
+
+ reject();
+
+ await notified;
+
+ // Reload the update manager so that we can download the same update again
+ reloadUpdateManagerData(true);
+});
+
+add_task(async function testLangpackStaged() {
+ let updateStagedNotified = false;
+ let notified = waitForEvent("update-staged").then(
+ () => (updateStagedNotified = true)
+ );
+
+ let stagingCall = mockLangpackUpdate();
+
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, true);
+ copyTestUpdaterToBinDir();
+
+ let greDir = getGREDir();
+ let updateSettingsIni = greDir.clone();
+ updateSettingsIni.append(FILE_UPDATE_SETTINGS_INI);
+ writeFile(updateSettingsIni, UPDATE_SETTINGS_CONTENTS);
+
+ await downloadUpdate();
+
+ // We have to wait for the update to be applied and then check that the
+ // notification hasn't been sent.
+ await TestUtils.waitForCondition(() => readStatusFile() == "applied");
+
+ Assert.ok(
+ !updateStagedNotified,
+ "Should not have seen the notification yet."
+ );
+
+ let { appVersion, platformVersion, resolve } = await stagingCall;
+ Assert.equal(
+ appVersion,
+ DEFAULT_UPDATE_VERSION,
+ "Should see the right app version"
+ );
+ Assert.equal(
+ platformVersion,
+ DEFAULT_UPDATE_VERSION,
+ "Should see the right platform version"
+ );
+
+ resolve();
+
+ await notified;
+
+ // Reload the update manager so that we can download the same update again
+ reloadUpdateManagerData(true);
+});
+
+add_task(async function testRedownload() {
+ // When the download of a partial mar fails the same downloader is re-used to
+ // download the complete mar. We should only call the add-ons manager to stage
+ // language packs once in this case.
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false);
+ let histogram = TelemetryTestUtils.getAndClearHistogram(
+ "UPDATE_LANGPACK_OVERTIME"
+ );
+
+ let partialPatch = getRemotePatchString({
+ type: "partial",
+ url: gURLData + "missing.mar",
+ size: 28,
+ });
+ let completePatch = getRemotePatchString({});
+ let updateString = getRemoteUpdateString({}, partialPatch + completePatch);
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+
+ let { updates } = await waitForUpdateCheck(true);
+
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 3;
+
+ let stageCount = 0;
+ XPIExports.XPIInstall.stageLangpacksForAppUpdate = () => {
+ stageCount++;
+ return Promise.resolve();
+ };
+
+ let downloadCount = 0;
+ let listener = {
+ onStartRequest: aRequest => {},
+ onProgress: (aRequest, aContext, aProgress, aMaxProgress) => {},
+ onStatus: (aRequest, aStatus, aStatusText) => {},
+ onStopRequest: (request, status) => {
+ Assert.equal(
+ status,
+ downloadCount ? 0 : Cr.NS_ERROR_CORRUPTED_CONTENT,
+ "Should have seen the right status."
+ );
+ downloadCount++;
+
+ // Keep the same status.
+ gIncrementalDownloadErrorType = 3;
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsIProgressEventSink",
+ ]),
+ };
+ gAUS.addDownloadListener(listener);
+
+ let bestUpdate = gAUS.selectUpdate(updates);
+ await gAUS.downloadUpdate(bestUpdate, false);
+
+ await waitForEvent("update-downloaded");
+
+ gAUS.removeDownloadListener(listener);
+
+ Assert.equal(downloadCount, 2, "Should have seen two downloads");
+ Assert.equal(stageCount, 1, "Should have only tried to stage langpacks once");
+
+ // Because we resolved the lang pack call before the download completed a value
+ // should not have been recorded in telemetry.
+ let snapshot = histogram.snapshot();
+ Assert.ok(
+ Object.values(snapshot.values).every(val => val == 0),
+ "Should have recorded a time"
+ );
+
+ // Reload the update manager so that we can download the same update again
+ reloadUpdateManagerData(true);
+});
+
+add_task(async function finish() {
+ stop_httpserver(doTestFinish);
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/multiUpdate.js b/toolkit/mozapps/update/tests/unit_aus_update/multiUpdate.js
new file mode 100644
index 0000000000..3767a44feb
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/multiUpdate.js
@@ -0,0 +1,398 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 tests the multiple update downloads per Firefox session feature.
+ *
+ * This test does some unusual things, compared to the other files in this
+ * directory. We want to start the updates with aus.checkForBackgroundUpdates()
+ * to ensure that we test the whole flow. Other tests start update with things
+ * like aus.downloadUpdate(), but that bypasses some of the exact checks that we
+ * are trying to test as part of the multiupdate flow.
+ *
+ * In order to accomplish all this, we will be using app_update.sjs to serve
+ * updates XMLs and MARs. Outside of this test, this is really only done
+ * by browser-chrome mochitests (in ../browser). So we have to do some weird
+ * things to make it work properly in an xpcshell test. Things like
+ * defining URL_HTTP_UPDATE_SJS in testConstants.js so that it can be read by
+ * app_update.sjs in order to provide the correct download URL for MARs, but
+ * not reading that file here, because URL_HTTP_UPDATE_SJS is already defined
+ * (as something else) in xpcshellUtilsAUS.js.
+ */
+
+let { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+// These are from testConstants.js, which cannot be loaded by this file, because
+// some values are already defined at this point. However, we need these some
+// other values to be defined because continueFileHandler in shared.js expects
+// them to be.
+const REL_PATH_DATA = "";
+// This should be URL_HOST, but that conflicts with an existing constant.
+const APP_UPDATE_SJS_HOST = "http://127.0.0.1:8888";
+const URL_PATH_UPDATE_XML = "/" + REL_PATH_DATA + "app_update.sjs";
+// This should be URL_HTTP_UPDATE_SJS, but that conflicts with an existing
+// constant.
+const APP_UPDATE_SJS_URL = APP_UPDATE_SJS_HOST + URL_PATH_UPDATE_XML;
+const CONTINUE_CHECK = "continueCheck";
+const CONTINUE_DOWNLOAD = "continueDownload";
+const CONTINUE_STAGING = "continueStaging";
+
+const FIRST_UPDATE_VERSION = "999998.0";
+const SECOND_UPDATE_VERSION = "999999.0";
+
+/**
+ * Downloads an update via aus.checkForBackgroundUpdates()
+ * Function returns only after the update has been downloaded.
+ *
+ * The provided callback will be invoked once during the update download,
+ * specifically when onStartRequest is fired.
+ *
+ * If automatic update downloads are turned off (appUpdateAuto is false), then
+ * we listen for the update-available notification and then roughly simulate
+ * accepting the prompt by calling:
+ * AppUpdateService.downloadUpdate(update, true);
+ * This is what is normally called when the user accepts the update-available
+ * prompt.
+ */
+async function downloadUpdate(appUpdateAuto, onDownloadStartCallback) {
+ let downloadFinishedPromise = waitForEvent("update-downloaded");
+ let updateAvailablePromise;
+ if (!appUpdateAuto) {
+ updateAvailablePromise = new Promise(resolve => {
+ let observer = (subject, topic, status) => {
+ Services.obs.removeObserver(observer, "update-available");
+ subject.QueryInterface(Ci.nsIUpdate);
+ resolve({ update: subject, status });
+ };
+ Services.obs.addObserver(observer, "update-available");
+ });
+ }
+ let waitToStartPromise = new Promise(resolve => {
+ let listener = {
+ onStartRequest: aRequest => {
+ gAUS.removeDownloadListener(listener);
+ onDownloadStartCallback();
+ resolve();
+ },
+ onProgress: (aRequest, aContext, aProgress, aMaxProgress) => {},
+ onStatus: (aRequest, aStatus, aStatusText) => {},
+ onStopRequest(request, status) {},
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIRequestObserver",
+ "nsIProgressEventSink",
+ ]),
+ };
+ gAUS.addDownloadListener(listener);
+ });
+
+ let updateCheckStarted = gAUS.checkForBackgroundUpdates();
+ Assert.ok(updateCheckStarted, "Update check should have started");
+
+ if (!appUpdateAuto) {
+ let { update, status } = await updateAvailablePromise;
+ Assert.equal(
+ status,
+ "show-prompt",
+ "Should attempt to show the update-available prompt"
+ );
+ // Simulate accepting the update-available prompt
+ await gAUS.downloadUpdate(update, true);
+ }
+
+ await continueFileHandler(CONTINUE_DOWNLOAD);
+ await waitToStartPromise;
+ await downloadFinishedPromise;
+ // Wait an extra tick after the download has finished. If we try to check for
+ // another update exactly when "update-downloaded" fires,
+ // Downloader:onStopRequest won't have finished yet, which it normally would
+ // have.
+ await TestUtils.waitForTick();
+}
+
+/**
+ * This is like downloadUpdate. The difference is that downloadUpdate assumes
+ * that an update actually will be downloaded. This function instead verifies
+ * that we the update wasn't downloaded.
+ *
+ * downloadUpdate(), above, uses aus.checkForBackgroundUpdates() to download
+ * updates to verify that background updates actually will check for subsequent
+ * updates, but this function will use some slightly different mechanisms. We
+ * can call aus.downloadUpdate() and check its return value to see if it started
+ * a download. But this doesn't properly check that the update-available
+ * notification isn't shown. So we will do an additional check where we follow
+ * the normal flow a bit more closely by forwarding the results that we got from
+ * checkForUpdates() to aus.onCheckComplete() and make sure that the update
+ * prompt isn't shown.
+ */
+async function testUpdateDoesNotDownload() {
+ let check = gUpdateChecker.checkForUpdates(gUpdateChecker.BACKGROUND_CHECK);
+ let result = await check.result;
+ Assert.ok(result.checksAllowed, "Should be able to check for updates");
+ Assert.ok(result.succeeded, "Update check should have succeeded");
+
+ Assert.equal(
+ result.updates.length,
+ 1,
+ "Should have gotten 1 update in update check"
+ );
+ let update = result.updates[0];
+
+ let downloadStarted = await gAUS.downloadUpdate(update, true);
+ Assert.equal(
+ downloadStarted,
+ false,
+ "Expected that we would not start downloading an update"
+ );
+
+ let updateAvailableObserved = false;
+ let observer = (subject, topic, status) => {
+ updateAvailableObserved = true;
+ };
+ Services.obs.addObserver(observer, "update-available");
+ await gAUS.onCheckComplete(result);
+ Services.obs.removeObserver(observer, "update-available");
+ Assert.equal(
+ updateAvailableObserved,
+ false,
+ "update-available notification should not fire if we aren't going to " +
+ "download the update."
+ );
+}
+
+function testUpdateCheckDoesNotStart() {
+ let updateCheckStarted = gAUS.checkForBackgroundUpdates();
+ Assert.equal(
+ updateCheckStarted,
+ false,
+ "Update check should not have started"
+ );
+}
+
+function prepareToDownloadVersion(version, onlyCompleteMar = false) {
+ let updateUrl = `${APP_UPDATE_SJS_URL}?useSlowDownloadMar=1&appVersion=${version}`;
+ if (onlyCompleteMar) {
+ updateUrl += "&completePatchOnly=1";
+ }
+ setUpdateURL(updateUrl);
+}
+
+function startUpdateServer() {
+ let httpServer = new HttpServer();
+ httpServer.registerContentType("sjs", "sjs");
+ httpServer.registerDirectory("/", do_get_cwd());
+ httpServer.start(8888);
+ registerCleanupFunction(async function cleanup_httpServer() {
+ await new Promise(resolve => {
+ httpServer.stop(resolve);
+ });
+ });
+}
+
+async function multi_update_test(appUpdateAuto) {
+ await UpdateUtils.setAppUpdateAutoEnabled(appUpdateAuto);
+
+ prepareToDownloadVersion(FIRST_UPDATE_VERSION);
+
+ await downloadUpdate(appUpdateAuto, () => {
+ Assert.ok(
+ !gUpdateManager.readyUpdate,
+ "There should not be a ready update yet"
+ );
+ Assert.ok(
+ !!gUpdateManager.downloadingUpdate,
+ "First update download should be in downloadingUpdate"
+ );
+ Assert.equal(
+ gUpdateManager.downloadingUpdate.state,
+ STATE_DOWNLOADING,
+ "downloadingUpdate should be downloading"
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_DOWNLOADING,
+ "Updater state should be downloading"
+ );
+ });
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "First update download should no longer be in downloadingUpdate"
+ );
+ Assert.ok(
+ !!gUpdateManager.readyUpdate,
+ "First update download should be in readyUpdate"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING,
+ "readyUpdate should be pending"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.appVersion,
+ FIRST_UPDATE_VERSION,
+ "readyUpdate version should be match the version of the first update"
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_PENDING,
+ "Updater state should be pending"
+ );
+
+ let existingUpdate = gUpdateManager.readyUpdate;
+ await testUpdateDoesNotDownload();
+
+ Assert.equal(
+ gUpdateManager.readyUpdate,
+ existingUpdate,
+ "readyUpdate should not have changed when no newer update is available"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING,
+ "readyUpdate should still be pending"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.appVersion,
+ FIRST_UPDATE_VERSION,
+ "readyUpdate version should be match the version of the first update"
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_PENDING,
+ "Updater state should still be pending"
+ );
+
+ // With only a complete update available, we should not download the newer
+ // update when we already have an update ready.
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION, true);
+ await testUpdateDoesNotDownload();
+
+ Assert.equal(
+ gUpdateManager.readyUpdate,
+ existingUpdate,
+ "readyUpdate should not have changed when no newer partial update is available"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING,
+ "readyUpdate should still be pending"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.appVersion,
+ FIRST_UPDATE_VERSION,
+ "readyUpdate version should be match the version of the first update"
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_PENDING,
+ "Updater state should still be pending"
+ );
+
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION);
+
+ await downloadUpdate(appUpdateAuto, () => {
+ Assert.ok(
+ !!gUpdateManager.downloadingUpdate,
+ "Second update download should be in downloadingUpdate"
+ );
+ Assert.equal(
+ gUpdateManager.downloadingUpdate.state,
+ STATE_DOWNLOADING,
+ "downloadingUpdate should be downloading"
+ );
+ Assert.ok(
+ !!gUpdateManager.readyUpdate,
+ "First update download should still be in readyUpdate"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING,
+ "readyUpdate should still be pending"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.appVersion,
+ FIRST_UPDATE_VERSION,
+ "readyUpdate version should be match the version of the first update"
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_PENDING,
+ "Updater state should match the readyUpdate's state"
+ );
+ });
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "Second update download should no longer be in downloadingUpdate"
+ );
+ Assert.ok(
+ !!gUpdateManager.readyUpdate,
+ "Second update download should be in readyUpdate"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.state,
+ STATE_PENDING,
+ "readyUpdate should be pending"
+ );
+ Assert.equal(
+ gUpdateManager.readyUpdate.appVersion,
+ SECOND_UPDATE_VERSION,
+ "readyUpdate version should be match the version of the second update"
+ );
+ Assert.equal(
+ readStatusFile(),
+ STATE_PENDING,
+ "Updater state should be pending"
+ );
+
+ // Reset the updater to its initial state to test that the complete/partial
+ // MAR behavior is correct
+ reloadUpdateManagerData(true);
+
+ // Second parameter forces a complete MAR download.
+ prepareToDownloadVersion(FIRST_UPDATE_VERSION, true);
+
+ await downloadUpdate(appUpdateAuto, () => {
+ Assert.equal(
+ gUpdateManager.downloadingUpdate.selectedPatch.type,
+ "complete",
+ "First update download should be a complete patch"
+ );
+ });
+
+ Assert.equal(
+ gUpdateManager.readyUpdate.selectedPatch.type,
+ "complete",
+ "First update download should be a complete patch"
+ );
+
+ // Even a newer partial update should not be downloaded at this point.
+ prepareToDownloadVersion(SECOND_UPDATE_VERSION);
+ testUpdateCheckDoesNotStart();
+}
+
+add_task(async function all_multi_update_tests() {
+ setupTestCommon(true);
+ startUpdateServer();
+
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, false);
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_STAGING_ENABLED, false);
+
+ let origAppUpdateAutoVal = await UpdateUtils.getAppUpdateAutoEnabled();
+ registerCleanupFunction(async () => {
+ await UpdateUtils.setAppUpdateAutoEnabled(origAppUpdateAutoVal);
+ });
+
+ await multi_update_test(true);
+
+ // Reset the update system so we can start again from scratch.
+ reloadUpdateManagerData(true);
+
+ await multi_update_test(false);
+
+ doTestFinish();
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/onlyDownloadUpdatesThisSession.js b/toolkit/mozapps/update/tests/unit_aus_update/onlyDownloadUpdatesThisSession.js
new file mode 100644
index 0000000000..ab08ac854f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/onlyDownloadUpdatesThisSession.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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";
+
+function setup() {
+ setupTestCommon();
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ setUpdateChannel("test_channel");
+}
+setup();
+
+/**
+ * Checks for updates and makes sure that the update process does not proceed
+ * beyond the downloading stage.
+ */
+async function downloadUpdate() {
+ let patches = getRemotePatchString({});
+ let updateString = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+
+ let { updates } = await waitForUpdateCheck(true);
+
+ initMockIncrementalDownload();
+ gIncrementalDownloadErrorType = 3;
+
+ let downloadRestrictionHitPromise = new Promise(resolve => {
+ let downloadRestrictionHitListener = (subject, topic) => {
+ Services.obs.removeObserver(downloadRestrictionHitListener, topic);
+ resolve();
+ };
+ Services.obs.addObserver(
+ downloadRestrictionHitListener,
+ "update-download-restriction-hit"
+ );
+ });
+
+ let bestUpdate = gAUS.selectUpdate(updates);
+ let success = await gAUS.downloadUpdate(bestUpdate, false);
+ Assert.ok(success, "Update download should have started");
+ return downloadRestrictionHitPromise;
+}
+
+add_task(async function onlyDownloadUpdatesThisSession() {
+ gAUS.onlyDownloadUpdatesThisSession = true;
+
+ await downloadUpdate();
+
+ Assert.ok(
+ !gUpdateManager.readyUpdate,
+ "There should not be a ready update. The update should still be downloading"
+ );
+ Assert.ok(
+ !!gUpdateManager.downloadingUpdate,
+ "A downloading update should exist"
+ );
+ Assert.equal(
+ gUpdateManager.downloadingUpdate.state,
+ STATE_DOWNLOADING,
+ "The downloading update should still be in the downloading state"
+ );
+});
+
+add_task(async function finish() {
+ stop_httpserver(doTestFinish);
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/perInstallationPrefs.js b/toolkit/mozapps/update/tests/unit_aus_update/perInstallationPrefs.js
new file mode 100644
index 0000000000..4a4a8ff2ae
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/perInstallationPrefs.js
@@ -0,0 +1,238 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+let gPolicyFunctionResult;
+
+async function testSetup() {
+ // The setup functions we use for update testing typically allow for update.
+ // But we are just testing preferences here. We don't want anything to
+ // actually attempt to update. Also, because we are messing with the pref
+ // system itself in this test, we want to make sure to use a pref outside of
+ // that system to disable update.
+ Services.prefs.setBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, true);
+
+ // We run these tests whether per-installation prefs are supported or not,
+ // because this API needs to work in both cases.
+ logTestInfo(
+ "PER_INSTALLATION_PREFS_SUPPORTED = " +
+ UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED.toString()
+ );
+
+ // Save the original state so we can restore it after the test.
+ const originalPerInstallationPrefs = UpdateUtils.PER_INSTALLATION_PREFS;
+ let configFile = getUpdateDirFile(FILE_UPDATE_CONFIG_JSON);
+ try {
+ configFile.moveTo(null, FILE_BACKUP_UPDATE_CONFIG_JSON);
+ } catch (e) {}
+
+ // Currently, features exist for per-installation prefs that are not used by
+ // any actual pref. We added them because we intend to use these features in
+ // the future. Thus, we will override the normally defined per-installation
+ // prefs and provide our own, for the purposes of testing.
+ UpdateUtils.PER_INSTALLATION_PREFS = {
+ "test.pref.boolean": {
+ type: UpdateUtils.PER_INSTALLATION_PREF_TYPE_BOOL,
+ defaultValue: true,
+ observerTopic: "test-pref-change-observer-boolean",
+ },
+ "test.pref.integer": {
+ type: UpdateUtils.PER_INSTALLATION_PREF_TYPE_INT,
+ defaultValue: 1234,
+ observerTopic: "test-pref-change-observer-integer",
+ },
+ "test.pref.string": {
+ type: UpdateUtils.PER_INSTALLATION_PREF_TYPE_ASCII_STRING,
+ defaultValue: "<default>",
+ observerTopic: "test-pref-change-observer-string",
+ },
+ "test.pref.policy": {
+ type: UpdateUtils.PER_INSTALLATION_PREF_TYPE_INT,
+ defaultValue: 1234,
+ observerTopic: "test-pref-change-observer-policy",
+ policyFn: () => gPolicyFunctionResult,
+ },
+ };
+ // We need to re-initialize the pref system with these new prefs
+ UpdateUtils.initPerInstallPrefs();
+
+ registerCleanupFunction(() => {
+ if (UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED) {
+ let testConfigFile = getUpdateDirFile(FILE_UPDATE_CONFIG_JSON);
+ let backupConfigFile = getUpdateDirFile(FILE_BACKUP_UPDATE_CONFIG_JSON);
+ try {
+ testConfigFile.remove(false);
+ backupConfigFile.moveTo(null, FILE_UPDATE_CONFIG_JSON);
+ } catch (ex) {}
+ } else {
+ for (const prefName in UpdateUtils.PER_INSTALLATION_PREFS) {
+ Services.prefs.clearUserPref(prefName);
+ }
+ }
+
+ UpdateUtils.PER_INSTALLATION_PREFS = originalPerInstallationPrefs;
+ UpdateUtils.initPerInstallPrefs();
+ });
+}
+
+let gObserverSeenCount = 0;
+let gExpectedObserverData;
+function observerCallback(subject, topic, data) {
+ gObserverSeenCount += 1;
+ Assert.equal(
+ data,
+ gExpectedObserverData,
+ `Expected observer to have data: "${gExpectedObserverData}". ` +
+ `It actually has data: "${data}"`
+ );
+}
+
+async function changeAndVerifyPref(
+ prefName,
+ expectedInitialValue,
+ newValue,
+ setterShouldThrow = false
+) {
+ let initialValue = await UpdateUtils.readUpdateConfigSetting(prefName);
+ Assert.strictEqual(
+ initialValue,
+ expectedInitialValue,
+ `Expected pref '${prefName}' to have an initial value of ` +
+ `${JSON.stringify(expectedInitialValue)}. Its actual initial value is ` +
+ `${JSON.stringify(initialValue)}`
+ );
+
+ let expectedObserverCount = 1;
+ if (initialValue == newValue || setterShouldThrow) {
+ expectedObserverCount = 0;
+ }
+
+ let observerTopic =
+ UpdateUtils.PER_INSTALLATION_PREFS[prefName].observerTopic;
+ gObserverSeenCount = 0;
+ gExpectedObserverData = newValue.toString();
+ Services.obs.addObserver(observerCallback, observerTopic);
+
+ let returned;
+ let exceptionThrown;
+ try {
+ returned = await UpdateUtils.writeUpdateConfigSetting(prefName, newValue);
+ } catch (e) {
+ exceptionThrown = e;
+ }
+ if (setterShouldThrow) {
+ Assert.ok(!!exceptionThrown, "Expected an exception to be thrown");
+ } else {
+ Assert.ok(
+ !exceptionThrown,
+ `Unexpected exception thrown by writeUpdateConfigSetting: ` +
+ `${exceptionThrown}`
+ );
+ }
+
+ if (!exceptionThrown) {
+ Assert.strictEqual(
+ returned,
+ newValue,
+ `Expected writeUpdateConfigSetting to return ` +
+ `${JSON.stringify(newValue)}. It actually returned ` +
+ `${JSON.stringify(returned)}`
+ );
+ }
+
+ let readValue = await UpdateUtils.readUpdateConfigSetting(prefName);
+ let expectedReadValue = exceptionThrown ? expectedInitialValue : newValue;
+ Assert.strictEqual(
+ readValue,
+ expectedReadValue,
+ `Expected pref '${prefName}' to be ${JSON.stringify(expectedReadValue)}.` +
+ ` It was actually ${JSON.stringify(readValue)}.`
+ );
+
+ Assert.equal(
+ gObserverSeenCount,
+ expectedObserverCount,
+ `Expected to see observer fire ${expectedObserverCount} times. It ` +
+ `actually fired ${gObserverSeenCount} times.`
+ );
+ Services.obs.removeObserver(observerCallback, observerTopic);
+}
+
+async function run_test() {
+ setupTestCommon(null);
+ standardInit();
+ await testSetup();
+
+ logTestInfo("Testing boolean pref and its observer");
+ let pref = "test.pref.boolean";
+ let defaultValue = UpdateUtils.PER_INSTALLATION_PREFS[pref].defaultValue;
+ await changeAndVerifyPref(pref, defaultValue, defaultValue);
+ await changeAndVerifyPref(pref, defaultValue, !defaultValue);
+ await changeAndVerifyPref(pref, !defaultValue, !defaultValue);
+ await changeAndVerifyPref(pref, !defaultValue, defaultValue);
+ await changeAndVerifyPref(pref, defaultValue, defaultValue);
+ await changeAndVerifyPref(pref, defaultValue, "true", true);
+ await changeAndVerifyPref(pref, defaultValue, 1, true);
+
+ logTestInfo("Testing string pref and its observer");
+ pref = "test.pref.string";
+ defaultValue = UpdateUtils.PER_INSTALLATION_PREFS[pref].defaultValue;
+ await changeAndVerifyPref(pref, defaultValue, defaultValue);
+ await changeAndVerifyPref(pref, defaultValue, defaultValue + "1");
+ await changeAndVerifyPref(pref, defaultValue + "1", "");
+ await changeAndVerifyPref(pref, "", 1, true);
+ await changeAndVerifyPref(pref, "", true, true);
+
+ logTestInfo("Testing integer pref and its observer");
+ pref = "test.pref.integer";
+ defaultValue = UpdateUtils.PER_INSTALLATION_PREFS[pref].defaultValue;
+ await changeAndVerifyPref(pref, defaultValue, defaultValue);
+ await changeAndVerifyPref(pref, defaultValue, defaultValue + 1);
+ await changeAndVerifyPref(pref, defaultValue + 1, 0);
+ await changeAndVerifyPref(pref, 0, "1", true);
+ await changeAndVerifyPref(pref, 0, true, true);
+
+ // Testing that the default pref branch works the same way that the default
+ // branch works for our per-profile prefs.
+ logTestInfo("Testing default branch behavior");
+ pref = "test.pref.integer";
+ let originalDefault = UpdateUtils.PER_INSTALLATION_PREFS[pref].defaultValue;
+ // Make sure the value is the default value, then change the default value
+ // and check that the effective value changes.
+ await UpdateUtils.writeUpdateConfigSetting(pref, originalDefault, {
+ setDefaultOnly: true,
+ });
+ await UpdateUtils.writeUpdateConfigSetting(pref, originalDefault);
+ await UpdateUtils.writeUpdateConfigSetting(pref, originalDefault + 1, {
+ setDefaultOnly: true,
+ });
+ Assert.strictEqual(
+ await UpdateUtils.readUpdateConfigSetting(pref),
+ originalDefault + 1,
+ `Expected that changing the default of a pref with no user value should ` +
+ `change the effective value`
+ );
+ // Now make the user value different from the default value and ensure that
+ // changing the default value does not affect the effective value
+ await UpdateUtils.writeUpdateConfigSetting(pref, originalDefault + 10);
+ await UpdateUtils.writeUpdateConfigSetting(pref, originalDefault + 20, {
+ setDefaultOnly: true,
+ });
+ Assert.strictEqual(
+ await UpdateUtils.readUpdateConfigSetting(pref),
+ originalDefault + 10,
+ `Expected that changing the default of a pref with a user value should ` +
+ `NOT change the effective value`
+ );
+
+ logTestInfo("Testing policy behavior");
+ pref = "test.pref.policy";
+ defaultValue = UpdateUtils.PER_INSTALLATION_PREFS[pref].defaultValue;
+ gPolicyFunctionResult = null;
+ await changeAndVerifyPref(pref, defaultValue, defaultValue + 1);
+ gPolicyFunctionResult = defaultValue + 10;
+ await changeAndVerifyPref(pref, gPolicyFunctionResult, 0, true);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/remoteUpdateXML.js b/toolkit/mozapps/update/tests/unit_aus_update/remoteUpdateXML.js
new file mode 100644
index 0000000000..94081a01a5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/remoteUpdateXML.js
@@ -0,0 +1,327 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function run_test() {
+ setupTestCommon();
+ debugDump("testing remote update xml attributes");
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ setUpdateChannel("test_channel");
+
+ debugDump("testing update xml not available");
+ await waitForUpdateCheck(false).then(aArgs => {
+ Assert.equal(
+ aArgs.updates[0].errorCode,
+ 1500,
+ "the update errorCode" + MSG_SHOULD_EQUAL
+ );
+ });
+
+ debugDump(
+ "testing one update available, the update's property values and " +
+ "the property values of the update's patches"
+ );
+ let patchProps = {
+ type: "complete",
+ url: "http://complete/",
+ size: "9856459",
+ };
+ let patches = getRemotePatchString(patchProps);
+ patchProps = { type: "partial", url: "http://partial/", size: "1316138" };
+ patches += getRemotePatchString(patchProps);
+ let updateProps = {
+ type: "minor",
+ name: "Minor Test",
+ displayVersion: "version 2.1a1pre",
+ appVersion: "2.1a1pre",
+ buildID: "20080811053724",
+ detailsURL: "http://details/",
+ promptWaitTime: "345600",
+ custom1: 'custom1_attr="custom1 value"',
+ custom2: 'custom2_attr="custom2 value"',
+ };
+ let updates = getRemoteUpdateString(updateProps, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 1 }).then(aArgs => {
+ // XXXrstrong - not specifying a detailsURL will cause a leak due to
+ // bug 470244 and until this is fixed this will not test the value for
+ // detailsURL when it isn't specified in the update xml.
+
+ let bestUpdate = gAUS
+ .selectUpdate(aArgs.updates)
+ .QueryInterface(Ci.nsIWritablePropertyBag);
+ Assert.equal(
+ bestUpdate.type,
+ "minor",
+ "the update type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.name,
+ "Minor Test",
+ "the update name attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.displayVersion,
+ "version 2.1a1pre",
+ "the update displayVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.appVersion,
+ "2.1a1pre",
+ "the update appVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.buildID,
+ "20080811053724",
+ "the update buildID attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.detailsURL,
+ "http://details/",
+ "the update detailsURL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.promptWaitTime,
+ "345600",
+ "the update promptWaitTime attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.serviceURL,
+ gURLData + gHTTPHandlerPath + "?force=1",
+ "the update serviceURL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.channel,
+ "test_channel",
+ "the update channel attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !bestUpdate.isCompleteUpdate,
+ "the update isCompleteUpdate attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !bestUpdate.isSecurityUpdate,
+ "the update isSecurityUpdate attribute" + MSG_SHOULD_EQUAL
+ );
+ // Check that installDate is within 10 seconds of the current date.
+ Assert.ok(
+ Date.now() - bestUpdate.installDate < 10000,
+ "the update installDate attribute should be within 10 seconds " +
+ "of the current time"
+ );
+ Assert.ok(
+ !bestUpdate.statusText,
+ "the update statusText attribute" + MSG_SHOULD_EQUAL
+ );
+ // nsIUpdate:state returns an empty string when no action has been performed
+ // on an available update
+ Assert.equal(
+ bestUpdate.state,
+ "",
+ "the update state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.errorCode,
+ 0,
+ "the update errorCode attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.patchCount,
+ 2,
+ "the update patchCount attribute" + MSG_SHOULD_EQUAL
+ );
+ // XXX TODO - test nsIUpdate:serialize
+
+ Assert.equal(
+ bestUpdate.getProperty("custom1_attr"),
+ "custom1 value",
+ "the update custom1_attr property" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.getProperty("custom2_attr"),
+ "custom2 value",
+ "the update custom2_attr property" + MSG_SHOULD_EQUAL
+ );
+
+ let patch = bestUpdate.getPatchAt(0);
+ Assert.equal(
+ patch.type,
+ "complete",
+ "the update patch type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.URL,
+ "http://complete/",
+ "the update patch URL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.size,
+ 9856459,
+ "the update patch size attribute" + MSG_SHOULD_EQUAL
+ );
+ // The value for patch.state can be the string 'null' as a valid value. This
+ // is confusing if it returns null which is an invalid value since the test
+ // failure output will show a failure for null == null. To lessen the
+ // confusion first check that the typeof for patch.state is string.
+ Assert.equal(
+ typeof patch.state,
+ "string",
+ "the update patch state typeof value should equal |string|"
+ );
+ Assert.equal(
+ patch.state,
+ STATE_NONE,
+ "the update patch state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !patch.selected,
+ "the update patch selected attribute" + MSG_SHOULD_EQUAL
+ );
+ // XXX TODO - test nsIUpdatePatch:serialize
+
+ patch = bestUpdate.getPatchAt(1);
+ Assert.equal(
+ patch.type,
+ "partial",
+ "the update patch type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.URL,
+ "http://partial/",
+ "the update patch URL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.size,
+ 1316138,
+ "the update patch size attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.state,
+ STATE_NONE,
+ "the update patch state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !patch.selected,
+ "the update patch selected attribute" + MSG_SHOULD_EQUAL
+ );
+ // XXX TODO - test nsIUpdatePatch:serialize
+ });
+
+ debugDump(
+ "testing an empty update xml that returns a root node name of " +
+ "parsererror"
+ );
+ gResponseBody = "<parsererror/>";
+ await waitForUpdateCheck(false).then(aArgs => {
+ Assert.equal(
+ aArgs.updates[0].errorCode,
+ 1200,
+ "the update errorCode" + MSG_SHOULD_EQUAL
+ );
+ });
+
+ debugDump("testing no updates available");
+ gResponseBody = getRemoteUpdatesXMLString("");
+ await waitForUpdateCheck(true, { updateCount: 0 });
+
+ debugDump("testing one update available with two patches");
+ patches = getRemotePatchString({});
+ patchProps = { type: "partial" };
+ patches += getRemotePatchString(patchProps);
+ updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 1 });
+
+ debugDump("testing three updates available each with two patches");
+ patches = getRemotePatchString({});
+ patchProps = { type: "partial" };
+ patches += getRemotePatchString(patchProps);
+ updates = getRemoteUpdateString({}, patches);
+ updates += updates + updates;
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 3 });
+
+ debugDump(
+ "testing one update with complete and partial patches with size " +
+ "0 specified in the update xml"
+ );
+ patchProps = { size: "0" };
+ patches = getRemotePatchString(patchProps);
+ patchProps = { type: "partial", size: "0" };
+ patches += getRemotePatchString(patchProps);
+ updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true).then(aArgs => {
+ Assert.equal(
+ aArgs.updates.length,
+ 0,
+ "the update count" + MSG_SHOULD_EQUAL
+ );
+ });
+
+ debugDump(
+ "testing one update with complete patch with size 0 specified in " +
+ "the update xml"
+ );
+ patchProps = { size: "0" };
+ patches = getRemotePatchString(patchProps);
+ updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 0 });
+
+ debugDump(
+ "testing one update with partial patch with size 0 specified in " +
+ "the update xml"
+ );
+ patchProps = { type: "partial", size: "0" };
+ patches = getRemotePatchString(patchProps);
+ updates = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 0 });
+
+ debugDump(
+ "testing that updates for older versions of the application " +
+ "aren't selected"
+ );
+ patches = getRemotePatchString({});
+ patchProps = { type: "partial" };
+ patches += getRemotePatchString(patchProps);
+ updateProps = { appVersion: "1.0pre" };
+ updates = getRemoteUpdateString(updateProps, patches);
+ updateProps = { appVersion: "1.0a" };
+ updates += getRemoteUpdateString(updateProps, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 2 }).then(aArgs => {
+ let bestUpdate = gAUS.selectUpdate(aArgs.updates);
+ Assert.ok(!bestUpdate, "there shouldn't be an update available");
+ });
+
+ debugDump(
+ "testing that updates for the current version of the application " +
+ "are selected"
+ );
+ patches = getRemotePatchString({});
+ patchProps = { type: "partial" };
+ patches += getRemotePatchString(patchProps);
+ updateProps = { appVersion: "1.0" };
+ updates = getRemoteUpdateString(updateProps, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updates);
+ await waitForUpdateCheck(true, { updateCount: 1 }).then(aArgs => {
+ let bestUpdate = gAUS.selectUpdate(aArgs.updates);
+ Assert.ok(!!bestUpdate, "there should be one update available");
+ Assert.equal(
+ bestUpdate.appVersion,
+ "1.0",
+ "the update appVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ bestUpdate.displayVersion,
+ "1.0",
+ "the update displayVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ });
+
+ stop_httpserver(doTestFinish);
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/testConstants.js b/toolkit/mozapps/update/tests/unit_aus_update/testConstants.js
new file mode 100644
index 0000000000..ace6134a38
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/testConstants.js
@@ -0,0 +1,8 @@
+/* eslint-disable no-unused-vars */
+const REL_PATH_DATA = "";
+const URL_HOST = "http://127.0.0.1:8888";
+const URL_PATH_UPDATE_XML = "/" + REL_PATH_DATA + "app_update.sjs";
+const URL_HTTP_UPDATE_SJS = URL_HOST + URL_PATH_UPDATE_XML;
+const CONTINUE_CHECK = "continueCheck";
+const CONTINUE_DOWNLOAD = "continueDownload";
+const CONTINUE_STAGING = "continueStaging";
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/updateAutoPrefMigrate.js b/toolkit/mozapps/update/tests/unit_aus_update/updateAutoPrefMigrate.js
new file mode 100644
index 0000000000..8f62b9458b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/updateAutoPrefMigrate.js
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+async function verifyPref(expectedValue) {
+ let configValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ Assert.equal(
+ configValue,
+ expectedValue,
+ "Value returned by getAppUpdateAutoEnabled should have " +
+ "matched the expected value"
+ );
+}
+
+async function run_test() {
+ setupTestCommon(null);
+ standardInit();
+
+ let configFile = getUpdateDirFile(FILE_UPDATE_CONFIG_JSON);
+
+ // Test that, if there is no value to migrate, the default value is set.
+ Services.prefs.setBoolPref("app.update.auto.migrated", false);
+ Services.prefs.clearUserPref("app.update.auto");
+ Assert.ok(!configFile.exists(), "Config file should not exist yet");
+ await verifyPref(
+ UpdateUtils.PER_INSTALLATION_PREFS["app.update.auto"].defaultValue
+ );
+
+ debugDump("about to remove config file");
+ configFile.remove(false);
+
+ // Test migration of a |false| value
+ Services.prefs.setBoolPref("app.update.auto.migrated", false);
+ Services.prefs.setBoolPref("app.update.auto", false);
+ Assert.ok(!configFile.exists(), "Config file should have been removed");
+ await verifyPref(false);
+
+ // Test that migration doesn't happen twice
+ Services.prefs.setBoolPref("app.update.auto", true);
+ await verifyPref(false);
+
+ // If the file is deleted after migration, the default value should be
+ // returned, regardless of the pref value.
+ debugDump("about to remove config file");
+ configFile.remove(false);
+ Assert.ok(!configFile.exists(), "Config file should have been removed");
+ let configValue = await UpdateUtils.getAppUpdateAutoEnabled();
+ Assert.equal(
+ configValue,
+ true,
+ "getAppUpdateAutoEnabled should have returned the default value (true)"
+ );
+
+ // Setting a new value should cause the value to get written out again
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ await verifyPref(false);
+
+ // Test migration of a |true| value
+ Services.prefs.setBoolPref("app.update.auto.migrated", false);
+ Services.prefs.setBoolPref("app.update.auto", true);
+ configFile.remove(false);
+ Assert.ok(
+ !configFile.exists(),
+ "App update config file should have been removed"
+ );
+ await verifyPref(true);
+
+ // Test that setting app.update.auto without migrating also works
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ await verifyPref(false);
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/updateCheckCombine.js b/toolkit/mozapps/update/tests/unit_aus_update/updateCheckCombine.js
new file mode 100644
index 0000000000..1b2c9ef78c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/updateCheckCombine.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+"use strict";
+
+/**
+ * This test checks that multiple foreground update checks are combined into
+ * a single web request.
+ */
+
+add_task(async function setup() {
+ setupTestCommon();
+ start_httpserver();
+ setUpdateURL(gURLData + gHTTPHandlerPath);
+ setUpdateChannel("test_channel");
+
+ let patches = getRemotePatchString({});
+ let updateString = getRemoteUpdateString({}, patches);
+ gResponseBody = getRemoteUpdatesXMLString(updateString);
+});
+
+add_task(async function testUpdateCheckCombine() {
+ gUpdateCheckCount = 0;
+ let check1 = gUpdateChecker.checkForUpdates(gUpdateChecker.FOREGROUND_CHECK);
+ let check2 = gUpdateChecker.checkForUpdates(gUpdateChecker.FOREGROUND_CHECK);
+
+ let result1 = await check1.result;
+ let result2 = await check2.result;
+ Assert.ok(result1.succeeded, "Check 1 should have succeeded");
+ Assert.ok(result2.succeeded, "Check 2 should have succeeded");
+ Assert.equal(gUpdateCheckCount, 1, "Should only have made a single request");
+});
+
+add_task(async function finish() {
+ stop_httpserver(doTestFinish);
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/updateDirectoryMigrate.js b/toolkit/mozapps/update/tests/unit_aus_update/updateDirectoryMigrate.js
new file mode 100644
index 0000000000..6c566d76fc
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/updateDirectoryMigrate.js
@@ -0,0 +1,246 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * Gets the root directory for the old (unmigrated) updates directory.
+ *
+ * @return nsIFile for the updates root directory.
+ */
+function getOldUpdatesRootDir() {
+ return Services.dirsvc.get(XRE_OLD_UPDATE_ROOT_DIR, Ci.nsIFile);
+}
+
+/**
+ * Gets the old (unmigrated) updates directory.
+ *
+ * @return nsIFile for the updates directory.
+ */
+function getOldUpdatesDir() {
+ let dir = getOldUpdatesRootDir();
+ dir.append(DIR_UPDATES);
+ return dir;
+}
+
+/**
+ * Gets the directory for update patches in the old (unmigrated) updates
+ * directory.
+ *
+ * @return nsIFile for the updates directory.
+ */
+function getOldUpdatesPatchDir() {
+ let dir = getOldUpdatesDir();
+ dir.append(DIR_PATCH);
+ return dir;
+}
+
+/**
+ * Returns either the active or regular update database XML file in the old
+ * (unmigrated) updates directory
+ *
+ * @param isActiveUpdate
+ * If true this will return the active-update.xml otherwise it will
+ * return the updates.xml file.
+ */
+function getOldUpdatesXMLFile(aIsActiveUpdate) {
+ let file = getOldUpdatesRootDir();
+ file.append(aIsActiveUpdate ? FILE_ACTIVE_UPDATE_XML : FILE_UPDATES_XML);
+ return file;
+}
+
+/**
+ * Writes the updates specified to either the active-update.xml or the
+ * updates.xml in the old (unmigrated) update directory
+ *
+ * @param aContent
+ * The updates represented as a string to write to the XML file.
+ * @param isActiveUpdate
+ * If true this will write to the active-update.xml otherwise it will
+ * write to the updates.xml file.
+ */
+function writeUpdatesToOldXMLFile(aContent, aIsActiveUpdate) {
+ writeFile(getOldUpdatesXMLFile(aIsActiveUpdate), aContent);
+}
+
+/**
+ * Writes the given update operation/state to a file in the old (unmigrated)
+ * patch directory, indicating to the patching system what operations need
+ * to be performed.
+ *
+ * @param aStatus
+ * The status value to write.
+ */
+function writeOldStatusFile(aStatus) {
+ let file = getOldUpdatesPatchDir();
+ file.append(FILE_UPDATE_STATUS);
+ writeFile(file, aStatus + "\n");
+}
+
+/**
+ * Writes the given data to the config file in the old (unmigrated)
+ * patch directory.
+ *
+ * @param aData
+ * The config data to write.
+ */
+function writeOldConfigFile(aData) {
+ let file = getOldUpdatesRootDir();
+ file.append(FILE_UPDATE_CONFIG_JSON);
+ writeFile(file, aData);
+}
+
+/**
+ * Gets the specified update log from the old (unmigrated) update directory
+ *
+ * @param aLogLeafName
+ * The leaf name of the log to get.
+ * @return nsIFile for the update log.
+ */
+function getOldUpdateLog(aLogLeafName) {
+ let updateLog = getOldUpdatesDir();
+ if (aLogLeafName == FILE_UPDATE_LOG) {
+ updateLog.append(DIR_PATCH);
+ }
+ updateLog.append(aLogLeafName);
+ return updateLog;
+}
+
+async function run_test() {
+ setupTestCommon(null);
+
+ debugDump(
+ "testing that the update directory is migrated after a successful update"
+ );
+
+ Services.prefs.setIntPref(PREF_APP_UPDATE_CANCELATIONS, 5);
+
+ let patchProps = { state: STATE_PENDING };
+ let patches = getLocalPatchString(patchProps);
+ let updates = getLocalUpdateString({}, patches);
+ writeUpdatesToOldXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeOldStatusFile(STATE_SUCCEEDED);
+ writeOldConfigFile('{"app.update.auto":false}');
+
+ let log = getOldUpdateLog(FILE_UPDATE_LOG);
+ writeFile(log, "Last Update Log");
+
+ let oldUninstallPingFile = getOldUpdatesRootDir();
+ const hash = oldUninstallPingFile.leafName;
+ const uninstallPingFilename = `uninstall_ping_${hash}_98537294-d37b-4b8b-a4e9-ab417a5d7a87.json`;
+ oldUninstallPingFile = oldUninstallPingFile.parent.parent;
+ oldUninstallPingFile.append(uninstallPingFilename);
+ const uninstallPingContents = "arbitrary uninstall ping file contents";
+ writeFile(oldUninstallPingFile, uninstallPingContents);
+
+ let oldBackgroundUpdateLog1File = getOldUpdatesRootDir();
+ const oldBackgroundUpdateLog1Filename = "backgroundupdate.moz_log";
+ oldBackgroundUpdateLog1File.append(oldBackgroundUpdateLog1Filename);
+ const oldBackgroundUpdateLog1Contents = "arbitrary log 1 contents";
+ writeFile(oldBackgroundUpdateLog1File, oldBackgroundUpdateLog1Contents);
+
+ let oldBackgroundUpdateLog2File = getOldUpdatesRootDir();
+ const oldBackgroundUpdateLog2Filename = "backgroundupdate.child-1.moz_log";
+ oldBackgroundUpdateLog2File.append(oldBackgroundUpdateLog2Filename);
+ const oldBackgroundUpdateLog2Contents = "arbitrary log 2 contents";
+ writeFile(oldBackgroundUpdateLog2File, oldBackgroundUpdateLog2Contents);
+
+ const pendingPingRelativePath =
+ "backgroundupdate\\datareporting\\glean\\pending_pings\\" +
+ "01234567-89ab-cdef-fedc-0123456789ab";
+ let oldPendingPingFile = getOldUpdatesRootDir();
+ oldPendingPingFile.appendRelativePath(pendingPingRelativePath);
+ const pendingPingContents = "arbitrary pending ping file contents";
+ writeFile(oldPendingPingFile, pendingPingContents);
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 1,
+ "the update manager update count" + MSG_SHOULD_EQUAL
+ );
+ await waitForUpdateXMLFiles();
+
+ let cancelations = Services.prefs.getIntPref(PREF_APP_UPDATE_CANCELATIONS, 0);
+ Assert.equal(
+ cancelations,
+ 0,
+ "the " + PREF_APP_UPDATE_CANCELATIONS + " preference " + MSG_SHOULD_EQUAL
+ );
+
+ let oldDir = getOldUpdatesRootDir();
+ let newDir = getUpdateDirFile();
+ if (oldDir.path != newDir.path) {
+ Assert.ok(
+ !oldDir.exists(),
+ "Old update directory should have been deleted after migration"
+ );
+ }
+
+ log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(log),
+ "Last Update Log",
+ "the last update log contents" + MSG_SHOULD_EQUAL
+ );
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST);
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST);
+
+ Assert.equal(
+ await UpdateUtils.getAppUpdateAutoEnabled(),
+ false,
+ "Automatic update download setting should have been migrated."
+ );
+
+ let newUninstallPing = newDir.parent.parent;
+ newUninstallPing.append(uninstallPingFilename);
+ Assert.ok(newUninstallPing.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(newUninstallPing),
+ uninstallPingContents,
+ "the uninstall ping contents" + MSG_SHOULD_EQUAL
+ );
+
+ let newBackgroundUpdateLog1File = newDir.clone();
+ newBackgroundUpdateLog1File.append(oldBackgroundUpdateLog1Filename);
+ Assert.ok(newBackgroundUpdateLog1File.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(newBackgroundUpdateLog1File),
+ oldBackgroundUpdateLog1Contents,
+ "background log file 1 contents" + MSG_SHOULD_EQUAL
+ );
+
+ let newBackgroundUpdateLog2File = newDir.clone();
+ newBackgroundUpdateLog2File.append(oldBackgroundUpdateLog2Filename);
+ Assert.ok(newBackgroundUpdateLog2File.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(newBackgroundUpdateLog2File),
+ oldBackgroundUpdateLog2Contents,
+ "background log file 2 contents" + MSG_SHOULD_EQUAL
+ );
+
+ let newPendingPing = newDir.clone();
+ newPendingPing.appendRelativePath(pendingPingRelativePath);
+ Assert.ok(newPendingPing.exists(), MSG_SHOULD_EXIST);
+ Assert.equal(
+ readFile(newPendingPing),
+ pendingPingContents,
+ "the pending ping contents" + MSG_SHOULD_EQUAL
+ );
+
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/updateManagerXML.js b/toolkit/mozapps/update/tests/unit_aus_update/updateManagerXML.js
new file mode 100644
index 0000000000..f15e51827e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/updateManagerXML.js
@@ -0,0 +1,593 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+function run_test() {
+ setupTestCommon();
+
+ debugDump(
+ "testing addition of a successful update to " +
+ FILE_UPDATES_XML +
+ " and verification of update properties including the format " +
+ "prior to bug 530872"
+ );
+
+ setUpdateChannel("test_channel");
+
+ let patchProps = {
+ type: "partial",
+ url: "http://partial/",
+ size: "86",
+ selected: "true",
+ state: STATE_PENDING,
+ custom1: 'custom1_attr="custom1 patch value"',
+ custom2: 'custom2_attr="custom2 patch value"',
+ };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = {
+ type: "major",
+ name: "New",
+ displayVersion: "version 4",
+ appVersion: "4.0",
+ buildID: "20070811053724",
+ detailsURL: "http://details1/",
+ serviceURL: "http://service1/",
+ installDate: "1238441300314",
+ statusText: "test status text",
+ isCompleteUpdate: "false",
+ channel: "test_channel",
+ foregroundDownload: "true",
+ promptWaitTime: "345600",
+ previousAppVersion: "3.0",
+ custom1: 'custom1_attr="custom1 value"',
+ custom2: 'custom2_attr="custom2 value"',
+ };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ writeStatusFile(STATE_SUCCEEDED);
+
+ patchProps = {
+ type: "complete",
+ url: "http://complete/",
+ size: "75",
+ selected: "true",
+ state: STATE_FAILED,
+ custom1: 'custom3_attr="custom3 patch value"',
+ custom2: 'custom4_attr="custom4 patch value"',
+ };
+ patches = getLocalPatchString(patchProps);
+ updateProps = {
+ type: "minor",
+ name: "Existing",
+ appVersion: "3.0",
+ detailsURL: "http://details2/",
+ serviceURL: "http://service2/",
+ statusText: getString("patchApplyFailure"),
+ isCompleteUpdate: "true",
+ channel: "test_channel",
+ foregroundDownload: "false",
+ promptWaitTime: "691200",
+ custom1: 'custom3_attr="custom3 value"',
+ custom2: 'custom4_attr="custom4 value"',
+ };
+ updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), false);
+
+ standardInit();
+
+ Assert.ok(
+ !gUpdateManager.downloadingUpdate,
+ "there should not be a downloading update"
+ );
+ Assert.ok(!gUpdateManager.readyUpdate, "there should not be a ready update");
+ Assert.equal(
+ gUpdateManager.getUpdateCount(),
+ 2,
+ "the update manager updateCount attribute" + MSG_SHOULD_EQUAL
+ );
+
+ debugDump("checking the first update properties");
+ let update = gUpdateManager
+ .getUpdateAt(0)
+ .QueryInterface(Ci.nsIWritablePropertyBag);
+ Assert.equal(
+ update.state,
+ STATE_SUCCEEDED,
+ "the update state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.type,
+ "major",
+ "the update type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.name,
+ "New",
+ "the update name attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.displayVersion,
+ "version 4",
+ "the update displayVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.appVersion,
+ "4.0",
+ "the update appVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.buildID,
+ "20070811053724",
+ "the update buildID attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.detailsURL,
+ "http://details1/",
+ "the update detailsURL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.serviceURL,
+ "http://service1/",
+ "the update serviceURL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.installDate,
+ "1238441300314",
+ "the update installDate attribute" + MSG_SHOULD_EQUAL
+ );
+ // statusText is updated
+ Assert.equal(
+ update.statusText,
+ getString("installSuccess"),
+ "the update statusText attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !update.isCompleteUpdate,
+ "the update isCompleteUpdate attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.channel,
+ "test_channel",
+ "the update channel attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.promptWaitTime,
+ "345600",
+ "the update promptWaitTime attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.previousAppVersion,
+ "3.0",
+ "the update previousAppVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ // Custom attributes
+ Assert.equal(
+ update.getProperty("custom1_attr"),
+ "custom1 value",
+ "the update custom1_attr property" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.getProperty("custom2_attr"),
+ "custom2 value",
+ "the update custom2_attr property" + MSG_SHOULD_EQUAL
+ );
+ // nsIPropertyBag enumerator
+ debugDump("checking the first update enumerator");
+ Assert.ok(
+ update.enumerator instanceof Ci.nsISimpleEnumerator,
+ "update enumerator should be an instance of nsISimpleEnumerator"
+ );
+ let results = Array.from(update.enumerator);
+ Assert.equal(
+ results.length,
+ 3,
+ "the length of the array created from the update enumerator" +
+ MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ results.every(prop => prop instanceof Ci.nsIProperty),
+ "the objects in the array created from the update enumerator " +
+ "should all be an instance of nsIProperty"
+ );
+ Assert.equal(
+ results[0].name,
+ "custom1_attr",
+ "the first property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[0].value,
+ "custom1 value",
+ "the first property value" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].name,
+ "custom2_attr",
+ "the second property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].value,
+ "custom2 value",
+ "the second property value" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[2].name,
+ "foregroundDownload",
+ "the second property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[2].value,
+ "true",
+ "the third property value" + MSG_SHOULD_EQUAL
+ );
+
+ debugDump("checking the first update patch properties");
+ let patch = update.selectedPatch.QueryInterface(Ci.nsIWritablePropertyBag);
+ Assert.equal(
+ patch.type,
+ "partial",
+ "the update patch type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.URL,
+ "http://partial/",
+ "the update patch URL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.size,
+ "86",
+ "the update patch size attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !!patch.selected,
+ "the update patch selected attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.state,
+ STATE_SUCCEEDED,
+ "the update patch state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.getProperty("custom1_attr"),
+ "custom1 patch value",
+ "the update patch custom1_attr property" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.getProperty("custom2_attr"),
+ "custom2 patch value",
+ "the update patch custom2_attr property" + MSG_SHOULD_EQUAL
+ );
+ // nsIPropertyBag enumerator
+ debugDump("checking the first update patch enumerator");
+ Assert.ok(
+ patch.enumerator instanceof Ci.nsISimpleEnumerator,
+ "patch enumerator should be an instance of nsISimpleEnumerator"
+ );
+ results = Array.from(patch.enumerator);
+ Assert.equal(
+ results.length,
+ 2,
+ "the length of the array created from the patch enumerator" +
+ MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ results.every(prop => prop instanceof Ci.nsIProperty),
+ "the objects in the array created from the patch enumerator " +
+ "should all be an instance of nsIProperty"
+ );
+ Assert.equal(
+ results[0].name,
+ "custom1_attr",
+ "the first property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[0].value,
+ "custom1 patch value",
+ "the first property value" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].name,
+ "custom2_attr",
+ "the second property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].value,
+ "custom2 patch value",
+ "the second property value" + MSG_SHOULD_EQUAL
+ );
+
+ debugDump("checking the second update properties");
+ update = gUpdateManager
+ .getUpdateAt(1)
+ .QueryInterface(Ci.nsIWritablePropertyBag);
+ Assert.equal(
+ update.state,
+ STATE_FAILED,
+ "the update state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.name,
+ "Existing",
+ "the update name attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.type,
+ "minor",
+ "the update type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.displayVersion,
+ "3.0",
+ "the update displayVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.appVersion,
+ "3.0",
+ "the update appVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.detailsURL,
+ "http://details2/",
+ "the update detailsURL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.serviceURL,
+ "http://service2/",
+ "the update serviceURL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.installDate,
+ "1238441400314",
+ "the update installDate attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.statusText,
+ getString("patchApplyFailure"),
+ "the update statusText attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.buildID,
+ "20080811053724",
+ "the update buildID attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !!update.isCompleteUpdate,
+ "the update isCompleteUpdate attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.channel,
+ "test_channel",
+ "the update channel attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.promptWaitTime,
+ "691200",
+ "the update promptWaitTime attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.previousAppVersion,
+ "1.0",
+ "the update previousAppVersion attribute" + MSG_SHOULD_EQUAL
+ );
+ // Custom attributes
+ Assert.equal(
+ update.getProperty("custom3_attr"),
+ "custom3 value",
+ "the update custom3_attr property" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ update.getProperty("custom4_attr"),
+ "custom4 value",
+ "the update custom4_attr property" + MSG_SHOULD_EQUAL
+ );
+ // nsIPropertyBag enumerator
+ debugDump("checking the second update enumerator");
+ Assert.ok(
+ update.enumerator instanceof Ci.nsISimpleEnumerator,
+ "update enumerator should be an instance of nsISimpleEnumerator"
+ );
+ results = Array.from(update.enumerator);
+ Assert.equal(
+ results.length,
+ 3,
+ "the length of the array created from the update enumerator" +
+ MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ results.every(prop => prop instanceof Ci.nsIProperty),
+ "the objects in the array created from the update enumerator " +
+ "should all be an instance of nsIProperty"
+ );
+ Assert.equal(
+ results[0].name,
+ "custom3_attr",
+ "the first property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[0].value,
+ "custom3 value",
+ "the first property value" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].name,
+ "custom4_attr",
+ "the second property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].value,
+ "custom4 value",
+ "the second property value" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[2].name,
+ "foregroundDownload",
+ "the third property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[2].value,
+ "false",
+ "the third property value" + MSG_SHOULD_EQUAL
+ );
+
+ debugDump("checking the second update patch properties");
+ patch = update.selectedPatch.QueryInterface(Ci.nsIWritablePropertyBag);
+ Assert.equal(
+ patch.type,
+ "complete",
+ "the update patch type attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.URL,
+ "http://complete/",
+ "the update patch URL attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.size,
+ "75",
+ "the update patch size attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ !!patch.selected,
+ "the update patch selected attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.state,
+ STATE_FAILED,
+ "the update patch state attribute" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.getProperty("custom3_attr"),
+ "custom3 patch value",
+ "the update patch custom3_attr property" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ patch.getProperty("custom4_attr"),
+ "custom4 patch value",
+ "the update patch custom4_attr property" + MSG_SHOULD_EQUAL
+ );
+ // nsIPropertyBag enumerator
+ debugDump("checking the second update patch enumerator");
+ Assert.ok(
+ patch.enumerator instanceof Ci.nsISimpleEnumerator,
+ "patch enumerator should be an instance of nsISimpleEnumerator"
+ );
+ results = Array.from(patch.enumerator);
+ Assert.equal(
+ results.length,
+ 2,
+ "the length of the array created from the patch enumerator" +
+ MSG_SHOULD_EQUAL
+ );
+ Assert.ok(
+ results.every(prop => prop instanceof Ci.nsIProperty),
+ "the objects in the array created from the patch enumerator " +
+ "should all be an instance of nsIProperty"
+ );
+ Assert.equal(
+ results[0].name,
+ "custom3_attr",
+ "the first property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[0].value,
+ "custom3 patch value",
+ "the first property value" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].name,
+ "custom4_attr",
+ "the second property name" + MSG_SHOULD_EQUAL
+ );
+ Assert.equal(
+ results[1].value,
+ "custom4 patch value",
+ "the second property value" + MSG_SHOULD_EQUAL
+ );
+
+ let attrNames = [
+ "appVersion",
+ "buildID",
+ "channel",
+ "detailsURL",
+ "displayVersion",
+ "elevationFailure",
+ "errorCode",
+ "installDate",
+ "isCompleteUpdate",
+ "name",
+ "previousAppVersion",
+ "promptWaitTime",
+ "serviceURL",
+ "state",
+ "statusText",
+ "type",
+ "unsupported",
+ ];
+ checkIllegalProperties(update, attrNames);
+
+ attrNames = [
+ "errorCode",
+ "finalURL",
+ "selected",
+ "size",
+ "state",
+ "type",
+ "URL",
+ ];
+ checkIllegalProperties(patch, attrNames);
+
+ executeSoon(doTestFinish);
+}
+
+function checkIllegalProperties(object, propertyNames) {
+ let objectName =
+ object instanceof Ci.nsIUpdate ? "nsIUpdate" : "nsIUpdatePatch";
+ propertyNames.forEach(function (name) {
+ // Check that calling getProperty, setProperty, and deleteProperty on an
+ // nsIUpdate attribute throws NS_ERROR_ILLEGAL_VALUE
+ let result = 0;
+ try {
+ object.getProperty(name);
+ } catch (e) {
+ result = e.result;
+ }
+ Assert.equal(
+ result,
+ Cr.NS_ERROR_ILLEGAL_VALUE,
+ "calling getProperty using an " +
+ objectName +
+ " attribute " +
+ "name should throw NS_ERROR_ILLEGAL_VALUE"
+ );
+
+ result = 0;
+ try {
+ object.setProperty(name, "value");
+ } catch (e) {
+ result = e.result;
+ }
+ Assert.equal(
+ result,
+ Cr.NS_ERROR_ILLEGAL_VALUE,
+ "calling setProperty using an " +
+ objectName +
+ " attribute " +
+ "name should throw NS_ERROR_ILLEGAL_VALUE"
+ );
+
+ result = 0;
+ try {
+ object.deleteProperty(name);
+ } catch (e) {
+ result = e.result;
+ }
+ Assert.equal(
+ result,
+ Cr.NS_ERROR_ILLEGAL_VALUE,
+ "calling deleteProperty using an " +
+ objectName +
+ " attribute " +
+ "name should throw NS_ERROR_ILLEGAL_VALUE"
+ );
+ });
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/updateSyncManager.js b/toolkit/mozapps/update/tests/unit_aus_update/updateSyncManager.js
new file mode 100644
index 0000000000..9123f9e1e3
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/updateSyncManager.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// This test verifies that the update sync manager is working correctly by
+// a) making sure we're the only one that's opened it to begin with, and then
+// b) starting a second copy of the same binary and making sure we can tell we
+// are no longer the only one that's opened it.
+
+const { Subprocess } = ChromeUtils.importESModule(
+ "resource://gre/modules/Subprocess.sys.mjs"
+);
+
+// Save off the real GRE directory and binary path before we register our
+// mock directory service which overrides them both.
+const thisBinary = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+const greDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+
+add_task(async function () {
+ setupTestCommon();
+
+ // First check that we believe we exclusively hold the lock.
+ let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+ );
+ Assert.ok(
+ !syncManager.isOtherInstanceRunning(),
+ "no other instance is running yet"
+ );
+
+ // Now start a second copy of this xpcshell binary so that something else
+ // takes the same lock. First we'll define its command line.
+ // Most of the child's code is in a separate script file, so all the command
+ // line has to do is set up a few required path strings we need to pass
+ // through to the child, and then include the script file.
+ const args = [
+ "-g",
+ greDir.path,
+ "-e",
+ `
+ const customGreDirPath = "${getApplyDirFile(
+ DIR_RESOURCES
+ ).path.replaceAll("\\", "\\\\")}";
+ const customGreBinDirPath = "${getApplyDirFile(DIR_MACOS).path.replaceAll(
+ "\\",
+ "\\\\"
+ )}";
+ const customExePath = "${getApplyDirFile(
+ DIR_MACOS + FILE_APP_BIN
+ ).path.replaceAll("\\", "\\\\")}";
+ const customUpdDirPath = "${getMockUpdRootD().path.replaceAll(
+ "\\",
+ "\\\\"
+ )}";
+ const customOldUpdDirPath = "${getMockUpdRootD(true).path.replaceAll(
+ "\\",
+ "\\\\"
+ )}";
+ `,
+ "-f",
+ getTestDirFile("syncManagerTestChild.js").path,
+ ];
+
+ // Run the second copy two times, to show the lock is usable after having
+ // been closed.
+ for (let runs = 0; runs < 2; runs++) {
+ // Now we can actually invoke the process.
+ debugDump(
+ `launching child process at ${thisBinary.path} with args ${args}`
+ );
+ Subprocess.call({
+ command: thisBinary.path,
+ arguments: args,
+ stderr: "stdout",
+ });
+
+ // It will take the new xpcshell a little time to start up, but we should see
+ // the effect on the lock within at most a few seconds.
+ await TestUtils.waitForCondition(
+ () => syncManager.isOtherInstanceRunning(),
+ "waiting for child process to take the lock"
+ ).catch(e => {
+ // Rather than throwing out of waitForCondition(), catch and log the failure
+ // manually so that we get output that's a bit more readable.
+ Assert.ok(
+ syncManager.isOtherInstanceRunning(),
+ "child process has the lock"
+ );
+ });
+
+ // The lock should have been closed when the process exited, but we'll allow
+ // a little time for the OS to clean up the handle.
+ await TestUtils.waitForCondition(
+ () => !syncManager.isOtherInstanceRunning(),
+ "waiting for child process to release the lock"
+ ).catch(e => {
+ Assert.ok(
+ !syncManager.isOtherInstanceRunning(),
+ "child process has released the lock"
+ );
+ });
+ }
+
+ doTestFinish();
+});
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/urlConstruction.js b/toolkit/mozapps/update/tests/unit_aus_update/urlConstruction.js
new file mode 100644
index 0000000000..6fd1cf8a73
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/urlConstruction.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/.
+ */
+
+/* Application Update URL Construction Tests */
+
+/**
+ * Tests for the majority of values are located in
+ * toolkit/modules/tests/xpcshell/test_UpdateUtils_updatechannel.js
+ * toolkit/modules/tests/xpcshell/test_UpdateUtils_url.js
+ */
+
+async function run_test() {
+ const URL_PREFIX = URL_HOST + "/";
+ setupTestCommon();
+ let url = URL_PREFIX;
+ debugDump("testing url force param is present: " + url);
+ setUpdateURL(url);
+ await waitForUpdateCheck(false, { url: url + "?force=1" });
+ url = URL_PREFIX + "?testparam=1";
+ debugDump("testing url force param when there is already a param: " + url);
+ setUpdateURL(url);
+ await waitForUpdateCheck(false, { url: url + "&force=1" });
+ doTestFinish();
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/verifyChannelPrefsFile.js b/toolkit/mozapps/update/tests/unit_aus_update/verifyChannelPrefsFile.js
new file mode 100644
index 0000000000..4cca77c73d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/verifyChannelPrefsFile.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * This test exists solely to ensure that channel-prefs.js is not changed.
+ * If it does get changed, it will cause a variation of Bug 1431342.
+ * To summarize, our updater doesn't update that file. But, on macOS, it is
+ * still used to compute the application's signature. This means that if Firefox
+ * updates and that file has been changed, the signature no will no longer
+ * validate.
+ */
+
+const expectedChannelPrefsContents = `/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 pref is in its own file for complex reasons. See the comment in
+// browser/app/Makefile.in, bug 756325, and bug 1431342 for details. Do not add
+// other prefs to this file.
+
+pref("app.update.channel", "${UpdateUtils.UpdateChannel}");
+`;
+
+async function run_test() {
+ let channelPrefsFile = Services.dirsvc.get("GreD", Ci.nsIFile);
+ channelPrefsFile.append("defaults");
+ channelPrefsFile.append("pref");
+ channelPrefsFile.append("channel-prefs.js");
+
+ const contents = await IOUtils.readUTF8(channelPrefsFile.path);
+ Assert.equal(
+ contents,
+ expectedChannelPrefsContents,
+ "Channel Prefs file should should not change"
+ );
+}
diff --git a/toolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml b/toolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml
new file mode 100644
index 0000000000..74790016e4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_aus_update/xpcshell.toml
@@ -0,0 +1,89 @@
+[DEFAULT]
+tags = "appupdate"
+head = "head_update.js"
+skip-if = ["os == 'win' && (ccov || msix)"] # Our updater is disabled in MSIX builds
+prefs = ["app.update.staging.enabled=false"]
+support-files = [
+ "../data/shared.js",
+ "../data/sharedUpdateXML.js",
+ "../data/xpcshellUtilsAUS.js",
+ "../data/app_update.sjs",
+ "testConstants.js",
+ "../data/simple.mar",
+]
+
+["ausReadStrings.js"]
+
+["backgroundUpdateTaskInternalUpdater.js"]
+
+["canCheckForAndCanApplyUpdates.js"]
+
+["cleanupDownloadingForDifferentChannel.js"]
+
+["cleanupDownloadingForOlderAppVersion.js"]
+
+["cleanupDownloadingForSameVersionAndBuildID.js"]
+
+["cleanupDownloadingIncorrectStatus.js"]
+
+["cleanupPendingVersionFileIncorrectStatus.js"]
+
+["cleanupSuccessLogMove.js"]
+
+["cleanupSuccessLogsFIFO.js"]
+
+["disableBackgroundUpdatesBackgroundTask.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["disableBackgroundUpdatesNonBackgroundTask.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["downloadInterruptedNoRecovery.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["downloadInterruptedOffline.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["downloadInterruptedRecovery.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["downloadResumeForSameAppVersion.js"]
+
+["ensureExperimentToRolloutTransitionPerformed.js"]
+run-if = ["os == 'win' && appname == 'firefox'"]
+reason = "Feature is Firefox-specific and Windows-specific."
+
+["languagePackUpdates.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["multiUpdate.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["onlyDownloadUpdatesThisSession.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["perInstallationPrefs.js"]
+
+["remoteUpdateXML.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["updateAutoPrefMigrate.js"]
+run-if = ["os == 'win'"]
+reason = "Update pref migration is currently Windows only"
+
+["updateCheckCombine.js"]
+
+["updateDirectoryMigrate.js"]
+run-if = ["os == 'win'"]
+reason = "Update directory migration is currently Windows only"
+
+["updateManagerXML.js"]
+
+["updateSyncManager.js"]
+
+["urlConstruction.js"]
+skip-if = ["socketprocess_networking"] # Bug 1759035
+
+["verifyChannelPrefsFile.js"]
+run-if = ["appname == 'firefox'"]
+reason = "File being verified is Firefox-specific."
diff --git a/toolkit/mozapps/update/tests/unit_background_update/head.js b/toolkit/mozapps/update/tests/unit_background_update/head.js
new file mode 100644
index 0000000000..c7ed24a0a7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/head.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* import-globals-from ../data/xpcshellUtilsAUS.js */
+load("xpcshellUtilsAUS.js");
+gIsServiceTest = false;
+
+const { BackgroundTasksTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BackgroundTasksTestUtils.sys.mjs"
+);
+BackgroundTasksTestUtils.init(this);
+const do_backgroundtask = BackgroundTasksTestUtils.do_backgroundtask.bind(
+ BackgroundTasksTestUtils
+);
+const setupProfileService = BackgroundTasksTestUtils.setupProfileService.bind(
+ BackgroundTasksTestUtils
+);
+
+// Helper function to register a callback to catch a Glean ping before its
+// submission. The function returns all string_list items, but not as they
+// appear in the ping itself, but as full text representation, which is the
+// value of the corresponding field. This makes the test more unique, because
+// the values often contain chars, which are not allowed in glean metric labels
+//
+// @returns: an array which contains all glean metrics, but as full text
+// representation from the BackgroundUpdate.REASON object => its
+// values, see description for further details.
+//
+async function checkGleanPing() {
+ let retval = ["EMPTY"];
+ let ping_submitted = false;
+
+ const { maybeSubmitBackgroundUpdatePing } = ChromeUtils.importESModule(
+ "resource://gre/modules/backgroundtasks/BackgroundTask_backgroundupdate.sys.mjs"
+ );
+ const { BackgroundUpdate } = ChromeUtils.importESModule(
+ "resource://gre/modules/BackgroundUpdate.sys.mjs"
+ );
+
+ GleanPings.backgroundUpdate.testBeforeNextSubmit(_ => {
+ ping_submitted = true;
+ retval = Glean.backgroundUpdate.reasonsToNotUpdate.testGetValue().map(v => {
+ return BackgroundUpdate.REASON[v];
+ });
+ Assert.ok(Array.isArray(retval));
+ return retval;
+ });
+ await maybeSubmitBackgroundUpdatePing();
+ Assert.ok(ping_submitted, "Glean ping successfully submitted");
+
+ // The metric has `lifetime: application` set, but when testing we do not
+ // want to keep the results around and avoid, that one test can influence
+ // another. That is why we clear this string_list.
+ Glean.backgroundUpdate.reasonsToNotUpdate.set([]);
+
+ return retval;
+}
diff --git a/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_exitcodes.js b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_exitcodes.js
new file mode 100644
index 0000000000..f862815ab1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_exitcodes.js
@@ -0,0 +1,80 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=4 ts=4 sts=4 et
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 test exercises functionality and also ensures the exit codes,
+// which are a public API, do not change over time.
+const { EXIT_CODE } = ChromeUtils.importESModule(
+ "resource://gre/modules/BackgroundUpdate.sys.mjs"
+).BackgroundUpdate;
+
+setupProfileService();
+
+// Ensure launched background tasks don't see this xpcshell as a concurrent
+// instance.
+let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService(
+ Ci.nsIUpdateSyncManager
+);
+let lockFile = do_get_profile();
+lockFile.append("customExePath");
+lockFile.append("customExe");
+syncManager.resetLock(lockFile);
+
+add_task(async function test_default_profile_does_not_exist() {
+ // Pretend there's no default profile.
+ let exitCode = await do_backgroundtask("backgroundupdate", {
+ extraEnv: {
+ MOZ_BACKGROUNDTASKS_NO_DEFAULT_PROFILE: "1",
+ },
+ });
+ Assert.equal(EXIT_CODE.DEFAULT_PROFILE_DOES_NOT_EXIST, exitCode);
+ Assert.equal(11, exitCode);
+});
+
+add_task(async function test_default_profile_cannot_be_locked() {
+ // Now, lock the default profile.
+ let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
+ Ci.nsIToolkitProfileService
+ );
+
+ let file = do_get_profile();
+ file.append("profile_cannot_be_locked");
+
+ let profile = profileService.createUniqueProfile(
+ file,
+ "test_default_profile"
+ );
+ let lock = profile.lock({});
+
+ try {
+ let exitCode = await do_backgroundtask("backgroundupdate", {
+ extraEnv: {
+ MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH: lock.directory.path,
+ },
+ });
+ Assert.equal(EXIT_CODE.DEFAULT_PROFILE_CANNOT_BE_LOCKED, exitCode);
+ Assert.equal(12, exitCode);
+ } finally {
+ lock.unlock();
+ }
+});
+
+add_task(async function test_default_profile_cannot_be_read() {
+ // Finally, provide an empty default profile, one without prefs.
+ let file = do_get_profile();
+ file.append("profile_cannot_be_read");
+
+ await IOUtils.makeDirectory(file.path);
+
+ let exitCode = await do_backgroundtask("backgroundupdate", {
+ extraEnv: {
+ MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH: file.path,
+ },
+ });
+ Assert.equal(EXIT_CODE.DEFAULT_PROFILE_CANNOT_BE_READ, exitCode);
+ Assert.equal(13, exitCode);
+});
diff --git a/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_glean.js b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_glean.js
new file mode 100644
index 0000000000..0d9c06b2d0
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_glean.js
@@ -0,0 +1,275 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=4 ts=4 sts=4 et
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { ASRouterTargeting } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs"
+);
+const { BackgroundUpdate } = ChromeUtils.importESModule(
+ "resource://gre/modules/BackgroundUpdate.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const { maybeSubmitBackgroundUpdatePing } = ChromeUtils.importESModule(
+ "resource://gre/modules/backgroundtasks/BackgroundTask_backgroundupdate.sys.mjs"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "UpdateService",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+add_setup(function test_setup() {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+
+ // We need to initialize it once, otherwise operations will be stuck in the pre-init queue.
+ Services.fog.initializeFOG();
+
+ setupProfileService();
+});
+
+add_task(async function test_record_update_environment() {
+ await BackgroundUpdate.recordUpdateEnvironment();
+
+ let pingSubmitted = false;
+ let appUpdateAutoEnabled = await UpdateUtils.getAppUpdateAutoEnabled();
+ let backgroundUpdateEnabled = await UpdateUtils.readUpdateConfigSetting(
+ "app.update.background.enabled"
+ );
+ GleanPings.backgroundUpdate.testBeforeNextSubmit(reason => {
+ Assert.equal(reason, "backgroundupdate_task");
+
+ pingSubmitted = true;
+ Assert.equal(
+ Services.prefs.getBoolPref("app.update.service.enabled", false),
+ Glean.update.serviceEnabled.testGetValue()
+ );
+
+ Assert.equal(
+ appUpdateAutoEnabled,
+ Glean.update.autoDownload.testGetValue()
+ );
+
+ Assert.equal(
+ backgroundUpdateEnabled,
+ Glean.update.backgroundUpdate.testGetValue()
+ );
+
+ Assert.equal(
+ UpdateUtils.UpdateChannel,
+ Glean.update.channel.testGetValue()
+ );
+ Assert.equal(
+ !Services.policies || Services.policies.isAllowed("appUpdate"),
+ Glean.update.enabled.testGetValue()
+ );
+
+ Assert.equal(
+ UpdateService.canUsuallyApplyUpdates,
+ Glean.update.canUsuallyApplyUpdates.testGetValue()
+ );
+ Assert.equal(
+ UpdateService.canUsuallyCheckForUpdates,
+ Glean.update.canUsuallyCheckForUpdates.testGetValue()
+ );
+ Assert.equal(
+ UpdateService.canUsuallyStageUpdates,
+ Glean.update.canUsuallyStageUpdates.testGetValue()
+ );
+ Assert.equal(
+ UpdateService.canUsuallyUseBits,
+ Glean.update.canUsuallyUseBits.testGetValue()
+ );
+ });
+
+ // There's nothing async in this function atm, but it's annotated async, so..
+ await maybeSubmitBackgroundUpdatePing();
+
+ ok(pingSubmitted, "'background-update' ping was submitted");
+});
+
+async function do_readTargeting(content, beforeNextSubmitCallback) {
+ let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
+ Ci.nsIToolkitProfileService
+ );
+
+ let file = do_get_profile();
+ file.append("profile_cannot_be_locked");
+
+ let profile = profileService.createUniqueProfile(
+ file,
+ "test_default_profile"
+ );
+
+ let targetingSnapshot = profile.rootDir.clone();
+ targetingSnapshot.append("targeting.snapshot.json");
+
+ if (content) {
+ await IOUtils.writeUTF8(targetingSnapshot.path, content);
+ }
+
+ let lock = profile.lock({});
+
+ Services.fog.testResetFOG();
+ try {
+ await BackgroundUpdate.readFirefoxMessagingSystemTargetingSnapshot(lock);
+ } finally {
+ lock.unlock();
+ }
+
+ let pingSubmitted = false;
+ GleanPings.backgroundUpdate.testBeforeNextSubmit(reason => {
+ pingSubmitted = true;
+ return beforeNextSubmitCallback(reason);
+ });
+
+ // There's nothing async in this function atm, but it's annotated async, so..
+ await maybeSubmitBackgroundUpdatePing();
+
+ ok(pingSubmitted, "'background-update' ping was submitted");
+}
+
+// Missing targeting is anticipated.
+add_task(async function test_targeting_missing() {
+ await do_readTargeting(null, reason => {
+ Assert.equal(false, Glean.backgroundUpdate.targetingExists.testGetValue());
+
+ Assert.equal(
+ false,
+ Glean.backgroundUpdate.targetingException.testGetValue()
+ );
+ });
+});
+
+// Malformed JSON yields an exception.
+add_task(async function test_targeting_exception() {
+ await do_readTargeting("{", reason => {
+ Assert.equal(false, Glean.backgroundUpdate.targetingExists.testGetValue());
+
+ Assert.equal(
+ true,
+ Glean.backgroundUpdate.targetingException.testGetValue()
+ );
+ });
+});
+
+// Well formed targeting values are reflected into the Glean telemetry.
+add_task(async function test_targeting_exists() {
+ // We can't take a full environment snapshot under `xpcshell`; these are just
+ // the items we need.
+ let target = {
+ currentDate: ASRouterTargeting.Environment.currentDate,
+ profileAgeCreated: ASRouterTargeting.Environment.profileAgeCreated,
+ firefoxVersion: ASRouterTargeting.Environment.firefoxVersion,
+ };
+
+ // Arrange fake experiment enrollment details.
+ const manager = ExperimentFakes.manager();
+
+ await manager.onStartup();
+ await manager.store.addEnrollment(ExperimentFakes.experiment("foo"));
+ manager.unenroll("foo", "some-reason");
+ await manager.store.addEnrollment(
+ ExperimentFakes.experiment("bar", { active: false })
+ );
+ await manager.store.addEnrollment(
+ ExperimentFakes.experiment("baz", { active: true })
+ );
+
+ manager.store.addEnrollment(ExperimentFakes.rollout("rol1"));
+ manager.unenroll("rol1", "some-reason");
+ manager.store.addEnrollment(ExperimentFakes.rollout("rol2"));
+
+ let targetSnapshot = await ASRouterTargeting.getEnvironmentSnapshot({
+ targets: [manager.createTargetingContext(), target],
+ });
+
+ await do_readTargeting(JSON.stringify(targetSnapshot), reason => {
+ Assert.equal(true, Glean.backgroundUpdate.targetingExists.testGetValue());
+
+ Assert.equal(
+ false,
+ Glean.backgroundUpdate.targetingException.testGetValue()
+ );
+
+ // `environment.firefoxVersion` is a positive integer.
+ Assert.ok(
+ Glean.backgroundUpdate.targetingEnvFirefoxVersion.testGetValue() > 0
+ );
+
+ Assert.equal(
+ targetSnapshot.environment.firefoxVersion,
+ Glean.backgroundUpdate.targetingEnvFirefoxVersion.testGetValue()
+ );
+
+ let profileAge =
+ Glean.backgroundUpdate.targetingEnvProfileAge.testGetValue();
+
+ Assert.ok(profileAge instanceof Date);
+ Assert.ok(0 < profileAge.getTime());
+ Assert.ok(profileAge.getTime() < Date.now());
+
+ // `environment.profileAgeCreated` is an integer, milliseconds since the
+ // Unix epoch.
+ let targetProfileAge = new Date(
+ targetSnapshot.environment.profileAgeCreated
+ );
+ // Our `time_unit: day` has Glean round to the nearest day *in the local
+ // timezone*, so we must do the same.
+ targetProfileAge.setHours(0, 0, 0, 0);
+
+ Assert.equal(targetProfileAge.toISOString(), profileAge.toISOString());
+
+ let currentDate =
+ Glean.backgroundUpdate.targetingEnvCurrentDate.testGetValue();
+
+ Assert.ok(0 < currentDate.getTime());
+ Assert.ok(currentDate.getTime() < Date.now());
+
+ // `environment.currentDate` is in ISO string format.
+ let targetCurrentDate = new Date(targetSnapshot.environment.currentDate);
+ // Our `time_unit: day` has Glean round to the nearest day *in the local
+ // timezone*, so we must do the same.
+ targetCurrentDate.setHours(0, 0, 0, 0);
+
+ Assert.equal(targetCurrentDate.toISOString(), currentDate.toISOString());
+
+ // Verify active experiments.
+ Assert.deepEqual(
+ {
+ branch: "treatment",
+ extra: { source: "defaultProfile", type: "nimbus-nimbus" },
+ },
+ Services.fog.testGetExperimentData("baz"),
+ "experiment data for active experiment 'baz' is correct"
+ );
+
+ Assert.deepEqual(
+ {
+ branch: "treatment",
+ extra: { source: "defaultProfile", type: "nimbus-rollout" },
+ },
+ Services.fog.testGetExperimentData("rol2"),
+ "experiment data for active experiment 'rol2' is correct"
+ );
+
+ // Bug 1879247: there is currently no API (even test-only) to get experiment
+ // data for inactive experiments.
+ for (let inactive of ["bar", "foo", "rol1"]) {
+ Assert.equal(
+ null,
+ Services.fog.testGetExperimentData(inactive),
+ `no experiment data for inactive experiment '${inactive}`
+ );
+ }
+ });
+});
diff --git a/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason.js b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason.js
new file mode 100644
index 0000000000..71ce7f0361
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason.js
@@ -0,0 +1,66 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 sts=2 et
+ * this source code form is subject to the terms of the mozilla public license,
+ * v. 2.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 { BackgroundUpdate } = ChromeUtils.importESModule(
+ "resource://gre/modules/BackgroundUpdate.sys.mjs"
+);
+
+// These tests use per-installation prefs, and those are a shared resource, so
+// they require some non-trivial setup.
+setupTestCommon(null);
+standardInit();
+
+add_setup(function test_setup() {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+
+ // We need to initialize it once, otherwise operations will be stuck in the
+ // pre-init queue.
+ Services.fog.initializeFOG();
+});
+
+// Because we want to use the keys from REASON as strings and send these with
+// Glean, we have to make sure, that they meet the requirements for `String
+// Lists` and are not too long.
+add_task(async function test_reasons_length() {
+ for (const key of Object.keys(BackgroundUpdate.REASON)) {
+ Glean.backgroundUpdate.reasonsToNotUpdate.add(key);
+ // No exception means success.
+ Assert.ok(
+ Array.isArray(Glean.backgroundUpdate.reasonsToNotUpdate.testGetValue()),
+ "Glean allows the name of the reason to be '" + key + "'"
+ );
+ }
+});
+
+// The string list in Glean can overflow and has a hard limit of 20 entries.
+// This test toggles a switch to reach this limit and fails if this causes an
+// exception, because we want to avoid that statistical data collection can have
+// an negative impact on the success rate of background updates.
+add_task(async function test_reasons_overflow() {
+ let prev = await UpdateUtils.getAppUpdateAutoEnabled();
+ try {
+ for (let i = 1; i <= 21; i++) {
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ await BackgroundUpdate._reasonsToNotUpdateInstallation();
+ await UpdateUtils.setAppUpdateAutoEnabled(true);
+ await BackgroundUpdate._reasonsToNotUpdateInstallation();
+ Assert.ok(true, "Overflow test successful for run #" + i);
+ }
+ } finally {
+ ok(true, "resetting AppUpdateAutoEnabled to " + prev);
+ await UpdateUtils.setAppUpdateAutoEnabled(prev);
+ }
+});
+
+add_task(() => {
+ // `setupTestCommon()` calls `do_test_pending()`; this calls
+ // `do_test_finish()`. The `add_task` schedules this to run after all the
+ // other tests have completed.
+ doTestFinish();
+});
diff --git a/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_schedule.js b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_schedule.js
new file mode 100644
index 0000000000..277ea993ec
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_schedule.js
@@ -0,0 +1,136 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=4 ts=4 sts=4 et
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { BackgroundUpdate } = ChromeUtils.importESModule(
+ "resource://gre/modules/BackgroundUpdate.sys.mjs"
+);
+let reasons = () => BackgroundUpdate._reasonsToNotScheduleUpdates();
+let REASON = BackgroundUpdate.REASON;
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+setupProfileService();
+
+// Setup that allows to install a langpack.
+ExtensionTestUtils.init(this);
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "42",
+ "42"
+);
+
+add_setup(function test_setup() {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+
+ // We need to initialize it once, otherwise operations will be stuck in the pre-init queue.
+ Services.fog.initializeFOG();
+
+ setupProfileService();
+});
+
+add_task(
+ {
+ skip_if: () => !AppConstants.MOZ_BACKGROUNDTASKS,
+ },
+ async function test_reasons_schedule_langpacks() {
+ await AddonTestUtils.promiseStartupManager();
+
+ Services.prefs.setBoolPref("app.update.langpack.enabled", true);
+
+ let result = await reasons();
+ Assert.ok(
+ !result.includes(REASON.LANGPACK_INSTALLED),
+ "Reasons does not include LANGPACK_INSTALLED"
+ );
+
+ // Install a langpack.
+ let langpack = {
+ "manifest.json": {
+ name: "test Language Pack",
+ version: "1.0",
+ manifest_version: 2,
+ browser_specific_settings: {
+ 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/",
+ },
+ },
+ },
+ };
+
+ await Promise.all([
+ TestUtils.topicObserved("webextension-langpack-startup"),
+ AddonTestUtils.promiseInstallXPI(langpack),
+ ]);
+
+ result = await reasons();
+ Assert.ok(
+ result.includes(REASON.LANGPACK_INSTALLED),
+ "Reasons include LANGPACK_INSTALLED"
+ );
+ result = await checkGleanPing();
+ Assert.ok(
+ result.includes(REASON.LANGPACK_INSTALLED),
+ "Recognizes a language pack is installed."
+ );
+
+ // Now turn off langpack updating.
+ Services.prefs.setBoolPref("app.update.langpack.enabled", false);
+
+ result = await reasons();
+ Assert.ok(
+ !result.includes(REASON.LANGPACK_INSTALLED),
+ "Reasons does not include LANGPACK_INSTALLED"
+ );
+ result = await checkGleanPing();
+ Assert.ok(
+ !result.includes(REASON.LANGPACK_INSTALLED),
+ "No Glean metric when no language pack is installed."
+ );
+ }
+);
+
+add_task(
+ {
+ skip_if: () => !AppConstants.MOZ_BACKGROUNDTASKS,
+ },
+ async function test_reasons_schedule_default_profile() {
+ // It's difficult to arrange a default profile in a testing environment, so
+ // this is not as thorough as we'd like.
+ let result = await reasons();
+
+ Assert.ok(result.includes(REASON.NO_DEFAULT_PROFILE_EXISTS));
+ Assert.ok(result.includes(REASON.NOT_DEFAULT_PROFILE));
+ }
+);
diff --git a/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_update.js b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_update.js
new file mode 100644
index 0000000000..c5349f5a06
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/test_backgroundupdate_reason_update.js
@@ -0,0 +1,321 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=4 ts=4 sts=4 et
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 { BackgroundUpdate } = ChromeUtils.importESModule(
+ "resource://gre/modules/BackgroundUpdate.sys.mjs"
+);
+let reasons = () => BackgroundUpdate._reasonsToNotUpdateInstallation();
+let REASON = BackgroundUpdate.REASON;
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+const { UpdateService } = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateService.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+// We can't reasonably check NO_MOZ_BACKGROUNDTASKS, nor NO_OMNIJAR.
+
+// These tests use per-installation prefs, and those are a shared resource, so
+// they require some non-trivial setup.
+setupTestCommon(null);
+standardInit();
+
+function setup_enterprise_policy_testing() {
+ // This initializes the policy engine for xpcshell tests
+ let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService(
+ Ci.nsIObserver
+ );
+ policies.observe(null, "policies-startup", null);
+}
+setup_enterprise_policy_testing();
+
+async function setupPolicyEngineWithJson(json, customSchema) {
+ if (typeof json != "object") {
+ let filePath = do_get_file(json ? json : "non-existing-file.json").path;
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(
+ filePath,
+ customSchema
+ );
+ }
+ return EnterprisePolicyTesting.setupPolicyEngineWithJson(json, customSchema);
+}
+
+add_setup(function test_setup() {
+ // FOG needs a profile directory to put its data in.
+ do_get_profile();
+
+ // We need to initialize it once, otherwise operations will be stuck in the pre-init queue.
+ Services.fog.initializeFOG();
+
+ setupProfileService();
+});
+
+add_task(async function test_reasons_update_no_app_update_auto() {
+ let prev = await UpdateUtils.getAppUpdateAutoEnabled();
+ try {
+ await UpdateUtils.setAppUpdateAutoEnabled(false);
+ let result = await reasons();
+ Assert.ok(result.includes(REASON.NO_APP_UPDATE_AUTO));
+ result = await checkGleanPing();
+ Assert.ok(result.includes(REASON.NO_APP_UPDATE_AUTO));
+
+ await UpdateUtils.setAppUpdateAutoEnabled(true);
+ result = await reasons();
+ Assert.ok(!result.includes(REASON.NO_APP_UPDATE_AUTO));
+
+ result = await checkGleanPing();
+ Assert.ok(!result.includes(REASON.NO_APP_UPDATE_AUTO));
+ } finally {
+ await UpdateUtils.setAppUpdateAutoEnabled(prev);
+ }
+});
+
+add_task(async function test_reasons_update_no_app_update_background_enabled() {
+ let prev = await UpdateUtils.readUpdateConfigSetting(
+ "app.update.background.enabled"
+ );
+ try {
+ await UpdateUtils.writeUpdateConfigSetting(
+ "app.update.background.enabled",
+ false
+ );
+ let result = await reasons();
+ Assert.ok(result.includes(REASON.NO_APP_UPDATE_BACKGROUND_ENABLED));
+ result = await checkGleanPing();
+ Assert.ok(result.includes(REASON.NO_APP_UPDATE_BACKGROUND_ENABLED));
+
+ await UpdateUtils.writeUpdateConfigSetting(
+ "app.update.background.enabled",
+ true
+ );
+ result = await reasons();
+ Assert.ok(!result.includes(REASON.NO_APP_UPDATE_BACKGROUND_ENABLED));
+ result = await checkGleanPing();
+ Assert.ok(!result.includes(REASON.NO_APP_UPDATE_BACKGROUND_ENABLED));
+ } finally {
+ await UpdateUtils.writeUpdateConfigSetting(
+ "app.update.background.enabled",
+ prev
+ );
+ }
+});
+
+add_task(async function test_reasons_update_cannot_usually_check() {
+ // It's difficult to arrange the conditions in a testing environment, so
+ // we'll use mocks to get a little assurance.
+ let result = await reasons();
+ Assert.ok(!result.includes(REASON.CANNOT_USUALLY_CHECK));
+
+ let sandbox = sinon.createSandbox();
+ try {
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyCheckForUpdates")
+ .get(() => false);
+ result = await reasons();
+ Assert.ok(result.includes(REASON.CANNOT_USUALLY_CHECK));
+ result = await checkGleanPing();
+ Assert.ok(result.includes(REASON.CANNOT_USUALLY_CHECK));
+ } finally {
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_reasons_update_can_usually_stage_or_appl() {
+ // It's difficult to arrange the conditions in a testing environment, so
+ // we'll use mocks to get a little assurance.
+ let sandbox = sinon.createSandbox();
+ try {
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyStageUpdates")
+ .get(() => true);
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyApplyUpdates")
+ .get(() => true);
+ let result = await reasons();
+ Assert.ok(
+ !result.includes(REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY)
+ );
+ result = await checkGleanPing();
+ Assert.ok(
+ !result.includes(REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY)
+ );
+
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyStageUpdates")
+ .get(() => false);
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyApplyUpdates")
+ .get(() => false);
+ result = await reasons();
+ Assert.ok(
+ result.includes(REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY)
+ );
+ result = await checkGleanPing();
+ Assert.ok(
+ result.includes(REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY)
+ );
+ } finally {
+ sandbox.restore();
+ }
+});
+
+add_task(
+ {
+ skip_if: () =>
+ !AppConstants.MOZ_BITS_DOWNLOAD || AppConstants.platform != "win",
+ },
+ async function test_reasons_update_can_usually_use_bits() {
+ let prev = Services.prefs.getBoolPref("app.update.BITS.enabled");
+
+ // Here we use mocks to "get by" preconditions that are not
+ // satisfied in the testing environment.
+ let sandbox = sinon.createSandbox();
+ try {
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyStageUpdates")
+ .get(() => true);
+ sandbox
+ .stub(UpdateService.prototype, "canUsuallyApplyUpdates")
+ .get(() => true);
+
+ Services.prefs.setBoolPref("app.update.BITS.enabled", false);
+ let result = await reasons();
+ Assert.ok(result.includes(REASON.WINDOWS_CANNOT_USUALLY_USE_BITS));
+ result = await checkGleanPing();
+ Assert.ok(
+ result.includes(REASON.WINDOWS_CANNOT_USUALLY_USE_BITS),
+ "result : " + result.join("', '") + "']"
+ );
+
+ Services.prefs.setBoolPref("app.update.BITS.enabled", true);
+ result = await reasons();
+ Assert.ok(!result.includes(REASON.WINDOWS_CANNOT_USUALLY_USE_BITS));
+ result = await checkGleanPing();
+ Assert.ok(!result.includes(REASON.WINDOWS_CANNOT_USUALLY_USE_BITS));
+ } finally {
+ sandbox.restore();
+ Services.prefs.setBoolPref("app.update.BITS.enabled", prev);
+ }
+ }
+);
+
+add_task(async function test_reasons_update_manual_update_only() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ ManualAppUpdateOnly: true,
+ },
+ });
+ Assert.equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Engine is active"
+ );
+
+ let result = await reasons();
+ Assert.ok(result.includes(REASON.MANUAL_UPDATE_ONLY));
+ result = await checkGleanPing();
+ Assert.ok(result.includes(REASON.MANUAL_UPDATE_ONLY));
+
+ await setupPolicyEngineWithJson({});
+
+ result = await reasons();
+ Assert.ok(!result.includes(REASON.MANUAL_UPDATE_ONLY));
+ result = await checkGleanPing();
+ Assert.ok(!result.includes(REASON.MANUAL_UPDATE_ONLY));
+});
+
+add_task(
+ {
+ skip_if: () => AppConstants.platform != "win",
+ },
+ async function test_unelevated_nimbus_enabled() {
+ // Enable feature.
+ Services.prefs.setBoolPref(
+ "app.update.background.allowUpdatesForUnelevatedInstallations",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "app.update.background.allowUpdatesForUnelevatedInstallations"
+ );
+ });
+
+ // execute!
+ let r = await reasons();
+ Assert.ok(
+ !r.includes(BackgroundUpdate.REASON.SERVICE_REGISTRY_KEY_MISSING),
+ `no SERVICE_REGISTRY_KEY_MISSING in ${JSON.stringify(r)}`
+ );
+ Assert.ok(
+ !r.includes(BackgroundUpdate.REASON.APPBASEDIR_NOT_WRITABLE),
+ `no APPBASEDIR_NOT_WRITABLE in ${JSON.stringify(r)}`
+ );
+
+ // the test directory usually is writable, but we now create a file and keep
+ // it open, so that the test file can neither be deleted nor recreated and
+ // appears to be locked. With that we re-execute the test and expect to find
+ // APPBASEDIR_NOT_WRITABLE in the telemetry data.
+ let appDirTestFile = Services.dirsvc.get(
+ XRE_EXECUTABLE_FILE,
+ Ci.nsIFile
+ ).parent;
+ appDirTestFile.append(FILE_UPDATE_TEST);
+ appDirTestFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+ var outputStream = Cc[
+ "@mozilla.org/network/file-output-stream;1"
+ ].createInstance(Ci.nsIFileOutputStream);
+ // WR_ONLY|CREATE|TRUNC
+ outputStream.init(appDirTestFile, 0x02 | 0x08 | 0x20, 0o644, null);
+ registerCleanupFunction(() => {
+ outputStream.close();
+ appDirTestFile.remove(false);
+ });
+ // after the preperation: execute again!
+ r = await reasons();
+ Assert.ok(
+ r.includes(BackgroundUpdate.REASON.APPBASEDIR_NOT_WRITABLE),
+ `no APPBASEDIR_NOT_WRITABLE in ${JSON.stringify(r)}`
+ );
+ }
+);
+
+add_task(
+ {
+ skip_if: () => AppConstants.platform != "win",
+ },
+ async function test_unelevated_nimbus_disabled() {
+ // Disable feature.
+ Services.prefs.setBoolPref(
+ "app.update.background.allowUpdatesForUnelevatedInstallations",
+ false
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "app.update.background.allowUpdatesForUnelevatedInstallations"
+ );
+ });
+
+ let r = await reasons();
+ Assert.ok(
+ r.includes(BackgroundUpdate.REASON.SERVICE_REGISTRY_KEY_MISSING),
+ `SERVICE_REGISTRY_KEY_MISSING in ${JSON.stringify(r)}`
+ );
+ }
+);
+
+add_task(() => {
+ // `setupTestCommon()` calls `do_test_pending()`; this calls
+ // `do_test_finish()`. The `add_task` schedules this to run after all the
+ // other tests have completed.
+ doTestFinish();
+});
diff --git a/toolkit/mozapps/update/tests/unit_background_update/xpcshell.toml b/toolkit/mozapps/update/tests/unit_background_update/xpcshell.toml
new file mode 100644
index 0000000000..6cf14e9eaa
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_background_update/xpcshell.toml
@@ -0,0 +1,24 @@
+[DEFAULT]
+firefox-appdir = "browser"
+skip-if = [
+ "os == 'android'",
+ "os == 'win' && msix", # Our updater is disabled in MSIX builds
+]
+head = "head.js"
+support-files = [
+ "../data/shared.js",
+ "../data/sharedUpdateXML.js",
+ "../data/xpcshellUtilsAUS.js",
+]
+
+["test_backgroundupdate_exitcodes.js"]
+run-sequentially = "very high failure rate in parallel"
+
+["test_backgroundupdate_glean.js"]
+
+["test_backgroundupdate_reason.js"]
+
+["test_backgroundupdate_reason_schedule.js"]
+
+["test_backgroundupdate_reason_update.js"]
+run-sequentially = "very high failure rate in parallel"
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/head_update.js b/toolkit/mozapps/update/tests/unit_base_updater/head_update.js
new file mode 100644
index 0000000000..1ab1a70a0b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/head_update.js
@@ -0,0 +1,7 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* import-globals-from ../data/xpcshellUtilsAUS.js */
+load("xpcshellUtilsAUS.js");
+gIsServiceTest = false;
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFileNotInInstallDirFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFileNotInInstallDirFailure.js
new file mode 100644
index 0000000000..cc61a75dd7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFileNotInInstallDirFailure.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/.
+ */
+
+/* Callback file not in install directory or a sub-directory of the install
+ directory failure */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = STATE_FAILED_INVALID_CALLBACK_DIR_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getTestDirFile(FILE_HELPER_BIN).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, null, path);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_CALLBACK_DIR_ERROR,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFilePathTooLongFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFilePathTooLongFailure.js
new file mode 100644
index 0000000000..0b432e2e15
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgCallbackFilePathTooLongFailure.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Too long callback file path failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = STATE_FAILED_INVALID_CALLBACK_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "123456789";
+ if (AppConstants.platform == "win") {
+ path = "\\" + path;
+ path = path.repeat(30); // 300 characters
+ path = "C:" + path;
+ } else {
+ path = "/" + path;
+ path = path.repeat(1000); // 10000 characters
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, null, path);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_CALLBACK_PATH_ERROR,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTooLongFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTooLongFailure.js
new file mode 100644
index 0000000000..c2746e2bd1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTooLongFailure.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Too long install directory path failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_INSTALL_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_INSTALL_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "123456789";
+ if (AppConstants.platform == "win") {
+ path = "\\" + path;
+ path = path.repeat(30); // 300 characters
+ path = "C:" + path;
+ } else {
+ path = "/" + path;
+ path = path.repeat(1000); // 10000 characters
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, path, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_INSTALL_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTraversalFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTraversalFailure.js
new file mode 100644
index 0000000000..b611fac972
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallDirPathTraversalFailure.js
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Install directory path traversal failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_INSTALL_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_INSTALL_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "123456789";
+ if (AppConstants.platform == "win") {
+ path = "C:\\" + path + "\\..\\" + path;
+ } else {
+ path = "/" + path + "/../" + path;
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, path, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_INSTALL_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallWorkingDirPathNotSameFailure_win.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallWorkingDirPathNotSameFailure_win.js
new file mode 100644
index 0000000000..0506d100fd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgInstallWorkingDirPathNotSameFailure_win.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Different install and working directories for a regular update failure */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_APPLYTO_DIR_ERROR
+ : STATE_FAILED_INVALID_APPLYTO_DIR_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getApplyDirFile("..", false).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, path, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_APPLYTO_DIR_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgPatchDirPathTraversalFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgPatchDirPathTraversalFailure.js
new file mode 100644
index 0000000000..cf053e3ff5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgPatchDirPathTraversalFailure.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/.
+ */
+
+/* Patch directory path traversal failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getUpdateDirFile(DIR_PATCH);
+ if (AppConstants.platform == "win") {
+ path = path + "\\..\\";
+ } else {
+ path = path + "/../";
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, path, null, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgStageDirNotInInstallDirFailure_win.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgStageDirNotInInstallDirFailure_win.js
new file mode 100644
index 0000000000..4624179bfd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgStageDirNotInInstallDirFailure_win.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Different install and working directories for a regular update failure */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_APPLYTO_DIR_STAGED_ERROR
+ : STATE_FAILED_INVALID_APPLYTO_DIR_STAGED_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getApplyDirFile("..", false).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true, null, null, path, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_APPLYTO_DIR_STAGED_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathLocalUNCFailure_win.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathLocalUNCFailure_win.js
new file mode 100644
index 0000000000..7c0af26e37
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathLocalUNCFailure_win.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Working directory path local UNC failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_WORKING_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_WORKING_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "\\\\.\\" + getApplyDirFile(null, false).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, path, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_WORKING_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathRelativeFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathRelativeFailure.js
new file mode 100644
index 0000000000..cfbd9eaec9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/invalidArgWorkingDirPathRelativeFailure.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/.
+ */
+
+/* Relative working directory path failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_WORKING_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_WORKING_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, "test", null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_WORKING_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyDirLockedStageFailure_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyDirLockedStageFailure_win.js
new file mode 100644
index 0000000000..a845cb70c5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyDirLockedStageFailure_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by staging an update and launching an application to
+ * apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ createUpdateInProgressLockFile(getGREBinDir());
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, false);
+ removeUpdateInProgressLockFile(getGREBinDir());
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(PERFORMING_STAGED_UPDATE);
+ checkUpdateLogContains(ERR_UPDATE_IN_PROGRESS);
+ await waitForUpdateXMLFiles(true, false);
+ checkUpdateManager(STATE_AFTER_STAGE, true, STATE_AFTER_STAGE, 0, 0);
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateAppBinInUseStageSuccess_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateAppBinInUseStageSuccess_win.js
new file mode 100644
index 0000000000..319aee8e34
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateAppBinInUseStageSuccess_win.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by staging an update and launching an application to
+ * apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true, false);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ lockDirectory(getGREBinDir().path);
+ // Switch the application to the staged application that was updated.
+ await runUpdateUsingApp(STATE_SUCCEEDED);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSkippedWriteAccess_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSkippedWriteAccess_win.js
new file mode 100644
index 0000000000..aa1336b7ed
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSkippedWriteAccess_win.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test an update isn't attempted when the update.status file can't be written
+ * to.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+
+ // To simulate a user that doesn't have write access to the update directory
+ // lock the relevant files in the update directory.
+ let filesToLock = [
+ FILE_ACTIVE_UPDATE_XML,
+ FILE_UPDATE_MAR,
+ FILE_UPDATE_STATUS,
+ FILE_UPDATE_TEST,
+ FILE_UPDATE_VERSION,
+ ];
+ filesToLock.forEach(function (aFileLeafName) {
+ let file = getUpdateDirFile(aFileLeafName);
+ if (!file.exists()) {
+ file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o444);
+ }
+ file.QueryInterface(Ci.nsILocalFileWin);
+ file.readOnly = true;
+ Assert.ok(file.exists(), MSG_SHOULD_EXIST + getMsgPath(file.path));
+ Assert.ok(!file.isWritable(), "the file should not be writeable");
+ });
+
+ registerCleanupFunction(() => {
+ filesToLock.forEach(function (aFileLeafName) {
+ let file = getUpdateDirFile(aFileLeafName);
+ if (file.exists()) {
+ file.QueryInterface(Ci.nsILocalFileWin);
+ file.readOnly = false;
+ file.remove(false);
+ }
+ });
+ });
+
+ // Reload the update manager now that the update directory files are locked.
+ reloadUpdateManagerData();
+ await runUpdateUsingApp(STATE_PENDING);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateManager(STATE_PENDING, false, STATE_NONE, 0, 0);
+
+ let dir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(dir.exists(), MSG_SHOULD_EXIST + getMsgPath(dir.path));
+
+ let file = getUpdateDirFile(FILE_UPDATES_XML);
+ Assert.ok(!file.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(file.path));
+
+ file = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!file.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(file.path));
+
+ file = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(!file.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(file.path));
+
+ file = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!file.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(file.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageOldVersionFailure.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageOldVersionFailure.js
new file mode 100644
index 0000000000..3dfad0f58e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageOldVersionFailure.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test a replace request for a staged update with a version file that specifies
+ * an older version failure. The same check is used in nsUpdateDriver.cpp for
+ * all update types which is why there aren't tests for the maintenance service
+ * as well as for other update types.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, null, "", false);
+ let patchProps = { state: STATE_AFTER_STAGE };
+ let patches = getLocalPatchString(patchProps);
+ let updateProps = { appVersion: "0.9" };
+ let updates = getLocalUpdateString(updateProps, patches);
+ writeUpdatesToXMLFile(getLocalUpdatesXMLString(updates), true);
+ getUpdateDirFile(FILE_UPDATE_LOG).create(
+ Ci.nsIFile.NORMAL_FILE_TYPE,
+ PERMS_FILE
+ );
+ writeStatusFile(STATE_AFTER_STAGE);
+ // Create the version file with an older version to simulate installing a new
+ // version of the application while there is an update that has been staged.
+ writeVersionFile("0.9");
+ // Try to switch the application to the fake staged application.
+ await runUpdateUsingApp(STATE_AFTER_STAGE);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ ERR_OLDER_VERSION_OR_SAME_BUILD,
+ 1
+ );
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageSuccess.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageSuccess.js
new file mode 100644
index 0000000000..34b47866b1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateStageSuccess.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by staging an update and launching an application to
+ * apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ await runUpdateUsingApp(STATE_SUCCEEDED);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSuccess.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSuccess.js
new file mode 100644
index 0000000000..980b0cb89a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppApplyUpdateSuccess.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by launching an application to apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ // The third parameter will test that a full path to the post update binary
+ // doesn't execute.
+ await setupUpdaterTest(
+ FILE_COMPLETE_MAR,
+ undefined,
+ getApplyDirFile(null, true).path + "/"
+ );
+ await runUpdateUsingApp(STATE_SUCCEEDED);
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseBackgroundTaskFailure_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseBackgroundTaskFailure_win.js
new file mode 100644
index 0000000000..bcb24bee94
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseBackgroundTaskFailure_win.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Fail to apply a complete MAR when the application is in use and the callback is a background task. */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+
+ // Add a dummy --backgroundtask arg; this will have no effect on the
+ // callback (TestAUSHelper) but will cause the updater to detect
+ // that this is a background task.
+ gCallbackArgs = gCallbackArgs.concat(["--backgroundtask", "not_found"]);
+
+ // Run the update with the helper file in use, expecting failure.
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ runUpdate(
+ STATE_FAILED_WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION,
+ false, // aSwitchApp
+ 1, // aExpectedExitValue
+ true // aCheckSvcLog
+ );
+ await waitForHelperExit();
+
+ standardInit();
+
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_BGTASK_EXCLUSIVE);
+
+ // Check that the update was reset to "pending".
+ await waitForUpdateXMLFiles(
+ true, // aActiveUpdateExists
+ false // aUpdatesExists
+ );
+ checkUpdateManager(
+ STATE_PENDING, // aStatusFileState
+ true, // aHasActiveUpdate
+ STATE_PENDING, // aUpdateStatusState
+ WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION,
+ 0 // aUpdateCount
+ );
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageFailureComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageFailureComplete_win.js
new file mode 100644
index 0000000000..502561ed1e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageFailureComplete_win.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Application in use complete MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageSuccessComplete_unix.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageSuccessComplete_unix.js
new file mode 100644
index 0000000000..29c2c2a30e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseStageSuccessComplete_unix.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Application in use complete MAR file staged patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromComplete\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ setupSymLinks();
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ checkSymLinks();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
+
+/**
+ * Setup symlinks for the test.
+ */
+function setupSymLinks() {
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "linux") {
+ removeSymlink();
+ createSymlink();
+ registerCleanupFunction(removeSymlink);
+ gTestFiles.splice(gTestFiles.length - 3, 0, {
+ description: "Readable symlink",
+ fileName: "link",
+ relPathDir: DIR_RESOURCES,
+ originalContents: "test",
+ compareContents: "test",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ });
+ }
+}
+
+/**
+ * Checks the state of the symlinks for the test.
+ */
+function checkSymLinks() {
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "linux") {
+ checkSymlink();
+ }
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseSuccessComplete.js b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseSuccessComplete.js
new file mode 100644
index 0000000000..e08777d042
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marAppInUseSuccessComplete.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Application in use complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessComplete_win.js
new file mode 100644
index 0000000000..2f8155c790
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessComplete_win.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Replace app binary complete MAR file staged patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessPartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessPartial_win.js
new file mode 100644
index 0000000000..04c72896b4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppStageSuccessPartial_win.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Patch app binary partial MAR file staged patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessComplete_win.js
new file mode 100644
index 0000000000..fa13f07f46
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessComplete_win.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Replace app binary complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessPartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessPartial_win.js
new file mode 100644
index 0000000000..e74509c06e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackAppSuccessPartial_win.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Patch app binary partial MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marCallbackUmask_unix.js b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackUmask_unix.js
new file mode 100644
index 0000000000..be3b3707b4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marCallbackUmask_unix.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Verify that app callback is launched with the same umask as was set
+ * before applying an update. */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ preventDistributionFiles();
+
+ // Our callback is `TestAUSHelper check-umask <umask from before updating>`.
+ // Including the umask from before updating as an argument allows to re-use
+ // the callback log checking code below. The argument is also used as the log
+ // file name, so we prefix it with "umask" so that it doesn't clash with
+ // numericfile and directory names in the update data. In particular, "2"
+ // clashes with an existing directory name in the update data, leading to
+ // failing tests.
+ let umask = Services.sysinfo.getProperty("umask");
+ gCallbackArgs = ["check-umask", `umask-${umask}`];
+
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, false, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ // This compares the callback arguments given, including the umask before
+ // updating, to the umask set when the app callback is launched. They should
+ // be the same.
+ checkCallbackLog(getApplyDirFile(DIR_RESOURCES + "callback_app.log"));
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFailurePartial.js b/toolkit/mozapps/update/tests/unit_base_updater/marFailurePartial.js
new file mode 100644
index 0000000000..de8db067bc
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFailurePartial.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Patch Apply Failure Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[11].originalFile = "partial.png";
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ // If execv is used the updater process will turn into the callback process
+ // and the updater's return code will be that of the callback process.
+ runUpdate(
+ STATE_FAILED_LOADSOURCE_ERROR_WRONG_SIZE,
+ false,
+ USE_EXECV ? 0 : 1,
+ true
+ );
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_FAILURE);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ LOADSOURCE_ERROR_WRONG_SIZE,
+ 1
+ );
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailureComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailureComplete_win.js
new file mode 100644
index 0000000000..b93b023934
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailureComplete_win.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use complete MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[13].relPathDir + gTestFiles[13].fileName,
+ false
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailurePartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailurePartial_win.js
new file mode 100644
index 0000000000..b41da12396
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseStageFailurePartial_win.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use partial MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[11].relPathDir + gTestFiles[11].fileName,
+ false
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessComplete_win.js
new file mode 100644
index 0000000000..4b946ac3e4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessComplete_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[13].relPathDir + gTestFiles[13].fileName,
+ false
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessPartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessPartial_win.js
new file mode 100644
index 0000000000..15c3a1121a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileInUseSuccessPartial_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use partial MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[11].relPathDir + gTestFiles[11].fileName,
+ false
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailureComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailureComplete_win.js
new file mode 100644
index 0000000000..698ccb7fe5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailureComplete_win.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked complete MAR file patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperLockFile(gTestFiles[3]);
+ runUpdate(STATE_FAILED_WRITE_ERROR, false, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(ERR_BACKUP_CREATE_7);
+ checkUpdateLogContains(STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles(true, false);
+ checkUpdateManager(STATE_PENDING, true, STATE_PENDING, WRITE_ERROR, 0);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailurePartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailurePartial_win.js
new file mode 100644
index 0000000000..c8c019ec5c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedFailurePartial_win.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked partial MAR file patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperLockFile(gTestFiles[2]);
+ runUpdate(STATE_FAILED_READ_ERROR, false, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_UNABLE_OPEN_DEST);
+ checkUpdateLogContains(STATE_FAILED_READ_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_FAILED, READ_ERROR, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailureComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailureComplete_win.js
new file mode 100644
index 0000000000..7b582dbd45
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailureComplete_win.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked complete MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperLockFile(gTestFiles[3]);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ // Files aren't checked after staging since this test locks a file which
+ // prevents reading the file.
+ checkUpdateLogContains(ERR_ENSURE_COPY);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_FAILED_WRITE_ERROR, false, 1, false);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(ERR_BACKUP_CREATE_7);
+ checkUpdateLogContains(STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles(true, false);
+ checkUpdateManager(STATE_PENDING, true, STATE_PENDING, WRITE_ERROR, 0);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailurePartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailurePartial_win.js
new file mode 100644
index 0000000000..bf3abd8c37
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marFileLockedStageFailurePartial_win.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked partial MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_PENDING;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperLockFile(gTestFiles[2]);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ // Files aren't checked after staging since this test locks a file which
+ // prevents reading the file.
+ checkUpdateLogContains(ERR_ENSURE_COPY);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_FAILED_READ_ERROR, false, 1, false);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_UNABLE_OPEN_DEST);
+ checkUpdateLogContains(STATE_FAILED_READ_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_FAILED, READ_ERROR, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettings.js b/toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettings.js
new file mode 100644
index 0000000000..b0a0cfe657
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettings.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Test update-settings.ini missing channel MAR security check */
+
+async function run_test() {
+ if (!MOZ_VERIFY_MAR_SIGNATURE) {
+ return;
+ }
+
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 2].originalContents = null;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ // If execv is used the updater process will turn into the callback process
+ // and the updater's return code will be that of the callback process.
+ runUpdate(
+ STATE_FAILED_UPDATE_SETTINGS_FILE_CHANNEL,
+ false,
+ USE_EXECV ? 0 : 1,
+ false
+ );
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(STATE_FAILED_UPDATE_SETTINGS_FILE_CHANNEL);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ UPDATE_SETTINGS_FILE_CHANNEL,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettingsStage.js b/toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettingsStage.js
new file mode 100644
index 0000000000..e26d2aefc3
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marMissingUpdateSettingsStage.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Test update-settings.ini missing channel MAR security check */
+
+async function run_test() {
+ if (!MOZ_VERIFY_MAR_SIGNATURE) {
+ return;
+ }
+
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_FAILED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 2].originalContents = null;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(STATE_FAILED_UPDATE_SETTINGS_FILE_CHANNEL);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ UPDATE_SETTINGS_FILE_CHANNEL,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marPIDPersistsSuccessComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marPIDPersistsSuccessComplete_win.js
new file mode 100644
index 0000000000..4918fea140
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marPIDPersistsSuccessComplete_win.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Application in use complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperPIDPersists(DIR_RESOURCES + gCallbackBinFile, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContains(ERR_PARENT_PID_PERSISTS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailureComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailureComplete_win.js
new file mode 100644
index 0000000000..31c5b8bd7a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailureComplete_win.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir complete MAR file staged patch apply failure
+ test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[4].relPathDir +
+ gTestDirs[4].subDirs[0] +
+ gTestDirs[4].subDirFiles[0],
+ true
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailurePartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailurePartial_win.js
new file mode 100644
index 0000000000..b57f8c81b7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseStageFailurePartial_win.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir partial MAR file staged patch apply failure
+ test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[2].relPathDir + gTestDirs[2].files[0],
+ true
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessComplete_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessComplete_win.js
new file mode 100644
index 0000000000..0683df0d8d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessComplete_win.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[4].relPathDir +
+ gTestDirs[4].subDirs[0] +
+ gTestDirs[4].subDirFiles[0],
+ true
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessPartial_win.js b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessPartial_win.js
new file mode 100644
index 0000000000..d4da3a5f37
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marRMRFDirFileInUseSuccessPartial_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir partial MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[2].relPathDir + gTestDirs[2].files[0],
+ true
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marStageFailurePartial.js b/toolkit/mozapps/update/tests/unit_base_updater/marStageFailurePartial.js
new file mode 100644
index 0000000000..a1a0de0fe4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marStageFailurePartial.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/.
+ */
+
+/* General Partial MAR File Staged Patch Apply Failure Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_FAILED;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[11].originalFile = "partial.png";
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_LOADSOURCEFILE_FAILED);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ LOADSOURCE_ERROR_WRONG_SIZE,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessComplete.js b/toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessComplete.js
new file mode 100644
index 0000000000..943a45ba95
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessComplete.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Complete MAR File Staged Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromComplete\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setupSymLinks();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ checkSymLinks();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
+
+/**
+ * Setup symlinks for the test.
+ */
+function setupSymLinks() {
+ // Don't test symlinks on Mac OS X in this test since it tends to timeout.
+ // It is tested on Mac OS X in marAppInUseStageSuccessComplete_unix.js
+ if (AppConstants.platform == "linux") {
+ removeSymlink();
+ createSymlink();
+ registerCleanupFunction(removeSymlink);
+ gTestFiles.splice(gTestFiles.length - 3, 0, {
+ description: "Readable symlink",
+ fileName: "link",
+ relPathDir: DIR_RESOURCES,
+ originalContents: "test",
+ compareContents: "test",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ });
+ }
+}
+
+/**
+ * Checks the state of the symlinks for the test.
+ */
+function checkSymLinks() {
+ // Don't test symlinks on Mac OS X in this test since it tends to timeout.
+ // It is tested on Mac OS X in marAppInUseStageSuccessComplete_unix.js
+ if (AppConstants.platform == "linux") {
+ checkSymlink();
+ }
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessPartial.js b/toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessPartial.js
new file mode 100644
index 0000000000..dd5c240919
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marStageSuccessPartial.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Staged Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromPartial\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsPartialSuccess;
+ preventDistributionFiles();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, true);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true, false, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marSuccessComplete.js b/toolkit/mozapps/update/tests/unit_base_updater/marSuccessComplete.js
new file mode 100644
index 0000000000..2dd1e54f90
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marSuccessComplete.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/.
+ */
+
+/* General Complete MAR File Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ preventDistributionFiles();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, false, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartial.js b/toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartial.js
new file mode 100644
index 0000000000..8e8e9d094a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartial.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromPartial\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsPartialSuccess;
+ // The third parameter will test that a relative path that contains a
+ // directory traversal to the post update binary doesn't execute.
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false, "test/../");
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartialWhileBackgroundTaskRunning.js b/toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartialWhileBackgroundTaskRunning.js
new file mode 100644
index 0000000000..37511bd789
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marSuccessPartialWhileBackgroundTaskRunning.js
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Patch Apply Test */
+
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+const { BackgroundTasksTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BackgroundTasksTestUtils.sys.mjs"
+);
+BackgroundTasksTestUtils.init(this);
+const do_backgroundtask = BackgroundTasksTestUtils.do_backgroundtask.bind(
+ BackgroundTasksTestUtils
+);
+
+async function run_test() {
+ // Without omnijars, the background task apparatus will fail to find task
+ // definitions.
+ {
+ let omniJa = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+ omniJa.append("omni.ja");
+ if (!omniJa.exists()) {
+ Assert.ok(
+ false,
+ "This test requires a packaged build, " +
+ "run 'mach package' and then use 'mach xpcshell-test --xre-path=...'"
+ );
+ return;
+ }
+ }
+
+ if (!setupTestCommon()) {
+ return;
+ }
+
+ // `channel-prefs.js` is required for Firefox to launch, including in
+ // background task mode. The testing partial MAR updates `channel-prefs.js`
+ // to have contents `FromPartial`, which is not a valid prefs file and causes
+ // Firefox to crash. However, `channel-prefs.js` is listed as `add-if-not
+ // "defaults/pref/channel-prefs.js" "defaults/pref/channel-prefs.js"`, so it
+ // won't be updated if it already exists. The manipulations below arrange a)
+ // for the file to exist and b) for the comparison afterward to succeed.
+ gTestFiles = gTestFilesPartialSuccess;
+ let channelPrefs = gTestFiles[gTestFiles.length - 1];
+ Assert.equal("channel-prefs.js", channelPrefs.fileName);
+ let f = gGREDirOrig.clone();
+ f.append("defaults");
+ f.append("pref");
+ f.append("channel-prefs.js");
+ // `originalFile` is a relative path, so we can't just point to the one in the
+ // original GRE directory.
+ channelPrefs.originalFile = null;
+ channelPrefs.originalContents = readFile(f);
+ channelPrefs.compareContents = channelPrefs.originalContents;
+ gTestDirs = gTestDirsPartialSuccess;
+ // The third parameter will test that a relative path that contains a
+ // directory traversal to the post update binary doesn't execute.
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false, "test/../", true, {
+ // We need packaged JavaScript to run background tasks.
+ requiresOmnijar: true,
+ });
+
+ // `0/00/00text2` is just a random file in the testing partial MAR that does
+ // not exist before the update and is always written by the update.
+ let exitCode;
+ exitCode = await do_backgroundtask("file_exists", {
+ extraArgs: [getApplyDirFile(DIR_RESOURCES + "0/00/00text2").path],
+ });
+ // Before updating, file doesn't exist.
+ Assert.equal(11, exitCode);
+
+ // This task will wait 10 seconds before exiting, which should overlap with
+ // the update below. We wait for some output from the wait background task,
+ // so that there is meaningful overlap.
+ let taskStarted = Promise.withResolvers();
+ let p = do_backgroundtask("wait", {
+ onStdoutLine: (line, proc) => {
+ // This sentinel seems pretty safe: it's printed by the task itself and so
+ // there should be a straight line between future test failures and
+ // logging changes.
+ if (line.includes("runBackgroundTask: wait")) {
+ taskStarted.resolve(proc);
+ }
+ },
+ });
+ let proc = await taskStarted.promise;
+
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ // Once we've seen what we want, there's no need to let the task wait complete.
+ await proc.kill();
+
+ Assert.ok("Waiting for background task to die after kill()");
+ exitCode = await p;
+
+ // Windows does not support killing processes gracefully, so they will
+ // always exit with -9 there.
+ let retVal = AppConstants.platform == "win" ? -9 : -15;
+ Assert.equal(retVal, exitCode);
+
+ exitCode = await do_backgroundtask("file_exists", {
+ extraArgs: [getApplyDirFile(DIR_RESOURCES + "0/00/00text2").path],
+ });
+ // After updating, file exists.
+ Assert.equal(0, exitCode);
+
+ // This finishes the test, so must be last.
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marVersionDowngrade.js b/toolkit/mozapps/update/tests/unit_base_updater/marVersionDowngrade.js
new file mode 100644
index 0000000000..6f3e26fe0f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marVersionDowngrade.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Test version downgrade MAR security check */
+
+async function run_test() {
+ if (!MOZ_VERIFY_MAR_SIGNATURE) {
+ return;
+ }
+
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_OLD_VERSION_MAR, false);
+ // If execv is used the updater process will turn into the callback process
+ // and the updater's return code will be that of the callback process.
+ runUpdate(
+ STATE_FAILED_VERSION_DOWNGRADE_ERROR,
+ false,
+ USE_EXECV ? 0 : 1,
+ false
+ );
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(STATE_FAILED_VERSION_DOWNGRADE_ERROR);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ VERSION_DOWNGRADE_ERROR,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marWrongChannel.js b/toolkit/mozapps/update/tests/unit_base_updater/marWrongChannel.js
new file mode 100644
index 0000000000..d31188dcca
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marWrongChannel.js
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Test product/channel MAR security check */
+
+async function run_test() {
+ if (!MOZ_VERIFY_MAR_SIGNATURE) {
+ return;
+ }
+
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 2].originalContents =
+ UPDATE_SETTINGS_CONTENTS.replace("xpcshell-test", "wrong-channel");
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ // If execv is used the updater process will turn into the callback process
+ // and the updater's return code will be that of the callback process.
+ runUpdate(
+ STATE_FAILED_MAR_CHANNEL_MISMATCH_ERROR,
+ false,
+ USE_EXECV ? 0 : 1,
+ false
+ );
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(STATE_FAILED_MAR_CHANNEL_MISMATCH_ERROR);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ MAR_CHANNEL_MISMATCH_ERROR,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/marWrongChannelStage.js b/toolkit/mozapps/update/tests/unit_base_updater/marWrongChannelStage.js
new file mode 100644
index 0000000000..4d512fd12a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/marWrongChannelStage.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Test product/channel MAR security check */
+
+async function run_test() {
+ if (!MOZ_VERIFY_MAR_SIGNATURE) {
+ return;
+ }
+
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_FAILED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 2].originalContents =
+ UPDATE_SETTINGS_CONTENTS.replace("xpcshell-test", "wrong-channel");
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(STATE_FAILED_MAR_CHANNEL_MISMATCH_ERROR);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ MAR_CHANNEL_MISMATCH_ERROR,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_base_updater/xpcshell.toml b/toolkit/mozapps/update/tests/unit_base_updater/xpcshell.toml
new file mode 100644
index 0000000000..0997027c28
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_base_updater/xpcshell.toml
@@ -0,0 +1,181 @@
+[DEFAULT]
+tags = "appupdate"
+head = "head_update.js"
+skip-if = ["os == 'win' && (ccov || msix)"] # Our updater is disabled in MSIX builds
+support-files = [
+ "../data/shared.js",
+ "../data/sharedUpdateXML.js",
+ "../data/xpcshellUtilsAUS.js",
+]
+
+["invalidArgCallbackFileNotInInstallDirFailure.js"]
+
+["invalidArgCallbackFilePathTooLongFailure.js"]
+
+["invalidArgInstallDirPathTooLongFailure.js"]
+
+["invalidArgInstallDirPathTraversalFailure.js"]
+
+["invalidArgInstallWorkingDirPathNotSameFailure_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["invalidArgPatchDirPathTraversalFailure.js"]
+
+["invalidArgStageDirNotInInstallDirFailure_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["invalidArgWorkingDirPathLocalUNCFailure_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["invalidArgWorkingDirPathRelativeFailure.js"]
+
+["marAppApplyDirLockedStageFailure_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marAppApplyUpdateAppBinInUseStageSuccess_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marAppApplyUpdateSkippedWriteAccess_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marAppApplyUpdateStageOldVersionFailure.js"]
+
+["marAppApplyUpdateStageSuccess.js"]
+skip-if = [
+ "apple_silicon", # bug 1707753
+ "apple_catalina", # Bug 1713329
+]
+
+["marAppApplyUpdateSuccess.js"]
+skip-if = ["apple_silicon"] # bug 1724579
+
+["marAppInUseBackgroundTaskFailure_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marAppInUseStageFailureComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marAppInUseStageSuccessComplete_unix.js"]
+run-if = ["os != 'win'"] # not a Windows test
+skip-if = [
+ "apple_silicon", # bug 1707753
+ "apple_catalina", # Bug 1713329
+]
+
+["marAppInUseSuccessComplete.js"]
+
+["marCallbackAppStageSuccessComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marCallbackAppStageSuccessPartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marCallbackAppSuccessComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marCallbackAppSuccessPartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marCallbackUmask_unix.js"]
+run-if = ["os != 'win'"] # not a Windows test
+reason = "Unix only test"
+
+["marFailurePartial.js"]
+
+["marFileInUseStageFailureComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileInUseStageFailurePartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileInUseSuccessComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileInUseSuccessPartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileLockedFailureComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileLockedFailurePartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileLockedStageFailureComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marFileLockedStageFailurePartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marMissingUpdateSettings.js"]
+
+["marMissingUpdateSettingsStage.js"]
+
+["marPIDPersistsSuccessComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marRMRFDirFileInUseStageFailureComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marRMRFDirFileInUseStageFailurePartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marRMRFDirFileInUseSuccessComplete_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marRMRFDirFileInUseSuccessPartial_win.js"]
+run-if = ["os == 'win'"]
+reason = "Windows only test"
+
+["marStageFailurePartial.js"]
+
+["marStageSuccessComplete.js"]
+skip-if = [
+ "apple_silicon", # bug 1707753
+ "apple_catalina", # Bug 1713329
+]
+
+["marStageSuccessPartial.js"]
+skip-if = [
+ "apple_silicon", # bug 1707753
+ "apple_catalina", # Bug 1713329
+]
+
+["marSuccessComplete.js"]
+
+["marSuccessPartial.js"]
+
+["marSuccessPartialWhileBackgroundTaskRunning.js"]
+skip-if = [
+ "apple_silicon", # Bug 1754931
+ "apple_catalina", # Bug 1754931
+]
+
+["marVersionDowngrade.js"]
+
+["marWrongChannel.js"]
+
+["marWrongChannelStage.js"]
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/bootstrapSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/bootstrapSvc.js
new file mode 100644
index 0000000000..57eb630248
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/bootstrapSvc.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Bootstrap the tests using the service by installing our own version of the service */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ // We don't actually care if the MAR has any data, we only care about the
+ // application return code and update.status result.
+ gTestFiles = gTestFilesCommon;
+ gTestDirs = [];
+ await setupUpdaterTest(FILE_COMPLETE_MAR, null);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, false);
+
+ // We need to check the service log even though this is a bootstrap
+ // because the app bin could be in use by this test by the time the next
+ // test runs.
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/checkUpdaterSigSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/checkUpdaterSigSvc.js
new file mode 100644
index 0000000000..d6e1753f35
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/checkUpdaterSigSvc.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * We skip authenticode cert checks from the service udpates
+ * so that we can use updater-xpcshell with the wrong certs for testing.
+ * This tests that code path. */
+
+function run_test() {
+ if (!IS_AUTHENTICODE_CHECK_ENABLED) {
+ return;
+ }
+
+ let binDir = getGREBinDir();
+ let maintenanceServiceBin = binDir.clone();
+ maintenanceServiceBin.append(FILE_MAINTENANCE_SERVICE_BIN);
+
+ let updaterBin = binDir.clone();
+ updaterBin.append(FILE_UPDATER_BIN);
+
+ debugDump(
+ "Launching maintenance service bin: " +
+ maintenanceServiceBin.path +
+ " to check updater: " +
+ updaterBin.path +
+ " signature."
+ );
+
+ // Bypass the manifest and run as invoker
+ Services.env.set("__COMPAT_LAYER", "RunAsInvoker");
+
+ let dummyInstallPath = "---";
+ let maintenanceServiceBinArgs = [
+ "check-cert",
+ dummyInstallPath,
+ updaterBin.path,
+ ];
+ let maintenanceServiceBinProcess = Cc[
+ "@mozilla.org/process/util;1"
+ ].createInstance(Ci.nsIProcess);
+ maintenanceServiceBinProcess.init(maintenanceServiceBin);
+ maintenanceServiceBinProcess.run(
+ true,
+ maintenanceServiceBinArgs,
+ maintenanceServiceBinArgs.length
+ );
+ Assert.equal(
+ maintenanceServiceBinProcess.exitValue,
+ 0,
+ "the maintenance service exit value should be 0"
+ );
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/fallbackOnSvcFailure.js b/toolkit/mozapps/update/tests/unit_service_updater/fallbackOnSvcFailure.js
new file mode 100644
index 0000000000..d09ea7b448
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/fallbackOnSvcFailure.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * If updating with the maintenance service fails in a way specific to the
+ * maintenance service, we should fall back to not using the maintenance
+ * service, which should succeed. This test ensures that that happens as
+ * expected.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ // This variable forces the service to fail by having the updater pass it the
+ // wrong number of arguments. Then we can verify that the fallback happens
+ // properly.
+ gEnvForceServiceFallback = true;
+
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ preventDistributionFiles();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ // It's very important that we pass in true for aCheckSvcLog (4th param),
+ // because otherwise we may not have used the service at all, so we wouldn't
+ // really check that we fell back (to not using the service) properly.
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/head_update.js b/toolkit/mozapps/update/tests/unit_service_updater/head_update.js
new file mode 100644
index 0000000000..8d30c09e4f
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/head_update.js
@@ -0,0 +1,7 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* import-globals-from ../data/xpcshellUtilsAUS.js */
+load("xpcshellUtilsAUS.js");
+gIsServiceTest = true;
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTooLongFailureSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTooLongFailureSvc.js
new file mode 100644
index 0000000000..c2746e2bd1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTooLongFailureSvc.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Too long install directory path failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_INSTALL_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_INSTALL_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "123456789";
+ if (AppConstants.platform == "win") {
+ path = "\\" + path;
+ path = path.repeat(30); // 300 characters
+ path = "C:" + path;
+ } else {
+ path = "/" + path;
+ path = path.repeat(1000); // 10000 characters
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, path, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_INSTALL_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTraversalFailureSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTraversalFailureSvc.js
new file mode 100644
index 0000000000..b611fac972
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallDirPathTraversalFailureSvc.js
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Install directory path traversal failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_INSTALL_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_INSTALL_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "123456789";
+ if (AppConstants.platform == "win") {
+ path = "C:\\" + path + "\\..\\" + path;
+ } else {
+ path = "/" + path + "/../" + path;
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, path, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_INSTALL_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallWorkingDirPathNotSameFailureSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallWorkingDirPathNotSameFailureSvc_win.js
new file mode 100644
index 0000000000..0506d100fd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgInstallWorkingDirPathNotSameFailureSvc_win.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Different install and working directories for a regular update failure */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_APPLYTO_DIR_ERROR
+ : STATE_FAILED_INVALID_APPLYTO_DIR_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getApplyDirFile("..", false).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, path, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_APPLYTO_DIR_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathSuffixFailureSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathSuffixFailureSvc.js
new file mode 100644
index 0000000000..3eb792ecb4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathSuffixFailureSvc.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Patch directory path must end with \updates\0 failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getUpdateDirFile(DIR_PATCH).parent.path;
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, path, null, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathTraversalFailureSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathTraversalFailureSvc.js
new file mode 100644
index 0000000000..cf053e3ff5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgPatchDirPathTraversalFailureSvc.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/.
+ */
+
+/* Patch directory path traversal failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getUpdateDirFile(DIR_PATCH);
+ if (AppConstants.platform == "win") {
+ path = path + "\\..\\";
+ } else {
+ path = path + "/../";
+ }
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, path, null, null, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgStageDirNotInInstallDirFailureSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgStageDirNotInInstallDirFailureSvc_win.js
new file mode 100644
index 0000000000..4624179bfd
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgStageDirNotInInstallDirFailureSvc_win.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Different install and working directories for a regular update failure */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_APPLYTO_DIR_STAGED_ERROR
+ : STATE_FAILED_INVALID_APPLYTO_DIR_STAGED_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = getApplyDirFile("..", false).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true, null, null, path, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_APPLYTO_DIR_STAGED_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathLocalUNCFailureSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathLocalUNCFailureSvc_win.js
new file mode 100644
index 0000000000..7c0af26e37
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathLocalUNCFailureSvc_win.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* Working directory path local UNC failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_WORKING_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_WORKING_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ let path = "\\\\.\\" + getApplyDirFile(null, false).path;
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, path, null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_WORKING_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathRelativeFailureSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathRelativeFailureSvc.js
new file mode 100644
index 0000000000..cfbd9eaec9
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/invalidArgWorkingDirPathRelativeFailureSvc.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/.
+ */
+
+/* Relative working directory path failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_FAILED_SERVICE_INVALID_WORKING_DIR_PATH_ERROR
+ : STATE_FAILED_INVALID_WORKING_DIR_PATH_ERROR;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ runUpdate(STATE_AFTER_RUNUPDATE, false, 1, true, null, null, "test", null);
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ await waitForUpdateXMLFiles();
+ if (gIsServiceTest) {
+ // The invalid argument service tests launch the maintenance service
+ // directly so the unelevated updater doesn't handle the invalid argument.
+ // By doing this it is possible to test that the maintenance service
+ // properly handles the invalid argument but since the updater isn't used to
+ // launch the maintenance service the update.status file isn't copied from
+ // the secure log directory to the patch directory and the update manager
+ // won't read the failure from the update.status file.
+ checkUpdateManager(STATE_NONE, false, STATE_PENDING_SVC, 0, 1);
+ } else {
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ INVALID_WORKING_DIR_PATH_ERROR,
+ 1
+ );
+ }
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyDirLockedStageFailureSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyDirLockedStageFailureSvc_win.js
new file mode 100644
index 0000000000..9280a0736e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyDirLockedStageFailureSvc_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by staging an update and launching an application to
+ * apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_PENDING_SVC;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ createUpdateInProgressLockFile(getGREBinDir());
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, false);
+ removeUpdateInProgressLockFile(getGREBinDir());
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(PERFORMING_STAGED_UPDATE);
+ checkUpdateLogContains(ERR_UPDATE_IN_PROGRESS);
+ await waitForUpdateXMLFiles(true, false);
+ checkUpdateManager(STATE_AFTER_STAGE, true, STATE_AFTER_STAGE, 0, 0);
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateAppBinInUseStageSuccessSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateAppBinInUseStageSuccessSvc_win.js
new file mode 100644
index 0000000000..319aee8e34
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateAppBinInUseStageSuccessSvc_win.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by staging an update and launching an application to
+ * apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true, false);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ lockDirectory(getGREBinDir().path);
+ // Switch the application to the staged application that was updated.
+ await runUpdateUsingApp(STATE_SUCCEEDED);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateStageSuccessSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateStageSuccessSvc.js
new file mode 100644
index 0000000000..34b47866b1
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateStageSuccessSvc.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by staging an update and launching an application to
+ * apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ await runUpdateUsingApp(STATE_SUCCEEDED);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateSuccessSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateSuccessSvc.js
new file mode 100644
index 0000000000..980b0cb89a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppApplyUpdateSuccessSvc.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Test applying an update by launching an application to apply it.
+ */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ // The third parameter will test that a full path to the post update binary
+ // doesn't execute.
+ await setupUpdaterTest(
+ FILE_COMPLETE_MAR,
+ undefined,
+ getApplyDirFile(null, true).path + "/"
+ );
+ await runUpdateUsingApp(STATE_SUCCEEDED);
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+
+ let updatesDir = getUpdateDirFile(DIR_PATCH);
+ Assert.ok(
+ updatesDir.exists(),
+ MSG_SHOULD_EXIST + getMsgPath(updatesDir.path)
+ );
+
+ let log = getUpdateDirFile(FILE_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_LAST_UPDATE_LOG);
+ Assert.ok(log.exists(), MSG_SHOULD_EXIST + getMsgPath(log.path));
+
+ log = getUpdateDirFile(FILE_BACKUP_UPDATE_LOG);
+ Assert.ok(!log.exists(), MSG_SHOULD_NOT_EXIST + getMsgPath(log.path));
+
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseBackgroundTaskFailureSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseBackgroundTaskFailureSvc_win.js
new file mode 100644
index 0000000000..bcb24bee94
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseBackgroundTaskFailureSvc_win.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Fail to apply a complete MAR when the application is in use and the callback is a background task. */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+
+ // Add a dummy --backgroundtask arg; this will have no effect on the
+ // callback (TestAUSHelper) but will cause the updater to detect
+ // that this is a background task.
+ gCallbackArgs = gCallbackArgs.concat(["--backgroundtask", "not_found"]);
+
+ // Run the update with the helper file in use, expecting failure.
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ runUpdate(
+ STATE_FAILED_WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION,
+ false, // aSwitchApp
+ 1, // aExpectedExitValue
+ true // aCheckSvcLog
+ );
+ await waitForHelperExit();
+
+ standardInit();
+
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_BGTASK_EXCLUSIVE);
+
+ // Check that the update was reset to "pending".
+ await waitForUpdateXMLFiles(
+ true, // aActiveUpdateExists
+ false // aUpdatesExists
+ );
+ checkUpdateManager(
+ STATE_PENDING, // aStatusFileState
+ true, // aHasActiveUpdate
+ STATE_PENDING, // aUpdateStatusState
+ WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION,
+ 0 // aUpdateCount
+ );
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseStageFailureCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseStageFailureCompleteSvc_win.js
new file mode 100644
index 0000000000..502561ed1e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseStageFailureCompleteSvc_win.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Application in use complete MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseSuccessCompleteSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseSuccessCompleteSvc.js
new file mode 100644
index 0000000000..e08777d042
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marAppInUseSuccessCompleteSvc.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Application in use complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(DIR_RESOURCES + gCallbackBinFile, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessCompleteSvc_win.js
new file mode 100644
index 0000000000..2f8155c790
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessCompleteSvc_win.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Replace app binary complete MAR file staged patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessPartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessPartialSvc_win.js
new file mode 100644
index 0000000000..04c72896b4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppStageSuccessPartialSvc_win.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Patch app binary partial MAR file staged patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessCompleteSvc_win.js
new file mode 100644
index 0000000000..fa13f07f46
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessCompleteSvc_win.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Replace app binary complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessPartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessPartialSvc_win.js
new file mode 100644
index 0000000000..e74509c06e
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marCallbackAppSuccessPartialSvc_win.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* Patch app binary partial MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ gCallbackBinFile = "exe0.exe";
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFailurePartialSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marFailurePartialSvc.js
new file mode 100644
index 0000000000..de8db067bc
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFailurePartialSvc.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Patch Apply Failure Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[11].originalFile = "partial.png";
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ // If execv is used the updater process will turn into the callback process
+ // and the updater's return code will be that of the callback process.
+ runUpdate(
+ STATE_FAILED_LOADSOURCE_ERROR_WRONG_SIZE,
+ false,
+ USE_EXECV ? 0 : 1,
+ true
+ );
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_FAILURE);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ LOADSOURCE_ERROR_WRONG_SIZE,
+ 1
+ );
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailureCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailureCompleteSvc_win.js
new file mode 100644
index 0000000000..b93b023934
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailureCompleteSvc_win.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use complete MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[13].relPathDir + gTestFiles[13].fileName,
+ false
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailurePartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailurePartialSvc_win.js
new file mode 100644
index 0000000000..b41da12396
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseStageFailurePartialSvc_win.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use partial MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[11].relPathDir + gTestFiles[11].fileName,
+ false
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessCompleteSvc_win.js
new file mode 100644
index 0000000000..4b946ac3e4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessCompleteSvc_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[13].relPathDir + gTestFiles[13].fileName,
+ false
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessPartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessPartialSvc_win.js
new file mode 100644
index 0000000000..15c3a1121a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileInUseSuccessPartialSvc_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use partial MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestFiles[11].relPathDir + gTestFiles[11].fileName,
+ false
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailureCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailureCompleteSvc_win.js
new file mode 100644
index 0000000000..698ccb7fe5
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailureCompleteSvc_win.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked complete MAR file patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperLockFile(gTestFiles[3]);
+ runUpdate(STATE_FAILED_WRITE_ERROR, false, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(ERR_BACKUP_CREATE_7);
+ checkUpdateLogContains(STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles(true, false);
+ checkUpdateManager(STATE_PENDING, true, STATE_PENDING, WRITE_ERROR, 0);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailurePartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailurePartialSvc_win.js
new file mode 100644
index 0000000000..c8c019ec5c
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedFailurePartialSvc_win.js
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked partial MAR file patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperLockFile(gTestFiles[2]);
+ runUpdate(STATE_FAILED_READ_ERROR, false, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_UNABLE_OPEN_DEST);
+ checkUpdateLogContains(STATE_FAILED_READ_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_FAILED, READ_ERROR, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailureCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailureCompleteSvc_win.js
new file mode 100644
index 0000000000..7b582dbd45
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailureCompleteSvc_win.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked complete MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperLockFile(gTestFiles[3]);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ // Files aren't checked after staging since this test locks a file which
+ // prevents reading the file.
+ checkUpdateLogContains(ERR_ENSURE_COPY);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_FAILED_WRITE_ERROR, false, 1, false);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(ERR_BACKUP_CREATE_7);
+ checkUpdateLogContains(STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles(true, false);
+ checkUpdateManager(STATE_PENDING, true, STATE_PENDING, WRITE_ERROR, 0);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailurePartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailurePartialSvc_win.js
new file mode 100644
index 0000000000..bf3abd8c37
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marFileLockedStageFailurePartialSvc_win.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File locked partial MAR file staged patch apply failure test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_PENDING;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperLockFile(gTestFiles[2]);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ // Files aren't checked after staging since this test locks a file which
+ // prevents reading the file.
+ checkUpdateLogContains(ERR_ENSURE_COPY);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_FAILED_READ_ERROR, false, 1, false);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_UNABLE_OPEN_DEST);
+ checkUpdateLogContains(STATE_FAILED_READ_ERROR + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_FAILED, READ_ERROR, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailureCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailureCompleteSvc_win.js
new file mode 100644
index 0000000000..31c5b8bd7a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailureCompleteSvc_win.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir complete MAR file staged patch apply failure
+ test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[4].relPathDir +
+ gTestDirs[4].subDirs[0] +
+ gTestDirs[4].subDirFiles[0],
+ true
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailurePartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailurePartialSvc_win.js
new file mode 100644
index 0000000000..b57f8c81b7
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseStageFailurePartialSvc_win.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir partial MAR file staged patch apply failure
+ test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ const STATE_AFTER_RUNUPDATE = gIsServiceTest
+ ? STATE_PENDING_SVC
+ : STATE_PENDING;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[2].relPathDir + gTestDirs[2].files[0],
+ true
+ );
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_AFTER_RUNUPDATE, true, 1, true);
+ await waitForHelperExit();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ setTestFilesAndDirsForFailure();
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_RENAME_FILE);
+ checkUpdateLogContains(
+ ERR_MOVE_DESTDIR_7 + "\n" + STATE_FAILED_WRITE_ERROR + "\n" + CALL_QUIT
+ );
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_AFTER_RUNUPDATE, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessCompleteSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessCompleteSvc_win.js
new file mode 100644
index 0000000000..0683df0d8d
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessCompleteSvc_win.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir complete MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[4].relPathDir +
+ gTestDirs[4].subDirs[0] +
+ gTestDirs[4].subDirFiles[0],
+ true
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessPartialSvc_win.js b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessPartialSvc_win.js
new file mode 100644
index 0000000000..d4da3a5f37
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marRMRFDirFileInUseSuccessPartialSvc_win.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/* File in use inside removed dir partial MAR file patch apply success test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestDirs = gTestDirsPartialSuccess;
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await runHelperFileInUse(
+ gTestDirs[2].relPathDir + gTestDirs[2].files[0],
+ true
+ );
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await waitForHelperExit();
+ await checkPostUpdateAppLog();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContains(ERR_BACKUP_DISCARD);
+ checkUpdateLogContains(STATE_SUCCEEDED + "\n" + CALL_QUIT);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marStageFailurePartialSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marStageFailurePartialSvc.js
new file mode 100644
index 0000000000..a1a0de0fe4
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marStageFailurePartialSvc.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/.
+ */
+
+/* General Partial MAR File Staged Patch Apply Failure Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = STATE_FAILED;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[11].originalFile = "partial.png";
+ gTestDirs = gTestDirsPartialSuccess;
+ setTestFilesAndDirsForFailure();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateFailure(getApplyDirFile);
+ checkUpdateLogContains(ERR_LOADSOURCEFILE_FAILED);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(
+ STATE_NONE,
+ false,
+ STATE_FAILED,
+ LOADSOURCE_ERROR_WRONG_SIZE,
+ 1
+ );
+ waitForFilesInUse();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessCompleteSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessCompleteSvc.js
new file mode 100644
index 0000000000..943a45ba95
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessCompleteSvc.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Complete MAR File Staged Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromComplete\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsCompleteSuccess;
+ setupSymLinks();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, false);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ checkSymLinks();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
+
+/**
+ * Setup symlinks for the test.
+ */
+function setupSymLinks() {
+ // Don't test symlinks on Mac OS X in this test since it tends to timeout.
+ // It is tested on Mac OS X in marAppInUseStageSuccessComplete_unix.js
+ if (AppConstants.platform == "linux") {
+ removeSymlink();
+ createSymlink();
+ registerCleanupFunction(removeSymlink);
+ gTestFiles.splice(gTestFiles.length - 3, 0, {
+ description: "Readable symlink",
+ fileName: "link",
+ relPathDir: DIR_RESOURCES,
+ originalContents: "test",
+ compareContents: "test",
+ originalFile: null,
+ compareFile: null,
+ originalPerms: 0o666,
+ comparePerms: 0o666,
+ });
+ }
+}
+
+/**
+ * Checks the state of the symlinks for the test.
+ */
+function checkSymLinks() {
+ // Don't test symlinks on Mac OS X in this test since it tends to timeout.
+ // It is tested on Mac OS X in marAppInUseStageSuccessComplete_unix.js
+ if (AppConstants.platform == "linux") {
+ checkSymlink();
+ }
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessPartialSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessPartialSvc.js
new file mode 100644
index 0000000000..dd5c240919
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marStageSuccessPartialSvc.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Staged Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ const STATE_AFTER_STAGE = gIsServiceTest ? STATE_APPLIED_SVC : STATE_APPLIED;
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromPartial\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsPartialSuccess;
+ preventDistributionFiles();
+ await setupUpdaterTest(FILE_PARTIAL_MAR, true);
+ await stageUpdate(STATE_AFTER_STAGE, true);
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getStageDirFile, true);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS, true, false, true);
+ // Switch the application to the staged application that was updated.
+ runUpdate(STATE_SUCCEEDED, true, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile, false, true);
+ checkUpdateLogContents(LOG_REPLACE_SUCCESS, false, true, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marSuccessCompleteSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marSuccessCompleteSvc.js
new file mode 100644
index 0000000000..2dd1e54f90
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marSuccessCompleteSvc.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/.
+ */
+
+/* General Complete MAR File Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesCompleteSuccess;
+ gTestDirs = gTestDirsCompleteSuccess;
+ preventDistributionFiles();
+ await setupUpdaterTest(FILE_COMPLETE_MAR, true);
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ await checkPostUpdateAppLog();
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(true);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_COMPLETE_SUCCESS, false, false, true);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/marSuccessPartialSvc.js b/toolkit/mozapps/update/tests/unit_service_updater/marSuccessPartialSvc.js
new file mode 100644
index 0000000000..8e8e9d094a
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/marSuccessPartialSvc.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/* General Partial MAR File Patch Apply Test */
+
+async function run_test() {
+ if (!setupTestCommon()) {
+ return;
+ }
+ gTestFiles = gTestFilesPartialSuccess;
+ gTestFiles[gTestFiles.length - 1].originalContents = null;
+ gTestFiles[gTestFiles.length - 1].compareContents = "FromPartial\n";
+ gTestFiles[gTestFiles.length - 1].comparePerms = 0o644;
+ gTestDirs = gTestDirsPartialSuccess;
+ // The third parameter will test that a relative path that contains a
+ // directory traversal to the post update binary doesn't execute.
+ await setupUpdaterTest(FILE_PARTIAL_MAR, false, "test/../");
+ runUpdate(STATE_SUCCEEDED, false, 0, true);
+ checkAppBundleModTime();
+ standardInit();
+ checkPostUpdateRunningFile(false);
+ checkFilesAfterUpdateSuccess(getApplyDirFile);
+ checkUpdateLogContents(LOG_PARTIAL_SUCCESS);
+ await waitForUpdateXMLFiles();
+ checkUpdateManager(STATE_NONE, false, STATE_SUCCEEDED, 0, 1);
+ checkCallbackLog();
+}
diff --git a/toolkit/mozapps/update/tests/unit_service_updater/xpcshell.toml b/toolkit/mozapps/update/tests/unit_service_updater/xpcshell.toml
new file mode 100644
index 0000000000..d10ae8ec3b
--- /dev/null
+++ b/toolkit/mozapps/update/tests/unit_service_updater/xpcshell.toml
@@ -0,0 +1,134 @@
+[DEFAULT]
+skip-if = [
+ "os == 'win' && verify",
+ "os == 'win' && ccov", # 1532801
+ "os == 'win' && asan", # updater binary must be signed for these tests, but it isn't in this build config
+ "os == 'win' && msix", # Updates are disabled for MSIX builds
+]
+tags = "appupdate"
+head = "head_update.js"
+support-files = [
+ "../data/shared.js",
+ "../data/sharedUpdateXML.js",
+ "../data/xpcshellUtilsAUS.js",
+]
+
+["bootstrapSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["checkUpdaterSigSvc.js"]
+
+["fallbackOnSvcFailure.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgInstallDirPathTooLongFailureSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgInstallDirPathTraversalFailureSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgInstallWorkingDirPathNotSameFailureSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgPatchDirPathSuffixFailureSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgPatchDirPathTraversalFailureSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgStageDirNotInInstallDirFailureSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgWorkingDirPathLocalUNCFailureSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["invalidArgWorkingDirPathRelativeFailureSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marAppApplyDirLockedStageFailureSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marAppApplyUpdateAppBinInUseStageSuccessSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+skip-if = ["ccov && os == 'win'"] #Bug 1651090
+
+["marAppApplyUpdateStageSuccessSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marAppApplyUpdateSuccessSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marAppInUseBackgroundTaskFailureSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marAppInUseStageFailureCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marAppInUseSuccessCompleteSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marCallbackAppStageSuccessCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marCallbackAppStageSuccessPartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marCallbackAppSuccessCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marCallbackAppSuccessPartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFailurePartialSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileInUseStageFailureCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileInUseStageFailurePartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileInUseSuccessCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileInUseSuccessPartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileLockedFailureCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileLockedFailurePartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileLockedStageFailureCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marFileLockedStageFailurePartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marRMRFDirFileInUseStageFailureCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marRMRFDirFileInUseStageFailurePartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marRMRFDirFileInUseSuccessCompleteSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marRMRFDirFileInUseSuccessPartialSvc_win.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marStageFailurePartialSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marStageSuccessCompleteSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marStageSuccessPartialSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marSuccessCompleteSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
+
+["marSuccessPartialSvc.js"]
+run-sequentially = "Uses the Mozilla Maintenance Service."
diff --git a/toolkit/mozapps/update/updater/Launchd.plist b/toolkit/mozapps/update/updater/Launchd.plist
new file mode 100644
index 0000000000..f0b5cef085
--- /dev/null
+++ b/toolkit/mozapps/update/updater/Launchd.plist
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>org.mozilla.updater</string>
+ <key>RunAtLoad</key>
+ <true/>
+</dict>
+</plist>
diff --git a/toolkit/mozapps/update/updater/Makefile.in b/toolkit/mozapps/update/updater/Makefile.in
new file mode 100644
index 0000000000..70cf32378a
--- /dev/null
+++ b/toolkit/mozapps/update/updater/Makefile.in
@@ -0,0 +1,28 @@
+# vim:set ts=8 sw=8 sts=8 noet:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# For changes here, also consider ./updater-xpcshell/Makefile.in
+
+ifndef MOZ_WINCONSOLE
+ifdef MOZ_DEBUG
+MOZ_WINCONSOLE = 1
+else
+MOZ_WINCONSOLE = 0
+endif
+endif
+
+include $(topsrcdir)/config/rules.mk
+
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+export::
+ $(call py_action,preprocessor dist/bin/Info.plist,-Fsubstitution -DMOZ_MACBUNDLE_ID='$(MOZ_MACBUNDLE_ID)' $(srcdir)/macbuild/Contents/Info.plist.in -o $(DIST)/bin/Info.plist)
+libs::
+ $(NSINSTALL) -D $(DIST)/bin/updater.app
+ rsync -a -C --exclude '*.in' $(srcdir)/macbuild/Contents $(DIST)/bin/updater.app
+ rsync -a -C $(DIST)/bin/Info.plist $(DIST)/bin/updater.app/Contents
+ $(call py_action,preprocessor updater.app/Contents/Resources/English.lproj/InfoPlist.strings,-Fsubstitution --output-encoding utf-16 -DAPP_NAME='$(MOZ_APP_DISPLAYNAME)' $(srcdir)/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in -o $(DIST)/bin/updater.app/Contents/Resources/English.lproj/InfoPlist.strings)
+ $(NSINSTALL) -D $(DIST)/bin/updater.app/Contents/MacOS
+ $(NSINSTALL) $(DIST)/bin/org.mozilla.updater $(DIST)/bin/updater.app/Contents/MacOS
+endif
diff --git a/toolkit/mozapps/update/updater/TsanOptions.cpp b/toolkit/mozapps/update/updater/TsanOptions.cpp
new file mode 100644
index 0000000000..44a0b53afc
--- /dev/null
+++ b/toolkit/mozapps/update/updater/TsanOptions.cpp
@@ -0,0 +1,23 @@
+/* -*- 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/Attributes.h"
+#include "mozilla/TsanOptions.h"
+
+#ifndef _MSC_VER // Not supported by clang-cl yet
+
+// See also mozglue/build/TsanOptions.cpp before modifying this
+extern "C" const char* __tsan_default_suppressions() {
+ // clang-format off
+ return "# Add your suppressions below\n"
+
+ // External uninstrumented libraries
+ MOZ_TSAN_DEFAULT_EXTLIB_SUPPRESSIONS
+
+ // End of suppressions.
+ ; // Please keep this semicolon.
+ // clang-format on
+}
+#endif // _MSC_VER
diff --git a/toolkit/mozapps/update/updater/archivereader.cpp b/toolkit/mozapps/update/updater/archivereader.cpp
new file mode 100644
index 0000000000..d3d1242db8
--- /dev/null
+++ b/toolkit/mozapps/update/updater/archivereader.cpp
@@ -0,0 +1,348 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <string.h>
+#include <stdlib.h>
+#include <fcntl.h>
+#ifdef XP_WIN
+# include <windows.h>
+#endif
+#include "archivereader.h"
+#include "updatererrors.h"
+#ifdef XP_WIN
+# include "nsAlgorithm.h" // Needed by nsVersionComparator.cpp
+# include "updatehelper.h"
+#endif
+#define XZ_USE_CRC64
+#include "xz.h"
+
+// These are generated at compile time based on the DER file for the channel
+// being used
+#ifdef MOZ_VERIFY_MAR_SIGNATURE
+# ifdef TEST_UPDATER
+# include "../xpcshellCert.h"
+# elif DEP_UPDATER
+# include "../dep1Cert.h"
+# include "../dep2Cert.h"
+# else
+# include "primaryCert.h"
+# include "secondaryCert.h"
+# endif
+#endif
+
+#define UPDATER_NO_STRING_GLUE_STL
+#include "nsVersionComparator.cpp"
+#undef UPDATER_NO_STRING_GLUE_STL
+
+#if defined(XP_WIN)
+# include <io.h>
+#endif
+
+/**
+ * Performs a verification on the opened MAR file with the passed in
+ * certificate name ID and type ID.
+ *
+ * @param archive The MAR file to verify the signature on.
+ * @param certData The certificate data.
+ * @return OK on success, CERT_VERIFY_ERROR on failure.
+ */
+template <uint32_t SIZE>
+int VerifyLoadedCert(MarFile* archive, const uint8_t (&certData)[SIZE]) {
+ (void)archive;
+ (void)certData;
+
+#ifdef MOZ_VERIFY_MAR_SIGNATURE
+ const uint32_t size = SIZE;
+ const uint8_t* const data = &certData[0];
+ if (mar_verify_signatures(archive, &data, &size, 1)) {
+ return CERT_VERIFY_ERROR;
+ }
+#endif
+
+ return OK;
+}
+
+/**
+ * Performs a verification on the opened MAR file. Both the primary and backup
+ * keys stored are stored in the current process and at least the primary key
+ * will be tried. Success will be returned as long as one of the two
+ * signatures verify.
+ *
+ * @return OK on success
+ */
+int ArchiveReader::VerifySignature() {
+ if (!mArchive) {
+ return ARCHIVE_NOT_OPEN;
+ }
+
+#ifndef MOZ_VERIFY_MAR_SIGNATURE
+ return OK;
+#else
+# ifdef TEST_UPDATER
+ int rv = VerifyLoadedCert(mArchive, xpcshellCertData);
+# elif DEP_UPDATER
+ int rv = VerifyLoadedCert(mArchive, dep1CertData);
+ if (rv != OK) {
+ rv = VerifyLoadedCert(mArchive, dep2CertData);
+ }
+# else
+ int rv = VerifyLoadedCert(mArchive, primaryCertData);
+ if (rv != OK) {
+ rv = VerifyLoadedCert(mArchive, secondaryCertData);
+ }
+# endif
+ return rv;
+#endif
+}
+
+/**
+ * Verifies that the MAR file matches the current product, channel, and version
+ *
+ * @param MARChannelID The MAR channel name to use, only updates from MARs
+ * with a matching MAR channel name will succeed.
+ * If an empty string is passed, no check will be done
+ * for the channel name in the product information block.
+ * If a comma separated list of values is passed then
+ * one value must match.
+ * @param appVersion The application version to use, only MARs with an
+ * application version >= to appVersion will be applied.
+ * @return OK on success
+ * COULD_NOT_READ_PRODUCT_INFO_BLOCK if the product info block
+ * could not be read.
+ * MARCHANNEL_MISMATCH_ERROR if update-settings.ini's MAR
+ * channel ID doesn't match the MAR
+ * file's MAR channel ID.
+ * VERSION_DOWNGRADE_ERROR if the application version for
+ * this updater is newer than the
+ * one in the MAR.
+ */
+int ArchiveReader::VerifyProductInformation(const char* MARChannelID,
+ const char* appVersion) {
+ if (!mArchive) {
+ return ARCHIVE_NOT_OPEN;
+ }
+
+ ProductInformationBlock productInfoBlock;
+ int rv = mar_read_product_info_block(mArchive, &productInfoBlock);
+ if (rv != OK) {
+ return COULD_NOT_READ_PRODUCT_INFO_BLOCK_ERROR;
+ }
+
+ // Only check the MAR channel name if specified, it should be passed in from
+ // the update-settings.ini file.
+ if (MARChannelID && strlen(MARChannelID)) {
+ // Check for at least one match in the comma separated list of values.
+ const char* delimiter = " ,\t";
+ // Make a copy of the string in case a read only memory buffer
+ // was specified. strtok modifies the input buffer.
+ char channelCopy[512] = {0};
+ strncpy(channelCopy, MARChannelID, sizeof(channelCopy) - 1);
+ char* channel = strtok(channelCopy, delimiter);
+ rv = MAR_CHANNEL_MISMATCH_ERROR;
+ while (channel) {
+ if (!strcmp(channel, productInfoBlock.MARChannelID)) {
+ rv = OK;
+ break;
+ }
+ channel = strtok(nullptr, delimiter);
+ }
+ }
+
+ if (rv == OK) {
+ /* Compare both versions to ensure we don't have a downgrade
+ -1 if appVersion is older than productInfoBlock.productVersion
+ 1 if appVersion is newer than productInfoBlock.productVersion
+ 0 if appVersion is the same as productInfoBlock.productVersion
+ This even works with strings like:
+ - 12.0a1 being older than 12.0a2
+ - 12.0a2 being older than 12.0b1
+ - 12.0a1 being older than 12.0
+ - 12.0 being older than 12.1a1 */
+ int versionCompareResult =
+ mozilla::CompareVersions(appVersion, productInfoBlock.productVersion);
+ if (1 == versionCompareResult) {
+ rv = VERSION_DOWNGRADE_ERROR;
+ }
+ }
+
+ free((void*)productInfoBlock.MARChannelID);
+ free((void*)productInfoBlock.productVersion);
+ return rv;
+}
+
+int ArchiveReader::Open(const NS_tchar* path) {
+ if (mArchive) {
+ Close();
+ }
+
+ if (!mInBuf) {
+ mInBuf = (uint8_t*)malloc(mInBufSize);
+ if (!mInBuf) {
+ // Try again with a smaller buffer.
+ mInBufSize = 1024;
+ mInBuf = (uint8_t*)malloc(mInBufSize);
+ if (!mInBuf) {
+ return ARCHIVE_READER_MEM_ERROR;
+ }
+ }
+ }
+
+ if (!mOutBuf) {
+ mOutBuf = (uint8_t*)malloc(mOutBufSize);
+ if (!mOutBuf) {
+ // Try again with a smaller buffer.
+ mOutBufSize = 1024;
+ mOutBuf = (uint8_t*)malloc(mOutBufSize);
+ if (!mOutBuf) {
+ return ARCHIVE_READER_MEM_ERROR;
+ }
+ }
+ }
+
+ MarReadResult result =
+#ifdef XP_WIN
+ mar_wopen(path, &mArchive);
+#else
+ mar_open(path, &mArchive);
+#endif
+ if (result == MAR_MEM_ERROR) {
+ return ARCHIVE_READER_MEM_ERROR;
+ }
+ if (result != MAR_READ_SUCCESS) {
+ return READ_ERROR;
+ }
+
+ xz_crc32_init();
+ xz_crc64_init();
+
+ return OK;
+}
+
+void ArchiveReader::Close() {
+ if (mArchive) {
+ mar_close(mArchive);
+ mArchive = nullptr;
+ }
+
+ if (mInBuf) {
+ free(mInBuf);
+ mInBuf = nullptr;
+ }
+
+ if (mOutBuf) {
+ free(mOutBuf);
+ mOutBuf = nullptr;
+ }
+}
+
+int ArchiveReader::ExtractFile(const char* name, const NS_tchar* dest) {
+ const MarItem* item = mar_find_item(mArchive, name);
+ if (!item) {
+ return READ_ERROR;
+ }
+
+#ifdef XP_WIN
+ FILE* fp = _wfopen(dest, L"wb+");
+#else
+ int fd = creat(dest, item->flags);
+ if (fd == -1) {
+ return WRITE_ERROR;
+ }
+
+ FILE* fp = fdopen(fd, "wb");
+#endif
+ if (!fp) {
+ return WRITE_ERROR;
+ }
+
+ int rv = ExtractItemToStream(item, fp);
+
+ fclose(fp);
+ return rv;
+}
+
+int ArchiveReader::ExtractFileToStream(const char* name, FILE* fp) {
+ const MarItem* item = mar_find_item(mArchive, name);
+ if (!item) {
+ return READ_ERROR;
+ }
+
+ return ExtractItemToStream(item, fp);
+}
+
+int ArchiveReader::ExtractItemToStream(const MarItem* item, FILE* fp) {
+ /* decompress the data chunk by chunk */
+
+ int offset, inlen, ret = OK;
+ struct xz_buf strm = {0};
+ enum xz_ret xz_rv = XZ_OK;
+
+ struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64 * 1024 * 1024);
+ if (!dec) {
+ return UNEXPECTED_XZ_ERROR;
+ }
+
+ strm.in = mInBuf;
+ strm.in_pos = 0;
+ strm.in_size = 0;
+ strm.out = mOutBuf;
+ strm.out_pos = 0;
+ strm.out_size = mOutBufSize;
+
+ offset = 0;
+ for (;;) {
+ if (!item->length) {
+ ret = UNEXPECTED_MAR_ERROR;
+ break;
+ }
+
+ if (offset < (int)item->length && strm.in_pos == strm.in_size) {
+ inlen = mar_read(mArchive, item, offset, mInBuf, mInBufSize);
+ if (inlen <= 0) {
+ ret = READ_ERROR;
+ break;
+ }
+ offset += inlen;
+ strm.in_size = inlen;
+ strm.in_pos = 0;
+ }
+
+ xz_rv = xz_dec_run(dec, &strm);
+
+ if (strm.out_pos == mOutBufSize) {
+ if (fwrite(mOutBuf, 1, strm.out_pos, fp) != strm.out_pos) {
+ ret = WRITE_ERROR_EXTRACT;
+ break;
+ }
+
+ strm.out_pos = 0;
+ }
+
+ if (xz_rv == XZ_OK) {
+ // There is still more data to decompress.
+ continue;
+ }
+
+ // The return value of xz_dec_run is not XZ_OK and if it isn't XZ_STREAM_END
+ // an error has occured.
+ if (xz_rv != XZ_STREAM_END) {
+ ret = UNEXPECTED_XZ_ERROR;
+ break;
+ }
+
+ // Write out the remainder of the decompressed data. In the case of
+ // strm.out_pos == 0 this is needed to create empty files included in the
+ // mar file.
+ if (fwrite(mOutBuf, 1, strm.out_pos, fp) != strm.out_pos) {
+ ret = WRITE_ERROR_EXTRACT;
+ }
+ break;
+ }
+
+ xz_dec_end(dec);
+ return ret;
+}
diff --git a/toolkit/mozapps/update/updater/archivereader.h b/toolkit/mozapps/update/updater/archivereader.h
new file mode 100644
index 0000000000..1f18327f1d
--- /dev/null
+++ b/toolkit/mozapps/update/updater/archivereader.h
@@ -0,0 +1,46 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 ArchiveReader_h__
+#define ArchiveReader_h__
+
+#include <stdio.h>
+#include "mar.h"
+
+#ifdef XP_WIN
+# include <windows.h>
+
+typedef WCHAR NS_tchar;
+#else
+typedef char NS_tchar;
+#endif
+
+// This class provides an API to extract files from an update archive.
+class ArchiveReader {
+ public:
+ ArchiveReader() = default;
+ ~ArchiveReader() { Close(); }
+
+ int Open(const NS_tchar* path);
+ int VerifySignature();
+ int VerifyProductInformation(const char* MARChannelID,
+ const char* appVersion);
+ void Close();
+
+ int ExtractFile(const char* item, const NS_tchar* destination);
+ int ExtractFileToStream(const char* item, FILE* fp);
+
+ private:
+ int ExtractItemToStream(const MarItem* item, FILE* fp);
+
+ MarFile* mArchive = nullptr;
+ uint8_t* mInBuf = nullptr;
+ uint8_t* mOutBuf = nullptr;
+ size_t mInBufSize = 262144;
+ size_t mOutBufSize = 262144;
+};
+
+#endif // ArchiveReader_h__
diff --git a/toolkit/mozapps/update/updater/autograph_stage.pem b/toolkit/mozapps/update/updater/autograph_stage.pem
new file mode 100644
index 0000000000..bd8513a04d
--- /dev/null
+++ b/toolkit/mozapps/update/updater/autograph_stage.pem
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvN249lqptaK9L7VltsDt
+6X/Hik/iqHSdJMwAWOoB8exyUvura0VMZhYNGCl046zKnE9aX5aMk4s4MJX0Kw9Q
+KofWUZ+hni18gyXFjecg6AyuEiMAJpSDknWnkZ1hucXTLNpwXwRHPW5YHIinKidz
+kTCREsZl0IU+gieEYXziQ4eBvc9eSNnprKhN/00AxHlmwCtY+3HLso9PYptcOspf
+yuQC/PKLwBb6hqcwEoHsT0w1roRRSACZCHfJYtzXteW7uY3NcUOrSlWFMtZguXuO
+K0U/OJaVnfcJ6REB9HTAzgmL54QlXlGTge8Vj+XMx4GqZD1fuM7rctIFclSL/wWi
+tq8MOedINL2lj2YKB8ArU2kWmi+v7HLcS94WHHcGsBh7SrNRZQEfiMBKrHHW+mqO
+xRRbyR3zAn6M78UOFqMboEQWzWHKFNhw8VI1CA8maylNuArAZhJzdLvUUo2IuQQo
+floKjdeooezDYBeeeJXOcGUv3VrulIuL3MA5k1l+c6uBX7NFWX8ukBTG09b3sNP+
+iH4P2AIcKoccxFpjswlUZCnSKF0jRu1Ue+IulHDNzora8WDOqK0IsfNfZMNyykGf
+8WsELSO3m4CxXuCbY8hmm67dTQ5DKYf874GUm7yOCe2u4piRSJ20eA4WguwxmEIj
+96Kk7NgCLtRU3G754oOTksUCAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/toolkit/mozapps/update/updater/bspatch/LICENSE b/toolkit/mozapps/update/updater/bspatch/LICENSE
new file mode 100644
index 0000000000..f2521d71cd
--- /dev/null
+++ b/toolkit/mozapps/update/updater/bspatch/LICENSE
@@ -0,0 +1,23 @@
+Copyright 2003,2004 Colin Percival
+All rights reserved
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted providing that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. 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.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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/mozapps/update/updater/bspatch/bspatch.cpp b/toolkit/mozapps/update/updater/bspatch/bspatch.cpp
new file mode 100644
index 0000000000..8eabd0e427
--- /dev/null
+++ b/toolkit/mozapps/update/updater/bspatch/bspatch.cpp
@@ -0,0 +1,216 @@
+/*-
+ * Copyright 2003,2004 Colin Percival
+ * All rights reserved
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted providing that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. 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.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
+ *
+ * Changelog:
+ * 2005-04-26 - Define the header as a C structure, add a CRC32 checksum to
+ * the header, and make all the types 32-bit.
+ * --Benjamin Smedberg <benjamin@smedbergs.us>
+ */
+
+#include "bspatch.h"
+#include "updatererrors.h"
+
+#include <sys/stat.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+#include <limits.h>
+
+#if defined(XP_WIN)
+# include <io.h>
+#endif
+
+#ifdef XP_WIN
+# include <winsock2.h>
+#else
+# include <arpa/inet.h>
+#endif
+
+#ifndef SSIZE_MAX
+# define SSIZE_MAX LONG_MAX
+#endif
+
+int MBS_ReadHeader(FILE* file, MBSPatchHeader* header) {
+ size_t s = fread(header, 1, sizeof(MBSPatchHeader), file);
+ if (s != sizeof(MBSPatchHeader)) {
+ return READ_ERROR;
+ }
+
+ header->slen = ntohl(header->slen);
+ header->scrc32 = ntohl(header->scrc32);
+ header->dlen = ntohl(header->dlen);
+ header->cblen = ntohl(header->cblen);
+ header->difflen = ntohl(header->difflen);
+ header->extralen = ntohl(header->extralen);
+
+ struct stat hs;
+ s = fstat(fileno(file), &hs);
+ if (s != 0) {
+ return READ_ERROR;
+ }
+
+ if (memcmp(header->tag, "MBDIFF10", 8) != 0) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+
+ if (hs.st_size > INT_MAX) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+
+ size_t size = static_cast<size_t>(hs.st_size);
+ if (size < sizeof(MBSPatchHeader)) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+ size -= sizeof(MBSPatchHeader);
+
+ if (size < header->cblen) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+ size -= header->cblen;
+
+ if (size < header->difflen) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+ size -= header->difflen;
+
+ if (size < header->extralen) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+ size -= header->extralen;
+
+ if (size != 0) {
+ return UNEXPECTED_BSPATCH_ERROR;
+ }
+
+ return OK;
+}
+
+int MBS_ApplyPatch(const MBSPatchHeader* header, FILE* patchFile,
+ unsigned char* fbuffer, FILE* file) {
+ unsigned char* fbufstart = fbuffer;
+ unsigned char* fbufend = fbuffer + header->slen;
+
+ unsigned char* buf = (unsigned char*)malloc(header->cblen + header->difflen +
+ header->extralen);
+ if (!buf) {
+ return BSPATCH_MEM_ERROR;
+ }
+
+ int rv = OK;
+
+ size_t r = header->cblen + header->difflen + header->extralen;
+ unsigned char* wb = buf;
+ while (r) {
+ const size_t count = (r > SSIZE_MAX) ? SSIZE_MAX : r;
+ size_t c = fread(wb, 1, count, patchFile);
+ if (c != count) {
+ rv = READ_ERROR;
+ goto end;
+ }
+
+ r -= c;
+ wb += c;
+
+ if (c == 0 && r) {
+ rv = UNEXPECTED_BSPATCH_ERROR;
+ goto end;
+ }
+ }
+
+ {
+ MBSPatchTriple* ctrlsrc = (MBSPatchTriple*)buf;
+ if (header->cblen % sizeof(MBSPatchTriple) != 0) {
+ rv = UNEXPECTED_BSPATCH_ERROR;
+ goto end;
+ }
+
+ unsigned char* diffsrc = buf + header->cblen;
+ unsigned char* extrasrc = diffsrc + header->difflen;
+
+ MBSPatchTriple* ctrlend = (MBSPatchTriple*)diffsrc;
+ unsigned char* diffend = extrasrc;
+ unsigned char* extraend = extrasrc + header->extralen;
+
+ while (ctrlsrc < ctrlend) {
+ ctrlsrc->x = ntohl(ctrlsrc->x);
+ ctrlsrc->y = ntohl(ctrlsrc->y);
+ ctrlsrc->z = ntohl(ctrlsrc->z);
+
+#ifdef DEBUG_bsmedberg
+ printf(
+ "Applying block:\n"
+ " x: %u\n"
+ " y: %u\n"
+ " z: %i\n",
+ ctrlsrc->x, ctrlsrc->y, ctrlsrc->z);
+#endif
+
+ /* Add x bytes from oldfile to x bytes from the diff block */
+
+ if (ctrlsrc->x > static_cast<size_t>(fbufend - fbuffer) ||
+ ctrlsrc->x > static_cast<size_t>(diffend - diffsrc)) {
+ rv = UNEXPECTED_BSPATCH_ERROR;
+ goto end;
+ }
+ for (uint32_t i = 0; i < ctrlsrc->x; ++i) {
+ diffsrc[i] += fbuffer[i];
+ }
+ if ((uint32_t)fwrite(diffsrc, 1, ctrlsrc->x, file) != ctrlsrc->x) {
+ rv = WRITE_ERROR_PATCH_FILE;
+ goto end;
+ }
+ fbuffer += ctrlsrc->x;
+ diffsrc += ctrlsrc->x;
+
+ /* Copy y bytes from the extra block */
+
+ if (ctrlsrc->y > static_cast<size_t>(extraend - extrasrc)) {
+ rv = UNEXPECTED_BSPATCH_ERROR;
+ goto end;
+ }
+ if ((uint32_t)fwrite(extrasrc, 1, ctrlsrc->y, file) != ctrlsrc->y) {
+ rv = WRITE_ERROR_PATCH_FILE;
+ goto end;
+ }
+ extrasrc += ctrlsrc->y;
+
+ /* "seek" forwards in oldfile by z bytes */
+
+ if (ctrlsrc->z < fbufstart - fbuffer || ctrlsrc->z > fbufend - fbuffer) {
+ rv = UNEXPECTED_BSPATCH_ERROR;
+ goto end;
+ }
+ fbuffer += ctrlsrc->z;
+
+ /* and on to the next control block */
+
+ ++ctrlsrc;
+ }
+ }
+
+end:
+ free(buf);
+ return rv;
+}
diff --git a/toolkit/mozapps/update/updater/bspatch/bspatch.h b/toolkit/mozapps/update/updater/bspatch/bspatch.h
new file mode 100644
index 0000000000..189e618557
--- /dev/null
+++ b/toolkit/mozapps/update/updater/bspatch/bspatch.h
@@ -0,0 +1,93 @@
+/*-
+ * Copyright 2003,2004 Colin Percival
+ * All rights reserved
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted providing that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. 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.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
+ *
+ * Changelog:
+ * 2005-04-26 - Define the header as a C structure, add a CRC32 checksum to
+ * the header, and make all the types 32-bit.
+ * --Benjamin Smedberg <benjamin@smedbergs.us>
+ */
+
+#ifndef bspatch_h__
+#define bspatch_h__
+
+#include <stdint.h>
+#include <stdio.h>
+
+typedef struct MBSPatchHeader_ {
+ /* "MBDIFF10" */
+ char tag[8];
+
+ /* Length of the file to be patched */
+ uint32_t slen;
+
+ /* CRC32 of the file to be patched */
+ uint32_t scrc32;
+
+ /* Length of the result file */
+ uint32_t dlen;
+
+ /* Length of the control block in bytes */
+ uint32_t cblen;
+
+ /* Length of the diff block in bytes */
+ uint32_t difflen;
+
+ /* Length of the extra block in bytes */
+ uint32_t extralen;
+
+ /* Control block (MBSPatchTriple[]) */
+ /* Diff block (binary data) */
+ /* Extra block (binary data) */
+} MBSPatchHeader;
+
+/**
+ * Read the header of a patch file into the MBSPatchHeader structure.
+ *
+ * @param fd Must have been opened for reading, and be at the beginning
+ * of the file.
+ */
+int MBS_ReadHeader(FILE* file, MBSPatchHeader* header);
+
+/**
+ * Apply a patch. This method does not validate the checksum of the original
+ * file: client code should validate the checksum before calling this method.
+ *
+ * @param patchfd Must have been processed by MBS_ReadHeader
+ * @param fbuffer The original file read into a memory buffer of length
+ * header->slen.
+ * @param filefd Must have been opened for writing. Should be truncated
+ * to header->dlen if it is an existing file. The offset
+ * should be at the beginning of the file.
+ */
+int MBS_ApplyPatch(const MBSPatchHeader* header, FILE* patchFile,
+ unsigned char* fbuffer, FILE* file);
+
+typedef struct MBSPatchTriple_ {
+ uint32_t x; /* add x bytes from oldfile to x bytes from the diff block */
+ uint32_t y; /* copy y bytes from the extra block */
+ int32_t z; /* seek forwards in oldfile by z bytes */
+} MBSPatchTriple;
+
+#endif // bspatch_h__
diff --git a/toolkit/mozapps/update/updater/bspatch/moz.build b/toolkit/mozapps/update/updater/bspatch/moz.build
new file mode 100644
index 0000000000..46c6d11fad
--- /dev/null
+++ b/toolkit/mozapps/update/updater/bspatch/moz.build
@@ -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/.
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ USE_STATIC_LIBS = True
+
+EXPORTS += [
+ "bspatch.h",
+]
+
+SOURCES += [
+ "bspatch.cpp",
+]
+
+USE_LIBS += [
+ "updatecommon",
+]
+
+Library("bspatch")
diff --git a/toolkit/mozapps/update/updater/bspatch/moz.yaml b/toolkit/mozapps/update/updater/bspatch/moz.yaml
new file mode 100644
index 0000000000..ce611f97a1
--- /dev/null
+++ b/toolkit/mozapps/update/updater/bspatch/moz.yaml
@@ -0,0 +1,30 @@
+# Version of this schema
+schema: 1
+
+bugzilla:
+ # Bugzilla product and component for this directory and subdirectories
+ product: "Toolkit"
+ component: "Application Update"
+
+# The source from this directory was adapted from Colin Percival's bspatch
+# tool in mid 2005 and was obtained from bsdiff version 4.2. Edits were
+# later added by the Chromium dev team and were copied to here as well
+
+# Document the source of externally hosted code
+origin:
+ name: "bsdiff/bspatch"
+ description: "Builds and applies patches to binary files"
+
+ # Full URL for the package's homepage/etc
+ # Usually different from repository url
+ url: "https://www.daemonology.net/bsdiff/bsdiff-4.2.tar.gz"
+
+ # Human-readable identifier for this version/release
+ # Generally "version NNN", "tag SSS", "bookmark SSS"
+ release: "version 4.2"
+
+ # The package's license, where possible using the mnemonic from
+ # https://spdx.org/licenses/
+ # Multiple licenses can be specified (as a YAML list)
+ # A "LICENSE" file must exist containing the full license text
+ license: "BSD-2-Clause"
diff --git a/toolkit/mozapps/update/updater/crctable.h b/toolkit/mozapps/update/updater/crctable.h
new file mode 100644
index 0000000000..dadabcc9a1
--- /dev/null
+++ b/toolkit/mozapps/update/updater/crctable.h
@@ -0,0 +1,71 @@
+/**
+ * This file was part of bzip2/libbzip2.
+ * We extracted only this table for bzip2 crc comptability
+ */
+
+/**
+ I think this is an implementation of the AUTODIN-II,
+ Ethernet & FDDI 32-bit CRC standard. Vaguely derived
+ from code by Rob Warnock, in Section 51 of the
+ comp.compression FAQ.
+*/
+unsigned int BZ2_crc32Table[256] = {
+
+ /*-- Ugly, innit? --*/
+
+ 0x00000000L, 0x04c11db7L, 0x09823b6eL, 0x0d4326d9L, 0x130476dcL,
+ 0x17c56b6bL, 0x1a864db2L, 0x1e475005L, 0x2608edb8L, 0x22c9f00fL,
+ 0x2f8ad6d6L, 0x2b4bcb61L, 0x350c9b64L, 0x31cd86d3L, 0x3c8ea00aL,
+ 0x384fbdbdL, 0x4c11db70L, 0x48d0c6c7L, 0x4593e01eL, 0x4152fda9L,
+ 0x5f15adacL, 0x5bd4b01bL, 0x569796c2L, 0x52568b75L, 0x6a1936c8L,
+ 0x6ed82b7fL, 0x639b0da6L, 0x675a1011L, 0x791d4014L, 0x7ddc5da3L,
+ 0x709f7b7aL, 0x745e66cdL, 0x9823b6e0L, 0x9ce2ab57L, 0x91a18d8eL,
+ 0x95609039L, 0x8b27c03cL, 0x8fe6dd8bL, 0x82a5fb52L, 0x8664e6e5L,
+ 0xbe2b5b58L, 0xbaea46efL, 0xb7a96036L, 0xb3687d81L, 0xad2f2d84L,
+ 0xa9ee3033L, 0xa4ad16eaL, 0xa06c0b5dL, 0xd4326d90L, 0xd0f37027L,
+ 0xddb056feL, 0xd9714b49L, 0xc7361b4cL, 0xc3f706fbL, 0xceb42022L,
+ 0xca753d95L, 0xf23a8028L, 0xf6fb9d9fL, 0xfbb8bb46L, 0xff79a6f1L,
+ 0xe13ef6f4L, 0xe5ffeb43L, 0xe8bccd9aL, 0xec7dd02dL, 0x34867077L,
+ 0x30476dc0L, 0x3d044b19L, 0x39c556aeL, 0x278206abL, 0x23431b1cL,
+ 0x2e003dc5L, 0x2ac12072L, 0x128e9dcfL, 0x164f8078L, 0x1b0ca6a1L,
+ 0x1fcdbb16L, 0x018aeb13L, 0x054bf6a4L, 0x0808d07dL, 0x0cc9cdcaL,
+ 0x7897ab07L, 0x7c56b6b0L, 0x71159069L, 0x75d48ddeL, 0x6b93dddbL,
+ 0x6f52c06cL, 0x6211e6b5L, 0x66d0fb02L, 0x5e9f46bfL, 0x5a5e5b08L,
+ 0x571d7dd1L, 0x53dc6066L, 0x4d9b3063L, 0x495a2dd4L, 0x44190b0dL,
+ 0x40d816baL, 0xaca5c697L, 0xa864db20L, 0xa527fdf9L, 0xa1e6e04eL,
+ 0xbfa1b04bL, 0xbb60adfcL, 0xb6238b25L, 0xb2e29692L, 0x8aad2b2fL,
+ 0x8e6c3698L, 0x832f1041L, 0x87ee0df6L, 0x99a95df3L, 0x9d684044L,
+ 0x902b669dL, 0x94ea7b2aL, 0xe0b41de7L, 0xe4750050L, 0xe9362689L,
+ 0xedf73b3eL, 0xf3b06b3bL, 0xf771768cL, 0xfa325055L, 0xfef34de2L,
+ 0xc6bcf05fL, 0xc27dede8L, 0xcf3ecb31L, 0xcbffd686L, 0xd5b88683L,
+ 0xd1799b34L, 0xdc3abdedL, 0xd8fba05aL, 0x690ce0eeL, 0x6dcdfd59L,
+ 0x608edb80L, 0x644fc637L, 0x7a089632L, 0x7ec98b85L, 0x738aad5cL,
+ 0x774bb0ebL, 0x4f040d56L, 0x4bc510e1L, 0x46863638L, 0x42472b8fL,
+ 0x5c007b8aL, 0x58c1663dL, 0x558240e4L, 0x51435d53L, 0x251d3b9eL,
+ 0x21dc2629L, 0x2c9f00f0L, 0x285e1d47L, 0x36194d42L, 0x32d850f5L,
+ 0x3f9b762cL, 0x3b5a6b9bL, 0x0315d626L, 0x07d4cb91L, 0x0a97ed48L,
+ 0x0e56f0ffL, 0x1011a0faL, 0x14d0bd4dL, 0x19939b94L, 0x1d528623L,
+ 0xf12f560eL, 0xf5ee4bb9L, 0xf8ad6d60L, 0xfc6c70d7L, 0xe22b20d2L,
+ 0xe6ea3d65L, 0xeba91bbcL, 0xef68060bL, 0xd727bbb6L, 0xd3e6a601L,
+ 0xdea580d8L, 0xda649d6fL, 0xc423cd6aL, 0xc0e2d0ddL, 0xcda1f604L,
+ 0xc960ebb3L, 0xbd3e8d7eL, 0xb9ff90c9L, 0xb4bcb610L, 0xb07daba7L,
+ 0xae3afba2L, 0xaafbe615L, 0xa7b8c0ccL, 0xa379dd7bL, 0x9b3660c6L,
+ 0x9ff77d71L, 0x92b45ba8L, 0x9675461fL, 0x8832161aL, 0x8cf30badL,
+ 0x81b02d74L, 0x857130c3L, 0x5d8a9099L, 0x594b8d2eL, 0x5408abf7L,
+ 0x50c9b640L, 0x4e8ee645L, 0x4a4ffbf2L, 0x470cdd2bL, 0x43cdc09cL,
+ 0x7b827d21L, 0x7f436096L, 0x7200464fL, 0x76c15bf8L, 0x68860bfdL,
+ 0x6c47164aL, 0x61043093L, 0x65c52d24L, 0x119b4be9L, 0x155a565eL,
+ 0x18197087L, 0x1cd86d30L, 0x029f3d35L, 0x065e2082L, 0x0b1d065bL,
+ 0x0fdc1becL, 0x3793a651L, 0x3352bbe6L, 0x3e119d3fL, 0x3ad08088L,
+ 0x2497d08dL, 0x2056cd3aL, 0x2d15ebe3L, 0x29d4f654L, 0xc5a92679L,
+ 0xc1683bceL, 0xcc2b1d17L, 0xc8ea00a0L, 0xd6ad50a5L, 0xd26c4d12L,
+ 0xdf2f6bcbL, 0xdbee767cL, 0xe3a1cbc1L, 0xe760d676L, 0xea23f0afL,
+ 0xeee2ed18L, 0xf0a5bd1dL, 0xf464a0aaL, 0xf9278673L, 0xfde69bc4L,
+ 0x89b8fd09L, 0x8d79e0beL, 0x803ac667L, 0x84fbdbd0L, 0x9abc8bd5L,
+ 0x9e7d9662L, 0x933eb0bbL, 0x97ffad0cL, 0xafb010b1L, 0xab710d06L,
+ 0xa6322bdfL, 0xa2f33668L, 0xbcb4666dL, 0xb8757bdaL, 0xb5365d03L,
+ 0xb1f740b4L};
+
+/*-------------------------------------------------------------*/
+/*--- end crctable.h ---*/
+/*-------------------------------------------------------------*/
diff --git a/toolkit/mozapps/update/updater/dep1.der b/toolkit/mozapps/update/updater/dep1.der
new file mode 100644
index 0000000000..655c2d10d4
--- /dev/null
+++ b/toolkit/mozapps/update/updater/dep1.der
Binary files differ
diff --git a/toolkit/mozapps/update/updater/dep2.der b/toolkit/mozapps/update/updater/dep2.der
new file mode 100644
index 0000000000..c59ac7f790
--- /dev/null
+++ b/toolkit/mozapps/update/updater/dep2.der
Binary files differ
diff --git a/toolkit/mozapps/update/updater/gen_cert_header.py b/toolkit/mozapps/update/updater/gen_cert_header.py
new file mode 100644
index 0000000000..da78cad674
--- /dev/null
+++ b/toolkit/mozapps/update/updater/gen_cert_header.py
@@ -0,0 +1,27 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+
+
+def file_byte_generator(filename, block_size=512):
+ with open(filename, "rb") as f:
+ while True:
+ block = f.read(block_size)
+ if block:
+ for byte in block:
+ yield byte
+ else:
+ break
+
+
+def create_header(out_fh, in_filename):
+ assert out_fh.name.endswith(".h")
+ array_name = os.path.basename(out_fh.name)[:-2] + "Data"
+ hexified = ["0x%02x" % byte for byte in file_byte_generator(in_filename)]
+
+ print("const uint8_t " + array_name + "[] = {", file=out_fh)
+ print(", ".join(hexified), file=out_fh)
+ print("};", file=out_fh)
+ return 0
diff --git a/toolkit/mozapps/update/updater/launchchild_osx.mm b/toolkit/mozapps/update/updater/launchchild_osx.mm
new file mode 100644
index 0000000000..917f282d9f
--- /dev/null
+++ b/toolkit/mozapps/update/updater/launchchild_osx.mm
@@ -0,0 +1,519 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <Cocoa/Cocoa.h>
+#include <CoreServices/CoreServices.h>
+#include <crt_externs.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <spawn.h>
+#include <SystemConfiguration/SystemConfiguration.h>
+#include <sys/types.h>
+#include <sys/sysctl.h>
+#include "readstrings.h"
+
+#define ARCH_PATH "/usr/bin/arch"
+#if defined(__x86_64__)
+// Work around the fact that this constant is not available in the macOS SDK
+# define kCFBundleExecutableArchitectureARM64 0x0100000c
+#endif
+
+class MacAutoreleasePool {
+ public:
+ MacAutoreleasePool() { mPool = [[NSAutoreleasePool alloc] init]; }
+ ~MacAutoreleasePool() { [mPool release]; }
+
+ private:
+ NSAutoreleasePool* mPool;
+};
+
+#if defined(__x86_64__)
+/*
+ * Returns true if the process is running under Rosetta translation. Returns
+ * false if running natively or if an error was encountered. We use the
+ * `sysctl.proc_translated` sysctl which is documented by Apple to be used
+ * for this purpose.
+ */
+bool IsProcessRosettaTranslated() {
+ int ret = 0;
+ size_t size = sizeof(ret);
+ if (sysctlbyname("sysctl.proc_translated", &ret, &size, NULL, 0) == -1) {
+ if (errno != ENOENT) {
+ fprintf(stderr, "Failed to check for translation environment\n");
+ }
+ return false;
+ }
+ return (ret == 1);
+}
+
+// Returns true if the binary at |executablePath| can be executed natively
+// on an arm64 Mac. Returns false otherwise or if an error occurred.
+bool IsBinaryArmExecutable(const char* executablePath) {
+ bool isArmExecutable = false;
+
+ CFURLRef url = ::CFURLCreateFromFileSystemRepresentation(
+ kCFAllocatorDefault, (const UInt8*)executablePath, strlen(executablePath),
+ false);
+ if (!url) {
+ return false;
+ }
+
+ CFArrayRef archs = ::CFBundleCopyExecutableArchitecturesForURL(url);
+ if (!archs) {
+ CFRelease(url);
+ return false;
+ }
+
+ CFIndex archCount = ::CFArrayGetCount(archs);
+ for (CFIndex i = 0; i < archCount; i++) {
+ CFNumberRef currentArch =
+ static_cast<CFNumberRef>(::CFArrayGetValueAtIndex(archs, i));
+ int currentArchInt = 0;
+ if (!::CFNumberGetValue(currentArch, kCFNumberIntType, &currentArchInt)) {
+ continue;
+ }
+ if (currentArchInt == kCFBundleExecutableArchitectureARM64) {
+ isArmExecutable = true;
+ break;
+ }
+ }
+
+ CFRelease(url);
+ CFRelease(archs);
+
+ return isArmExecutable;
+}
+
+// Returns true if the executable provided in |executablePath| should be
+// launched with a preference for arm64. After updating from an x64 version
+// running under Rosetta, if the update is to a universal binary with arm64
+// support we want to switch to arm64 execution mode. Returns true if those
+// conditions are met and the arch(1) utility at |archPath| is executable.
+// It should be safe to always launch with arch and fallback to x64, but we
+// limit its use to the only scenario it is necessary to minimize risk.
+bool ShouldPreferArmLaunch(const char* archPath, const char* executablePath) {
+ // If not running under Rosetta, we are not on arm64 hardware.
+ if (!IsProcessRosettaTranslated()) {
+ return false;
+ }
+
+ // Ensure the arch(1) utility is present and executable.
+ NSFileManager* fileMgr = [NSFileManager defaultManager];
+ NSString* archPathString = [NSString stringWithUTF8String:archPath];
+ if (![fileMgr isExecutableFileAtPath:archPathString]) {
+ return false;
+ }
+
+ // Ensure the binary can be run natively on arm64.
+ return IsBinaryArmExecutable(executablePath);
+}
+#endif // __x86_64__
+
+void LaunchChild(int argc, const char** argv) {
+ MacAutoreleasePool pool;
+
+ @try {
+ bool preferArmLaunch = false;
+
+#if defined(__x86_64__)
+ // When running under Rosetta, child processes inherit the architecture
+ // preference of their parent and therefore universal binaries launched
+ // by an emulated x64 process will launch in x64 mode. If we are running
+ // under Rosetta, launch the child process with a preference for arm64 so
+ // that we will switch to arm64 execution if we have just updated from
+ // x64 to a universal build. This includes if we were already a universal
+ // build and the user is intentionally running under Rosetta.
+ preferArmLaunch = ShouldPreferArmLaunch(ARCH_PATH, argv[0]);
+#endif // __x86_64__
+
+ NSString* launchPath;
+ NSMutableArray* arguments;
+
+ if (preferArmLaunch) {
+ launchPath = [NSString stringWithUTF8String:ARCH_PATH];
+
+ // Size the arguments array to include all the arguments
+ // in |argv| plus two arguments to pass to the arch(1) utility.
+ arguments = [NSMutableArray arrayWithCapacity:argc + 2];
+ [arguments addObject:[NSString stringWithUTF8String:"-arm64"]];
+ [arguments addObject:[NSString stringWithUTF8String:"-x86_64"]];
+
+ // Add the first argument from |argv|. The rest are added below.
+ [arguments addObject:[NSString stringWithUTF8String:argv[0]]];
+ } else {
+ launchPath = [NSString stringWithUTF8String:argv[0]];
+ arguments = [NSMutableArray arrayWithCapacity:argc - 1];
+ }
+
+ for (int i = 1; i < argc; i++) {
+ [arguments addObject:[NSString stringWithUTF8String:argv[i]]];
+ }
+ [NSTask launchedTaskWithLaunchPath:launchPath arguments:arguments];
+ } @catch (NSException* e) {
+ NSLog(@"%@: %@", e.name, e.reason);
+ }
+}
+
+void LaunchMacPostProcess(const char* aAppBundle) {
+ MacAutoreleasePool pool;
+
+ // Launch helper to perform post processing for the update; this is the Mac
+ // analogue of LaunchWinPostProcess (PostUpdateWin).
+ NSString* iniPath = [NSString stringWithUTF8String:aAppBundle];
+ iniPath = [iniPath
+ stringByAppendingPathComponent:@"Contents/Resources/updater.ini"];
+
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ if (![fileManager fileExistsAtPath:iniPath]) {
+ // the file does not exist; there is nothing to run
+ return;
+ }
+
+ int readResult;
+ mozilla::UniquePtr<char[]> values[2];
+ readResult = ReadStrings([iniPath UTF8String], "ExeRelPath\0ExeArg\0", 2,
+ values, "PostUpdateMac");
+ if (readResult) {
+ return;
+ }
+
+ NSString* exeRelPath = [NSString stringWithUTF8String:values[0].get()];
+ NSString* exeArg = [NSString stringWithUTF8String:values[1].get()];
+ if (!exeArg || !exeRelPath) {
+ return;
+ }
+
+ // The path must not traverse directories and it must be a relative path.
+ if ([exeRelPath isEqualToString:@".."] || [exeRelPath hasPrefix:@"/"] ||
+ [exeRelPath hasPrefix:@"../"] || [exeRelPath hasSuffix:@"/.."] ||
+ [exeRelPath containsString:@"/../"]) {
+ return;
+ }
+
+ NSString* exeFullPath = [NSString stringWithUTF8String:aAppBundle];
+ exeFullPath = [exeFullPath stringByAppendingPathComponent:exeRelPath];
+
+ mozilla::UniquePtr<char[]> optVal;
+ readResult = ReadStrings([iniPath UTF8String], "ExeAsync\0", 1, &optVal,
+ "PostUpdateMac");
+
+ NSTask* task = [[NSTask alloc] init];
+ [task setLaunchPath:exeFullPath];
+ [task setArguments:[NSArray arrayWithObject:exeArg]];
+ [task launch];
+ if (!readResult) {
+ NSString* exeAsync = [NSString stringWithUTF8String:optVal.get()];
+ if ([exeAsync isEqualToString:@"false"]) {
+ [task waitUntilExit];
+ }
+ }
+ // ignore the return value of the task, there's nothing we can do with it
+ [task release];
+}
+
+id ConnectToUpdateServer() {
+ MacAutoreleasePool pool;
+
+ id updateServer = nil;
+ BOOL isConnected = NO;
+ int currTry = 0;
+ const int numRetries = 10; // Number of IPC connection retries before
+ // giving up.
+ while (!isConnected && currTry < numRetries) {
+ @try {
+ updateServer = (id)[NSConnection
+ rootProxyForConnectionWithRegisteredName:@"org.mozilla.updater.server"
+ host:nil
+ usingNameServer:[NSSocketPortNameServer
+ sharedInstance]];
+ if (!updateServer ||
+ ![updateServer respondsToSelector:@selector(abort)] ||
+ ![updateServer respondsToSelector:@selector(getArguments)] ||
+ ![updateServer respondsToSelector:@selector(shutdown)]) {
+ NSLog(@"Server doesn't exist or doesn't provide correct selectors.");
+ sleep(1); // Wait 1 second.
+ currTry++;
+ } else {
+ isConnected = YES;
+ }
+ } @catch (NSException* e) {
+ NSLog(@"Encountered exception, retrying: %@: %@", e.name, e.reason);
+ sleep(1); // Wait 1 second.
+ currTry++;
+ }
+ }
+ if (!isConnected) {
+ NSLog(@"Failed to connect to update server after several retries.");
+ return nil;
+ }
+ return updateServer;
+}
+
+void CleanupElevatedMacUpdate(bool aFailureOccurred) {
+ MacAutoreleasePool pool;
+
+ id updateServer = ConnectToUpdateServer();
+ if (updateServer) {
+ @try {
+ if (aFailureOccurred) {
+ [updateServer performSelector:@selector(abort)];
+ } else {
+ [updateServer performSelector:@selector(shutdown)];
+ }
+ } @catch (NSException* e) {
+ }
+ }
+
+ NSFileManager* manager = [NSFileManager defaultManager];
+ [manager
+ removeItemAtPath:@"/Library/PrivilegedHelperTools/org.mozilla.updater"
+ error:nil];
+ [manager removeItemAtPath:@"/Library/LaunchDaemons/org.mozilla.updater.plist"
+ error:nil];
+ const char* launchctlArgs[] = {"/bin/launchctl", "remove",
+ "org.mozilla.updater"};
+ // The following call will terminate the current process due to the "remove"
+ // argument in launchctlArgs.
+ LaunchChild(3, launchctlArgs);
+}
+
+// Note: Caller is responsible for freeing argv.
+bool ObtainUpdaterArguments(int* argc, char*** argv) {
+ MacAutoreleasePool pool;
+
+ id updateServer = ConnectToUpdateServer();
+ if (!updateServer) {
+ // Let's try our best and clean up.
+ CleanupElevatedMacUpdate(true);
+ return false; // Won't actually get here due to CleanupElevatedMacUpdate.
+ }
+
+ @try {
+ NSArray* updaterArguments =
+ [updateServer performSelector:@selector(getArguments)];
+ *argc = [updaterArguments count];
+ char** tempArgv = (char**)malloc(sizeof(char*) * (*argc));
+ for (int i = 0; i < *argc; i++) {
+ int argLen = [[updaterArguments objectAtIndex:i] length] + 1;
+ tempArgv[i] = (char*)malloc(argLen);
+ strncpy(tempArgv[i], [[updaterArguments objectAtIndex:i] UTF8String],
+ argLen);
+ }
+ *argv = tempArgv;
+ } @catch (NSException* e) {
+ // Let's try our best and clean up.
+ CleanupElevatedMacUpdate(true);
+ return false; // Won't actually get here due to CleanupElevatedMacUpdate.
+ }
+ return true;
+}
+
+/**
+ * The ElevatedUpdateServer is launched from a non-elevated updater process.
+ * It allows an elevated updater process (usually a privileged helper tool) to
+ * connect to it and receive all the necessary arguments to complete a
+ * successful update.
+ */
+@interface ElevatedUpdateServer : NSObject {
+ NSArray* mUpdaterArguments;
+ BOOL mShouldKeepRunning;
+ BOOL mAborted;
+}
+- (id)initWithArgs:(NSArray*)args;
+- (BOOL)runServer;
+- (NSArray*)getArguments;
+- (void)abort;
+- (BOOL)wasAborted;
+- (void)shutdown;
+- (BOOL)shouldKeepRunning;
+@end
+
+@implementation ElevatedUpdateServer
+
+- (id)initWithArgs:(NSArray*)args {
+ self = [super init];
+ if (!self) {
+ return nil;
+ }
+ mUpdaterArguments = args;
+ mShouldKeepRunning = YES;
+ mAborted = NO;
+ return self;
+}
+
+- (BOOL)runServer {
+ NSPort* serverPort = [NSSocketPort port];
+ NSConnection* server = [NSConnection connectionWithReceivePort:serverPort
+ sendPort:serverPort];
+ [server setRootObject:self];
+ if ([server registerName:@"org.mozilla.updater.server"
+ withNameServer:[NSSocketPortNameServer sharedInstance]] == NO) {
+ NSLog(@"Unable to register as DirectoryServer.");
+ NSLog(@"Is another copy running?");
+ return NO;
+ }
+
+ while ([self shouldKeepRunning] &&
+ [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
+ beforeDate:[NSDate distantFuture]])
+ ;
+ return ![self wasAborted];
+}
+
+- (NSArray*)getArguments {
+ return mUpdaterArguments;
+}
+
+- (void)abort {
+ mAborted = YES;
+ [self shutdown];
+}
+
+- (BOOL)wasAborted {
+ return mAborted;
+}
+
+- (void)shutdown {
+ mShouldKeepRunning = NO;
+}
+
+- (BOOL)shouldKeepRunning {
+ return mShouldKeepRunning;
+}
+
+@end
+
+bool ServeElevatedUpdate(int argc, const char** argv) {
+ MacAutoreleasePool pool;
+
+ NSMutableArray* updaterArguments = [NSMutableArray arrayWithCapacity:argc];
+ for (int i = 0; i < argc; i++) {
+ [updaterArguments addObject:[NSString stringWithUTF8String:argv[i]]];
+ }
+
+ ElevatedUpdateServer* updater =
+ [[ElevatedUpdateServer alloc] initWithArgs:[updaterArguments copy]];
+ bool didSucceed = [updater runServer];
+
+ [updater release];
+ return didSucceed;
+}
+
+bool IsOwnedByGroupAdmin(const char* aAppBundle) {
+ MacAutoreleasePool pool;
+
+ NSString* appDir = [NSString stringWithUTF8String:aAppBundle];
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+
+ NSDictionary* attributes = [fileManager attributesOfItemAtPath:appDir
+ error:nil];
+ bool isOwnedByAdmin = false;
+ if (attributes &&
+ [[attributes valueForKey:NSFileGroupOwnerAccountID] intValue] == 80) {
+ isOwnedByAdmin = true;
+ }
+ return isOwnedByAdmin;
+}
+
+void SetGroupOwnershipAndPermissions(const char* aAppBundle) {
+ MacAutoreleasePool pool;
+
+ NSString* appDir = [NSString stringWithUTF8String:aAppBundle];
+ NSFileManager* fileManager = [NSFileManager defaultManager];
+ NSError* error = nil;
+ NSArray* paths = [fileManager subpathsOfDirectoryAtPath:appDir error:&error];
+ if (error) {
+ return;
+ }
+
+ // Set group ownership of Firefox.app to 80 ("admin") and permissions to
+ // 0775.
+ if (![fileManager setAttributes:@{
+ NSFileGroupOwnerAccountID : @(80),
+ NSFilePosixPermissions : @(0775)
+ }
+ ofItemAtPath:appDir
+ error:&error] ||
+ error) {
+ return;
+ }
+
+ NSArray* permKeys = [NSArray
+ arrayWithObjects:NSFileGroupOwnerAccountID, NSFilePosixPermissions, nil];
+ // For all descendants of Firefox.app, set group ownership to 80 ("admin") and
+ // ensure write permission for the group.
+ for (NSString* currPath in paths) {
+ NSString* child = [appDir stringByAppendingPathComponent:currPath];
+ NSDictionary* oldAttributes = [fileManager attributesOfItemAtPath:child
+ error:&error];
+ if (error) {
+ return;
+ }
+ // Skip symlinks, since they could be pointing to files outside of the .app
+ // bundle.
+ if ([oldAttributes fileType] == NSFileTypeSymbolicLink) {
+ continue;
+ }
+ NSNumber* oldPerms =
+ (NSNumber*)[oldAttributes valueForKey:NSFilePosixPermissions];
+ NSArray* permObjects = [NSArray
+ arrayWithObjects:[NSNumber numberWithUnsignedLong:80],
+ [NSNumber
+ numberWithUnsignedLong:[oldPerms shortValue] |
+ 020],
+ nil];
+ NSDictionary* attributes = [NSDictionary dictionaryWithObjects:permObjects
+ forKeys:permKeys];
+ if (![fileManager setAttributes:attributes
+ ofItemAtPath:child
+ error:&error] ||
+ error) {
+ return;
+ }
+ }
+}
+
+/**
+ * Helper to launch macOS tasks via NSTask.
+ */
+static void LaunchTask(NSString* aPath, NSArray* aArguments) {
+ NSTask* task = [[NSTask alloc] init];
+ [task setExecutableURL:[NSURL fileURLWithPath:aPath]];
+ if (aArguments) {
+ [task setArguments:aArguments];
+ }
+ [task launchAndReturnError:nil];
+ [task release];
+}
+
+static void RegisterAppWithLaunchServices(NSString* aBundlePath) {
+ NSArray* arguments = @[ @"-f", aBundlePath ];
+ LaunchTask(@"/System/Library/Frameworks/CoreServices.framework/Frameworks/"
+ @"LaunchServices.framework/Support/lsregister",
+ arguments);
+}
+
+static void StripQuarantineBit(NSString* aBundlePath) {
+ NSArray* arguments = @[ @"-d", @"com.apple.quarantine", aBundlePath ];
+ LaunchTask(@"/usr/bin/xattr", arguments);
+}
+
+bool PerformInstallationFromDMG(int argc, char** argv) {
+ MacAutoreleasePool pool;
+ if (argc < 4) {
+ return false;
+ }
+ NSString* bundlePath = [NSString stringWithUTF8String:argv[2]];
+ NSString* destPath = [NSString stringWithUTF8String:argv[3]];
+ if ([[NSFileManager defaultManager] copyItemAtPath:bundlePath
+ toPath:destPath
+ error:nil]) {
+ RegisterAppWithLaunchServices(destPath);
+ StripQuarantineBit(destPath);
+ return true;
+ }
+ return false;
+}
diff --git a/toolkit/mozapps/update/updater/loaddlls.cpp b/toolkit/mozapps/update/updater/loaddlls.cpp
new file mode 100644
index 0000000000..462bd0bc18
--- /dev/null
+++ b/toolkit/mozapps/update/updater/loaddlls.cpp
@@ -0,0 +1,84 @@
+/* -*- 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 <windows.h>
+
+// Delayed load libraries are loaded when the first symbol is used.
+// The following ensures that we load the delayed loaded libraries from the
+// system directory.
+struct AutoLoadSystemDependencies {
+ AutoLoadSystemDependencies() {
+ // Remove the current directory from the search path for dynamically loaded
+ // DLLs as a precaution. This call has no effect for delay load DLLs.
+ SetDllDirectory(L"");
+
+ HMODULE module = ::GetModuleHandleW(L"kernel32.dll");
+ if (module) {
+ // SetDefaultDllDirectories is always available on Windows 8 and above. It
+ // is also available on Windows Vista, Windows Server 2008, and
+ // Windows 7 when MS KB2533623 has been applied.
+ decltype(SetDefaultDllDirectories)* setDefaultDllDirectories =
+ (decltype(SetDefaultDllDirectories)*)GetProcAddress(
+ module, "SetDefaultDllDirectories");
+ if (setDefaultDllDirectories) {
+ setDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32);
+ return;
+ }
+ }
+
+ // When SetDefaultDllDirectories is not available, fallback to preloading
+ // dlls. The order that these are loaded does not matter since they are
+ // loaded using the LOAD_WITH_ALTERED_SEARCH_PATH flag.
+#ifdef HAVE_64BIT_BUILD
+ // DLLs for Firefox x64 on Windows 7 (x64).
+ // Note: dwmapi.dll is preloaded since a crash will try to load it from the
+ // application's directory.
+ static LPCWSTR delayDLLs[] = {
+ L"apphelp.dll", L"cryptbase.dll", L"cryptsp.dll", L"dwmapi.dll",
+ L"mpr.dll", L"ntmarta.dll", L"profapi.dll", L"propsys.dll",
+ L"sspicli.dll", L"wsock32.dll"};
+
+#else
+ // DLLs for Firefox x86 on Windows XP through Windows 7 (x86 and x64).
+ // Note: dwmapi.dll is preloaded since a crash will try to load it from the
+ // application's directory.
+ static LPCWSTR delayDLLs[] = {
+ L"apphelp.dll", L"crypt32.dll", L"cryptbase.dll", L"cryptsp.dll",
+ L"dwmapi.dll", L"mpr.dll", L"msasn1.dll", L"ntmarta.dll",
+ L"profapi.dll", L"propsys.dll", L"psapi.dll", L"secur32.dll",
+ L"sspicli.dll", L"userenv.dll", L"uxtheme.dll", L"ws2_32.dll",
+ L"ws2help.dll", L"wsock32.dll"};
+#endif
+
+ WCHAR systemDirectory[MAX_PATH + 1] = {L'\0'};
+ // If GetSystemDirectory fails we accept that we'll load the DLLs from the
+ // normal search path.
+ GetSystemDirectoryW(systemDirectory, MAX_PATH + 1);
+ size_t systemDirLen = wcslen(systemDirectory);
+
+ // Make the system directory path terminate with a slash
+ if (systemDirectory[systemDirLen - 1] != L'\\' && systemDirLen) {
+ systemDirectory[systemDirLen] = L'\\';
+ ++systemDirLen;
+ // No need to re-null terminate
+ }
+
+ // For each known DLL ensure it is loaded from the system32 directory
+ for (size_t i = 0; i < sizeof(delayDLLs) / sizeof(delayDLLs[0]); ++i) {
+ size_t fileLen = wcslen(delayDLLs[i]);
+ wcsncpy(systemDirectory + systemDirLen, delayDLLs[i],
+ MAX_PATH - systemDirLen);
+ if (systemDirLen + fileLen <= MAX_PATH) {
+ systemDirectory[systemDirLen + fileLen] = L'\0';
+ } else {
+ systemDirectory[MAX_PATH] = L'\0';
+ }
+ LPCWSTR fullModulePath = systemDirectory; // just for code readability
+ // LOAD_WITH_ALTERED_SEARCH_PATH makes a dll look in its own directory for
+ // dependencies and is only available on Win 7 and below.
+ LoadLibraryExW(fullModulePath, nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
+ }
+ }
+} loadDLLs;
diff --git a/toolkit/mozapps/update/updater/macbuild/Contents/Info.plist.in b/toolkit/mozapps/update/updater/macbuild/Contents/Info.plist.in
new file mode 100644
index 0000000000..397818ec0c
--- /dev/null
+++ b/toolkit/mozapps/update/updater/macbuild/Contents/Info.plist.in
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDevelopmentRegion</key>
+ <string>English</string>
+ <key>CFBundleDisplayName</key>
+ <string>updater</string>
+ <key>CFBundleExecutable</key>
+ <string>org.mozilla.updater</string>
+ <key>CFBundleIconFile</key>
+ <string>updater.icns</string>
+ <key>CFBundleIdentifier</key>
+ <string>org.mozilla.updater</string>
+ <key>CFBundleInfoDictionaryVersion</key>
+ <string>6.0</string>
+ <key>CFBundleName</key>
+ <string>updater</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ <key>CFBundleSignature</key>
+ <string>????</string>
+ <key>CFBundleVersion</key>
+ <string>1.0</string>
+ <key>LSHasLocalizedDisplayName</key>
+ <true/>
+ <key>NSMainNibFile</key>
+ <string>MainMenu</string>
+ <key>NSRequiresAquaSystemAppearance</key>
+ <false/>
+ <key>NSPrincipalClass</key>
+ <string>NSApplication</string>
+ <key>LSUIElement</key>
+ <true/>
+ <key>SMAuthorizedClients</key>
+ <array>
+ <string>identifier "@MOZ_MACBUNDLE_ID@" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] and certificate leaf[field.1.2.840.113635.100.6.1.13] and certificate leaf[subject.OU] = "43AQ936H96"</string>
+ </array>
+</dict>
+</plist>
diff --git a/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in
new file mode 100644
index 0000000000..e8036ec8cc
--- /dev/null
+++ b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Localized versions of Info.plist keys */
+
+CFBundleName = "Software Update";
+CFBundleDisplayName = "@APP_NAME@ Software Update";
diff --git a/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib
new file mode 100644
index 0000000000..6cfb50406b
--- /dev/null
+++ b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/classes.nib
@@ -0,0 +1,19 @@
+{
+ IBClasses = (
+ {
+ CLASS = FirstResponder;
+ LANGUAGE = ObjC;
+ SUPERCLASS = NSObject;
+},
+ {
+ CLASS = UpdaterUI;
+ LANGUAGE = ObjC;
+ OUTLETS = {
+ progressBar = NSProgressIndicator;
+ progressTextField = NSTextField;
+ };
+ SUPERCLASS = NSObject;
+}
+ );
+ IBVersion = 1;
+} \ No newline at end of file
diff --git a/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib
new file mode 100644
index 0000000000..1509178370
--- /dev/null
+++ b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/English.lproj/MainMenu.nib/info.nib
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>IBDocumentLocation</key>
+ <string>111 162 356 240 0 0 1440 878 </string>
+ <key>IBEditorPositions</key>
+ <dict>
+ <key>29</key>
+ <string>106 299 84 44 0 0 1440 878 </string>
+ </dict>
+ <key>IBFramework Version</key>
+ <string>489.0</string>
+ <key>IBOpenObjects</key>
+ <array>
+ <integer>21</integer>
+ <integer>29</integer>
+ </array>
+ <key>IBSystem Version</key>
+ <string>10J567</string>
+</dict>
+</plist>
diff --git a/toolkit/mozapps/update/updater/macbuild/Contents/Resources/updater.icns b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/updater.icns
new file mode 100644
index 0000000000..d7499c6692
--- /dev/null
+++ b/toolkit/mozapps/update/updater/macbuild/Contents/Resources/updater.icns
Binary files differ
diff --git a/toolkit/mozapps/update/updater/module.ver b/toolkit/mozapps/update/updater/module.ver
new file mode 100644
index 0000000000..771416bb11
--- /dev/null
+++ b/toolkit/mozapps/update/updater/module.ver
@@ -0,0 +1 @@
+WIN32_MODULE_DESCRIPTION=@MOZ_APP_DISPLAYNAME@ Software Updater
diff --git a/toolkit/mozapps/update/updater/moz.build b/toolkit/mozapps/update/updater/moz.build
new file mode 100644
index 0000000000..eede9cd723
--- /dev/null
+++ b/toolkit/mozapps/update/updater/moz.build
@@ -0,0 +1,78 @@
+# -*- 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/.
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ Program("org.mozilla.updater")
+else:
+ Program("updater")
+
+updater_rel_path = ""
+include("updater-common.build")
+DIRS += ["updater-dep"]
+if CONFIG["ENABLE_TESTS"]:
+ DIRS += ["updater-xpcshell"]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ LDFLAGS += [
+ "-sectcreate",
+ "__TEXT",
+ "__info_plist",
+ TOPOBJDIR + "/dist/bin/Info.plist",
+ "-sectcreate",
+ "__TEXT",
+ "__launchd_plist",
+ SRCDIR + "/Launchd.plist",
+ ]
+
+GENERATED_FILES = [
+ "dep1Cert.h",
+ "dep2Cert.h",
+ "primaryCert.h",
+ "secondaryCert.h",
+ "xpcshellCert.h",
+]
+
+primary_cert = GENERATED_FILES["primaryCert.h"]
+secondary_cert = GENERATED_FILES["secondaryCert.h"]
+
+# This is how the xpcshellCertificate.der file is generated, in case we ever
+# have to regenerate it.
+# ./certutil -L -d modules/libmar/tests/unit/data -n mycert -r > xpcshellCertificate.der
+xpcshell_cert = GENERATED_FILES["xpcshellCert.h"]
+dep1_cert = GENERATED_FILES["dep1Cert.h"]
+dep2_cert = GENERATED_FILES["dep2Cert.h"]
+
+primary_cert.script = "gen_cert_header.py:create_header"
+secondary_cert.script = "gen_cert_header.py:create_header"
+xpcshell_cert.script = "gen_cert_header.py:create_header"
+dep1_cert.script = "gen_cert_header.py:create_header"
+dep2_cert.script = "gen_cert_header.py:create_header"
+
+if CONFIG["MOZ_UPDATE_CHANNEL"] in ("beta", "release", "esr"):
+ primary_cert.inputs += ["release_primary.der"]
+ secondary_cert.inputs += ["release_secondary.der"]
+elif CONFIG["MOZ_UPDATE_CHANNEL"] in (
+ "nightly",
+ "aurora",
+ "nightly-elm",
+ "nightly-pine",
+ "nightly-profiling",
+ "nightly-oak",
+ "nightly-ux",
+ "nightly-larch",
+):
+ primary_cert.inputs += ["nightly_aurora_level3_primary.der"]
+ secondary_cert.inputs += ["nightly_aurora_level3_secondary.der"]
+else:
+ primary_cert.inputs += ["dep1.der"]
+ secondary_cert.inputs += ["dep2.der"]
+
+dep1_cert.inputs += ["dep1.der"]
+dep2_cert.inputs += ["dep2.der"]
+xpcshell_cert.inputs += ["xpcshellCertificate.der"]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ FINAL_TARGET_FILES.icons += ["updater.png"]
diff --git a/toolkit/mozapps/update/updater/nightly_aurora_level3_primary.der b/toolkit/mozapps/update/updater/nightly_aurora_level3_primary.der
new file mode 100644
index 0000000000..44fd95dcff
--- /dev/null
+++ b/toolkit/mozapps/update/updater/nightly_aurora_level3_primary.der
Binary files differ
diff --git a/toolkit/mozapps/update/updater/nightly_aurora_level3_secondary.der b/toolkit/mozapps/update/updater/nightly_aurora_level3_secondary.der
new file mode 100644
index 0000000000..90f8e6e82c
--- /dev/null
+++ b/toolkit/mozapps/update/updater/nightly_aurora_level3_secondary.der
Binary files differ
diff --git a/toolkit/mozapps/update/updater/progressui.h b/toolkit/mozapps/update/updater/progressui.h
new file mode 100644
index 0000000000..e283c4d1cd
--- /dev/null
+++ b/toolkit/mozapps/update/updater/progressui.h
@@ -0,0 +1,40 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 PROGRESSUI_H__
+#define PROGRESSUI_H__
+
+#include "updatedefines.h"
+
+#if defined(XP_WIN)
+typedef WCHAR NS_tchar;
+# define NS_main wmain
+#else
+typedef char NS_tchar;
+# define NS_main main
+#endif
+
+// Called to perform any initialization of the widget toolkit
+int InitProgressUI(int* argc, NS_tchar*** argv);
+
+#if defined(XP_WIN)
+// Called on the main thread at startup
+int ShowProgressUI(bool indeterminate = false, bool initUIStrings = true);
+int InitProgressUIStrings();
+#elif defined(XP_MACOSX)
+// Called on the main thread at startup
+int ShowProgressUI(bool indeterminate = false);
+#else
+// Called on the main thread at startup
+int ShowProgressUI();
+#endif
+// May be called from any thread
+void QuitProgressUI();
+
+// May be called from any thread: progress is a number between 0 and 100
+void UpdateProgressUI(float progress);
+
+#endif // PROGRESSUI_H__
diff --git a/toolkit/mozapps/update/updater/progressui_gtk.cpp b/toolkit/mozapps/update/updater/progressui_gtk.cpp
new file mode 100644
index 0000000000..cfdcd5587c
--- /dev/null
+++ b/toolkit/mozapps/update/updater/progressui_gtk.cpp
@@ -0,0 +1,121 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <gtk/gtk.h>
+#include <unistd.h>
+#include "mozilla/Sprintf.h"
+#include "mozilla/Atomics.h"
+#include "progressui.h"
+#include "readstrings.h"
+#include "updatererrors.h"
+
+#define TIMER_INTERVAL 100
+
+static float sProgressVal; // between 0 and 100
+static mozilla::Atomic<gboolean> sQuit(FALSE);
+static gboolean sEnableUI;
+static guint sTimerID;
+
+static GtkWidget* sWin;
+static GtkWidget* sLabel;
+static GtkWidget* sProgressBar;
+static GdkPixbuf* sPixbuf;
+
+StringTable sStrings;
+
+static gboolean UpdateDialog(gpointer data) {
+ if (sQuit) {
+ gtk_widget_hide(sWin);
+ gtk_main_quit();
+ }
+
+ float progress = sProgressVal;
+
+ gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(sProgressBar),
+ progress / 100.0);
+
+ return TRUE;
+}
+
+static gboolean OnDeleteEvent(GtkWidget* widget, GdkEvent* event,
+ gpointer user_data) {
+ return TRUE;
+}
+
+int InitProgressUI(int* pargc, char*** pargv) {
+ sEnableUI = gtk_init_check(pargc, pargv);
+ if (sEnableUI) {
+ // Prepare to show the UI here in case the files are modified by the update.
+ char ini_path[PATH_MAX];
+ SprintfLiteral(ini_path, "%s.ini", (*pargv)[0]);
+ if (ReadStrings(ini_path, &sStrings) != OK) {
+ sEnableUI = false;
+ return -1;
+ }
+
+ char icon_path[PATH_MAX];
+ SprintfLiteral(icon_path, "%s/icons/updater.png", (*pargv)[2]);
+ sPixbuf = gdk_pixbuf_new_from_file(icon_path, nullptr);
+ }
+ return 0;
+}
+
+int ShowProgressUI() {
+ if (!sEnableUI) {
+ return -1;
+ }
+
+ // Only show the Progress UI if the process is taking a significant amount of
+ // time where a significant amount of time is defined as .5 seconds after
+ // ShowProgressUI is called sProgress is less than 70.
+ usleep(500000);
+
+ if (sQuit || sProgressVal > 70.0f) {
+ return 0;
+ }
+
+ sWin = gtk_window_new(GTK_WINDOW_TOPLEVEL);
+ if (!sWin) {
+ return -1;
+ }
+
+ g_signal_connect(G_OBJECT(sWin), "delete_event", G_CALLBACK(OnDeleteEvent),
+ nullptr);
+
+ gtk_window_set_title(GTK_WINDOW(sWin), sStrings.title.get());
+ gtk_window_set_type_hint(GTK_WINDOW(sWin), GDK_WINDOW_TYPE_HINT_DIALOG);
+ gtk_window_set_position(GTK_WINDOW(sWin), GTK_WIN_POS_CENTER_ALWAYS);
+ gtk_window_set_resizable(GTK_WINDOW(sWin), FALSE);
+ gtk_window_set_decorated(GTK_WINDOW(sWin), TRUE);
+ gtk_window_set_deletable(GTK_WINDOW(sWin), FALSE);
+ gtk_window_set_icon(GTK_WINDOW(sWin), sPixbuf);
+ g_object_unref(sPixbuf);
+
+ GtkWidget* vbox = gtk_vbox_new(TRUE, 6);
+ sLabel = gtk_label_new(sStrings.info.get());
+ gtk_misc_set_alignment(GTK_MISC(sLabel), 0.0f, 0.0f);
+ sProgressBar = gtk_progress_bar_new();
+
+ gtk_box_pack_start(GTK_BOX(vbox), sLabel, FALSE, FALSE, 0);
+ gtk_box_pack_start(GTK_BOX(vbox), sProgressBar, TRUE, TRUE, 0);
+
+ sTimerID = g_timeout_add(TIMER_INTERVAL, UpdateDialog, nullptr);
+
+ gtk_container_set_border_width(GTK_CONTAINER(sWin), 10);
+ gtk_container_add(GTK_CONTAINER(sWin), vbox);
+ gtk_widget_show_all(sWin);
+
+ gtk_main();
+ return 0;
+}
+
+// Called on a background thread
+void QuitProgressUI() { sQuit = TRUE; }
+
+// Called on a background thread
+void UpdateProgressUI(float progress) {
+ sProgressVal = progress; // 32-bit writes are atomic
+}
diff --git a/toolkit/mozapps/update/updater/progressui_null.cpp b/toolkit/mozapps/update/updater/progressui_null.cpp
new file mode 100644
index 0000000000..49877b2faf
--- /dev/null
+++ b/toolkit/mozapps/update/updater/progressui_null.cpp
@@ -0,0 +1,15 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 "progressui.h"
+
+int InitProgressUI(int* argc, char*** argv) { return 0; }
+
+int ShowProgressUI() { return 0; }
+
+void QuitProgressUI() {}
+
+void UpdateProgressUI(float progress) {}
diff --git a/toolkit/mozapps/update/updater/progressui_osx.mm b/toolkit/mozapps/update/updater/progressui_osx.mm
new file mode 100644
index 0000000000..4a9f8ef3b1
--- /dev/null
+++ b/toolkit/mozapps/update/updater/progressui_osx.mm
@@ -0,0 +1,137 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#import <Cocoa/Cocoa.h>
+#include <stdio.h>
+#include <unistd.h>
+#include "mozilla/Sprintf.h"
+#include "progressui.h"
+#include "readstrings.h"
+#include "updatererrors.h"
+
+#define TIMER_INTERVAL 0.2
+
+static float sProgressVal; // between 0 and 100
+static BOOL sQuit = NO;
+static BOOL sIndeterminate = NO;
+static StringTable sLabels;
+static const char* sUpdatePath;
+
+@interface UpdaterUI : NSObject {
+ IBOutlet NSProgressIndicator* progressBar;
+ IBOutlet NSTextField* progressTextField;
+}
+@end
+
+@implementation UpdaterUI
+
+- (void)awakeFromNib {
+ NSWindow* w = [progressBar window];
+
+ [w setTitle:[NSString stringWithUTF8String:sLabels.title.get()]];
+ [progressTextField
+ setStringValue:[NSString stringWithUTF8String:sLabels.info.get()]];
+
+ NSRect origTextFrame = [progressTextField frame];
+ [progressTextField sizeToFit];
+
+ int widthAdjust =
+ progressTextField.frame.size.width - origTextFrame.size.width;
+
+ if (widthAdjust > 0) {
+ NSRect f;
+ f.size.width = w.frame.size.width + widthAdjust;
+ f.size.height = w.frame.size.height;
+ [w setFrame:f display:YES];
+ }
+
+ [w center];
+
+ [progressBar setIndeterminate:sIndeterminate];
+ [progressBar setDoubleValue:0.0];
+
+ [[NSTimer scheduledTimerWithTimeInterval:TIMER_INTERVAL
+ target:self
+ selector:@selector(updateProgressUI:)
+ userInfo:nil
+ repeats:YES] retain];
+
+ // Make sure we are on top initially
+ [NSApp activateIgnoringOtherApps:YES];
+}
+
+// called when the timer goes off
+- (void)updateProgressUI:(NSTimer*)aTimer {
+ if (sQuit) {
+ [aTimer invalidate];
+ [aTimer release];
+
+ // It seems to be necessary to activate and hide ourselves before we stop,
+ // otherwise the "run" method will not return until the user focuses some
+ // other app. The activate step is necessary if we are not the active app.
+ // This is a big hack, but it seems to do the trick.
+ [NSApp activateIgnoringOtherApps:YES];
+ [NSApp hide:self];
+ [NSApp stop:self];
+ }
+
+ float progress = sProgressVal;
+
+ [progressBar setDoubleValue:(double)progress];
+}
+
+// leave this as returning a BOOL instead of NSApplicationTerminateReply
+// for backward compatibility
+- (BOOL)applicationShouldTerminate:(NSApplication*)sender {
+ return sQuit;
+}
+
+@end
+
+int InitProgressUI(int* pargc, char*** pargv) {
+ sUpdatePath = (*pargv)[1];
+
+ return 0;
+}
+
+int ShowProgressUI(bool indeterminate) {
+ if (!sUpdatePath) {
+ // InitProgressUI was never called.
+ return -1;
+ }
+
+ // Only show the Progress UI if the process is taking a significant amount of
+ // time where a significant amount of time is defined as .5 seconds after
+ // ShowProgressUI is called sProgress is less than 70.
+ usleep(500000);
+
+ if (sQuit || sProgressVal > 70.0f) {
+ return 0;
+ }
+
+ char path[PATH_MAX];
+ SprintfLiteral(path, "%s/updater.ini", sUpdatePath);
+ if (ReadStrings(path, &sLabels) != OK) {
+ return -1;
+ }
+
+ sIndeterminate = indeterminate;
+ [NSApplication sharedApplication];
+ [[NSBundle mainBundle] loadNibNamed:@"MainMenu"
+ owner:NSApp
+ topLevelObjects:nil];
+ [NSApp run];
+
+ return 0;
+}
+
+// Called on a background thread
+void QuitProgressUI() { sQuit = YES; }
+
+// Called on a background thread
+void UpdateProgressUI(float progress) {
+ sProgressVal = progress; // 32-bit writes are atomic
+}
diff --git a/toolkit/mozapps/update/updater/progressui_win.cpp b/toolkit/mozapps/update/updater/progressui_win.cpp
new file mode 100644
index 0000000000..51bd2d8cce
--- /dev/null
+++ b/toolkit/mozapps/update/updater/progressui_win.cpp
@@ -0,0 +1,302 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et cindent: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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 <stdio.h>
+#include <windows.h>
+#include <commctrl.h>
+#include <process.h>
+#include <io.h>
+
+#include "resource.h"
+#include "progressui.h"
+#include "readstrings.h"
+#include "updatererrors.h"
+
+#define TIMER_ID 1
+#define TIMER_INTERVAL 100
+
+#define RESIZE_WINDOW(hwnd, extrax, extray) \
+ { \
+ RECT windowSize; \
+ GetWindowRect(hwnd, &windowSize); \
+ SetWindowPos(hwnd, 0, 0, 0, windowSize.right - windowSize.left + extrax, \
+ windowSize.bottom - windowSize.top + extray, \
+ SWP_NOMOVE | SWP_NOZORDER); \
+ }
+
+#define MOVE_WINDOW(hwnd, dx, dy) \
+ { \
+ RECT rc; \
+ POINT pt; \
+ GetWindowRect(hwnd, &rc); \
+ pt.x = rc.left; \
+ pt.y = rc.top; \
+ ScreenToClient(GetParent(hwnd), &pt); \
+ SetWindowPos(hwnd, 0, pt.x + dx, pt.y + dy, 0, 0, \
+ SWP_NOSIZE | SWP_NOZORDER); \
+ }
+
+static float sProgress; // between 0 and 100
+static BOOL sQuit = FALSE;
+static BOOL sIndeterminate = FALSE;
+static StringTable sUIStrings;
+
+static BOOL GetStringsFile(WCHAR filename[MAX_PATH]) {
+ if (!GetModuleFileNameW(nullptr, filename, MAX_PATH)) {
+ return FALSE;
+ }
+
+ WCHAR* dot = wcsrchr(filename, '.');
+ if (!dot || wcsicmp(dot + 1, L"exe")) {
+ return FALSE;
+ }
+
+ wcscpy(dot + 1, L"ini");
+ return TRUE;
+}
+
+static void UpdateDialog(HWND hDlg) {
+ int pos = int(sProgress + 0.5f);
+ HWND hWndPro = GetDlgItem(hDlg, IDC_PROGRESS);
+ SendMessage(hWndPro, PBM_SETPOS, pos, 0L);
+}
+
+// The code in this function is from MSDN:
+// http://msdn.microsoft.com/library/default.asp?url=/library/en-us/winui/winui/windowsuserinterface/windowing/dialogboxes/usingdialogboxes.asp
+static void CenterDialog(HWND hDlg) {
+ RECT rc, rcOwner, rcDlg;
+
+ // Get the owner window and dialog box rectangles.
+ HWND desktop = GetDesktopWindow();
+
+ GetWindowRect(desktop, &rcOwner);
+ GetWindowRect(hDlg, &rcDlg);
+ CopyRect(&rc, &rcOwner);
+
+ // Offset the owner and dialog box rectangles so that
+ // right and bottom values represent the width and
+ // height, and then offset the owner again to discard
+ // space taken up by the dialog box.
+
+ OffsetRect(&rcDlg, -rcDlg.left, -rcDlg.top);
+ OffsetRect(&rc, -rc.left, -rc.top);
+ OffsetRect(&rc, -rcDlg.right, -rcDlg.bottom);
+
+ // The new position is the sum of half the remaining
+ // space and the owner's original position.
+
+ SetWindowPos(hDlg, HWND_TOP, rcOwner.left + (rc.right / 2),
+ rcOwner.top + (rc.bottom / 2), 0, 0, // ignores size arguments
+ SWP_NOSIZE);
+}
+
+static void InitDialog(HWND hDlg) {
+ mozilla::UniquePtr<WCHAR[]> szwTitle;
+ mozilla::UniquePtr<WCHAR[]> szwInfo;
+
+ int bufferSize =
+ MultiByteToWideChar(CP_UTF8, 0, sUIStrings.title.get(), -1, nullptr, 0);
+ szwTitle = mozilla::MakeUnique<WCHAR[]>(bufferSize);
+ MultiByteToWideChar(CP_UTF8, 0, sUIStrings.title.get(), -1, szwTitle.get(),
+ bufferSize);
+ bufferSize =
+ MultiByteToWideChar(CP_UTF8, 0, sUIStrings.info.get(), -1, nullptr, 0);
+ szwInfo = mozilla::MakeUnique<WCHAR[]>(bufferSize);
+ MultiByteToWideChar(CP_UTF8, 0, sUIStrings.info.get(), -1, szwInfo.get(),
+ bufferSize);
+
+ SetWindowTextW(hDlg, szwTitle.get());
+ SetWindowTextW(GetDlgItem(hDlg, IDC_INFO), szwInfo.get());
+
+ // Set dialog icon
+ HICON hIcon = LoadIcon(GetModuleHandle(nullptr), MAKEINTRESOURCE(IDI_DIALOG));
+ if (hIcon) {
+ SendMessage(hDlg, WM_SETICON, ICON_BIG, (LPARAM)hIcon);
+ }
+
+ HWND hWndPro = GetDlgItem(hDlg, IDC_PROGRESS);
+ SendMessage(hWndPro, PBM_SETRANGE, 0, MAKELPARAM(0, 100));
+ if (sIndeterminate) {
+ LONG_PTR val = GetWindowLongPtr(hWndPro, GWL_STYLE);
+ SetWindowLongPtr(hWndPro, GWL_STYLE, val | PBS_MARQUEE);
+ SendMessage(hWndPro, (UINT)PBM_SETMARQUEE, (WPARAM)TRUE, (LPARAM)50);
+ }
+
+ // Resize the dialog to fit all of the text if necessary.
+ RECT infoSize, textSize;
+ HWND hWndInfo = GetDlgItem(hDlg, IDC_INFO);
+
+ // Get the control's font for calculating the new size for the control
+ HDC hDCInfo = GetDC(hWndInfo);
+ HFONT hInfoFont, hOldFont = NULL;
+ hInfoFont = (HFONT)SendMessage(hWndInfo, WM_GETFONT, 0, 0);
+
+ if (hInfoFont) {
+ hOldFont = (HFONT)SelectObject(hDCInfo, hInfoFont);
+ }
+
+ // Measure the space needed for the text on a single line. DT_CALCRECT means
+ // nothing is drawn.
+ if (DrawText(hDCInfo, szwInfo.get(), -1, &textSize,
+ DT_CALCRECT | DT_NOCLIP | DT_SINGLELINE)) {
+ GetClientRect(hWndInfo, &infoSize);
+ SIZE extra;
+ // Calculate the additional space needed for the text by subtracting from
+ // the rectangle returned by DrawText the existing client rectangle's width
+ // and height.
+ extra.cx =
+ (textSize.right - textSize.left) - (infoSize.right - infoSize.left);
+ extra.cy =
+ (textSize.bottom - textSize.top) - (infoSize.bottom - infoSize.top);
+ if (extra.cx < 0) {
+ extra.cx = 0;
+ }
+ if (extra.cy < 0) {
+ extra.cy = 0;
+ }
+ if ((extra.cx > 0) || (extra.cy > 0)) {
+ RESIZE_WINDOW(hDlg, extra.cx, extra.cy);
+ RESIZE_WINDOW(hWndInfo, extra.cx, extra.cy);
+ RESIZE_WINDOW(hWndPro, extra.cx, 0);
+ MOVE_WINDOW(hWndPro, 0, extra.cy);
+ }
+ }
+
+ if (hOldFont) {
+ SelectObject(hDCInfo, hOldFont);
+ }
+
+ ReleaseDC(hWndInfo, hDCInfo);
+
+ CenterDialog(hDlg); // make dialog appear in the center of the screen
+
+ SetTimer(hDlg, TIMER_ID, TIMER_INTERVAL, nullptr);
+}
+
+// Message handler for update dialog.
+static LRESULT CALLBACK DialogProc(HWND hDlg, UINT message, WPARAM wParam,
+ LPARAM lParam) {
+ switch (message) {
+ case WM_INITDIALOG:
+ InitDialog(hDlg);
+ return TRUE;
+
+ case WM_TIMER:
+ if (sQuit) {
+ EndDialog(hDlg, 0);
+ } else {
+ UpdateDialog(hDlg);
+ }
+ return TRUE;
+
+ case WM_COMMAND:
+ return TRUE;
+ }
+ return FALSE;
+}
+
+int InitProgressUI(int* argc, WCHAR*** argv) { return 0; }
+
+/**
+ * Initializes the progress UI strings
+ *
+ * @return 0 on success, -1 on error
+ */
+int InitProgressUIStrings() {
+ // If we do not have updater.ini, then we should not bother showing UI.
+ WCHAR filename[MAX_PATH];
+ if (!GetStringsFile(filename)) {
+ return -1;
+ }
+
+ if (_waccess(filename, 04)) {
+ return -1;
+ }
+
+ // If the updater.ini doesn't have the required strings, then we should not
+ // bother showing UI.
+ if (ReadStrings(filename, &sUIStrings) != OK) {
+ return -1;
+ }
+
+ return 0;
+}
+
+int ShowProgressUI(bool indeterminate, bool initUIStrings) {
+ sIndeterminate = indeterminate;
+ if (!indeterminate) {
+ // Only show the Progress UI if the process is taking a significant amount
+ // of time where a significant amount of time is defined as .5 seconds after
+ // ShowProgressUI is called sProgress is less than 70.
+ Sleep(500);
+
+ if (sQuit || sProgress > 70.0f) {
+ return 0;
+ }
+ }
+
+ // Don't load the UI if there's an <exe_name>.Local directory for redirection.
+ WCHAR appPath[MAX_PATH + 1] = {L'\0'};
+ if (!GetModuleFileNameW(nullptr, appPath, MAX_PATH)) {
+ return -1;
+ }
+
+ if (wcslen(appPath) + wcslen(L".Local") >= MAX_PATH) {
+ return -1;
+ }
+
+ wcscat(appPath, L".Local");
+
+ if (!_waccess(appPath, 04)) {
+ return -1;
+ }
+
+ // Don't load the UI if the strings for the UI are not provided.
+ if (initUIStrings && InitProgressUIStrings() == -1) {
+ return -1;
+ }
+
+ if (!GetModuleFileNameW(nullptr, appPath, MAX_PATH)) {
+ return -1;
+ }
+
+ // Use an activation context that supports visual styles for the controls.
+ ACTCTXW actx = {0};
+ actx.cbSize = sizeof(ACTCTXW);
+ actx.dwFlags = ACTCTX_FLAG_RESOURCE_NAME_VALID | ACTCTX_FLAG_HMODULE_VALID;
+ actx.hModule = GetModuleHandle(NULL); // Use the embedded manifest
+ // This is needed only for Win XP but doesn't cause a problem with other
+ // versions of Windows.
+ actx.lpSource = appPath;
+ actx.lpResourceName = MAKEINTRESOURCE(IDR_COMCTL32_MANIFEST);
+
+ HANDLE hactx = INVALID_HANDLE_VALUE;
+ hactx = CreateActCtxW(&actx);
+ ULONG_PTR actxCookie = NULL;
+ if (hactx != INVALID_HANDLE_VALUE) {
+ // Push the specified activation context to the top of the activation stack.
+ ActivateActCtx(hactx, &actxCookie);
+ }
+
+ INITCOMMONCONTROLSEX icc = {sizeof(INITCOMMONCONTROLSEX), ICC_PROGRESS_CLASS};
+ InitCommonControlsEx(&icc);
+
+ DialogBox(GetModuleHandle(nullptr), MAKEINTRESOURCE(IDD_DIALOG), nullptr,
+ (DLGPROC)DialogProc);
+
+ if (hactx != INVALID_HANDLE_VALUE) {
+ // Deactivate the context now that the comctl32.dll is loaded.
+ DeactivateActCtx(0, actxCookie);
+ }
+
+ return 0;
+}
+
+void QuitProgressUI() { sQuit = TRUE; }
+
+void UpdateProgressUI(float progress) {
+ sProgress = progress; // 32-bit writes are atomic
+}
diff --git a/toolkit/mozapps/update/updater/release_primary.der b/toolkit/mozapps/update/updater/release_primary.der
new file mode 100644
index 0000000000..1d94f88ad7
--- /dev/null
+++ b/toolkit/mozapps/update/updater/release_primary.der
Binary files differ
diff --git a/toolkit/mozapps/update/updater/release_secondary.der b/toolkit/mozapps/update/updater/release_secondary.der
new file mode 100644
index 0000000000..474706c4b7
--- /dev/null
+++ b/toolkit/mozapps/update/updater/release_secondary.der
Binary files differ
diff --git a/toolkit/mozapps/update/updater/resource.h b/toolkit/mozapps/update/updater/resource.h
new file mode 100644
index 0000000000..1dcb47fca1
--- /dev/null
+++ b/toolkit/mozapps/update/updater/resource.h
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+//{{NO_DEPENDENCIES}}
+// Microsoft Visual C++ generated include file.
+// Used by updater.rc
+//
+#define IDD_DIALOG 101
+#define IDC_PROGRESS 1000
+#define IDC_INFO 1002
+#define IDI_DIALOG 1003
+#define TYPE_CERT 512
+#define IDR_PRIMARY_CERT 1004
+#define IDR_BACKUP_CERT 1005
+#define IDS_UPDATER_IDENTITY 1006
+#define IDR_XPCSHELL_CERT 1007
+#define IDR_COMCTL32_MANIFEST 17
+
+// Next default values for new objects
+//
+#ifdef APSTUDIO_INVOKED
+# ifndef APSTUDIO_READONLY_SYMBOLS
+# define _APS_NEXT_RESOURCE_VALUE 102
+# define _APS_NEXT_COMMAND_VALUE 40001
+# define _APS_NEXT_CONTROL_VALUE 1008
+# define _APS_NEXT_SYMED_VALUE 101
+# endif
+#endif
diff --git a/toolkit/mozapps/update/updater/updater-common.build b/toolkit/mozapps/update/updater/updater-common.build
new file mode 100644
index 0000000000..fe0b4a85fa
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater-common.build
@@ -0,0 +1,142 @@
+# -*- 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/.
+
+link_with_nss = CONFIG["MOZ_USE_NSS_FOR_MAR"] or (
+ CONFIG["OS_ARCH"] == "Linux" and CONFIG["MOZ_VERIFY_MAR_SIGNATURE"]
+)
+if link_with_nss:
+ DEFINES["MAR_NSS"] = True
+
+srcs = [
+ "archivereader.cpp",
+ "updater.cpp",
+]
+
+have_progressui = 0
+
+if CONFIG["MOZ_VERIFY_MAR_SIGNATURE"]:
+ USE_LIBS += [
+ "verifymar",
+ ]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ have_progressui = 1
+ srcs += [
+ "loaddlls.cpp",
+ "progressui_win.cpp",
+ ]
+ RCINCLUDE = "%supdater.rc" % updater_rel_path
+ DEFINES["UNICODE"] = True
+ DEFINES["_UNICODE"] = True
+ USE_STATIC_LIBS = True
+
+ # Pick up nsWindowsRestart.cpp
+ LOCAL_INCLUDES += [
+ "/toolkit/xre",
+ ]
+ OS_LIBS += [
+ "comctl32",
+ "ws2_32",
+ "shell32",
+ "shlwapi",
+ "gdi32",
+ "user32",
+ "userenv",
+ "uuid",
+ ]
+
+ if not link_with_nss:
+ OS_LIBS += [
+ "crypt32",
+ "advapi32",
+ ]
+
+USE_LIBS += [
+ "bspatch",
+ "mar",
+ "updatecommon",
+ "xz-embedded",
+]
+
+if link_with_nss:
+ USE_LIBS += [
+ "nspr",
+ "nss",
+ "signmar",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ have_progressui = 1
+ srcs += [
+ "progressui_gtk.cpp",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ have_progressui = 1
+ srcs += [
+ "launchchild_osx.mm",
+ "progressui_osx.mm",
+ ]
+ OS_LIBS += [
+ "-framework Cocoa",
+ "-framework SystemConfiguration",
+ ]
+ if link_with_nss:
+ LDFLAGS += ["-Wl,-rpath,@executable_path/../../../"]
+ else:
+ OS_LIBS += [
+ "-framework Security",
+ ]
+ UNIFIED_SOURCES += [
+ "/toolkit/xre/updaterfileutils_osx.mm",
+ ]
+ LOCAL_INCLUDES += [
+ "/toolkit/xre",
+ ]
+
+if have_progressui == 0:
+ srcs += [
+ "progressui_null.cpp",
+ ]
+
+SOURCES += sorted(srcs)
+
+if CONFIG["MOZ_TSAN"]:
+ # Since mozglue is not linked to the updater,
+ # we need to include our own TSan suppression list.
+ SOURCES += [
+ "TsanOptions.cpp",
+ ]
+
+DEFINES["SPRINTF_H_USES_VSNPRINTF"] = True
+DEFINES["NS_NO_XPCOM"] = True
+DisableStlWrapping()
+for var in ("MAR_CHANNEL_ID", "MOZ_APP_VERSION"):
+ DEFINES[var] = '"%s"' % CONFIG[var]
+
+LOCAL_INCLUDES += [
+ "/toolkit/mozapps/update/common",
+ "/xpcom/base", # for nsVersionComparator.cpp
+]
+
+DELAYLOAD_DLLS += [
+ "crypt32.dll",
+ "comctl32.dll",
+ "userenv.dll",
+ "wsock32.dll",
+]
+
+if CONFIG["CC_TYPE"] == "clang-cl":
+ WIN32_EXE_LDFLAGS += ["-ENTRY:wmainCRTStartup"]
+elif CONFIG["OS_ARCH"] == "WINNT":
+ WIN32_EXE_LDFLAGS += ["-municode"]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"]
+ OS_LIBS += CONFIG["MOZ_GTK3_LIBS"]
+
+if CONFIG["CC_TYPE"] == "gcc":
+ CXXFLAGS += ["-Wno-format-truncation"]
diff --git a/toolkit/mozapps/update/updater/updater-dep/moz.build b/toolkit/mozapps/update/updater/updater-dep/moz.build
new file mode 100644
index 0000000000..89c148987c
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater-dep/moz.build
@@ -0,0 +1,13 @@
+# -*- 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/.
+
+FINAL_TARGET = "_tests/updater-dep"
+
+Program("updater-dep")
+
+updater_rel_path = "../"
+DEFINES["DEP_UPDATER"] = True
+include("../updater-common.build")
diff --git a/toolkit/mozapps/update/updater/updater-xpcshell/Makefile.in b/toolkit/mozapps/update/updater/updater-xpcshell/Makefile.in
new file mode 100644
index 0000000000..533533c4d9
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater-xpcshell/Makefile.in
@@ -0,0 +1,48 @@
+# vim:set ts=8 sw=8 sts=8 noet:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# For changes here, also consider ../Makefile.in
+
+XPCSHELLTESTDIR = $(topobjdir)/_tests/xpcshell/toolkit/mozapps/update/tests
+
+ifeq (,$(MOZ_SUITE)$(MOZ_THUNDERBIRD))
+MOCHITESTBROWSERDIR = $(topobjdir)/_tests/testing/mochitest/browser/toolkit/mozapps/update/tests/browser
+endif
+
+ifndef MOZ_WINCONSOLE
+ifdef MOZ_DEBUG
+MOZ_WINCONSOLE = 1
+else
+MOZ_WINCONSOLE = 0
+endif
+endif
+
+include $(topsrcdir)/config/rules.mk
+
+ifneq (,$(COMPILE_ENVIRONMENT)$(MOZ_ARTIFACT_BUILDS))
+tools::
+ifeq (cocoa,$(MOZ_WIDGET_TOOLKIT))
+ # Copy for xpcshell tests
+ $(NSINSTALL) -D $(XPCSHELLTESTDIR)/data/updater-xpcshell.app
+ rsync -a -C --exclude '*.in' $(srcdir)/../macbuild/Contents $(XPCSHELLTESTDIR)/data/updater-xpcshell.app
+ $(call py_action,preprocessor updater-xpcshell.app/Contents/Resources/English.lproj/InfoPlist.strings,-Fsubstitution --output-encoding utf-16 -DAPP_NAME='$(MOZ_APP_DISPLAYNAME)' $(srcdir)/../macbuild/Contents/Resources/English.lproj/InfoPlist.strings.in -o $(XPCSHELLTESTDIR)/data/updater-xpcshell.app/Contents/Resources/English.lproj/InfoPlist.strings)
+ $(NSINSTALL) -D $(XPCSHELLTESTDIR)/data/updater-xpcshell.app/Contents/MacOS
+ $(NSINSTALL) $(FINAL_TARGET)/updater-xpcshell $(XPCSHELLTESTDIR)/data/updater-xpcshell.app/Contents/MacOS
+ rm -Rf $(XPCSHELLTESTDIR)/data/updater.app
+ mv $(XPCSHELLTESTDIR)/data/updater-xpcshell.app $(XPCSHELLTESTDIR)/data/updater.app
+ mv $(XPCSHELLTESTDIR)/data/updater.app/Contents/MacOS/updater-xpcshell $(XPCSHELLTESTDIR)/data/updater.app/Contents/MacOS/org.mozilla.updater
+
+ifdef MOCHITESTBROWSERDIR
+ rsync -a -C $(XPCSHELLTESTDIR)/data/updater.app $(MOCHITESTBROWSERDIR)/
+endif
+else
+ $(MKDIR) -p $(XPCSHELLTESTDIR)/data
+ cp $(FINAL_TARGET)/updater-xpcshell$(BIN_SUFFIX) $(XPCSHELLTESTDIR)/data/updater$(BIN_SUFFIX)
+ifdef MOCHITESTBROWSERDIR
+ $(MKDIR) -p $(MOCHITESTBROWSERDIR)
+ cp $(FINAL_TARGET)/updater-xpcshell$(BIN_SUFFIX) $(MOCHITESTBROWSERDIR)/updater$(BIN_SUFFIX)
+endif
+endif
+endif
diff --git a/toolkit/mozapps/update/updater/updater-xpcshell/moz.build b/toolkit/mozapps/update/updater/updater-xpcshell/moz.build
new file mode 100644
index 0000000000..33558a2c59
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater-xpcshell/moz.build
@@ -0,0 +1,13 @@
+# -*- 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/.
+
+FINAL_TARGET = "_tests/xpcshell/toolkit/mozapps/update/tests"
+
+Program("updater-xpcshell")
+
+updater_rel_path = "../"
+DEFINES["TEST_UPDATER"] = True
+include("../updater-common.build")
diff --git a/toolkit/mozapps/update/updater/updater.cpp b/toolkit/mozapps/update/updater/updater.cpp
new file mode 100644
index 0000000000..947e84aac3
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater.cpp
@@ -0,0 +1,4909 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Manifest Format
+ * ---------------
+ *
+ * contents = 1*( line )
+ * line = method LWS *( param LWS ) CRLF
+ * CRLF = "\r\n"
+ * LWS = 1*( " " | "\t" )
+ *
+ * Available methods for the manifest file:
+ *
+ * updatev3.manifest
+ * -----------------
+ * method = "add" | "add-if" | "add-if-not" | "patch" | "patch-if" |
+ * "remove" | "rmdir" | "rmrfdir" | type
+ *
+ * 'add-if-not' adds a file if it doesn't exist.
+ *
+ * 'type' is the update type (e.g. complete or partial) and when present MUST
+ * be the first entry in the update manifest. The type is used to support
+ * removing files that no longer exist when when applying a complete update by
+ * causing the actions defined in the precomplete file to be performed.
+ *
+ * precomplete
+ * -----------
+ * method = "remove" | "rmdir"
+ */
+#include "bspatch.h"
+#include "progressui.h"
+#include "archivereader.h"
+#include "readstrings.h"
+#include "updatererrors.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+
+#include <sys/stat.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <errno.h>
+
+#include "updatecommon.h"
+#ifdef XP_MACOSX
+# include "updaterfileutils_osx.h"
+#endif // XP_MACOSX
+
+#include "mozilla/CmdLineAndEnvUtils.h"
+#include "mozilla/UniquePtr.h"
+#ifdef XP_WIN
+# include "mozilla/Maybe.h"
+# include "mozilla/WinHeaderOnlyUtils.h"
+# include <climits>
+#endif // XP_WIN
+
+// Amount of the progress bar to use in each of the 3 update stages,
+// should total 100.0.
+#define PROGRESS_PREPARE_SIZE 20.0f
+#define PROGRESS_EXECUTE_SIZE 75.0f
+#define PROGRESS_FINISH_SIZE 5.0f
+
+// Maximum amount of time in ms to wait for the parent process to close. The 30
+// seconds is rather long but there have been bug reports where the parent
+// process has exited after 10 seconds and it is better to give it a chance.
+#define PARENT_WAIT 30000
+
+#if defined(XP_MACOSX)
+// These functions are defined in launchchild_osx.mm
+void CleanupElevatedMacUpdate(bool aFailureOccurred);
+bool IsOwnedByGroupAdmin(const char* aAppBundle);
+bool IsRecursivelyWritable(const char* aPath);
+void LaunchChild(int argc, const char** argv);
+void LaunchMacPostProcess(const char* aAppBundle);
+bool ObtainUpdaterArguments(int* argc, char*** argv);
+bool ServeElevatedUpdate(int argc, const char** argv);
+void SetGroupOwnershipAndPermissions(const char* aAppBundle);
+bool PerformInstallationFromDMG(int argc, char** argv);
+struct UpdateServerThreadArgs {
+ int argc;
+ const NS_tchar** argv;
+};
+#endif
+
+#ifndef _O_BINARY
+# define _O_BINARY 0
+#endif
+
+#ifndef NULL
+# define NULL (0)
+#endif
+
+#ifndef SSIZE_MAX
+# define SSIZE_MAX LONG_MAX
+#endif
+
+// We want to use execv to invoke the callback executable on platforms where
+// we were launched using execv. See nsUpdateDriver.cpp.
+#if defined(XP_UNIX) && !defined(XP_MACOSX)
+# define USE_EXECV
+#endif
+
+#if defined(XP_OPENBSD)
+# define stat64 stat
+#endif
+
+#if defined(MOZ_VERIFY_MAR_SIGNATURE) && defined(MAR_NSS)
+# include "nss.h"
+# include "prerror.h"
+#endif
+
+#include "crctable.h"
+
+#ifdef XP_WIN
+# ifdef MOZ_MAINTENANCE_SERVICE
+# include "registrycertificates.h"
+# endif
+BOOL PathAppendSafe(LPWSTR base, LPCWSTR extra);
+BOOL PathGetSiblingFilePath(LPWSTR destinationBuffer, LPCWSTR siblingFilePath,
+ LPCWSTR newFileName);
+# include "updatehelper.h"
+
+// Closes the handle if valid and if the updater is elevated returns with the
+// return code specified. This prevents multiple launches of the callback
+// application by preventing the elevated process from launching the callback.
+# define EXIT_WHEN_ELEVATED(path, handle, retCode) \
+ { \
+ if (handle != INVALID_HANDLE_VALUE) { \
+ CloseHandle(handle); \
+ } \
+ if (NS_tremove(path) && errno != ENOENT) { \
+ return retCode; \
+ } \
+ }
+#endif
+
+//-----------------------------------------------------------------------------
+
+// This BZ2_crc32Table variable lives in libbz2. We just took the
+// data structure from bz2 and created crctables.h
+
+static unsigned int crc32(const unsigned char* buf, unsigned int len) {
+ unsigned int crc = 0xffffffffL;
+
+ const unsigned char* end = buf + len;
+ for (; buf != end; ++buf)
+ crc = (crc << 8) ^ BZ2_crc32Table[(crc >> 24) ^ *buf];
+
+ crc = ~crc;
+ return crc;
+}
+
+//-----------------------------------------------------------------------------
+
+// A simple stack based container for a FILE struct that closes the
+// file descriptor from its destructor.
+class AutoFile {
+ public:
+ explicit AutoFile(FILE* file = nullptr) : mFile(file) {}
+
+ ~AutoFile() { close(); }
+
+ AutoFile& operator=(FILE* file) {
+ close();
+ mFile = file;
+ return *this;
+ }
+
+ operator FILE*() { return mFile; }
+
+ FILE* get() { return mFile; }
+
+ private:
+ FILE* mFile;
+
+ void close() {
+ if (mFile != nullptr) {
+ int rv = fclose(mFile);
+ if (rv != 0) {
+ LOG(("File close did not execute successfully"));
+ }
+ mFile = nullptr;
+ }
+ }
+};
+
+struct MARChannelStringTable {
+ MARChannelStringTable() {
+ MARChannelID = mozilla::MakeUnique<char[]>(1);
+ MARChannelID[0] = '\0';
+ }
+
+ mozilla::UniquePtr<char[]> MARChannelID;
+};
+
+//-----------------------------------------------------------------------------
+
+#ifdef XP_MACOSX
+
+// Just a simple class that sets a umask value in its constructor and resets
+// it in its destructor.
+class UmaskContext {
+ public:
+ explicit UmaskContext(mode_t umaskToSet);
+ ~UmaskContext();
+
+ private:
+ mode_t mPreviousUmask;
+};
+
+UmaskContext::UmaskContext(mode_t umaskToSet) {
+ mPreviousUmask = umask(umaskToSet);
+}
+
+UmaskContext::~UmaskContext() { umask(mPreviousUmask); }
+
+#endif
+
+//-----------------------------------------------------------------------------
+
+typedef void (*ThreadFunc)(void* param);
+
+#ifdef XP_WIN
+# include <process.h>
+
+class Thread {
+ public:
+ int Run(ThreadFunc func, void* param) {
+ mThreadFunc = func;
+ mThreadParam = param;
+
+ unsigned int threadID;
+
+ mThread =
+ (HANDLE)_beginthreadex(nullptr, 0, ThreadMain, this, 0, &threadID);
+
+ return mThread ? 0 : -1;
+ }
+ int Join() {
+ WaitForSingleObject(mThread, INFINITE);
+ CloseHandle(mThread);
+ return 0;
+ }
+
+ private:
+ static unsigned __stdcall ThreadMain(void* p) {
+ Thread* self = (Thread*)p;
+ self->mThreadFunc(self->mThreadParam);
+ return 0;
+ }
+ HANDLE mThread;
+ ThreadFunc mThreadFunc;
+ void* mThreadParam;
+};
+
+#elif defined(XP_UNIX)
+# include <pthread.h>
+
+class Thread {
+ public:
+ int Run(ThreadFunc func, void* param) {
+ return pthread_create(&thr, nullptr, (void* (*)(void*))func, param);
+ }
+ int Join() {
+ void* result;
+ return pthread_join(thr, &result);
+ }
+
+ private:
+ pthread_t thr;
+};
+
+#else
+# error "Unsupported platform"
+#endif
+
+//-----------------------------------------------------------------------------
+
+static NS_tchar gPatchDirPath[MAXPATHLEN];
+static NS_tchar gInstallDirPath[MAXPATHLEN];
+static NS_tchar gWorkingDirPath[MAXPATHLEN];
+static ArchiveReader gArchiveReader;
+static bool gSucceeded = false;
+static bool sStagedUpdate = false;
+static bool sReplaceRequest = false;
+static bool sUsingService = false;
+// The updater binary can potentially run twice. It will always initially run
+// with `gIsElevated == false`. If it is run an additional time with elevation,
+// that iteration will run with `gIsElevated == true`.
+static bool gIsElevated = false;
+
+// Normally, we run updates as a result of user action (the user started Firefox
+// or clicked a "Restart to Update" button). But there are some cases when
+// we are not:
+// a) The callback app is a background task. If true then the updater is
+// likely being run as part of a background task.
+// The updater could be run with no callback, but this only happens
+// when performing a staged update (see calls to ProcessUpdates), and there
+// are already checks for sStagedUpdate when showing UI or elevating.
+// b) The environment variable MOZ_APP_SILENT_START is set and not empty. This
+// is set, for instance, on macOS when Firefox had no windows open for a
+// while and restarted to apply updates.
+//
+// In these cases, the update should be installed silently, so we shouldn't:
+// a) show progress UI
+// b) prompt for elevation
+static bool sUpdateSilently = false;
+
+#ifdef XP_WIN
+static NS_tchar gCallbackRelPath[MAXPATHLEN];
+static NS_tchar gCallbackBackupPath[MAXPATHLEN];
+static NS_tchar gDeleteDirPath[MAXPATHLEN];
+
+// Whether to copy the update-elevated.log and update.status file to the update
+// patch directory from a secure directory.
+static bool gCopyOutputFiles = false;
+// Whether to write the update-elevated.log and update.status file to a secure
+// directory.
+static bool gUseSecureOutputPath = false;
+#endif
+
+static const NS_tchar kWhitespace[] = NS_T(" \t");
+static const NS_tchar kNL[] = NS_T("\r\n");
+static const NS_tchar kQuote[] = NS_T("\"");
+
+static inline size_t mmin(size_t a, size_t b) { return (a > b) ? b : a; }
+
+static NS_tchar* mstrtok(const NS_tchar* delims, NS_tchar** str) {
+ if (!*str || !**str) {
+ *str = nullptr;
+ return nullptr;
+ }
+
+ // skip leading "whitespace"
+ NS_tchar* ret = *str;
+ const NS_tchar* d;
+ do {
+ for (d = delims; *d != NS_T('\0'); ++d) {
+ if (*ret == *d) {
+ ++ret;
+ break;
+ }
+ }
+ } while (*d);
+
+ if (!*ret) {
+ *str = ret;
+ return nullptr;
+ }
+
+ NS_tchar* i = ret;
+ do {
+ for (d = delims; *d != NS_T('\0'); ++d) {
+ if (*i == *d) {
+ *i = NS_T('\0');
+ *str = ++i;
+ return ret;
+ }
+ }
+ ++i;
+ } while (*i);
+
+ *str = nullptr;
+ return ret;
+}
+
+#if defined(TEST_UPDATER) || defined(XP_WIN) || defined(XP_MACOSX)
+static bool EnvHasValue(const char* name) {
+ const char* val = getenv(name);
+ return (val && *val);
+}
+#endif
+
+static const NS_tchar* UpdateLogFilename() {
+ if (gIsElevated) {
+ return NS_T("update-elevated.log");
+ }
+ return NS_T("update.log");
+}
+
+#ifdef XP_WIN
+/**
+ * Obtains the update ID from the secure id file located in secure output
+ * directory.
+ *
+ * @param outBuf
+ * A buffer of size UUID_LEN (e.g. 37) to store the result. The uuid is
+ * 36 characters in length and 1 more for null termination.
+ * @return true if successful
+ */
+bool GetSecureID(char* outBuf) {
+ NS_tchar idFilePath[MAX_PATH + 1] = {L'\0'};
+ if (!GetSecureOutputFilePath(gPatchDirPath, L".id", idFilePath)) {
+ return false;
+ }
+
+ AutoFile idFile(NS_tfopen(idFilePath, NS_T("rb")));
+ if (idFile == nullptr) {
+ return false;
+ }
+
+ size_t read = fread(outBuf, UUID_LEN - 1, 1, idFile);
+ if (read != 1) {
+ return false;
+ }
+
+ outBuf[UUID_LEN - 1] = '\0';
+ return true;
+}
+#endif
+
+/**
+ * Calls LogFinish for the update log. On Windows, the unelevated updater copies
+ * the update status file and the update log file that were written by the
+ * elevated updater from the secure directory to the update patch directory.
+ *
+ * NOTE: All calls to WriteStatusFile MUST happen before calling output_finish
+ * because this function copies the update status file for the elevated
+ * updater and writing the status file after calling output_finish will
+ * overwrite it.
+ */
+static void output_finish() {
+ LogFinish();
+#ifdef XP_WIN
+ if (gCopyOutputFiles) {
+ NS_tchar srcStatusPath[MAXPATHLEN + 1] = {NS_T('\0')};
+ if (GetSecureOutputFilePath(gPatchDirPath, L".status", srcStatusPath)) {
+ NS_tchar dstStatusPath[MAXPATHLEN + 1] = {NS_T('\0')};
+ NS_tsnprintf(dstStatusPath,
+ sizeof(dstStatusPath) / sizeof(dstStatusPath[0]),
+ NS_T("%s\\update.status"), gPatchDirPath);
+ CopyFileW(srcStatusPath, dstStatusPath, false);
+ }
+
+ NS_tchar srcLogPath[MAXPATHLEN + 1] = {NS_T('\0')};
+ if (GetSecureOutputFilePath(gPatchDirPath, L".log", srcLogPath)) {
+ NS_tchar dstLogPath[MAXPATHLEN + 1] = {NS_T('\0')};
+ // Unconditionally use "update-elevated.log" here rather than
+ // `UpdateLogFilename` since (a) secure output files are only created by
+ // elevated instances and (b) the copying of the secure output file is
+ // done by the unelevated instance, so `UpdateLogFilename` will return
+ // the wrong thing for this.
+ NS_tsnprintf(dstLogPath, sizeof(dstLogPath) / sizeof(dstLogPath[0]),
+ NS_T("%s\\update-elevated.log"), gPatchDirPath);
+ CopyFileW(srcLogPath, dstLogPath, false);
+ }
+ }
+#endif
+}
+
+/**
+ * Coverts a relative update path to a full path.
+ *
+ * @param relpath
+ * The relative path to convert to a full path.
+ * @return valid filesystem full path or nullptr if memory allocation fails.
+ */
+static NS_tchar* get_full_path(const NS_tchar* relpath) {
+ NS_tchar* destpath = sStagedUpdate ? gWorkingDirPath : gInstallDirPath;
+ size_t lendestpath = NS_tstrlen(destpath);
+ size_t lenrelpath = NS_tstrlen(relpath);
+ NS_tchar* s = new NS_tchar[lendestpath + lenrelpath + 2];
+
+ NS_tchar* c = s;
+
+ NS_tstrcpy(c, destpath);
+ c += lendestpath;
+ NS_tstrcat(c, NS_T("/"));
+ c++;
+
+ NS_tstrcat(c, relpath);
+ c += lenrelpath;
+ *c = NS_T('\0');
+ return s;
+}
+
+/**
+ * Converts a full update path into a relative path; reverses get_full_path.
+ *
+ * @param fullpath
+ * The absolute path to convert into a relative path.
+ * return pointer to the location within fullpath where the relative path starts
+ * or fullpath itself if it already looks relative.
+ */
+#ifndef XP_WIN
+static const NS_tchar* get_relative_path(const NS_tchar* fullpath) {
+ if (fullpath[0] != '/') {
+ return fullpath;
+ }
+
+ NS_tchar* prefix = sStagedUpdate ? gWorkingDirPath : gInstallDirPath;
+
+ // If the path isn't long enough to be absolute, return it as-is.
+ if (NS_tstrlen(fullpath) <= NS_tstrlen(prefix)) {
+ return fullpath;
+ }
+
+ return fullpath + NS_tstrlen(prefix) + 1;
+}
+#endif
+
+/**
+ * Gets the platform specific path and performs simple checks to the path. If
+ * the path checks don't pass nullptr will be returned.
+ *
+ * @param line
+ * The line from the manifest that contains the path.
+ * @param isdir
+ * Whether the path is a directory path. Defaults to false.
+ * @return valid filesystem path or nullptr if the path checks fail.
+ */
+static NS_tchar* get_valid_path(NS_tchar** line, bool isdir = false) {
+ NS_tchar* path = mstrtok(kQuote, line);
+ if (!path) {
+ LOG(("get_valid_path: unable to determine path: " LOG_S, *line));
+ return nullptr;
+ }
+
+ // All paths must be relative from the current working directory
+ if (path[0] == NS_T('/')) {
+ LOG(("get_valid_path: path must be relative: " LOG_S, path));
+ return nullptr;
+ }
+
+#ifdef XP_WIN
+ // All paths must be relative from the current working directory
+ if (path[0] == NS_T('\\') || path[1] == NS_T(':')) {
+ LOG(("get_valid_path: path must be relative: " LOG_S, path));
+ return nullptr;
+ }
+#endif
+
+ if (isdir) {
+ // Directory paths must have a trailing forward slash.
+ if (path[NS_tstrlen(path) - 1] != NS_T('/')) {
+ LOG(
+ ("get_valid_path: directory paths must have a trailing forward "
+ "slash: " LOG_S,
+ path));
+ return nullptr;
+ }
+
+ // Remove the trailing forward slash because stat on Windows will return
+ // ENOENT if the path has a trailing slash.
+ path[NS_tstrlen(path) - 1] = NS_T('\0');
+ }
+
+ // Don't allow relative paths that resolve to a parent directory.
+ if (NS_tstrstr(path, NS_T("..")) != nullptr) {
+ LOG(("get_valid_path: paths must not contain '..': " LOG_S, path));
+ return nullptr;
+ }
+
+ return path;
+}
+
+/*
+ * Gets a quoted path. The return value is malloc'd and it is the responsibility
+ * of the caller to free it.
+ *
+ * @param path
+ * The path to quote.
+ * @return On success the quoted path and nullptr otherwise.
+ */
+static NS_tchar* get_quoted_path(const NS_tchar* path) {
+ size_t lenQuote = NS_tstrlen(kQuote);
+ size_t lenPath = NS_tstrlen(path);
+ size_t len = lenQuote + lenPath + lenQuote + 1;
+
+ NS_tchar* s = (NS_tchar*)malloc(len * sizeof(NS_tchar));
+ if (!s) {
+ return nullptr;
+ }
+
+ NS_tchar* c = s;
+ NS_tstrcpy(c, kQuote);
+ c += lenQuote;
+ NS_tstrcat(c, path);
+ c += lenPath;
+ NS_tstrcat(c, kQuote);
+ c += lenQuote;
+ *c = NS_T('\0');
+ return s;
+}
+
+static void ensure_write_permissions(const NS_tchar* path) {
+#ifdef XP_WIN
+ (void)_wchmod(path, _S_IREAD | _S_IWRITE);
+#else
+ struct stat fs;
+ if (!stat(path, &fs) && !(fs.st_mode & S_IWUSR)) {
+ (void)chmod(path, fs.st_mode | S_IWUSR);
+ }
+#endif
+}
+
+static int ensure_remove(const NS_tchar* path) {
+ ensure_write_permissions(path);
+ int rv = NS_tremove(path);
+ if (rv) {
+ LOG(("ensure_remove: failed to remove file: " LOG_S ", rv: %d, err: %d",
+ path, rv, errno));
+ }
+ return rv;
+}
+
+// Remove the directory pointed to by path and all of its files and
+// sub-directories.
+static int ensure_remove_recursive(const NS_tchar* path,
+ bool continueEnumOnFailure = false) {
+ // We use lstat rather than stat here so that we can successfully remove
+ // symlinks.
+ struct NS_tstat_t sInfo;
+ int rv = NS_tlstat(path, &sInfo);
+ if (rv) {
+ // This error is benign
+ return rv;
+ }
+ if (!S_ISDIR(sInfo.st_mode)) {
+ return ensure_remove(path);
+ }
+
+ NS_tDIR* dir;
+ NS_tdirent* entry;
+
+ dir = NS_topendir(path);
+ if (!dir) {
+ LOG(("ensure_remove_recursive: unable to open directory: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ return rv;
+ }
+
+ while ((entry = NS_treaddir(dir)) != 0) {
+ if (NS_tstrcmp(entry->d_name, NS_T(".")) &&
+ NS_tstrcmp(entry->d_name, NS_T(".."))) {
+ NS_tchar childPath[MAXPATHLEN];
+ NS_tsnprintf(childPath, sizeof(childPath) / sizeof(childPath[0]),
+ NS_T("%s/%s"), path, entry->d_name);
+ rv = ensure_remove_recursive(childPath);
+ if (rv && !continueEnumOnFailure) {
+ break;
+ }
+ }
+ }
+
+ NS_tclosedir(dir);
+
+ if (rv == OK) {
+ ensure_write_permissions(path);
+ rv = NS_trmdir(path);
+ if (rv) {
+ LOG(("ensure_remove_recursive: unable to remove directory: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ }
+ }
+ return rv;
+}
+
+static bool is_read_only(const NS_tchar* flags) {
+ size_t length = NS_tstrlen(flags);
+ if (length == 0) {
+ return false;
+ }
+
+ // Make sure the string begins with "r"
+ if (flags[0] != NS_T('r')) {
+ return false;
+ }
+
+ // Look for "r+" or "r+b"
+ if (length > 1 && flags[1] == NS_T('+')) {
+ return false;
+ }
+
+ // Look for "rb+"
+ if (NS_tstrcmp(flags, NS_T("rb+")) == 0) {
+ return false;
+ }
+
+ return true;
+}
+
+static FILE* ensure_open(const NS_tchar* path, const NS_tchar* flags,
+ unsigned int options) {
+ ensure_write_permissions(path);
+ FILE* f = NS_tfopen(path, flags);
+ if (is_read_only(flags)) {
+ // Don't attempt to modify the file permissions if the file is being opened
+ // in read-only mode.
+ return f;
+ }
+ if (NS_tchmod(path, options) != 0) {
+ if (f != nullptr) {
+ fclose(f);
+ }
+ return nullptr;
+ }
+ struct NS_tstat_t ss;
+ if (NS_tstat(path, &ss) != 0 || ss.st_mode != options) {
+ if (f != nullptr) {
+ fclose(f);
+ }
+ return nullptr;
+ }
+ return f;
+}
+
+// Ensure that the directory containing this file exists.
+static int ensure_parent_dir(const NS_tchar* path) {
+ int rv = OK;
+
+ NS_tchar* slash = (NS_tchar*)NS_tstrrchr(path, NS_T('/'));
+ if (slash) {
+ *slash = NS_T('\0');
+ rv = ensure_parent_dir(path);
+ // Only attempt to create the directory if we're not at the root
+ if (rv == OK && *path) {
+ rv = NS_tmkdir(path, 0755);
+ // If the directory already exists, then ignore the error.
+ if (rv < 0 && errno != EEXIST) {
+ LOG(("ensure_parent_dir: failed to create directory: " LOG_S ", "
+ "err: %d",
+ path, errno));
+ rv = WRITE_ERROR;
+ } else {
+ rv = OK;
+ }
+ }
+ *slash = NS_T('/');
+ }
+ return rv;
+}
+
+#ifdef XP_UNIX
+static int ensure_copy_symlink(const NS_tchar* path, const NS_tchar* dest) {
+ // Copy symlinks by creating a new symlink to the same target
+ NS_tchar target[MAXPATHLEN + 1] = {NS_T('\0')};
+ int rv = readlink(path, target, MAXPATHLEN);
+ if (rv == -1) {
+ LOG(("ensure_copy_symlink: failed to read the link: " LOG_S ", err: %d",
+ path, errno));
+ return READ_ERROR;
+ }
+ rv = symlink(target, dest);
+ if (rv == -1) {
+ LOG(("ensure_copy_symlink: failed to create the new link: " LOG_S
+ ", target: " LOG_S " err: %d",
+ dest, target, errno));
+ return READ_ERROR;
+ }
+ return 0;
+}
+#endif
+
+// Copy the file named path onto a new file named dest.
+static int ensure_copy(const NS_tchar* path, const NS_tchar* dest) {
+#ifdef XP_WIN
+ // Fast path for Windows
+ bool result = CopyFileW(path, dest, false);
+ if (!result) {
+ LOG(("ensure_copy: failed to copy the file " LOG_S " over to " LOG_S
+ ", lasterr: %lx",
+ path, dest, GetLastError()));
+ return WRITE_ERROR_FILE_COPY;
+ }
+ return OK;
+#else
+ struct NS_tstat_t ss;
+ int rv = NS_tlstat(path, &ss);
+ if (rv) {
+ LOG(("ensure_copy: failed to read file status info: " LOG_S ", err: %d",
+ path, errno));
+ return READ_ERROR;
+ }
+
+# ifdef XP_UNIX
+ if (S_ISLNK(ss.st_mode)) {
+ return ensure_copy_symlink(path, dest);
+ }
+# endif
+
+ AutoFile infile(ensure_open(path, NS_T("rb"), ss.st_mode));
+ if (!infile) {
+ LOG(("ensure_copy: failed to open the file for reading: " LOG_S ", err: %d",
+ path, errno));
+ return READ_ERROR;
+ }
+ AutoFile outfile(ensure_open(dest, NS_T("wb"), ss.st_mode));
+ if (!outfile) {
+ LOG(("ensure_copy: failed to open the file for writing: " LOG_S ", err: %d",
+ dest, errno));
+ return WRITE_ERROR;
+ }
+
+ // This block size was chosen pretty arbitrarily but seems like a reasonable
+ // compromise. For example, the optimal block size on a modern OS X machine
+ // is 100k */
+ const int blockSize = 32 * 1024;
+ void* buffer = malloc(blockSize);
+ if (!buffer) {
+ return UPDATER_MEM_ERROR;
+ }
+
+ while (!feof(infile.get())) {
+ size_t read = fread(buffer, 1, blockSize, infile);
+ if (ferror(infile.get())) {
+ LOG(("ensure_copy: failed to read the file: " LOG_S ", err: %d", path,
+ errno));
+ free(buffer);
+ return READ_ERROR;
+ }
+
+ size_t written = 0;
+
+ while (written < read) {
+ size_t chunkWritten = fwrite(buffer, 1, read - written, outfile);
+ if (chunkWritten <= 0) {
+ LOG(("ensure_copy: failed to write the file: " LOG_S ", err: %d", dest,
+ errno));
+ free(buffer);
+ return WRITE_ERROR_FILE_COPY;
+ }
+
+ written += chunkWritten;
+ }
+ }
+
+ rv = NS_tchmod(dest, ss.st_mode);
+
+ free(buffer);
+ return rv;
+#endif
+}
+
+template <unsigned N>
+struct copy_recursive_skiplist {
+ NS_tchar paths[N][MAXPATHLEN];
+
+ void append(unsigned index, const NS_tchar* path, const NS_tchar* suffix) {
+ NS_tsnprintf(paths[index], MAXPATHLEN, NS_T("%s/%s"), path, suffix);
+ }
+
+ bool find(const NS_tchar* path) {
+ for (int i = 0; i < static_cast<int>(N); ++i) {
+ if (!NS_tstricmp(paths[i], path)) {
+ return true;
+ }
+ }
+ return false;
+ }
+};
+
+// Copy all of the files and subdirectories under path to a new directory named
+// dest. The path names in the skiplist will be skipped and will not be copied.
+template <unsigned N>
+static int ensure_copy_recursive(const NS_tchar* path, const NS_tchar* dest,
+ copy_recursive_skiplist<N>& skiplist) {
+ struct NS_tstat_t sInfo;
+ int rv = NS_tlstat(path, &sInfo);
+ if (rv) {
+ LOG(("ensure_copy_recursive: path doesn't exist: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ return READ_ERROR;
+ }
+
+#ifdef XP_UNIX
+ if (S_ISLNK(sInfo.st_mode)) {
+ return ensure_copy_symlink(path, dest);
+ }
+#endif
+
+ if (!S_ISDIR(sInfo.st_mode)) {
+ return ensure_copy(path, dest);
+ }
+
+ rv = NS_tmkdir(dest, sInfo.st_mode);
+ if (rv < 0 && errno != EEXIST) {
+ LOG(("ensure_copy_recursive: could not create destination directory: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ return WRITE_ERROR;
+ }
+
+ NS_tDIR* dir;
+ NS_tdirent* entry;
+
+ dir = NS_topendir(path);
+ if (!dir) {
+ LOG(("ensure_copy_recursive: path is not a directory: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ return READ_ERROR;
+ }
+
+ while ((entry = NS_treaddir(dir)) != 0) {
+ if (NS_tstrcmp(entry->d_name, NS_T(".")) &&
+ NS_tstrcmp(entry->d_name, NS_T(".."))) {
+ NS_tchar childPath[MAXPATHLEN];
+ NS_tsnprintf(childPath, sizeof(childPath) / sizeof(childPath[0]),
+ NS_T("%s/%s"), path, entry->d_name);
+ if (skiplist.find(childPath)) {
+ continue;
+ }
+ NS_tchar childPathDest[MAXPATHLEN];
+ NS_tsnprintf(childPathDest,
+ sizeof(childPathDest) / sizeof(childPathDest[0]),
+ NS_T("%s/%s"), dest, entry->d_name);
+ rv = ensure_copy_recursive(childPath, childPathDest, skiplist);
+ if (rv) {
+ break;
+ }
+ }
+ }
+ NS_tclosedir(dir);
+ return rv;
+}
+
+// Renames the specified file to the new file specified. If the destination file
+// exists it is removed.
+static int rename_file(const NS_tchar* spath, const NS_tchar* dpath,
+ bool allowDirs = false) {
+ int rv = ensure_parent_dir(dpath);
+ if (rv) {
+ return rv;
+ }
+
+ struct NS_tstat_t spathInfo;
+ rv = NS_tstat(spath, &spathInfo);
+ if (rv) {
+ LOG(("rename_file: failed to read file status info: " LOG_S ", "
+ "err: %d",
+ spath, errno));
+ return READ_ERROR;
+ }
+
+ if (!S_ISREG(spathInfo.st_mode)) {
+ if (allowDirs && !S_ISDIR(spathInfo.st_mode)) {
+ LOG(("rename_file: path present, but not a file: " LOG_S ", err: %d",
+ spath, errno));
+ return RENAME_ERROR_EXPECTED_FILE;
+ }
+ LOG(("rename_file: proceeding to rename the directory"));
+ }
+
+ if (!NS_taccess(dpath, F_OK)) {
+ if (ensure_remove(dpath)) {
+ LOG(
+ ("rename_file: destination file exists and could not be "
+ "removed: " LOG_S,
+ dpath));
+ return WRITE_ERROR_DELETE_FILE;
+ }
+ }
+
+ if (NS_trename(spath, dpath) != 0) {
+ LOG(("rename_file: failed to rename file - src: " LOG_S ", "
+ "dst:" LOG_S ", err: %d",
+ spath, dpath, errno));
+ return WRITE_ERROR;
+ }
+
+ return OK;
+}
+
+#ifdef XP_WIN
+// Remove the directory pointed to by path and all of its files and
+// sub-directories. If a file is in use move it to the tobedeleted directory
+// and attempt to schedule removal of the file on reboot
+static int remove_recursive_on_reboot(const NS_tchar* path,
+ const NS_tchar* deleteDir) {
+ struct NS_tstat_t sInfo;
+ int rv = NS_tlstat(path, &sInfo);
+ if (rv) {
+ // This error is benign
+ return rv;
+ }
+
+ if (!S_ISDIR(sInfo.st_mode)) {
+ NS_tchar tmpDeleteFile[MAXPATHLEN + 1];
+ GetUUIDTempFilePath(deleteDir, L"rep", tmpDeleteFile);
+ if (NS_tremove(tmpDeleteFile) && errno != ENOENT) {
+ LOG(("remove_recursive_on_reboot: failed to remove temporary file: " LOG_S
+ ", err: %d",
+ tmpDeleteFile, errno));
+ }
+ rv = rename_file(path, tmpDeleteFile, false);
+ if (MoveFileEx(rv ? path : tmpDeleteFile, nullptr,
+ MOVEFILE_DELAY_UNTIL_REBOOT)) {
+ LOG(
+ ("remove_recursive_on_reboot: file will be removed on OS "
+ "reboot: " LOG_S,
+ rv ? path : tmpDeleteFile));
+ } else {
+ LOG((
+ "remove_recursive_on_reboot: failed to schedule OS reboot removal of "
+ "file: " LOG_S,
+ rv ? path : tmpDeleteFile));
+ }
+ return rv;
+ }
+
+ NS_tDIR* dir;
+ NS_tdirent* entry;
+
+ dir = NS_topendir(path);
+ if (!dir) {
+ LOG(("remove_recursive_on_reboot: unable to open directory: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ return rv;
+ }
+
+ while ((entry = NS_treaddir(dir)) != 0) {
+ if (NS_tstrcmp(entry->d_name, NS_T(".")) &&
+ NS_tstrcmp(entry->d_name, NS_T(".."))) {
+ NS_tchar childPath[MAXPATHLEN];
+ NS_tsnprintf(childPath, sizeof(childPath) / sizeof(childPath[0]),
+ NS_T("%s/%s"), path, entry->d_name);
+ // There is no need to check the return value of this call since this
+ // function is only called after an update is successful and there is not
+ // much that can be done to recover if it isn't successful. There is also
+ // no need to log the value since it will have already been logged.
+ remove_recursive_on_reboot(childPath, deleteDir);
+ }
+ }
+
+ NS_tclosedir(dir);
+
+ if (rv == OK) {
+ ensure_write_permissions(path);
+ rv = NS_trmdir(path);
+ if (rv) {
+ LOG(("remove_recursive_on_reboot: unable to remove directory: " LOG_S
+ ", rv: %d, err: %d",
+ path, rv, errno));
+ }
+ }
+ return rv;
+}
+#endif
+
+//-----------------------------------------------------------------------------
+
+// Create a backup of the specified file by renaming it.
+static int backup_create(const NS_tchar* path) {
+ NS_tchar backup[MAXPATHLEN];
+ NS_tsnprintf(backup, sizeof(backup) / sizeof(backup[0]),
+ NS_T("%s") BACKUP_EXT, path);
+
+ return rename_file(path, backup);
+}
+
+// Rename the backup of the specified file that was created by renaming it back
+// to the original file.
+static int backup_restore(const NS_tchar* path, const NS_tchar* relPath) {
+ NS_tchar backup[MAXPATHLEN];
+ NS_tsnprintf(backup, sizeof(backup) / sizeof(backup[0]),
+ NS_T("%s") BACKUP_EXT, path);
+
+ NS_tchar relBackup[MAXPATHLEN];
+ NS_tsnprintf(relBackup, sizeof(relBackup) / sizeof(relBackup[0]),
+ NS_T("%s") BACKUP_EXT, relPath);
+
+ if (NS_taccess(backup, F_OK)) {
+ LOG(("backup_restore: backup file doesn't exist: " LOG_S, relBackup));
+ return OK;
+ }
+
+ return rename_file(backup, path);
+}
+
+// Discard the backup of the specified file that was created by renaming it.
+static int backup_discard(const NS_tchar* path, const NS_tchar* relPath) {
+ NS_tchar backup[MAXPATHLEN];
+ NS_tsnprintf(backup, sizeof(backup) / sizeof(backup[0]),
+ NS_T("%s") BACKUP_EXT, path);
+
+ NS_tchar relBackup[MAXPATHLEN];
+ NS_tsnprintf(relBackup, sizeof(relBackup) / sizeof(relBackup[0]),
+ NS_T("%s") BACKUP_EXT, relPath);
+
+ // Nothing to discard
+ if (NS_taccess(backup, F_OK)) {
+ return OK;
+ }
+
+ int rv = ensure_remove(backup);
+#if defined(XP_WIN)
+ if (rv && !sStagedUpdate && !sReplaceRequest) {
+ LOG(("backup_discard: unable to remove: " LOG_S, relBackup));
+ NS_tchar path[MAXPATHLEN + 1];
+ GetUUIDTempFilePath(gDeleteDirPath, L"moz", path);
+ if (rename_file(backup, path)) {
+ LOG(("backup_discard: failed to rename file:" LOG_S ", dst:" LOG_S,
+ relBackup, relPath));
+ return WRITE_ERROR_DELETE_BACKUP;
+ }
+ // The MoveFileEx call to remove the file on OS reboot will fail if the
+ // process doesn't have write access to the HKEY_LOCAL_MACHINE registry key
+ // but this is ok since the installer / uninstaller will delete the
+ // directory containing the file along with its contents after an update is
+ // applied, on reinstall, and on uninstall.
+ if (MoveFileEx(path, nullptr, MOVEFILE_DELAY_UNTIL_REBOOT)) {
+ LOG(
+ ("backup_discard: file renamed and will be removed on OS "
+ "reboot: " LOG_S,
+ relPath));
+ } else {
+ LOG(
+ ("backup_discard: failed to schedule OS reboot removal of "
+ "file: " LOG_S,
+ relPath));
+ }
+ }
+#else
+ if (rv) {
+ return WRITE_ERROR_DELETE_BACKUP;
+ }
+#endif
+
+ return OK;
+}
+
+// Helper function for post-processing a temporary backup.
+static void backup_finish(const NS_tchar* path, const NS_tchar* relPath,
+ int status) {
+ if (status == OK) {
+ backup_discard(path, relPath);
+ } else {
+ backup_restore(path, relPath);
+ }
+}
+
+//-----------------------------------------------------------------------------
+
+static int DoUpdate();
+
+class Action {
+ public:
+ Action() : mProgressCost(1), mNext(nullptr) {}
+ virtual ~Action() = default;
+
+ virtual int Parse(NS_tchar* line) = 0;
+
+ // Do any preprocessing to ensure that the action can be performed. Execute
+ // will be called if this Action and all others return OK from this method.
+ virtual int Prepare() = 0;
+
+ // Perform the operation. Return OK to indicate success. After all actions
+ // have been executed, Finish will be called. A requirement of Execute is
+ // that its operation be reversable from Finish.
+ virtual int Execute() = 0;
+
+ // Finish is called after execution of all actions. If status is OK, then
+ // all actions were successfully executed. Otherwise, some action failed.
+ virtual void Finish(int status) = 0;
+
+ int mProgressCost;
+
+ private:
+ Action* mNext;
+
+ friend class ActionList;
+};
+
+class RemoveFile : public Action {
+ public:
+ RemoveFile() : mSkip(0) {}
+
+ int Parse(NS_tchar* line) override;
+ int Prepare() override;
+ int Execute() override;
+ void Finish(int status) override;
+
+ private:
+ mozilla::UniquePtr<NS_tchar[]> mFile;
+ mozilla::UniquePtr<NS_tchar[]> mRelPath;
+ int mSkip;
+};
+
+int RemoveFile::Parse(NS_tchar* line) {
+ // format "<deadfile>"
+
+ NS_tchar* validPath = get_valid_path(&line);
+ if (!validPath) {
+ return PARSE_ERROR;
+ }
+
+ mRelPath = mozilla::MakeUnique<NS_tchar[]>(MAXPATHLEN);
+ NS_tstrcpy(mRelPath.get(), validPath);
+
+ mFile.reset(get_full_path(validPath));
+ if (!mFile) {
+ return PARSE_ERROR;
+ }
+
+ return OK;
+}
+
+int RemoveFile::Prepare() {
+ // Skip the file if it already doesn't exist.
+ int rv = NS_taccess(mFile.get(), F_OK);
+ if (rv) {
+ mSkip = 1;
+ mProgressCost = 0;
+ return OK;
+ }
+
+ LOG(("PREPARE REMOVEFILE " LOG_S, mRelPath.get()));
+
+ // Make sure that we're actually a file...
+ struct NS_tstat_t fileInfo;
+ rv = NS_tstat(mFile.get(), &fileInfo);
+ if (rv) {
+ LOG(("failed to read file status info: " LOG_S ", err: %d", mFile.get(),
+ errno));
+ return READ_ERROR;
+ }
+
+ if (!S_ISREG(fileInfo.st_mode)) {
+ LOG(("path present, but not a file: " LOG_S, mFile.get()));
+ return DELETE_ERROR_EXPECTED_FILE;
+ }
+
+ NS_tchar* slash = (NS_tchar*)NS_tstrrchr(mFile.get(), NS_T('/'));
+ if (slash) {
+ *slash = NS_T('\0');
+ rv = NS_taccess(mFile.get(), W_OK);
+ *slash = NS_T('/');
+ } else {
+ rv = NS_taccess(NS_T("."), W_OK);
+ }
+
+ if (rv) {
+ LOG(("access failed: %d", errno));
+ return WRITE_ERROR_FILE_ACCESS_DENIED;
+ }
+
+ return OK;
+}
+
+int RemoveFile::Execute() {
+ if (mSkip) {
+ return OK;
+ }
+
+ LOG(("EXECUTE REMOVEFILE " LOG_S, mRelPath.get()));
+
+ // The file is checked for existence here and in Prepare since it might have
+ // been removed by a separate instruction: bug 311099.
+ int rv = NS_taccess(mFile.get(), F_OK);
+ if (rv) {
+ LOG(("file cannot be removed because it does not exist; skipping"));
+ mSkip = 1;
+ return OK;
+ }
+
+ if (sStagedUpdate) {
+ // Staged updates don't need backup files so just remove it.
+ rv = ensure_remove(mFile.get());
+ if (rv) {
+ return rv;
+ }
+ } else {
+ // Rename the old file. It will be removed in Finish.
+ rv = backup_create(mFile.get());
+ if (rv) {
+ LOG(("backup_create failed: %d", rv));
+ return rv;
+ }
+ }
+
+ return OK;
+}
+
+void RemoveFile::Finish(int status) {
+ if (mSkip) {
+ return;
+ }
+
+ LOG(("FINISH REMOVEFILE " LOG_S, mRelPath.get()));
+
+ // Staged updates don't create backup files.
+ if (!sStagedUpdate) {
+ backup_finish(mFile.get(), mRelPath.get(), status);
+ }
+}
+
+class RemoveDir : public Action {
+ public:
+ RemoveDir() : mSkip(0) {}
+
+ int Parse(NS_tchar* line) override;
+ int Prepare() override; // check that the source dir exists
+ int Execute() override;
+ void Finish(int status) override;
+
+ private:
+ mozilla::UniquePtr<NS_tchar[]> mDir;
+ mozilla::UniquePtr<NS_tchar[]> mRelPath;
+ int mSkip;
+};
+
+int RemoveDir::Parse(NS_tchar* line) {
+ // format "<deaddir>/"
+
+ NS_tchar* validPath = get_valid_path(&line, true);
+ if (!validPath) {
+ return PARSE_ERROR;
+ }
+
+ mRelPath = mozilla::MakeUnique<NS_tchar[]>(MAXPATHLEN);
+ NS_tstrcpy(mRelPath.get(), validPath);
+
+ mDir.reset(get_full_path(validPath));
+ if (!mDir) {
+ return PARSE_ERROR;
+ }
+
+ return OK;
+}
+
+int RemoveDir::Prepare() {
+ // We expect the directory to exist if we are to remove it.
+ int rv = NS_taccess(mDir.get(), F_OK);
+ if (rv) {
+ mSkip = 1;
+ mProgressCost = 0;
+ return OK;
+ }
+
+ LOG(("PREPARE REMOVEDIR " LOG_S "/", mRelPath.get()));
+
+ // Make sure that we're actually a dir.
+ struct NS_tstat_t dirInfo;
+ rv = NS_tstat(mDir.get(), &dirInfo);
+ if (rv) {
+ LOG(("failed to read directory status info: " LOG_S ", err: %d",
+ mRelPath.get(), errno));
+ return READ_ERROR;
+ }
+
+ if (!S_ISDIR(dirInfo.st_mode)) {
+ LOG(("path present, but not a directory: " LOG_S, mRelPath.get()));
+ return DELETE_ERROR_EXPECTED_DIR;
+ }
+
+ rv = NS_taccess(mDir.get(), W_OK);
+ if (rv) {
+ LOG(("access failed: %d, %d", rv, errno));
+ return WRITE_ERROR_DIR_ACCESS_DENIED;
+ }
+
+ return OK;
+}
+
+int RemoveDir::Execute() {
+ if (mSkip) {
+ return OK;
+ }
+
+ LOG(("EXECUTE REMOVEDIR " LOG_S "/", mRelPath.get()));
+
+ // The directory is checked for existence at every step since it might have
+ // been removed by a separate instruction: bug 311099.
+ int rv = NS_taccess(mDir.get(), F_OK);
+ if (rv) {
+ LOG(("directory no longer exists; skipping"));
+ mSkip = 1;
+ }
+
+ return OK;
+}
+
+void RemoveDir::Finish(int status) {
+ if (mSkip || status != OK) {
+ return;
+ }
+
+ LOG(("FINISH REMOVEDIR " LOG_S "/", mRelPath.get()));
+
+ // The directory is checked for existence at every step since it might have
+ // been removed by a separate instruction: bug 311099.
+ int rv = NS_taccess(mDir.get(), F_OK);
+ if (rv) {
+ LOG(("directory no longer exists; skipping"));
+ return;
+ }
+
+ if (status == OK) {
+ if (NS_trmdir(mDir.get())) {
+ LOG(("non-fatal error removing directory: " LOG_S "/, rv: %d, err: %d",
+ mRelPath.get(), rv, errno));
+ }
+ }
+}
+
+class AddFile : public Action {
+ public:
+ AddFile() : mAdded(false) {}
+
+ int Parse(NS_tchar* line) override;
+ int Prepare() override;
+ int Execute() override;
+ void Finish(int status) override;
+
+ private:
+ mozilla::UniquePtr<NS_tchar[]> mFile;
+ mozilla::UniquePtr<NS_tchar[]> mRelPath;
+ bool mAdded;
+};
+
+int AddFile::Parse(NS_tchar* line) {
+ // format "<newfile>"
+
+ NS_tchar* validPath = get_valid_path(&line);
+ if (!validPath) {
+ return PARSE_ERROR;
+ }
+
+ mRelPath = mozilla::MakeUnique<NS_tchar[]>(MAXPATHLEN);
+ NS_tstrcpy(mRelPath.get(), validPath);
+
+ mFile.reset(get_full_path(validPath));
+ if (!mFile) {
+ return PARSE_ERROR;
+ }
+
+ return OK;
+}
+
+int AddFile::Prepare() {
+ LOG(("PREPARE ADD " LOG_S, mRelPath.get()));
+
+ return OK;
+}
+
+int AddFile::Execute() {
+ LOG(("EXECUTE ADD " LOG_S, mRelPath.get()));
+
+ int rv;
+
+ // First make sure that we can actually get rid of any existing file.
+ rv = NS_taccess(mFile.get(), F_OK);
+ if (rv == 0) {
+ if (sStagedUpdate) {
+ // Staged updates don't need backup files so just remove it.
+ rv = ensure_remove(mFile.get());
+ } else {
+ rv = backup_create(mFile.get());
+ }
+ if (rv) {
+ return rv;
+ }
+ } else {
+ rv = ensure_parent_dir(mFile.get());
+ if (rv) {
+ return rv;
+ }
+ }
+
+#ifdef XP_WIN
+ char sourcefile[MAXPATHLEN];
+ if (!WideCharToMultiByte(CP_UTF8, 0, mRelPath.get(), -1, sourcefile,
+ MAXPATHLEN, nullptr, nullptr)) {
+ LOG(("error converting wchar to utf8: %lu", GetLastError()));
+ return STRING_CONVERSION_ERROR;
+ }
+
+ rv = gArchiveReader.ExtractFile(sourcefile, mFile.get());
+#else
+ rv = gArchiveReader.ExtractFile(mRelPath.get(), mFile.get());
+#endif
+ if (!rv) {
+ mAdded = true;
+ }
+ return rv;
+}
+
+void AddFile::Finish(int status) {
+ LOG(("FINISH ADD " LOG_S, mRelPath.get()));
+ // Staged updates don't create backup files.
+ if (!sStagedUpdate) {
+ // When there is an update failure and a file has been added it is removed
+ // here since there might not be a backup to replace it.
+ if (status && mAdded) {
+ if (NS_tremove(mFile.get()) && errno != ENOENT) {
+ LOG(("non-fatal error after update failure removing added file: " LOG_S
+ ", err: %d",
+ mFile.get(), errno));
+ }
+ }
+ backup_finish(mFile.get(), mRelPath.get(), status);
+ }
+}
+
+class PatchFile : public Action {
+ public:
+ PatchFile() : mPatchFile(nullptr), mPatchIndex(-1), buf(nullptr) {}
+
+ ~PatchFile() override;
+
+ int Parse(NS_tchar* line) override;
+ int Prepare() override; // should check for patch file and for checksum here
+ int Execute() override;
+ void Finish(int status) override;
+
+ private:
+ int LoadSourceFile(FILE* ofile);
+
+ static int sPatchIndex;
+
+ const NS_tchar* mPatchFile;
+ mozilla::UniquePtr<NS_tchar[]> mFile;
+ mozilla::UniquePtr<NS_tchar[]> mFileRelPath;
+ int mPatchIndex;
+ MBSPatchHeader header;
+ unsigned char* buf;
+ NS_tchar spath[MAXPATHLEN];
+ AutoFile mPatchStream;
+};
+
+int PatchFile::sPatchIndex = 0;
+
+PatchFile::~PatchFile() {
+ // Make sure mPatchStream gets unlocked on Windows; the system will do that,
+ // but not until some indeterminate future time, and we want determinism.
+ // Normally this happens at the end of Execute, when we close the stream;
+ // this call is here in case Execute errors out.
+#ifdef XP_WIN
+ if (mPatchStream) {
+ UnlockFile((HANDLE)_get_osfhandle(fileno(mPatchStream)), 0, 0, -1, -1);
+ }
+#endif
+ // Patch files are written to the <working_dir>/updating directory which is
+ // removed after the update has finished so don't delete patch files here.
+
+ if (buf) {
+ free(buf);
+ }
+}
+
+int PatchFile::LoadSourceFile(FILE* ofile) {
+ struct stat os;
+ int rv = fstat(fileno((FILE*)ofile), &os);
+ if (rv) {
+ LOG(("LoadSourceFile: unable to stat destination file: " LOG_S ", "
+ "err: %d",
+ mFileRelPath.get(), errno));
+ return READ_ERROR;
+ }
+
+ if (uint32_t(os.st_size) != header.slen) {
+ LOG(
+ ("LoadSourceFile: destination file size %d does not match expected "
+ "size %d",
+ uint32_t(os.st_size), header.slen));
+ return LOADSOURCE_ERROR_WRONG_SIZE;
+ }
+
+ buf = (unsigned char*)malloc(header.slen);
+ if (!buf) {
+ return UPDATER_MEM_ERROR;
+ }
+
+ size_t r = header.slen;
+ unsigned char* rb = buf;
+ while (r) {
+ const size_t count = mmin(SSIZE_MAX, r);
+ size_t c = fread(rb, 1, count, ofile);
+ if (c != count) {
+ LOG(("LoadSourceFile: error reading destination file: " LOG_S,
+ mFileRelPath.get()));
+ return READ_ERROR;
+ }
+
+ r -= c;
+ rb += c;
+ }
+
+ // Verify that the contents of the source file correspond to what we expect.
+
+ unsigned int crc = crc32(buf, header.slen);
+
+ if (crc != header.scrc32) {
+ LOG(
+ ("LoadSourceFile: destination file crc %d does not match expected "
+ "crc %d",
+ crc, header.scrc32));
+ return CRC_ERROR;
+ }
+
+ return OK;
+}
+
+int PatchFile::Parse(NS_tchar* line) {
+ // format "<patchfile>" "<filetopatch>"
+
+ // Get the path to the patch file inside of the mar
+ mPatchFile = mstrtok(kQuote, &line);
+ if (!mPatchFile) {
+ return PARSE_ERROR;
+ }
+
+ // consume whitespace between args
+ NS_tchar* q = mstrtok(kQuote, &line);
+ if (!q) {
+ return PARSE_ERROR;
+ }
+
+ NS_tchar* validPath = get_valid_path(&line);
+ if (!validPath) {
+ return PARSE_ERROR;
+ }
+
+ mFileRelPath = mozilla::MakeUnique<NS_tchar[]>(MAXPATHLEN);
+ NS_tstrcpy(mFileRelPath.get(), validPath);
+
+ mFile.reset(get_full_path(validPath));
+ if (!mFile) {
+ return PARSE_ERROR;
+ }
+
+ return OK;
+}
+
+int PatchFile::Prepare() {
+ LOG(("PREPARE PATCH " LOG_S, mFileRelPath.get()));
+
+ // extract the patch to a temporary file
+ mPatchIndex = sPatchIndex++;
+
+ NS_tsnprintf(spath, sizeof(spath) / sizeof(spath[0]),
+ NS_T("%s/updating/%d.patch"), gWorkingDirPath, mPatchIndex);
+
+ // The removal of pre-existing patch files here is in case a previous update
+ // crashed and left these files behind.
+ if (NS_tremove(spath) && errno != ENOENT) {
+ LOG(("failure removing pre-existing patch file: " LOG_S ", err: %d", spath,
+ errno));
+ return WRITE_ERROR;
+ }
+
+ mPatchStream = NS_tfopen(spath, NS_T("wb+"));
+ if (!mPatchStream) {
+ return WRITE_ERROR;
+ }
+
+#ifdef XP_WIN
+ // Lock the patch file, so it can't be messed with between
+ // when we're done creating it and when we go to apply it.
+ if (!LockFile((HANDLE)_get_osfhandle(fileno(mPatchStream)), 0, 0, -1, -1)) {
+ LOG(("Couldn't lock patch file: %lu", GetLastError()));
+ return LOCK_ERROR_PATCH_FILE;
+ }
+
+ char sourcefile[MAXPATHLEN];
+ if (!WideCharToMultiByte(CP_UTF8, 0, mPatchFile, -1, sourcefile, MAXPATHLEN,
+ nullptr, nullptr)) {
+ LOG(("error converting wchar to utf8: %lu", GetLastError()));
+ return STRING_CONVERSION_ERROR;
+ }
+
+ int rv = gArchiveReader.ExtractFileToStream(sourcefile, mPatchStream);
+#else
+ int rv = gArchiveReader.ExtractFileToStream(mPatchFile, mPatchStream);
+#endif
+
+ return rv;
+}
+
+int PatchFile::Execute() {
+ LOG(("EXECUTE PATCH " LOG_S, mFileRelPath.get()));
+
+ fseek(mPatchStream, 0, SEEK_SET);
+
+ int rv = MBS_ReadHeader(mPatchStream, &header);
+ if (rv) {
+ return rv;
+ }
+
+ FILE* origfile = nullptr;
+#ifdef XP_WIN
+ if (NS_tstrcmp(mFileRelPath.get(), gCallbackRelPath) == 0) {
+ // Read from the copy of the callback when patching since the callback can't
+ // be opened for reading to prevent the application from being launched.
+ origfile = NS_tfopen(gCallbackBackupPath, NS_T("rb"));
+ } else {
+ origfile = NS_tfopen(mFile.get(), NS_T("rb"));
+ }
+#else
+ origfile = NS_tfopen(mFile.get(), NS_T("rb"));
+#endif
+
+ if (!origfile) {
+ LOG(("unable to open destination file: " LOG_S ", err: %d",
+ mFileRelPath.get(), errno));
+ return READ_ERROR;
+ }
+
+ rv = LoadSourceFile(origfile);
+ fclose(origfile);
+ if (rv) {
+ LOG(("LoadSourceFile failed"));
+ return rv;
+ }
+
+ // Rename the destination file if it exists before proceeding so it can be
+ // used to restore the file to its original state if there is an error.
+ struct NS_tstat_t ss;
+ rv = NS_tstat(mFile.get(), &ss);
+ if (rv) {
+ LOG(("failed to read file status info: " LOG_S ", err: %d",
+ mFileRelPath.get(), errno));
+ return READ_ERROR;
+ }
+
+ // Staged updates don't need backup files.
+ if (!sStagedUpdate) {
+ rv = backup_create(mFile.get());
+ if (rv) {
+ return rv;
+ }
+ }
+
+#if defined(HAVE_POSIX_FALLOCATE)
+ AutoFile ofile(ensure_open(mFile.get(), NS_T("wb+"), ss.st_mode));
+ posix_fallocate(fileno((FILE*)ofile), 0, header.dlen);
+#elif defined(XP_WIN)
+ bool shouldTruncate = true;
+ // Creating the file, setting the size, and then closing the file handle
+ // lessens fragmentation more than any other method tested. Other methods that
+ // have been tested are:
+ // 1. _chsize / _chsize_s reduced fragmentation though not completely.
+ // 2. _get_osfhandle and then setting the size reduced fragmentation though
+ // not completely. There are also reports of _get_osfhandle failing on
+ // mingw.
+ HANDLE hfile = CreateFileW(mFile.get(), GENERIC_WRITE, 0, nullptr,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
+
+ if (hfile != INVALID_HANDLE_VALUE) {
+ if (SetFilePointer(hfile, header.dlen, nullptr, FILE_BEGIN) !=
+ INVALID_SET_FILE_POINTER &&
+ SetEndOfFile(hfile) != 0) {
+ shouldTruncate = false;
+ }
+ CloseHandle(hfile);
+ }
+
+ AutoFile ofile(ensure_open(
+ mFile.get(), shouldTruncate ? NS_T("wb+") : NS_T("rb+"), ss.st_mode));
+#elif defined(XP_MACOSX)
+ AutoFile ofile(ensure_open(mFile.get(), NS_T("wb+"), ss.st_mode));
+ // Modified code from FileUtils.cpp
+ fstore_t store = {F_ALLOCATECONTIG, F_PEOFPOSMODE, 0, header.dlen};
+ // Try to get a continous chunk of disk space
+ rv = fcntl(fileno((FILE*)ofile), F_PREALLOCATE, &store);
+ if (rv == -1) {
+ // OK, perhaps we are too fragmented, allocate non-continuous
+ store.fst_flags = F_ALLOCATEALL;
+ rv = fcntl(fileno((FILE*)ofile), F_PREALLOCATE, &store);
+ }
+
+ if (rv != -1) {
+ ftruncate(fileno((FILE*)ofile), header.dlen);
+ }
+#else
+ AutoFile ofile(ensure_open(mFile.get(), NS_T("wb+"), ss.st_mode));
+#endif
+
+ if (ofile == nullptr) {
+ LOG(("unable to create new file: " LOG_S ", err: %d", mFileRelPath.get(),
+ errno));
+ return WRITE_ERROR_OPEN_PATCH_FILE;
+ }
+
+#ifdef XP_WIN
+ if (!shouldTruncate) {
+ fseek(ofile, 0, SEEK_SET);
+ }
+#endif
+
+ rv = MBS_ApplyPatch(&header, mPatchStream, buf, ofile);
+
+ // Go ahead and do a bit of cleanup now to minimize runtime overhead.
+ // Make sure mPatchStream gets unlocked on Windows; the system will do that,
+ // but not until some indeterminate future time, and we want determinism.
+#ifdef XP_WIN
+ UnlockFile((HANDLE)_get_osfhandle(fileno(mPatchStream)), 0, 0, -1, -1);
+#endif
+ // Set mPatchStream to nullptr to make AutoFile close the file,
+ // so it can be deleted on Windows.
+ mPatchStream = nullptr;
+ // Patch files are written to the <working_dir>/updating directory which is
+ // removed after the update has finished so don't delete patch files here.
+ spath[0] = NS_T('\0');
+ free(buf);
+ buf = nullptr;
+
+ return rv;
+}
+
+void PatchFile::Finish(int status) {
+ LOG(("FINISH PATCH " LOG_S, mFileRelPath.get()));
+
+ // Staged updates don't create backup files.
+ if (!sStagedUpdate) {
+ backup_finish(mFile.get(), mFileRelPath.get(), status);
+ }
+}
+
+class AddIfFile : public AddFile {
+ public:
+ int Parse(NS_tchar* line) override;
+ int Prepare() override;
+ int Execute() override;
+ void Finish(int status) override;
+
+ protected:
+ mozilla::UniquePtr<NS_tchar[]> mTestFile;
+};
+
+int AddIfFile::Parse(NS_tchar* line) {
+ // format "<testfile>" "<newfile>"
+
+ mTestFile.reset(get_full_path(get_valid_path(&line)));
+ if (!mTestFile) {
+ return PARSE_ERROR;
+ }
+
+ // consume whitespace between args
+ NS_tchar* q = mstrtok(kQuote, &line);
+ if (!q) {
+ return PARSE_ERROR;
+ }
+
+ return AddFile::Parse(line);
+}
+
+int AddIfFile::Prepare() {
+ // If the test file does not exist, then skip this action.
+ if (NS_taccess(mTestFile.get(), F_OK)) {
+ mTestFile = nullptr;
+ return OK;
+ }
+
+ return AddFile::Prepare();
+}
+
+int AddIfFile::Execute() {
+ if (!mTestFile) {
+ return OK;
+ }
+
+ return AddFile::Execute();
+}
+
+void AddIfFile::Finish(int status) {
+ if (!mTestFile) {
+ return;
+ }
+
+ AddFile::Finish(status);
+}
+
+class AddIfNotFile : public AddFile {
+ public:
+ int Parse(NS_tchar* line) override;
+ int Prepare() override;
+ int Execute() override;
+ void Finish(int status) override;
+
+ protected:
+ mozilla::UniquePtr<NS_tchar[]> mTestFile;
+};
+
+int AddIfNotFile::Parse(NS_tchar* line) {
+ // format "<testfile>" "<newfile>"
+
+ mTestFile.reset(get_full_path(get_valid_path(&line)));
+ if (!mTestFile) {
+ return PARSE_ERROR;
+ }
+
+ // consume whitespace between args
+ NS_tchar* q = mstrtok(kQuote, &line);
+ if (!q) {
+ return PARSE_ERROR;
+ }
+
+ return AddFile::Parse(line);
+}
+
+int AddIfNotFile::Prepare() {
+ // If the test file exists, then skip this action.
+ if (!NS_taccess(mTestFile.get(), F_OK)) {
+ mTestFile = NULL;
+ return OK;
+ }
+
+ return AddFile::Prepare();
+}
+
+int AddIfNotFile::Execute() {
+ if (!mTestFile) {
+ return OK;
+ }
+
+ return AddFile::Execute();
+}
+
+void AddIfNotFile::Finish(int status) {
+ if (!mTestFile) {
+ return;
+ }
+
+ AddFile::Finish(status);
+}
+
+class PatchIfFile : public PatchFile {
+ public:
+ int Parse(NS_tchar* line) override;
+ int Prepare() override; // should check for patch file and for checksum here
+ int Execute() override;
+ void Finish(int status) override;
+
+ private:
+ mozilla::UniquePtr<NS_tchar[]> mTestFile;
+};
+
+int PatchIfFile::Parse(NS_tchar* line) {
+ // format "<testfile>" "<patchfile>" "<filetopatch>"
+
+ mTestFile.reset(get_full_path(get_valid_path(&line)));
+ if (!mTestFile) {
+ return PARSE_ERROR;
+ }
+
+ // consume whitespace between args
+ NS_tchar* q = mstrtok(kQuote, &line);
+ if (!q) {
+ return PARSE_ERROR;
+ }
+
+ return PatchFile::Parse(line);
+}
+
+int PatchIfFile::Prepare() {
+ // If the test file does not exist, then skip this action.
+ if (NS_taccess(mTestFile.get(), F_OK)) {
+ mTestFile = nullptr;
+ return OK;
+ }
+
+ return PatchFile::Prepare();
+}
+
+int PatchIfFile::Execute() {
+ if (!mTestFile) {
+ return OK;
+ }
+
+ return PatchFile::Execute();
+}
+
+void PatchIfFile::Finish(int status) {
+ if (!mTestFile) {
+ return;
+ }
+
+ PatchFile::Finish(status);
+}
+
+//-----------------------------------------------------------------------------
+
+#ifdef XP_WIN
+# include "nsWindowsRestart.cpp"
+# include "nsWindowsHelpers.h"
+# include "uachelper.h"
+# ifdef MOZ_MAINTENANCE_SERVICE
+# include "pathhash.h"
+# endif
+
+/**
+ * Launch the post update application (helper.exe). It takes in the path of the
+ * callback application to calculate the path of helper.exe. For service updates
+ * this is called from both the system account and the current user account.
+ *
+ * @param installationDir The path to the callback application binary.
+ * @param updateInfoDir The directory where update info is stored.
+ * @return true if there was no error starting the process.
+ */
+bool LaunchWinPostProcess(const WCHAR* installationDir,
+ const WCHAR* updateInfoDir) {
+ WCHAR workingDirectory[MAX_PATH + 1] = {L'\0'};
+ wcsncpy(workingDirectory, installationDir, MAX_PATH);
+
+ // Launch helper.exe to perform post processing (e.g. registry and log file
+ // modifications) for the update.
+ WCHAR inifile[MAX_PATH + 1] = {L'\0'};
+ wcsncpy(inifile, installationDir, MAX_PATH);
+ if (!PathAppendSafe(inifile, L"updater.ini")) {
+ LOG(
+ ("LaunchWinPostProcess failed because PathAppendSafe failed when "
+ "getting INI path"));
+ return false;
+ }
+
+ WCHAR exefile[MAX_PATH + 1];
+ WCHAR exearg[MAX_PATH + 1];
+ if (!GetPrivateProfileStringW(L"PostUpdateWin", L"ExeRelPath", nullptr,
+ exefile, MAX_PATH + 1, inifile)) {
+ LOG(("LaunchWinPostProcess failed due to failure to retrieve ExeRelPath"));
+ return false;
+ }
+
+ if (!GetPrivateProfileStringW(L"PostUpdateWin", L"ExeArg", nullptr, exearg,
+ MAX_PATH + 1, inifile)) {
+ LOG(("LaunchWinPostProcess failed due to failure to retrieve ExeArg"));
+ return false;
+ }
+
+ // The relative path must not contain directory traversals, current directory,
+ // or colons.
+ if (wcsstr(exefile, L"..") != nullptr || wcsstr(exefile, L"./") != nullptr ||
+ wcsstr(exefile, L".\\") != nullptr || wcsstr(exefile, L":") != nullptr) {
+ LOG(
+ ("LaunchWinPostProcess failed because executable path contains "
+ "disallowed characters"));
+ return false;
+ }
+
+ // The relative path must not start with a decimal point, backslash, or
+ // forward slash.
+ if (exefile[0] == L'.' || exefile[0] == L'\\' || exefile[0] == L'/') {
+ LOG(("LaunchWinPostProcess failed because first character is invalid"));
+ return false;
+ }
+
+ WCHAR exefullpath[MAX_PATH + 1] = {L'\0'};
+ wcsncpy(exefullpath, installationDir, MAX_PATH);
+ if (!PathAppendSafe(exefullpath, exefile)) {
+ LOG(
+ ("LaunchWinPostProcess failed because PathAppendSafe failed when "
+ "getting full executable path"));
+ return false;
+ }
+
+ if (!IsValidFullPath(exefullpath)) {
+ LOG(
+ ("LaunchWinPostProcess failed because executable path is not a valid, "
+ "full path"));
+ return false;
+ }
+
+# if !defined(TEST_UPDATER) && defined(MOZ_MAINTENANCE_SERVICE)
+ if (sUsingService &&
+ !DoesBinaryMatchAllowedCertificates(installationDir, exefullpath)) {
+ LOG(
+ ("LaunchWinPostProcess failed because the binary doesn't match the "
+ "allowed certificates"));
+ return false;
+ }
+# endif
+
+ WCHAR dlogFile[MAX_PATH + 1];
+ if (!PathGetSiblingFilePath(dlogFile, exefullpath, L"uninstall.update")) {
+ LOG(("LaunchWinPostProcess failed because dlogFile path is unavailable"));
+ return false;
+ }
+
+ WCHAR slogFile[MAX_PATH + 1] = {L'\0'};
+ if (gCopyOutputFiles) {
+ if (!GetSecureOutputFilePath(gPatchDirPath, L".log", slogFile)) {
+ LOG(
+ ("LaunchWinPostProcess failed because a secure slogFile path is "
+ "unavailable"));
+ return false;
+ }
+ } else {
+ wcsncpy(slogFile, updateInfoDir, MAX_PATH);
+ if (!PathAppendSafe(slogFile, UpdateLogFilename())) {
+ LOG(("LaunchWinPostProcess failed because slogFile path is unavailable"));
+ return false;
+ }
+ }
+
+ WCHAR dummyArg[14] = {L'\0'};
+ wcsncpy(dummyArg, L"argv0ignored ",
+ sizeof(dummyArg) / sizeof(dummyArg[0]) - 1);
+
+ size_t len = wcslen(exearg) + wcslen(dummyArg);
+ WCHAR* cmdline = (WCHAR*)malloc((len + 1) * sizeof(WCHAR));
+ if (!cmdline) {
+ LOG(
+ ("LaunchWinPostProcess failed due to failure to allocate %zu wchars "
+ "for cmdline",
+ len + 1));
+ return false;
+ }
+
+ wcsncpy(cmdline, dummyArg, len);
+ wcscat(cmdline, exearg);
+
+ // We want to launch the post update helper app to update the Windows
+ // registry even if there is a failure with removing the uninstall.update
+ // file or copying the update.log file.
+ CopyFileW(slogFile, dlogFile, false);
+
+ STARTUPINFOW si = {sizeof(si), 0};
+ si.lpDesktop = const_cast<LPWSTR>(L""); // -Wwritable-strings
+ PROCESS_INFORMATION pi = {0};
+
+ bool ok = CreateProcessW(exefullpath, cmdline,
+ nullptr, // no special security attributes
+ nullptr, // no special thread attributes
+ false, // don't inherit filehandles
+ 0, // No special process creation flags
+ nullptr, // inherit my environment
+ workingDirectory, &si, &pi);
+ free(cmdline);
+ if (ok) {
+ LOG(("LaunchWinPostProcess - Waiting for process to complete"));
+ WaitForSingleObject(pi.hProcess, INFINITE);
+ CloseHandle(pi.hProcess);
+ CloseHandle(pi.hThread);
+ LOG(("LaunchWinPostProcess - Process completed"));
+ } else {
+ LOG(("LaunchWinPostProcess - CreateProcessW failed: %lu", GetLastError()));
+ }
+ return ok;
+}
+
+#endif
+
+static void LaunchCallbackApp(const NS_tchar* workingDir, int argc,
+ NS_tchar** argv, bool usingService) {
+ putenv(const_cast<char*>("MOZ_LAUNCHED_CHILD=1"));
+
+ // Run from the specified working directory (see bug 312360).
+ if (NS_tchdir(workingDir) != 0) {
+ LOG(("Warning: chdir failed"));
+ }
+
+#if defined(USE_EXECV)
+ execv(argv[0], argv);
+#elif defined(XP_MACOSX)
+ LaunchChild(argc, (const char**)argv);
+#elif defined(XP_WIN)
+ // Do not allow the callback to run when running an update through the
+ // service as session 0. The unelevated updater.exe will do the launching.
+ if (!usingService) {
+ HANDLE hProcess;
+ if (WinLaunchChild(argv[0], argc, argv, nullptr, &hProcess)) {
+ // Keep the current process around until the callback process has created
+ // its message queue, to avoid the launched process's windows being forced
+ // into the background.
+ mozilla::WaitForInputIdle(hProcess);
+ CloseHandle(hProcess);
+ }
+ }
+#else
+# warning "Need implementaton of LaunchCallbackApp"
+#endif
+}
+
+static bool WriteToFile(const NS_tchar* aFilename, const char* aStatus) {
+ LOG(("Writing status to file: %s", aStatus));
+
+ NS_tchar statusFilePath[MAXPATHLEN + 1] = {NS_T('\0')};
+#if defined(XP_WIN)
+ if (gUseSecureOutputPath) {
+ if (!GetSecureOutputFilePath(gPatchDirPath, L".status", statusFilePath)) {
+ LOG(("WriteToFile failed to get secure output path"));
+ return false;
+ }
+ } else {
+ NS_tsnprintf(statusFilePath,
+ sizeof(statusFilePath) / sizeof(statusFilePath[0]),
+ NS_T("%s\\%s"), gPatchDirPath, aFilename);
+ }
+#else
+ NS_tsnprintf(statusFilePath,
+ sizeof(statusFilePath) / sizeof(statusFilePath[0]),
+ NS_T("%s/%s"), gPatchDirPath, aFilename);
+ // Make sure that the directory for the update status file exists
+ if (ensure_parent_dir(statusFilePath)) {
+ LOG(("WriteToFile failed to ensure parent directory's existence"));
+ return false;
+ }
+#endif
+
+ AutoFile statusFile(NS_tfopen(statusFilePath, NS_T("wb+")));
+ if (statusFile == nullptr) {
+ LOG(("WriteToFile failed to open status file: %d", errno));
+ return false;
+ }
+
+ if (fwrite(aStatus, strlen(aStatus), 1, statusFile) != 1) {
+ LOG(("WriteToFile failed to write to status file: %d", errno));
+ return false;
+ }
+
+#if defined(XP_WIN)
+ if (gUseSecureOutputPath) {
+ // This is done after the update status file has been written so if the
+ // write to the update status file fails an existing update status file
+ // won't be used.
+ if (!WriteSecureIDFile(gPatchDirPath)) {
+ LOG(("WriteToFile failed to write secure ID file"));
+ return false;
+ }
+ }
+#endif
+
+ return true;
+}
+
+/**
+ * Writes a string to the update.status file.
+ *
+ * NOTE: All calls to WriteStatusFile MUST happen before calling output_finish
+ * because the output_finish function copies the update status file for
+ * the elevated updater and writing the status file after calling
+ * output_finish will overwrite it.
+ *
+ * @param aStatus
+ * The string to write to the update.status file.
+ * @return true on success.
+ */
+static bool WriteStatusFile(const char* aStatus) {
+ return WriteToFile(NS_T("update.status"), aStatus);
+}
+
+/**
+ * Writes a string to the update.status file based on the status param.
+ *
+ * NOTE: All calls to WriteStatusFile MUST happen before calling output_finish
+ * because the output_finish function copies the update status file for
+ * the elevated updater and writing the status file after calling
+ * output_finish will overwrite it.
+ *
+ * @param status
+ * A status code used to determine what string to write to the
+ * update.status file (see code).
+ */
+static void WriteStatusFile(int status) {
+ const char* text;
+
+ char buf[32];
+ if (status == OK) {
+ if (sStagedUpdate) {
+ text = "applied\n";
+ } else {
+ text = "succeeded\n";
+ }
+ } else {
+ snprintf(buf, sizeof(buf) / sizeof(buf[0]), "failed: %d\n", status);
+ text = buf;
+ }
+
+ WriteStatusFile(text);
+}
+
+#if defined(XP_WIN)
+/*
+ * Parses the passed contents of an update status file and checks if the
+ * contained status matches the expected status.
+ *
+ * @param statusString The status file contents.
+ * @param expectedStatus The status to compare the update status file's
+ * contents against.
+ * @param errorCode Optional out parameter. If a pointer is passed and the
+ * update status file contains an error code, the code
+ * will be returned via the out parameter. If a pointer is
+ * passed and the update status file does not contain an error
+ * code, or any error code after the status could not be
+ * parsed, mozilla::Nothing will be returned via this
+ * parameter.
+ * @return true if the status is set to the value indicated by expectedStatus.
+ */
+static bool UpdateStatusIs(const char* statusString, const char* expectedStatus,
+ mozilla::Maybe<int>* errorCode = nullptr) {
+ if (errorCode) {
+ *errorCode = mozilla::Nothing();
+ }
+
+ // Parse the update status file. Expected format is:
+ // Update status string
+ // Optionally followed by:
+ // Colon character (':')
+ // Space character (' ')
+ // Integer error code
+ // Newline character
+ const char* statusEnd = strchr(statusString, ':');
+ if (statusEnd == nullptr) {
+ statusEnd = strchr(statusString, '\n');
+ }
+ if (statusEnd == nullptr) {
+ statusEnd = strchr(statusString, '\0');
+ }
+ size_t statusLen = statusEnd - statusString;
+ size_t expectedStatusLen = strlen(expectedStatus);
+
+ bool statusMatch =
+ statusLen == expectedStatusLen &&
+ strncmp(statusString, expectedStatus, expectedStatusLen) == 0;
+
+ // We only need to continue parsing if (a) there is a place to store the error
+ // code if we parse it, and (b) there is a status code to parse. If the status
+ // string didn't end with a ':', there won't be an error code after it.
+ if (!errorCode || *statusEnd != ':') {
+ return statusMatch;
+ }
+
+ const char* errorCodeStart = statusEnd + 1;
+ char* errorCodeEnd = nullptr;
+ // strtol skips an arbitrary number of leading whitespace characters. This
+ // technically allows us to successfully consume slightly misformatted status
+ // files, since the expected format is for there to be a single space only.
+ long longErrorCode = strtol(errorCodeStart, &errorCodeEnd, 10);
+ if (errorCodeEnd != errorCodeStart && longErrorCode < INT_MAX &&
+ longErrorCode > INT_MIN) {
+ // We don't allow equality with INT_MAX/INT_MIN for two reasons. It could
+ // be that, on this platform, INT_MAX/INT_MIN equal LONG_MAX/LONG_MIN, which
+ // is what strtol gives us if the parsed value was out of bounds. And those
+ // values are already way, way outside the set of valid update error codes
+ // anyways.
+ errorCode->emplace(static_cast<int>(longErrorCode));
+ }
+ return statusMatch;
+}
+
+/*
+ * Reads the secure update status file and sets statusMatch to true if the
+ * status matches the expected status that was passed.
+ *
+ * @param expectedStatus The status to compare the update status file's
+ * contents against.
+ * @param statusMatch Out parameter for specifying if the status is set to
+ * the value indicated by expectedStatus
+ * @param errorCode Optional out parameter. If a pointer is passed and the
+ * update status file contains an error code, the code
+ * will be returned via the out parameter. If a pointer is
+ * passed and the update status file does not contain an error
+ * code, or any error code after the status could not be
+ * parsed, mozilla::Nothing will be returned via this
+ * parameter.
+ * @return true if the information was retrieved successfully.
+ */
+static bool CompareSecureUpdateStatus(
+ const char* expectedStatus, bool& statusMatch,
+ mozilla::Maybe<int>* errorCode = nullptr) {
+ NS_tchar statusFilePath[MAX_PATH + 1] = {L'\0'};
+ if (!GetSecureOutputFilePath(gPatchDirPath, L".status", statusFilePath)) {
+ LOG(
+ ("CompareSecureUpdateStatus failed due to GetSecureOutputFilePath "
+ "failure"));
+ return false;
+ }
+
+ AutoFile file(NS_tfopen(statusFilePath, NS_T("rb")));
+ if (file == nullptr) {
+ LOG(("CompareSecureUpdateStatus failed to open the secure status file: %d",
+ errno));
+ return false;
+ }
+
+ const size_t bufferLength = 32;
+ char buf[bufferLength] = {0};
+ size_t charsRead = fread(buf, sizeof(buf[0]), bufferLength - 1, file);
+ if (ferror(file)) {
+ LOG(("CompareSecureUpdateStatus failed to read status file"));
+ return false;
+ }
+ buf[charsRead] = '\0';
+
+ statusMatch = UpdateStatusIs(buf, expectedStatus, errorCode);
+ LOG(("CompareSecureUpdateStatus %s %s %s", buf,
+ statusMatch ? "matches" : "does not match", expectedStatus));
+ return true;
+}
+
+/*
+ * Reads the secure update status file and sets isSucceeded to true if the
+ * status is set to succeeded.
+ *
+ * @param isSucceeded Out parameter for specifying if the status
+ * is set to succeeded or not.
+ * @return true if the information was retrieved successfully.
+ */
+static bool IsSecureUpdateStatusSucceeded(bool& isSucceeded) {
+ return CompareSecureUpdateStatus("succeeded", isSucceeded);
+}
+#endif
+
+#ifdef MOZ_MAINTENANCE_SERVICE
+/*
+ * Read the update.status file and sets isPendingService to true if
+ * the status is set to pending-service.
+ *
+ * @param isPendingService Out parameter for specifying if the status
+ * is set to pending-service or not.
+ * @return true if the information was retrieved and it is pending
+ * or pending-service.
+ */
+static bool IsUpdateStatusPendingService() {
+ NS_tchar filename[MAXPATHLEN];
+ NS_tsnprintf(filename, sizeof(filename) / sizeof(filename[0]),
+ NS_T("%s/update.status"), gPatchDirPath);
+
+ AutoFile file(NS_tfopen(filename, NS_T("rb")));
+ if (file == nullptr) {
+ return false;
+ }
+
+ const size_t bufferLength = 32;
+ char buf[bufferLength] = {0};
+ size_t charsRead = fread(buf, sizeof(buf[0]), bufferLength - 1, file);
+ if (ferror(file)) {
+ return false;
+ }
+ buf[charsRead] = '\0';
+
+ return UpdateStatusIs(buf, "pending-service") ||
+ UpdateStatusIs(buf, "applied-service");
+}
+
+/*
+ * Reads the secure update status file and sets isFailed to true if the
+ * status is set to failed.
+ *
+ * @param isFailed Out parameter for specifying if the status
+ * is set to failed or not.
+ * @param errorCode Optional out parameter. If a pointer is passed and the
+ * update status file contains an error code, the code
+ * will be returned via the out parameter. If a pointer is
+ * passed and the update status file does not contain an error
+ * code, or any error code after the status could not be
+ * parsed, mozilla::Nothing will be returned via this
+ * parameter.
+ * @return true if the information was retrieved successfully.
+ */
+static bool IsSecureUpdateStatusFailed(
+ bool& isFailed, mozilla::Maybe<int>* errorCode = nullptr) {
+ return CompareSecureUpdateStatus("failed", isFailed, errorCode);
+}
+
+/**
+ * This function determines whether the error represented by the passed error
+ * code could potentially be recovered from or bypassed by updating without
+ * using the Maintenance Service (i.e. by showing a UAC prompt).
+ * We don't really want to show a UAC prompt, but it's preferable over the
+ * manual update doorhanger
+ *
+ * @param errorCode An integer error code from the update.status file. Should
+ * be one of the codes enumerated in updatererrors.h.
+ * @returns true if the code represents a Maintenance Service specific error.
+ * Otherwise, false.
+ */
+static bool IsServiceSpecificErrorCode(int errorCode) {
+ return ((errorCode >= 24 && errorCode <= 33) ||
+ (errorCode >= 49 && errorCode <= 58));
+}
+#endif
+
+/*
+ * Copy the entire contents of the application installation directory to the
+ * destination directory for the update process.
+ *
+ * @return 0 if successful, an error code otherwise.
+ */
+static int CopyInstallDirToDestDir() {
+ // These files should not be copied over to the updated app
+#ifdef XP_WIN
+# define SKIPLIST_COUNT 3
+#elif XP_MACOSX
+# define SKIPLIST_COUNT 0
+#else
+# define SKIPLIST_COUNT 2
+#endif
+ copy_recursive_skiplist<SKIPLIST_COUNT> skiplist;
+#ifndef XP_MACOSX
+ skiplist.append(0, gInstallDirPath, NS_T("updated"));
+ skiplist.append(1, gInstallDirPath, NS_T("updates/0"));
+# ifdef XP_WIN
+ skiplist.append(2, gInstallDirPath, NS_T("updated.update_in_progress.lock"));
+# endif
+#endif
+
+ return ensure_copy_recursive(gInstallDirPath, gWorkingDirPath, skiplist);
+}
+
+/*
+ * Replace the application installation directory with the destination
+ * directory in order to finish a staged update task
+ *
+ * @return 0 if successful, an error code otherwise.
+ */
+static int ProcessReplaceRequest() {
+ // The replacement algorithm is like this:
+ // 1. Move destDir to tmpDir. In case of failure, abort.
+ // 2. Move newDir to destDir. In case of failure, revert step 1 and abort.
+ // 3. Delete tmpDir (or defer it to the next reboot).
+
+#ifdef XP_MACOSX
+ NS_tchar destDir[MAXPATHLEN];
+ NS_tsnprintf(destDir, sizeof(destDir) / sizeof(destDir[0]),
+ NS_T("%s/Contents"), gInstallDirPath);
+#elif XP_WIN
+ // Windows preserves the case of the file/directory names. We use the
+ // GetLongPathName API in order to get the correct case for the directory
+ // name, so that if the user has used a different case when launching the
+ // application, the installation directory's name does not change.
+ NS_tchar destDir[MAXPATHLEN];
+ if (!GetLongPathNameW(gInstallDirPath, destDir,
+ sizeof(destDir) / sizeof(destDir[0]))) {
+ return NO_INSTALLDIR_ERROR;
+ }
+#else
+ NS_tchar* destDir = gInstallDirPath;
+#endif
+
+ NS_tchar tmpDir[MAXPATHLEN];
+ NS_tsnprintf(tmpDir, sizeof(tmpDir) / sizeof(tmpDir[0]), NS_T("%s.bak"),
+ destDir);
+
+ NS_tchar newDir[MAXPATHLEN];
+ NS_tsnprintf(newDir, sizeof(newDir) / sizeof(newDir[0]),
+#ifdef XP_MACOSX
+ NS_T("%s/Contents"), gWorkingDirPath);
+#else
+ NS_T("%s.bak/updated"), gInstallDirPath);
+#endif
+
+ // First try to remove the possibly existing temp directory, because if this
+ // directory exists, we will fail to rename destDir.
+ // No need to error check here because if this fails, we will fail in the
+ // next step anyways.
+ ensure_remove_recursive(tmpDir);
+
+ LOG(("Begin moving destDir (" LOG_S ") to tmpDir (" LOG_S ")", destDir,
+ tmpDir));
+ int rv = rename_file(destDir, tmpDir, true);
+#ifdef XP_WIN
+ // On Windows, if Firefox is launched using the shortcut, it will hold a
+ // handle to its installation directory open, which might not get released in
+ // time. Therefore we wait a little bit here to see if the handle is released.
+ // If it's not released, we just fail to perform the replace request.
+ const int max_retries = 10;
+ int retries = 0;
+ while (rv == WRITE_ERROR && (retries++ < max_retries)) {
+ LOG(
+ ("PerformReplaceRequest: destDir rename attempt %d failed. "
+ "File: " LOG_S ". Last error: %lu, err: %d",
+ retries, destDir, GetLastError(), rv));
+
+ Sleep(100);
+
+ rv = rename_file(destDir, tmpDir, true);
+ }
+#endif
+ if (rv) {
+ // The status file will have 'pending' written to it so there is no value in
+ // returning an error specific for this failure.
+ LOG(("Moving destDir to tmpDir failed, err: %d", rv));
+ return rv;
+ }
+
+ LOG(("Begin moving newDir (" LOG_S ") to destDir (" LOG_S ")", newDir,
+ destDir));
+ rv = rename_file(newDir, destDir, true);
+#ifdef XP_MACOSX
+ if (rv) {
+ LOG(("Moving failed. Begin copying newDir (" LOG_S ") to destDir (" LOG_S
+ ")",
+ newDir, destDir));
+ copy_recursive_skiplist<0> skiplist;
+ rv = ensure_copy_recursive(newDir, destDir, skiplist);
+ }
+#endif
+ if (rv) {
+ LOG(("Moving newDir to destDir failed, err: %d", rv));
+ LOG(("Now, try to move tmpDir back to destDir"));
+ ensure_remove_recursive(destDir);
+ int rv2 = rename_file(tmpDir, destDir, true);
+ if (rv2) {
+ LOG(("Moving tmpDir back to destDir failed, err: %d", rv2));
+ }
+ // The status file will be have 'pending' written to it so there is no value
+ // in returning an error specific for this failure.
+ return rv;
+ }
+
+#if !defined(XP_WIN) && !defined(XP_MACOSX)
+ // Platforms that have their updates directory in the installation directory
+ // need to have the last-update.log and backup-update.log files moved from the
+ // old installation directory to the new installation directory.
+ NS_tchar tmpLog[MAXPATHLEN];
+ NS_tsnprintf(tmpLog, sizeof(tmpLog) / sizeof(tmpLog[0]),
+ NS_T("%s/updates/last-update.log"), tmpDir);
+ if (!NS_taccess(tmpLog, F_OK)) {
+ NS_tchar destLog[MAXPATHLEN];
+ NS_tsnprintf(destLog, sizeof(destLog) / sizeof(destLog[0]),
+ NS_T("%s/updates/last-update.log"), destDir);
+ if (NS_tremove(destLog) && errno != ENOENT) {
+ LOG(("non-fatal error removing log file: " LOG_S ", err: %d", destLog,
+ errno));
+ }
+ NS_trename(tmpLog, destLog);
+ }
+#endif
+
+ LOG(("Now, remove the tmpDir"));
+ rv = ensure_remove_recursive(tmpDir, true);
+ if (rv) {
+ LOG(("Removing tmpDir failed, err: %d", rv));
+#ifdef XP_WIN
+ NS_tchar deleteDir[MAXPATHLEN];
+ NS_tsnprintf(deleteDir, sizeof(deleteDir) / sizeof(deleteDir[0]),
+ NS_T("%s\\%s"), destDir, DELETE_DIR);
+ // Attempt to remove the tobedeleted directory and then recreate it if it
+ // was successfully removed.
+ _wrmdir(deleteDir);
+ if (NS_taccess(deleteDir, F_OK)) {
+ NS_tmkdir(deleteDir, 0755);
+ }
+ remove_recursive_on_reboot(tmpDir, deleteDir);
+#endif
+ }
+
+#ifdef XP_MACOSX
+ // On OS X, we we need to remove the staging directory after its Contents
+ // directory has been moved.
+ NS_tchar updatedAppDir[MAXPATHLEN];
+ NS_tsnprintf(updatedAppDir, sizeof(updatedAppDir) / sizeof(updatedAppDir[0]),
+ NS_T("%s/Updated.app"), gPatchDirPath);
+ ensure_remove_recursive(updatedAppDir);
+#endif
+
+ gSucceeded = true;
+
+ return 0;
+}
+
+#if defined(XP_WIN) && defined(MOZ_MAINTENANCE_SERVICE)
+static void WaitForServiceFinishThread(void* param) {
+ // We wait at most 10 minutes, we already waited 5 seconds previously
+ // before deciding to show this UI.
+ WaitForServiceStop(SVC_NAME, 595);
+ QuitProgressUI();
+}
+#endif
+
+#ifdef MOZ_VERIFY_MAR_SIGNATURE
+/**
+ * This function reads in the ACCEPTED_MAR_CHANNEL_IDS from update-settings.ini
+ *
+ * @param path The path to the ini file that is to be read
+ * @param results A pointer to the location to store the read strings
+ * @return OK on success
+ */
+static int ReadMARChannelIDs(const NS_tchar* path,
+ MARChannelStringTable* results) {
+ const unsigned int kNumStrings = 1;
+ const char* kUpdaterKeys = "ACCEPTED_MAR_CHANNEL_IDS\0";
+ int result = ReadStrings(path, kUpdaterKeys, kNumStrings,
+ &results->MARChannelID, "Settings");
+
+ return result;
+}
+#endif
+
+static int GetUpdateFileName(NS_tchar* fileName, int maxChars) {
+ NS_tsnprintf(fileName, maxChars, NS_T("%s/update.mar"), gPatchDirPath);
+ return OK;
+}
+
+static void UpdateThreadFunc(void* param) {
+ // open ZIP archive and process...
+ int rv;
+ if (sReplaceRequest) {
+ rv = ProcessReplaceRequest();
+ } else {
+ NS_tchar dataFile[MAXPATHLEN];
+ rv = GetUpdateFileName(dataFile, sizeof(dataFile) / sizeof(dataFile[0]));
+ if (rv == OK) {
+ rv = gArchiveReader.Open(dataFile);
+ }
+
+#ifdef MOZ_VERIFY_MAR_SIGNATURE
+ if (rv == OK) {
+ rv = gArchiveReader.VerifySignature();
+ }
+
+ if (rv == OK) {
+ NS_tchar updateSettingsPath[MAXPATHLEN];
+ NS_tsnprintf(updateSettingsPath,
+ sizeof(updateSettingsPath) / sizeof(updateSettingsPath[0]),
+# ifdef XP_MACOSX
+ NS_T("%s/Contents/Resources/update-settings.ini"),
+# else
+ NS_T("%s/update-settings.ini"),
+# endif
+ gInstallDirPath);
+ MARChannelStringTable MARStrings;
+ if (ReadMARChannelIDs(updateSettingsPath, &MARStrings) != OK) {
+ rv = UPDATE_SETTINGS_FILE_CHANNEL;
+ } else {
+ rv = gArchiveReader.VerifyProductInformation(
+ MARStrings.MARChannelID.get(), MOZ_APP_VERSION);
+ }
+ }
+#endif
+
+ if (rv == OK && sStagedUpdate) {
+#ifdef TEST_UPDATER
+ // The MOZ_TEST_SKIP_UPDATE_STAGE environment variable prevents copying
+ // the files in dist/bin in the test updater when staging an update since
+ // this can cause tests to timeout.
+ if (EnvHasValue("MOZ_TEST_SKIP_UPDATE_STAGE")) {
+ rv = OK;
+ } else if (EnvHasValue("MOZ_TEST_SLOW_SKIP_UPDATE_STAGE")) {
+ // The following is to simulate staging so the UI tests have time to
+ // show that the update is being staged.
+ NS_tchar continueFilePath[MAXPATHLEN] = {NS_T('\0')};
+ NS_tsnprintf(continueFilePath,
+ sizeof(continueFilePath) / sizeof(continueFilePath[0]),
+ NS_T("%s/continueStaging"), gInstallDirPath);
+ // Use 300 retries for staging requests to lessen the likelihood of
+ // tests intermittently failing on verify tasks due to launching the
+ // updater. The total time to wait with the default interval of 100 ms
+ // is approximately 30 seconds. The total time for tests is longer to
+ // account for the extra time it takes for the updater to launch.
+ const int max_retries = 300;
+ int retries = 0;
+ while (retries++ < max_retries) {
+# ifdef XP_WIN
+ Sleep(100);
+# else
+ usleep(100000);
+# endif
+ // Continue after the continue file exists and is removed.
+ if (!NS_tremove(continueFilePath)) {
+ break;
+ }
+ }
+ rv = OK;
+ } else {
+ rv = CopyInstallDirToDestDir();
+ }
+#else
+ rv = CopyInstallDirToDestDir();
+#endif
+ }
+
+ if (rv == OK) {
+ rv = DoUpdate();
+ gArchiveReader.Close();
+ NS_tchar updatingDir[MAXPATHLEN];
+ NS_tsnprintf(updatingDir, sizeof(updatingDir) / sizeof(updatingDir[0]),
+ NS_T("%s/updating"), gWorkingDirPath);
+ ensure_remove_recursive(updatingDir);
+ }
+ }
+
+ if (rv && (sReplaceRequest || sStagedUpdate)) {
+ ensure_remove_recursive(gWorkingDirPath);
+ // When attempting to replace the application, we should fall back
+ // to non-staged updates in case of a failure. We do this by
+ // setting the status to pending, exiting the updater, and
+ // launching the callback application. The callback application's
+ // startup path will see the pending status, and will start the
+ // updater application again in order to apply the update without
+ // staging.
+ if (sReplaceRequest) {
+ WriteStatusFile(sUsingService ? "pending-service" : "pending");
+ } else {
+ WriteStatusFile(rv);
+ }
+ LOG(("failed: %d", rv));
+#ifdef TEST_UPDATER
+ // Some tests need to use --test-process-updates again.
+ putenv(const_cast<char*>("MOZ_TEST_PROCESS_UPDATES="));
+#endif
+ } else {
+#ifdef TEST_UPDATER
+ const char* forceErrorCodeString = getenv("MOZ_FORCE_ERROR_CODE");
+ if (forceErrorCodeString && *forceErrorCodeString) {
+ rv = atoi(forceErrorCodeString);
+ }
+#endif
+ if (rv) {
+ LOG(("failed: %d", rv));
+ } else {
+#ifdef XP_MACOSX
+ // If the update was successful we need to update the timestamp on the
+ // top-level Mac OS X bundle directory so that Mac OS X's Launch Services
+ // picks up any major changes when the bundle is updated.
+ if (!sStagedUpdate && utimes(gInstallDirPath, nullptr) != 0) {
+ LOG(("Couldn't set access/modification time on application bundle."));
+ }
+#endif
+ LOG(("succeeded"));
+ }
+ WriteStatusFile(rv);
+ }
+
+ LOG(("calling QuitProgressUI"));
+ QuitProgressUI();
+}
+
+#ifdef XP_MACOSX
+static void ServeElevatedUpdateThreadFunc(void* param) {
+ UpdateServerThreadArgs* threadArgs = (UpdateServerThreadArgs*)param;
+ gSucceeded = ServeElevatedUpdate(threadArgs->argc, threadArgs->argv);
+ if (!gSucceeded) {
+ WriteStatusFile(ELEVATION_CANCELED);
+ }
+ QuitProgressUI();
+}
+
+void freeArguments(int argc, char** argv) {
+ for (int i = 0; i < argc; i++) {
+ free(argv[i]);
+ }
+ free(argv);
+}
+#endif
+
+int LaunchCallbackAndPostProcessApps(int argc, NS_tchar** argv,
+ int callbackIndex
+#ifdef XP_WIN
+ ,
+ const WCHAR* elevatedLockFilePath,
+ HANDLE updateLockFileHandle
+#elif XP_MACOSX
+ ,
+ mozilla::UniquePtr<UmaskContext>
+ umaskContext
+#endif
+) {
+ // We want to make sure to call `output_finish` before we leave this function
+ // and, if we end up launching the callback app, we want to call it before
+ // we do that (so that the callback app can operate on the output).
+ // But we want to do this as late as possible to make the log as detailed as
+ // possible.
+ class RaiiOutputFinish {
+ public:
+ RaiiOutputFinish() : mCalled(false) {}
+ ~RaiiOutputFinish() { call(); }
+ void call() {
+ if (!mCalled) {
+ mCalled = true;
+ output_finish();
+ }
+ }
+
+ private:
+ bool mCalled;
+ } raii_output_finish;
+
+#ifdef XP_MACOSX
+ umaskContext.reset();
+#endif
+
+ if (argc > callbackIndex) {
+#if defined(XP_WIN)
+ if (gSucceeded) {
+ LOG(("Launching Windows post update process"));
+ if (!LaunchWinPostProcess(gInstallDirPath, gPatchDirPath)) {
+ LOG(("The post update process was not launched successfully"));
+ }
+
+# ifdef MOZ_MAINTENANCE_SERVICE
+ // The service update will only be executed if it is already installed.
+ // For first time installs of the service, the install will happen from
+ // the PostUpdate process. We do the service update process here
+ // because it's possible we are updating with updater.exe without the
+ // service if the service failed to apply the update. We want to update
+ // the service to a newer version in that case. If we are not running
+ // through the service, then MOZ_USING_SERVICE will not exist.
+ if (!sUsingService) {
+ LOG(("Starting Service Update before launching callback app"));
+ StartServiceUpdate(gInstallDirPath);
+ }
+# endif
+ } else {
+ LOG(("Not launching Windows post update process because !gSucceeded"));
+ }
+
+ EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 0);
+#elif XP_MACOSX
+ if (!gIsElevated) {
+ if (gSucceeded) {
+ LOG(("Launching macOS post update process"));
+ LaunchMacPostProcess(gInstallDirPath);
+ } else {
+ LOG(("Not launching macOS post update process because !gSucceeded"));
+ }
+#endif
+
+ raii_output_finish.call();
+ LaunchCallbackApp(argv[5], argc - callbackIndex, argv + callbackIndex,
+ sUsingService);
+#ifdef XP_MACOSX
+ } else { // isElevated
+ LOG(
+ ("Not elevated. Skipping LaunchMacPostProcess and "
+ "LaunchCallbackApp"));
+ }
+#endif /* XP_MACOSX */
+}
+else {
+ LOG(
+ ("No callback arg. Skipping LaunchWinPostProcess and "
+ "LaunchCallbackApp"));
+}
+return 0;
+}
+
+bool ShouldRunSilently(int argc, NS_tchar** argv) {
+#ifdef MOZ_BACKGROUNDTASKS
+ // If the callback has a --backgroundtask switch, consider it a background
+ // task. The CheckArg semantics aren't reproduced in full here,
+ // there's e.g. no check for a parameter and no case-insensitive comparison.
+ for (int i = 1; i < argc; ++i) {
+ if (const auto option = mozilla::internal::ReadAsOption(argv[i])) {
+ const NS_tchar* arg = option.value();
+ if (NS_tstrcmp(arg, NS_T("backgroundtask")) == 0) {
+ return true;
+ }
+ }
+ }
+#endif // MOZ_BACKGROUNDTASKS
+
+#if defined(XP_WIN) || defined(XP_MACOSX)
+ if (EnvHasValue("MOZ_APP_SILENT_START")) {
+ return true;
+ }
+#endif
+
+ return false;
+}
+
+int NS_main(int argc, NS_tchar** argv) {
+#ifdef MOZ_MAINTENANCE_SERVICE
+ sUsingService = EnvHasValue("MOZ_USING_SERVICE");
+ putenv(const_cast<char*>("MOZ_USING_SERVICE="));
+#endif
+
+ // The callback is the remaining arguments starting at callbackIndex.
+ // The argument specified by callbackIndex is the callback executable and the
+ // argument prior to callbackIndex is the working directory.
+ const int callbackIndex = 6;
+
+ // `isDMGInstall` is only ever true for macOS, but we are declaring it here
+ // to avoid a ton of extra #ifdef's.
+ bool isDMGInstall = false;
+
+#ifdef XP_MACOSX
+ // We want to control file permissions explicitly, or else we could end up
+ // corrupting installs for other users on the system. Accordingly, set the
+ // umask to 0 for all file creations below and reset it on exit. See Bug
+ // 1337007
+ mozilla::UniquePtr<UmaskContext> umaskContext(new UmaskContext(0));
+
+ // This will be used to set `gIsElevated`, but we are going to do it later
+ // when we are ready to set it for every OS to avoid inconsistency.
+ bool isElevated =
+ strstr(argv[0], "/Library/PrivilegedHelperTools/org.mozilla.updater") !=
+ 0;
+ if (isElevated) {
+ if (!ObtainUpdaterArguments(&argc, &argv)) {
+ // Won't actually get here because ObtainUpdaterArguments will terminate
+ // the current process on failure.
+ return 1;
+ }
+ }
+
+ if (argc == 4 && (strstr(argv[1], "-dmgInstall") != 0)) {
+ isDMGInstall = true;
+ if (isElevated) {
+ PerformInstallationFromDMG(argc, argv);
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ return 0;
+ }
+ }
+#endif
+
+ if (!isDMGInstall) {
+ // Skip update-related code path for DMG installs.
+
+#if defined(MOZ_VERIFY_MAR_SIGNATURE) && defined(MAR_NSS)
+ // If using NSS for signature verification, initialize NSS but minimize
+ // the portion we depend on by avoiding all of the NSS databases.
+ if (NSS_NoDB_Init(nullptr) != SECSuccess) {
+ PRErrorCode error = PR_GetError();
+ fprintf(stderr, "Could not initialize NSS: %s (%d)",
+ PR_ErrorToName(error), (int)error);
+ _exit(1);
+ }
+#endif
+
+ // To process an update the updater command line must at a minimum have the
+ // directory path containing the updater.mar file to process as the first
+ // argument, the install directory as the second argument, and the directory
+ // to apply the update to as the third argument. When the updater is
+ // launched by another process the PID of the parent process should be
+ // provided in the optional fourth argument and the updater will wait on the
+ // parent process to exit if the value is non-zero and the process is
+ // present. This is necessary due to not being able to update files that are
+ // in use on Windows. The optional fifth argument is the callback's working
+ // directory and the optional sixth argument is the callback path. The
+ // callback is the application to launch after updating and it will be
+ // launched when these arguments are provided whether the update was
+ // successful or not. All remaining arguments are optional and are passed to
+ // the callback when it is launched.
+ if (argc < 4) {
+ fprintf(stderr,
+ "Usage: updater patch-dir install-dir apply-to-dir [wait-pid "
+ "[callback-working-dir callback-path args...]]\n");
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ return 1;
+ }
+
+#if defined(TEST_UPDATER) && defined(XP_WIN)
+ // The tests use nsIProcess to launch the updater and it is simpler for the
+ // tests to just set an environment variable and have the test updater set
+ // the current working directory than it is to set the current working
+ // directory in the test itself.
+ if (EnvHasValue("CURWORKDIRPATH")) {
+ const WCHAR* val = _wgetenv(L"CURWORKDIRPATH");
+ NS_tchdir(val);
+ }
+#endif
+
+ } // if (!isDMGInstall)
+
+ // The directory containing the update information.
+ NS_tstrncpy(gPatchDirPath, argv[1], MAXPATHLEN);
+ gPatchDirPath[MAXPATHLEN - 1] = NS_T('\0');
+
+#ifdef XP_WIN
+ NS_tchar elevatedLockFilePath[MAXPATHLEN] = {NS_T('\0')};
+ NS_tsnprintf(elevatedLockFilePath,
+ sizeof(elevatedLockFilePath) / sizeof(elevatedLockFilePath[0]),
+ NS_T("%s\\update_elevated.lock"), gPatchDirPath);
+ gUseSecureOutputPath =
+ sUsingService || (NS_tremove(elevatedLockFilePath) && errno != ENOENT);
+
+ // Even if a file has no sharing access, you can still get its attributes
+ // If we are running elevated, this file will exist, having been opened by
+ // the unelevated updater that started this one.
+ gIsElevated =
+ GetFileAttributesW(elevatedLockFilePath) != INVALID_FILE_ATTRIBUTES;
+#elif defined(XP_MACOSX)
+ // This is only ever true on macOS and Windows. We don't currently have a
+ // way of elevating on other platforms.
+ gIsElevated = isElevated;
+#endif
+
+ if (!isDMGInstall) {
+ // This check is also performed in workmonitor.cpp since the maintenance
+ // service can be called directly.
+ if (!IsValidFullPath(argv[1])) {
+ // Since the status file is written to the patch directory and the patch
+ // directory is invalid don't write the status file.
+ fprintf(stderr,
+ "The patch directory path is not valid for this "
+ "application (" LOG_S ")\n",
+ argv[1]);
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ return 1;
+ }
+
+ // This check is also performed in workmonitor.cpp since the maintenance
+ // service can be called directly.
+ if (!IsValidFullPath(argv[2])) {
+ WriteStatusFile(INVALID_INSTALL_DIR_PATH_ERROR);
+ fprintf(stderr,
+ "The install directory path is not valid for this "
+ "application (" LOG_S ")\n",
+ argv[2]);
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ return 1;
+ }
+
+ } // if (!isDMGInstall)
+
+ // The directory we're going to update to.
+ // We copy this string because we need to remove trailing slashes. The C++
+ // standard says that it's always safe to write to strings pointed to by argv
+ // elements, but I don't necessarily believe it.
+ NS_tstrncpy(gInstallDirPath, argv[2], MAXPATHLEN);
+ gInstallDirPath[MAXPATHLEN - 1] = NS_T('\0');
+ NS_tchar* slash = NS_tstrrchr(gInstallDirPath, NS_SLASH);
+ if (slash && !slash[1]) {
+ *slash = NS_T('\0');
+ }
+
+#ifdef XP_WIN
+ bool useService = false;
+ bool testOnlyFallbackKeyExists = false;
+ // Prevent the updater from falling back from updating with the Maintenance
+ // Service to updating without the Service. Used for Service tests.
+ // This is set below via the MOZ_NO_SERVICE_FALLBACK environment variable.
+ bool noServiceFallback = false;
+ // Force the updater to use the Maintenance Service incorrectly, causing it
+ // to fail. Used to test the mechanism that allows the updater to fall back
+ // from using the Maintenance Service to updating without it.
+ // This is set below via the MOZ_FORCE_SERVICE_FALLBACK environment variable.
+ bool forceServiceFallback = false;
+#endif
+
+ if (!isDMGInstall) {
+#ifdef XP_WIN
+ // We never want the service to be used unless we build with
+ // the maintenance service.
+# ifdef MOZ_MAINTENANCE_SERVICE
+ useService = IsUpdateStatusPendingService();
+# ifdef TEST_UPDATER
+ noServiceFallback = EnvHasValue("MOZ_NO_SERVICE_FALLBACK");
+ putenv(const_cast<char*>("MOZ_NO_SERVICE_FALLBACK="));
+ forceServiceFallback = EnvHasValue("MOZ_FORCE_SERVICE_FALLBACK");
+ putenv(const_cast<char*>("MOZ_FORCE_SERVICE_FALLBACK="));
+ // Our tests run with a different apply directory for each test.
+ // We use this registry key on our test machines to store the
+ // allowed name/issuers.
+ testOnlyFallbackKeyExists = DoesFallbackKeyExist();
+# endif
+# endif
+
+ // Remove everything except close window from the context menu
+ {
+ HKEY hkApp = nullptr;
+ RegCreateKeyExW(HKEY_CURRENT_USER, L"Software\\Classes\\Applications", 0,
+ nullptr, REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr,
+ &hkApp, nullptr);
+ RegCloseKey(hkApp);
+ if (RegCreateKeyExW(HKEY_CURRENT_USER,
+ L"Software\\Classes\\Applications\\updater.exe", 0,
+ nullptr, REG_OPTION_VOLATILE, KEY_SET_VALUE, nullptr,
+ &hkApp, nullptr) == ERROR_SUCCESS) {
+ RegSetValueExW(hkApp, L"IsHostApp", 0, REG_NONE, 0, 0);
+ RegSetValueExW(hkApp, L"NoOpenWith", 0, REG_NONE, 0, 0);
+ RegSetValueExW(hkApp, L"NoStartPage", 0, REG_NONE, 0, 0);
+ RegCloseKey(hkApp);
+ }
+ }
+#endif
+
+ } // if (!isDMGInstall)
+
+ // If there is a PID specified and it is not '0' then wait for the process to
+ // exit.
+ NS_tpid pid = 0;
+ if (argc > 4) {
+ pid = NS_tatoi(argv[4]);
+ if (pid == -1) {
+ // This is a signal from the parent process that the updater should stage
+ // the update.
+ sStagedUpdate = true;
+ } else if (NS_tstrstr(argv[4], NS_T("/replace"))) {
+ // We're processing a request to replace the application with a staged
+ // update.
+ sReplaceRequest = true;
+ }
+ }
+
+ if (!isDMGInstall) {
+ // This check is also performed in workmonitor.cpp since the maintenance
+ // service can be called directly.
+ if (!IsValidFullPath(argv[3])) {
+ WriteStatusFile(INVALID_WORKING_DIR_PATH_ERROR);
+ fprintf(stderr,
+ "The working directory path is not valid for this "
+ "application (" LOG_S ")\n",
+ argv[3]);
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ return 1;
+ }
+ // The directory we're going to update to.
+ // We copy this string because we need to remove trailing slashes. The C++
+ // standard says that it's always safe to write to strings pointed to by
+ // argv elements, but I don't necessarily believe it.
+ NS_tstrncpy(gWorkingDirPath, argv[3], MAXPATHLEN);
+ gWorkingDirPath[MAXPATHLEN - 1] = NS_T('\0');
+ slash = NS_tstrrchr(gWorkingDirPath, NS_SLASH);
+ if (slash && !slash[1]) {
+ *slash = NS_T('\0');
+ }
+
+ if (argc > callbackIndex) {
+ if (!IsValidFullPath(argv[callbackIndex])) {
+ WriteStatusFile(INVALID_CALLBACK_PATH_ERROR);
+ fprintf(stderr,
+ "The callback file path is not valid for this "
+ "application (" LOG_S ")\n",
+ argv[callbackIndex]);
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ return 1;
+ }
+
+ size_t len = NS_tstrlen(gInstallDirPath);
+ NS_tchar callbackInstallDir[MAXPATHLEN] = {NS_T('\0')};
+ NS_tstrncpy(callbackInstallDir, argv[callbackIndex], len);
+ if (NS_tstrcmp(gInstallDirPath, callbackInstallDir) != 0) {
+ WriteStatusFile(INVALID_CALLBACK_DIR_ERROR);
+ fprintf(stderr,
+ "The callback file must be located in the "
+ "installation directory (" LOG_S ")\n",
+ argv[callbackIndex]);
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ return 1;
+ }
+
+ sUpdateSilently =
+ ShouldRunSilently(argc - callbackIndex, argv + callbackIndex);
+ }
+
+ } // if (!isDMGInstall)
+
+ if (!sUpdateSilently && !isDMGInstall
+#ifdef XP_MACOSX
+ && !isElevated
+#endif
+ ) {
+ InitProgressUI(&argc, &argv);
+ }
+
+#ifdef XP_MACOSX
+ if (!isElevated && (!IsRecursivelyWritable(argv[2]) || isDMGInstall)) {
+ // If the app directory isn't recursively writeable or if this is a DMG
+ // install, an elevated helper process is required.
+ if (sUpdateSilently) {
+ // An elevated update always requires an elevation dialog, so if we are
+ // updating silently, don't do an elevated update.
+ // This means that we cannot successfully perform silent updates from
+ // non-admin accounts on a Mac.
+ // It also means that we cannot silently perform the first update by an
+ // admin who was not the installing user. Once the first update has been
+ // installed, the permissions of the installation directory should be
+ // changed such that we don't need to elevate in the future.
+ // Firefox shouldn't actually launch the updater at all in this case. This
+ // is defense in depth.
+ WriteStatusFile(SILENT_UPDATE_NEEDED_ELEVATION_ERROR);
+ fprintf(stderr,
+ "Skipping update to avoid elevation prompt from silent update.");
+ } else {
+ UpdateServerThreadArgs threadArgs;
+ threadArgs.argc = argc;
+ threadArgs.argv = const_cast<const NS_tchar**>(argv);
+
+ Thread t1;
+ if (t1.Run(ServeElevatedUpdateThreadFunc, &threadArgs) == 0) {
+ // Show an indeterminate progress bar while an elevated update is in
+ // progress.
+ if (!isDMGInstall) {
+ ShowProgressUI(true);
+ }
+ }
+ t1.Join();
+ }
+
+ LaunchCallbackAndPostProcessApps(argc, argv, callbackIndex,
+ std::move(umaskContext));
+ return gSucceeded ? 0 : 1;
+ }
+#endif
+
+#ifdef XP_WIN
+ HANDLE updateLockFileHandle = INVALID_HANDLE_VALUE;
+#endif
+
+ if (!isDMGInstall) {
+ NS_tchar logFilePath[MAXPATHLEN + 1] = {L'\0'};
+#ifdef XP_WIN
+ if (gUseSecureOutputPath) {
+ // Remove the secure output files so it is easier to determine when new
+ // files are created in the unelevated updater.
+ RemoveSecureOutputFiles(gPatchDirPath);
+
+ (void)GetSecureOutputFilePath(gPatchDirPath, L".log", logFilePath);
+ } else {
+ NS_tsnprintf(logFilePath, sizeof(logFilePath) / sizeof(logFilePath[0]),
+ NS_T("%s\\%s"), gPatchDirPath, UpdateLogFilename());
+ }
+#else
+ NS_tsnprintf(logFilePath, sizeof(logFilePath) / sizeof(logFilePath[0]),
+ NS_T("%s/%s"), gPatchDirPath, UpdateLogFilename());
+#endif
+ LogInit(logFilePath);
+
+ LOG(("sUsingService=%s", sUsingService ? "true" : "false"));
+ LOG(("sUpdateSilently=%s", sUpdateSilently ? "true" : "false"));
+#ifdef XP_WIN
+ LOG(("gUseSecureOutputPath=%s", gUseSecureOutputPath ? "true" : "false"));
+ // Note that this is not the final value of useService
+ LOG(("useService=%s", useService ? "true" : "false"));
+#endif
+ LOG(("gIsElevated=%s", gIsElevated ? "true" : "false"));
+
+ if (!WriteStatusFile("applying")) {
+ LOG(("failed setting status to 'applying'"));
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ output_finish();
+ return 1;
+ }
+
+ if (sStagedUpdate) {
+ LOG(("Performing a staged update"));
+ } else if (sReplaceRequest) {
+ LOG(("Performing a replace request"));
+ }
+
+ LOG(("PATCH DIRECTORY " LOG_S, gPatchDirPath));
+ LOG(("INSTALLATION DIRECTORY " LOG_S, gInstallDirPath));
+ LOG(("WORKING DIRECTORY " LOG_S, gWorkingDirPath));
+
+#if defined(XP_WIN)
+ // These checks are also performed in workmonitor.cpp since the maintenance
+ // service can be called directly.
+ if (_wcsnicmp(gWorkingDirPath, gInstallDirPath, MAX_PATH) != 0) {
+ if (!sStagedUpdate && !sReplaceRequest) {
+ WriteStatusFile(INVALID_APPLYTO_DIR_ERROR);
+ LOG(
+ ("Installation directory and working directory must be the same "
+ "for non-staged updates. Exiting."));
+ output_finish();
+ return 1;
+ }
+
+ NS_tchar workingDirParent[MAX_PATH];
+ NS_tsnprintf(workingDirParent,
+ sizeof(workingDirParent) / sizeof(workingDirParent[0]),
+ NS_T("%s"), gWorkingDirPath);
+ if (!PathRemoveFileSpecW(workingDirParent)) {
+ WriteStatusFile(REMOVE_FILE_SPEC_ERROR);
+ LOG(("Error calling PathRemoveFileSpecW: %lu", GetLastError()));
+ output_finish();
+ return 1;
+ }
+
+ if (_wcsnicmp(workingDirParent, gInstallDirPath, MAX_PATH) != 0) {
+ WriteStatusFile(INVALID_APPLYTO_DIR_STAGED_ERROR);
+ LOG(
+ ("The apply-to directory must be the same as or "
+ "a child of the installation directory! Exiting."));
+ output_finish();
+ return 1;
+ }
+ }
+#endif
+
+#ifdef XP_WIN
+ if (pid > 0) {
+ HANDLE parent = OpenProcess(SYNCHRONIZE, false, (DWORD)pid);
+ // May return nullptr if the parent process has already gone away.
+ // Otherwise, wait for the parent process to exit before starting the
+ // update.
+ if (parent) {
+ DWORD waitTime = PARENT_WAIT;
+# ifdef TEST_UPDATER
+ if (EnvHasValue("MOZ_TEST_SHORTER_WAIT_PID")) {
+ // Use a shorter time to wait for the PID to exit for the test.
+ waitTime = 100;
+ }
+# endif
+ DWORD result = WaitForSingleObject(parent, waitTime);
+ CloseHandle(parent);
+ if (result != WAIT_OBJECT_0) {
+ // Continue to update since the parent application sometimes doesn't
+ // exit (see bug 1375242) so any fixes to the parent application will
+ // be applied instead of leaving the client in a broken state.
+ LOG(("The parent process didn't exit! Continuing with update."));
+ }
+ }
+ }
+#endif
+
+#ifdef XP_WIN
+ if (sReplaceRequest || sStagedUpdate) {
+ // On Windows, when performing a stage or replace request the current
+ // working directory for the process must be changed so it isn't locked.
+ NS_tchar sysDir[MAX_PATH + 1] = {L'\0'};
+ if (GetSystemDirectoryW(sysDir, MAX_PATH + 1)) {
+ NS_tchdir(sysDir);
+ }
+ }
+
+ // lastFallbackError keeps track of the last error for the service not being
+ // used, in case of an error when fallback is not enabled we write the
+ // error to the update.status file.
+ // When fallback is disabled (MOZ_NO_SERVICE_FALLBACK does not exist) then
+ // we will instead fallback to not using the service and display a UAC
+ // prompt.
+ int lastFallbackError = FALLBACKKEY_UNKNOWN_ERROR;
+
+ // Check whether a second instance of the updater should be launched by the
+ // maintenance service or with the 'runas' verb when write access is denied
+ // to the installation directory.
+ if (!sUsingService &&
+ (argc > callbackIndex || sStagedUpdate || sReplaceRequest)) {
+ LOG(("Checking whether elevation is needed"));
+
+ NS_tchar updateLockFilePath[MAXPATHLEN];
+ if (sStagedUpdate) {
+ // When staging an update, the lock file is:
+ // <install_dir>\updated.update_in_progress.lock
+ NS_tsnprintf(updateLockFilePath,
+ sizeof(updateLockFilePath) / sizeof(updateLockFilePath[0]),
+ NS_T("%s/updated.update_in_progress.lock"),
+ gInstallDirPath);
+ } else if (sReplaceRequest) {
+ // When processing a replace request, the lock file is:
+ // <install_dir>\..\moz_update_in_progress.lock
+ NS_tchar installDir[MAXPATHLEN];
+ NS_tstrcpy(installDir, gInstallDirPath);
+ NS_tchar* slash = (NS_tchar*)NS_tstrrchr(installDir, NS_SLASH);
+ *slash = NS_T('\0');
+ NS_tsnprintf(updateLockFilePath,
+ sizeof(updateLockFilePath) / sizeof(updateLockFilePath[0]),
+ NS_T("%s\\moz_update_in_progress.lock"), installDir);
+ } else {
+ // In the non-staging update case, the lock file is:
+ // <install_dir>\<app_name>.exe.update_in_progress.lock
+ NS_tsnprintf(updateLockFilePath,
+ sizeof(updateLockFilePath) / sizeof(updateLockFilePath[0]),
+ NS_T("%s.update_in_progress.lock"), argv[callbackIndex]);
+ }
+
+ // The update_in_progress.lock file should only exist during an update. In
+ // case it exists attempt to remove it and exit if that fails to prevent
+ // simultaneous updates occurring.
+ if (NS_tremove(updateLockFilePath) && errno != ENOENT) {
+ // Try to fall back to the old way of doing updates if a staged
+ // update fails.
+ if (sReplaceRequest) {
+ // Note that this could fail, but if it does, there isn't too much we
+ // can do in order to recover anyways.
+ WriteStatusFile("pending");
+ } else if (sStagedUpdate) {
+ WriteStatusFile(DELETE_ERROR_STAGING_LOCK_FILE);
+ }
+ LOG(("Update already in progress! Exiting"));
+ output_finish();
+ return 1;
+ }
+
+ updateLockFileHandle =
+ CreateFileW(updateLockFilePath, GENERIC_READ | GENERIC_WRITE, 0,
+ nullptr, OPEN_ALWAYS, FILE_FLAG_DELETE_ON_CLOSE, nullptr);
+
+ if (updateLockFileHandle == INVALID_HANDLE_VALUE) {
+ LOG(("Failed to open update lock file: %lu", GetLastError()));
+ } else {
+ LOG(("Successfully opened lock file"));
+ }
+
+ // If we're running from the service, then we were started with the same
+ // token as the service so the permissions are already dropped. If we're
+ // running from an elevated updater that was started from an unelevated
+ // updater, then we drop the permissions here. We do not drop the
+ // permissions on the originally called updater because we use its token
+ // to start the callback application.
+ if (gIsElevated) {
+ // Disable every privilege we don't need. Processes started using
+ // CreateProcess will use the same token as this process.
+ UACHelper::DisablePrivileges(nullptr);
+ }
+
+ if (updateLockFileHandle == INVALID_HANDLE_VALUE ||
+ (useService && testOnlyFallbackKeyExists &&
+ (noServiceFallback || forceServiceFallback))) {
+ LOG(("Can't open lock file - seems like we need elevation"));
+
+ HANDLE elevatedFileHandle;
+ if (NS_tremove(elevatedLockFilePath) && errno != ENOENT) {
+ LOG(("Unable to create elevated lock file! Exiting"));
+ output_finish();
+ return 1;
+ }
+
+ elevatedFileHandle = CreateFileW(
+ elevatedLockFilePath, GENERIC_READ | GENERIC_WRITE, 0, nullptr,
+ OPEN_ALWAYS, FILE_FLAG_DELETE_ON_CLOSE, nullptr);
+ if (elevatedFileHandle == INVALID_HANDLE_VALUE) {
+ LOG(("Unable to create elevated lock file! Exiting"));
+ output_finish();
+ return 1;
+ }
+
+ auto cmdLine = mozilla::MakeCommandLine(argc - 1, argv + 1);
+ if (!cmdLine) {
+ LOG(("Failed to make command line! Exiting"));
+ CloseHandle(elevatedFileHandle);
+ output_finish();
+ return 1;
+ }
+
+# ifdef MOZ_MAINTENANCE_SERVICE
+// Only invoke the service for installations in Program Files.
+// This check is duplicated in workmonitor.cpp because the service can
+// be invoked directly without going through the updater.
+# ifndef TEST_UPDATER
+ if (useService) {
+ useService = IsProgramFilesPath(gInstallDirPath);
+ LOG(("After checking IsProgramFilesPath, useService=%s",
+ useService ? "true" : "false"));
+ }
+# endif
+
+ // Make sure the path to the updater to use for the update is on local.
+ // We do this check to make sure that file locking is available for
+ // race condition security checks.
+ if (useService) {
+ BOOL isLocal = FALSE;
+ useService = IsLocalFile(argv[0], isLocal) && isLocal;
+ LOG(("After checking IsLocalFile, useService=%s",
+ useService ? "true" : "false"));
+ }
+
+ // If we have unprompted elevation we should NOT use the service
+ // for the update. Service updates happen with the SYSTEM account
+ // which has more privs than we need to update with.
+ // Windows 8 provides a user interface so users can configure this
+ // behavior and it can be configured in the registry in all Windows
+ // versions that support UAC.
+ if (useService) {
+ BOOL unpromptedElevation;
+ if (IsUnpromptedElevation(unpromptedElevation)) {
+ useService = !unpromptedElevation;
+ LOG(("After checking IsUnpromptedElevation, useService=%s",
+ useService ? "true" : "false"));
+ }
+ }
+
+ // Make sure the service registry entries for the installation path
+ // are available. If not don't use the service.
+ if (useService) {
+ WCHAR maintenanceServiceKey[MAX_PATH + 1];
+ if (CalculateRegistryPathFromFilePath(gInstallDirPath,
+ maintenanceServiceKey)) {
+ HKEY baseKey = nullptr;
+ if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, maintenanceServiceKey, 0,
+ KEY_READ | KEY_WOW64_64KEY,
+ &baseKey) == ERROR_SUCCESS) {
+ RegCloseKey(baseKey);
+ } else {
+# ifdef TEST_UPDATER
+ useService = testOnlyFallbackKeyExists;
+ LOG(("After failing to open maintenanceServiceKey, useService=%s",
+ useService ? "true" : "false"));
+# endif
+ if (!useService) {
+ lastFallbackError = FALLBACKKEY_NOKEY_ERROR;
+ }
+ }
+ } else {
+ useService = false;
+ lastFallbackError = FALLBACKKEY_REGPATH_ERROR;
+ LOG(("Can't get registry certificate location. useService=false"));
+ }
+ }
+
+ // Originally we used to write "pending" to update.status before
+ // launching the service command. This is no longer needed now
+ // since the service command is launched from updater.exe. If anything
+ // fails in between, we can fall back to using the normal update process
+ // on our own.
+
+ // If we still want to use the service try to launch the service
+ // command for the update.
+ if (useService) {
+ // Get the secure ID before trying to update so it is possible to
+ // determine if the updater or the maintenance service has created a
+ // new one.
+ char uuidStringBefore[UUID_LEN] = {'\0'};
+ bool checkID = GetSecureID(uuidStringBefore);
+ // Write a catchall service failure status in case it fails without
+ // changing the status.
+ WriteStatusFile(SERVICE_UPDATE_STATUS_UNCHANGED);
+
+ int serviceArgc = argc;
+ if (forceServiceFallback && serviceArgc > 2) {
+ // To force the service to fail, we can just pass it too few
+ // arguments. However, we don't want to pass it no arguments,
+ // because then it won't have enough information to write out the
+ // update status file telling us that it failed.
+ serviceArgc = 2;
+ }
+
+ // If the update couldn't be started, then set useService to false so
+ // we do the update the old way.
+ DWORD ret =
+ LaunchServiceSoftwareUpdateCommand(serviceArgc, (LPCWSTR*)argv);
+ useService = (ret == ERROR_SUCCESS);
+ // If the command was launched then wait for the service to be done.
+ if (useService) {
+ LOG(("Launched service successfully"));
+ bool showProgressUI = false;
+ // Never show the progress UI when staging updates or in a
+ // background task.
+ if (!sStagedUpdate && !sUpdateSilently) {
+ // We need to call this separately instead of allowing
+ // ShowProgressUI to initialize the strings because the service
+ // will move the ini file out of the way when running updater.
+ showProgressUI = !InitProgressUIStrings();
+ }
+
+ // Wait for the service to stop for 5 seconds. If the service
+ // has still not stopped then show an indeterminate progress bar.
+ DWORD lastState = WaitForServiceStop(SVC_NAME, 5);
+ if (lastState != SERVICE_STOPPED) {
+ Thread t1;
+ if (t1.Run(WaitForServiceFinishThread, nullptr) == 0 &&
+ showProgressUI) {
+ ShowProgressUI(true, false);
+ }
+ t1.Join();
+ }
+
+ lastState = WaitForServiceStop(SVC_NAME, 1);
+ if (lastState != SERVICE_STOPPED) {
+ // If the service doesn't stop after 10 minutes there is
+ // something seriously wrong.
+ lastFallbackError = FALLBACKKEY_SERVICE_NO_STOP_ERROR;
+ useService = false;
+ LOG(("Service didn't stop after 10 minutes. useService=false"));
+ } else {
+ LOG(("Service stop detected."));
+ // Copy the secure output files if the secure ID has changed.
+ gCopyOutputFiles = true;
+ char uuidStringAfter[UUID_LEN] = {'\0'};
+ if (checkID && GetSecureID(uuidStringAfter) &&
+ strncmp(uuidStringBefore, uuidStringAfter,
+ sizeof(uuidStringBefore)) == 0) {
+ LOG(
+ ("The secure ID hasn't changed after launching the updater "
+ "using the service"));
+ gCopyOutputFiles = false;
+ }
+ if (gCopyOutputFiles && !sStagedUpdate && !noServiceFallback) {
+ // If the Maintenance Service fails for a Service-specific
+ // reason, we ought to fall back to attempting to update
+ // without the Service.
+ // However, we need the secure output files to be able to be
+ // check the error code, and we can't fall back when we are
+ // staging, because we will need to show a UAC.
+ bool updateFailed;
+ mozilla::Maybe<int> maybeErrorCode;
+ bool success =
+ IsSecureUpdateStatusFailed(updateFailed, &maybeErrorCode);
+ if (success && updateFailed && maybeErrorCode.isSome() &&
+ IsServiceSpecificErrorCode(maybeErrorCode.value())) {
+ useService = false;
+ LOG(("Service-specific failure detected. useService=false"));
+ }
+ }
+ }
+ } else {
+ LOG(("Launching service failed. useService=false"));
+ lastFallbackError = FALLBACKKEY_LAUNCH_ERROR;
+ }
+ }
+# endif
+
+ // If the service can't be used when staging an update, make sure that
+ // the UAC prompt is not shown!
+ if (!useService && sStagedUpdate) {
+ if (updateLockFileHandle != INVALID_HANDLE_VALUE) {
+ CloseHandle(updateLockFileHandle);
+ }
+ // Set an error so the failure is reported. This will be reset
+ // to pending so the update can be applied during the next startup,
+ // see bug 1552853.
+ WriteStatusFile(UNEXPECTED_STAGING_ERROR);
+ LOG(
+ ("Non-critical update staging error! Falling back to non-staged "
+ "updates and exiting"));
+ output_finish();
+ // We don't have a callback when staging so we can just exit.
+ return 0;
+ }
+
+ // If the service can't be used when in a background task, make sure
+ // that the UAC prompt is not shown!
+ if (!useService && sUpdateSilently) {
+ if (updateLockFileHandle != INVALID_HANDLE_VALUE) {
+ CloseHandle(updateLockFileHandle);
+ }
+ // Set an error so we don't get into an update loop when the callback
+ // runs. This will be reset to pending by handleUpdateFailure in
+ // UpdateService.jsm.
+ WriteStatusFile(SILENT_UPDATE_NEEDED_ELEVATION_ERROR);
+ LOG(("Skipping update to avoid UAC prompt from background task."));
+ output_finish();
+
+ LaunchCallbackApp(argv[5], argc - callbackIndex, argv + callbackIndex,
+ sUsingService);
+ return 0;
+ }
+
+ // If we didn't want to use the service at all, or if an update was
+ // already happening, or launching the service command failed, then
+ // launch the elevated updater.exe as we do without the service.
+ // We don't launch the elevated updater in the case that we did have
+ // write access all along because in that case the only reason we're
+ // using the service is because we are testing.
+ if (!useService && !noServiceFallback &&
+ (updateLockFileHandle == INVALID_HANDLE_VALUE ||
+ forceServiceFallback)) {
+ LOG(("Elevating via a UAC prompt"));
+ // Get the secure ID before trying to update so it is possible to
+ // determine if the updater has created a new one.
+ char uuidStringBefore[UUID_LEN] = {'\0'};
+ bool checkID = GetSecureID(uuidStringBefore);
+ // Write a catchall failure status in case it fails without changing
+ // the status.
+ WriteStatusFile(UPDATE_STATUS_UNCHANGED);
+
+ SHELLEXECUTEINFO sinfo;
+ memset(&sinfo, 0, sizeof(SHELLEXECUTEINFO));
+ sinfo.cbSize = sizeof(SHELLEXECUTEINFO);
+ sinfo.fMask = SEE_MASK_FLAG_NO_UI | SEE_MASK_FLAG_DDEWAIT |
+ SEE_MASK_NOCLOSEPROCESS;
+ sinfo.hwnd = nullptr;
+ sinfo.lpFile = argv[0];
+ sinfo.lpParameters = cmdLine.get();
+ if (forceServiceFallback) {
+ // In testing, we don't actually want a UAC prompt. We should
+ // already have the permissions such that we shouldn't need it.
+ // And we don't have a good way of accepting the prompt in
+ // automation.
+ sinfo.lpVerb = L"open";
+ // This handle is what lets the updater that we spawn below know
+ // that it's the elevated updater. We are going to close it so that
+ // it doesn't know that and will run un-elevated. Doing this make
+ // this makes for an imperfect test of the service fallback
+ // functionality because it changes how the (usually) elevated
+ // updater runs. One of the effects of this is that the secure
+ // output files will not be used. So that functionality won't really
+ // be covered by testing. But we can't really have the updater run
+ // elevated, because that would require a UAC, which we have no way
+ // to deal with in automation.
+ CloseHandle(elevatedFileHandle);
+ // We need to let go of the update lock to let the un-elevated
+ // updater we are about to spawn update.
+ if (updateLockFileHandle != INVALID_HANDLE_VALUE) {
+ CloseHandle(updateLockFileHandle);
+ }
+ } else {
+ sinfo.lpVerb = L"runas";
+ }
+ sinfo.nShow = SW_SHOWNORMAL;
+
+ bool result = ShellExecuteEx(&sinfo);
+
+ if (result) {
+ LOG(("Elevation successful. Waiting for elevated updater to run."));
+ WaitForSingleObject(sinfo.hProcess, INFINITE);
+ LOG(("Elevated updater has finished running."));
+ CloseHandle(sinfo.hProcess);
+
+ // Copy the secure output files if the secure ID has changed.
+ gCopyOutputFiles = true;
+ char uuidStringAfter[UUID_LEN] = {'\0'};
+ if (checkID && GetSecureID(uuidStringAfter) &&
+ strncmp(uuidStringBefore, uuidStringAfter,
+ sizeof(uuidStringBefore)) == 0) {
+ LOG(
+ ("The secure ID hasn't changed after launching the updater "
+ "using runas"));
+ gCopyOutputFiles = false;
+ }
+ } else {
+ // Don't copy the secure output files if the elevation request was
+ // canceled since the status file written below is in the patch
+ // directory. At this point it should already be set to false and
+ // this is set here to make it clear that it should be false at this
+ // point and to prevent future changes from regressing this code.
+ gCopyOutputFiles = false;
+ WriteStatusFile(ELEVATION_CANCELED);
+ LOG(("Elevation canceled."));
+ }
+ } else {
+ LOG(("Not showing a UAC prompt."));
+ LOG(("useService=%s", useService ? "true" : "false"));
+ LOG(("noServiceFallback=%s", noServiceFallback ? "true" : "false"));
+ LOG(("updateLockFileHandle%sINVALID_HANDLE_VALUE",
+ updateLockFileHandle == INVALID_HANDLE_VALUE ? "==" : "!="));
+ LOG(("forceServiceFallback=%s",
+ forceServiceFallback ? "true" : "false"));
+ }
+
+ // If we started the elevated updater, and it finished, check the secure
+ // update status file to make sure that it succeeded, and if it did we
+ // need to launch the PostUpdate process in the unelevated updater which
+ // is running in the current user's session. Note that we don't need to
+ // do this when staging an update since the PostUpdate step runs during
+ // the replace request.
+ if (!sStagedUpdate) {
+ bool updateStatusSucceeded = false;
+ if (IsSecureUpdateStatusSucceeded(updateStatusSucceeded) &&
+ updateStatusSucceeded) {
+ LOG(("Running LaunchWinPostProcess"));
+ if (!LaunchWinPostProcess(gInstallDirPath, gPatchDirPath)) {
+ LOG(("Failed to run LaunchWinPostProcess"));
+ }
+ } else {
+ LOG(
+ ("Not running LaunchWinPostProcess because update status is not"
+ "'succeeded'."));
+ }
+ }
+
+ CloseHandle(elevatedFileHandle);
+
+ if (updateLockFileHandle != INVALID_HANDLE_VALUE) {
+ CloseHandle(updateLockFileHandle);
+ }
+
+ if (!useService && noServiceFallback) {
+ // When the service command was not launched at all.
+ // We should only reach this code path because we had write access
+ // all along to the directory and a fallback key existed, and we
+ // have fallback disabled (MOZ_NO_SERVICE_FALLBACK env var exists).
+ // We only currently use this env var from XPCShell tests.
+ gCopyOutputFiles = false;
+ WriteStatusFile(lastFallbackError);
+ }
+
+ // The logging output needs to be finished before launching the callback
+ // application so the update status file contains the value from the
+ // secure directory used by the maintenance service and the elevated
+ // updater.
+ LOG(("Update complete"));
+ output_finish();
+ if (argc > callbackIndex) {
+ LaunchCallbackApp(argv[5], argc - callbackIndex, argv + callbackIndex,
+ sUsingService);
+ }
+ return 0;
+
+ // This is the end of the code block for launching another instance of
+ // the updater using either the maintenance service or with the 'runas'
+ // verb when the updater doesn't have write access to the installation
+ // directory.
+ }
+ // This is the end of the code block when the updater was not launched by
+ // the service that checks whether the updater has write access to the
+ // installation directory.
+ }
+ // If we made it this far this is the updater instance that will perform the
+ // actual update and gCopyOutputFiles will be false (e.g. the default
+ // value).
+ LOG(("Going to update via this updater instance."));
+#endif
+
+ if (sStagedUpdate) {
+#ifdef TEST_UPDATER
+ // This allows testing that the correct UI after an update staging failure
+ // that falls back to applying the update on startup. It is simulated due
+ // to the difficulty of creating the conditions for this type of staging
+ // failure.
+ if (EnvHasValue("MOZ_TEST_STAGING_ERROR")) {
+# ifdef XP_WIN
+ if (updateLockFileHandle != INVALID_HANDLE_VALUE) {
+ CloseHandle(updateLockFileHandle);
+ }
+# endif
+ // WRITE_ERROR is one of the cases where the staging failure falls back
+ // to applying the update on startup.
+ WriteStatusFile(WRITE_ERROR);
+ output_finish();
+ return 0;
+ }
+#endif
+ // When staging updates, blow away the old installation directory and
+ // create it from scratch.
+ ensure_remove_recursive(gWorkingDirPath);
+ }
+ if (!sReplaceRequest) {
+ // Try to create the destination directory if it doesn't exist
+ int rv = NS_tmkdir(gWorkingDirPath, 0755);
+ if (rv != OK && errno != EEXIST) {
+#ifdef XP_MACOSX
+ if (isElevated) {
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(true);
+ }
+#endif
+ output_finish();
+ return 1;
+ }
+ }
+
+#ifdef XP_WIN
+ NS_tchar applyDirLongPath[MAXPATHLEN];
+ if (!GetLongPathNameW(
+ gWorkingDirPath, applyDirLongPath,
+ sizeof(applyDirLongPath) / sizeof(applyDirLongPath[0]))) {
+ WriteStatusFile(WRITE_ERROR_APPLY_DIR_PATH);
+ LOG(("NS_main: unable to find apply to dir: " LOG_S, gWorkingDirPath));
+ output_finish();
+ EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 1);
+ if (argc > callbackIndex) {
+ LaunchCallbackApp(argv[5], argc - callbackIndex, argv + callbackIndex,
+ sUsingService);
+ }
+ return 1;
+ }
+
+ HANDLE callbackFile = INVALID_HANDLE_VALUE;
+ if (argc > callbackIndex) {
+ // If the callback executable is specified it must exist for a successful
+ // update. It is important we null out the whole buffer here because
+ // later we make the assumption that the callback application is inside
+ // the apply-to dir. If we don't have a fully null'ed out buffer it can
+ // lead to stack corruption which causes crashes and other problems.
+ NS_tchar callbackLongPath[MAXPATHLEN];
+ ZeroMemory(callbackLongPath, sizeof(callbackLongPath));
+ NS_tchar* targetPath = argv[callbackIndex];
+ NS_tchar buffer[MAXPATHLEN * 2] = {NS_T('\0')};
+ size_t bufferLeft = MAXPATHLEN * 2;
+ if (sReplaceRequest) {
+ // In case of replace requests, we should look for the callback file in
+ // the destination directory.
+ size_t commonPrefixLength =
+ PathCommonPrefixW(argv[callbackIndex], gInstallDirPath, nullptr);
+ NS_tchar* p = buffer;
+ NS_tstrncpy(p, argv[callbackIndex], commonPrefixLength);
+ p += commonPrefixLength;
+ bufferLeft -= commonPrefixLength;
+ NS_tstrncpy(p, gInstallDirPath + commonPrefixLength, bufferLeft);
+
+ size_t len = NS_tstrlen(gInstallDirPath + commonPrefixLength);
+ p += len;
+ bufferLeft -= len;
+ *p = NS_T('\\');
+ ++p;
+ bufferLeft--;
+ *p = NS_T('\0');
+ NS_tchar installDir[MAXPATHLEN];
+ NS_tstrcpy(installDir, gInstallDirPath);
+ size_t callbackPrefixLength =
+ PathCommonPrefixW(argv[callbackIndex], installDir, nullptr);
+ NS_tstrncpy(p,
+ argv[callbackIndex] +
+ std::max(callbackPrefixLength, commonPrefixLength),
+ bufferLeft);
+ targetPath = buffer;
+ }
+ if (!GetLongPathNameW(
+ targetPath, callbackLongPath,
+ sizeof(callbackLongPath) / sizeof(callbackLongPath[0]))) {
+ WriteStatusFile(WRITE_ERROR_CALLBACK_PATH);
+ LOG(("NS_main: unable to find callback file: " LOG_S, targetPath));
+ output_finish();
+ EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 1);
+ if (argc > callbackIndex) {
+ LaunchCallbackApp(argv[5], argc - callbackIndex, argv + callbackIndex,
+ sUsingService);
+ }
+ return 1;
+ }
+
+ // Doing this is only necessary when we're actually applying a patch.
+ if (!sReplaceRequest) {
+ int len = NS_tstrlen(applyDirLongPath);
+ NS_tchar* s = callbackLongPath;
+ NS_tchar* d = gCallbackRelPath;
+ // advance to the apply to directory and advance past the trailing
+ // backslash if present.
+ s += len;
+ if (*s == NS_T('\\')) {
+ ++s;
+ }
+
+ // Copy the string and replace backslashes with forward slashes along
+ // the way.
+ do {
+ if (*s == NS_T('\\')) {
+ *d = NS_T('/');
+ } else {
+ *d = *s;
+ }
+ ++s;
+ ++d;
+ } while (*s);
+ *d = NS_T('\0');
+ ++d;
+
+ const size_t callbackBackupPathBufSize =
+ sizeof(gCallbackBackupPath) / sizeof(gCallbackBackupPath[0]);
+ const int callbackBackupPathLen =
+ NS_tsnprintf(gCallbackBackupPath, callbackBackupPathBufSize,
+ NS_T("%s" CALLBACK_BACKUP_EXT), argv[callbackIndex]);
+
+ if (callbackBackupPathLen < 0 ||
+ callbackBackupPathLen >=
+ static_cast<int>(callbackBackupPathBufSize)) {
+ WriteStatusFile(USAGE_ERROR);
+ LOG(("NS_main: callback backup path truncated"));
+ output_finish();
+
+ // Don't attempt to launch the callback when the callback path is
+ // longer than expected.
+ EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 1);
+ return 1;
+ }
+
+ // Make a copy of the callback executable so it can be read when
+ // patching.
+ if (!CopyFileW(argv[callbackIndex], gCallbackBackupPath, false)) {
+ DWORD copyFileError = GetLastError();
+ if (copyFileError == ERROR_ACCESS_DENIED) {
+ WriteStatusFile(WRITE_ERROR_ACCESS_DENIED);
+ } else {
+ WriteStatusFile(WRITE_ERROR_CALLBACK_APP);
+ }
+ LOG(("NS_main: failed to copy callback file " LOG_S
+ " into place at " LOG_S,
+ argv[callbackIndex], gCallbackBackupPath));
+ output_finish();
+ EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 1);
+ LaunchCallbackApp(argv[callbackIndex], argc - callbackIndex,
+ argv + callbackIndex, sUsingService);
+ return 1;
+ }
+
+ // Since the process may be signaled as exited by WaitForSingleObject
+ // before the release of the executable image try to lock the main
+ // executable file multiple times before giving up. If we end up giving
+ // up, we won't fail the update.
+ const int max_retries = 10;
+ int retries = 1;
+ DWORD lastWriteError = 0;
+ do {
+ // By opening a file handle wihout FILE_SHARE_READ to the callback
+ // executable, the OS will prevent launching the process while it is
+ // being updated.
+ callbackFile = CreateFileW(targetPath, DELETE | GENERIC_WRITE,
+ // allow delete, rename, and write
+ FILE_SHARE_DELETE | FILE_SHARE_WRITE,
+ nullptr, OPEN_EXISTING, 0, nullptr);
+ if (callbackFile != INVALID_HANDLE_VALUE) {
+ break;
+ }
+
+ lastWriteError = GetLastError();
+ LOG(
+ ("NS_main: callback app file open attempt %d failed. "
+ "File: " LOG_S ". Last error: %lu",
+ retries, targetPath, lastWriteError));
+
+ Sleep(100);
+ } while (++retries <= max_retries);
+
+ // CreateFileW will fail if the callback executable is already in use.
+ if (callbackFile == INVALID_HANDLE_VALUE) {
+ bool proceedWithoutExclusive = true;
+
+ // Fail the update if the last error was not a sharing violation.
+ if (lastWriteError != ERROR_SHARING_VIOLATION) {
+ LOG((
+ "NS_main: callback app file in use, failed to exclusively open "
+ "executable file: " LOG_S,
+ argv[callbackIndex]));
+ if (lastWriteError == ERROR_ACCESS_DENIED) {
+ WriteStatusFile(WRITE_ERROR_ACCESS_DENIED);
+ } else {
+ WriteStatusFile(WRITE_ERROR_CALLBACK_APP);
+ }
+
+ proceedWithoutExclusive = false;
+ }
+
+ // Fail even on sharing violation from a background task, since a
+ // background task has a higher risk of interfering with a running
+ // app. Note that this does not apply when staging (when an exclusive
+ // lock isn't necessary), as there is no callback.
+ if (lastWriteError == ERROR_SHARING_VIOLATION && sUpdateSilently) {
+ LOG((
+ "NS_main: callback app file in use, failed to exclusively open "
+ "executable file from background task: " LOG_S,
+ argv[callbackIndex]));
+ WriteStatusFile(WRITE_ERROR_BACKGROUND_TASK_SHARING_VIOLATION);
+
+ proceedWithoutExclusive = false;
+ }
+
+ if (!proceedWithoutExclusive) {
+ if (NS_tremove(gCallbackBackupPath) && errno != ENOENT) {
+ LOG(
+ ("NS_main: unable to remove backup of callback app file, "
+ "path: " LOG_S,
+ gCallbackBackupPath));
+ }
+ output_finish();
+ EXIT_WHEN_ELEVATED(elevatedLockFilePath, updateLockFileHandle, 1);
+ LaunchCallbackApp(argv[5], argc - callbackIndex,
+ argv + callbackIndex, sUsingService);
+ return 1;
+ }
+
+ LOG(
+ ("NS_main: callback app file in use, continuing without "
+ "exclusive access for executable file: " LOG_S,
+ argv[callbackIndex]));
+ }
+ }
+ }
+
+ // DELETE_DIR is not required when performing a staged update or replace
+ // request; it can be used during a replace request but then it doesn't
+ // use gDeleteDirPath.
+ if (!sStagedUpdate && !sReplaceRequest) {
+ // The directory to move files that are in use to on Windows. This
+ // directory will be deleted after the update is finished, on OS reboot
+ // using MoveFileEx if it contains files that are in use, or by the post
+ // update process after the update finishes. On Windows when performing a
+ // normal update (e.g. the update is not a staged update and is not a
+ // replace request) gWorkingDirPath is the same as gInstallDirPath and
+ // gWorkingDirPath is used because it is the destination directory.
+ NS_tsnprintf(gDeleteDirPath,
+ sizeof(gDeleteDirPath) / sizeof(gDeleteDirPath[0]),
+ NS_T("%s/%s"), gWorkingDirPath, DELETE_DIR);
+
+ if (NS_taccess(gDeleteDirPath, F_OK)) {
+ NS_tmkdir(gDeleteDirPath, 0755);
+ }
+ }
+#endif /* XP_WIN */
+
+ // Run update process on a background thread. ShowProgressUI may return
+ // before QuitProgressUI has been called, so wait for UpdateThreadFunc to
+ // terminate. Avoid showing the progress UI when staging an update, or if
+ // this is an elevated process on OSX.
+ Thread t;
+ if (t.Run(UpdateThreadFunc, nullptr) == 0) {
+ if (!sStagedUpdate && !sReplaceRequest && !sUpdateSilently
+#ifdef XP_MACOSX
+ && !isElevated
+#endif
+ ) {
+ ShowProgressUI();
+ }
+ }
+ t.Join();
+
+#ifdef XP_WIN
+ if (argc > callbackIndex && !sReplaceRequest) {
+ if (callbackFile != INVALID_HANDLE_VALUE) {
+ CloseHandle(callbackFile);
+ }
+ // Remove the copy of the callback executable.
+ if (NS_tremove(gCallbackBackupPath) && errno != ENOENT) {
+ LOG(
+ ("NS_main: non-fatal error removing backup of callback app file, "
+ "path: " LOG_S,
+ gCallbackBackupPath));
+ }
+ }
+
+ if (!sStagedUpdate && !sReplaceRequest && _wrmdir(gDeleteDirPath)) {
+ LOG(("NS_main: unable to remove directory: " LOG_S ", err: %d",
+ DELETE_DIR, errno));
+ // The directory probably couldn't be removed due to it containing files
+ // that are in use and will be removed on OS reboot. The call to remove
+ // the directory on OS reboot is done after the calls to remove the files
+ // so the files are removed first on OS reboot since the directory must be
+ // empty for the directory removal to be successful. The MoveFileEx call
+ // to remove the directory on OS reboot will fail if the process doesn't
+ // have write access to the HKEY_LOCAL_MACHINE registry key but this is ok
+ // since the installer / uninstaller will delete the directory along with
+ // its contents after an update is applied, on reinstall, and on
+ // uninstall.
+ if (MoveFileEx(gDeleteDirPath, nullptr, MOVEFILE_DELAY_UNTIL_REBOOT)) {
+ LOG(("NS_main: directory will be removed on OS reboot: " LOG_S,
+ DELETE_DIR));
+ } else {
+ LOG(
+ ("NS_main: failed to schedule OS reboot removal of "
+ "directory: " LOG_S,
+ DELETE_DIR));
+ }
+ }
+#endif /* XP_WIN */
+
+ } // if (!isDMGInstall)
+
+#ifdef XP_MACOSX
+ if (isElevated) {
+ SetGroupOwnershipAndPermissions(gInstallDirPath);
+ freeArguments(argc, argv);
+ CleanupElevatedMacUpdate(false);
+ } else if (IsOwnedByGroupAdmin(gInstallDirPath)) {
+ // If the group ownership of the Firefox .app bundle was set to the "admin"
+ // group during a previous elevated update, we need to ensure that all files
+ // in the bundle have group ownership of "admin" as well as write permission
+ // for the group to not break updates in the future.
+ SetGroupOwnershipAndPermissions(gInstallDirPath);
+ }
+#endif /* XP_MACOSX */
+
+ LOG(("Running LaunchCallbackAndPostProcessApps"));
+
+ int retVal = LaunchCallbackAndPostProcessApps(argc, argv, callbackIndex
+#ifdef XP_WIN
+ ,
+ elevatedLockFilePath,
+ updateLockFileHandle
+#elif XP_MACOSX
+ ,
+ std::move(umaskContext)
+#endif
+ );
+
+ return retVal ? retVal : (gSucceeded ? 0 : 1);
+}
+
+class ActionList {
+ public:
+ ActionList() : mFirst(nullptr), mLast(nullptr), mCount(0) {}
+ ~ActionList();
+
+ void Append(Action* action);
+ int Prepare();
+ int Execute();
+ void Finish(int status);
+
+ private:
+ Action* mFirst;
+ Action* mLast;
+ int mCount;
+};
+
+ActionList::~ActionList() {
+ Action* a = mFirst;
+ while (a) {
+ Action* b = a;
+ a = a->mNext;
+ delete b;
+ }
+}
+
+void ActionList::Append(Action* action) {
+ if (mLast) {
+ mLast->mNext = action;
+ } else {
+ mFirst = action;
+ }
+
+ mLast = action;
+ mCount++;
+}
+
+int ActionList::Prepare() {
+ // If the action list is empty then we should fail in order to signal that
+ // something has gone wrong. Otherwise we report success when nothing is
+ // actually done. See bug 327140.
+ if (mCount == 0) {
+ LOG(("empty action list"));
+ return MAR_ERROR_EMPTY_ACTION_LIST;
+ }
+
+ Action* a = mFirst;
+ int i = 0;
+ while (a) {
+ int rv = a->Prepare();
+ if (rv) {
+ return rv;
+ }
+
+ float percent = float(++i) / float(mCount);
+ UpdateProgressUI(PROGRESS_PREPARE_SIZE * percent);
+
+ a = a->mNext;
+ }
+
+ return OK;
+}
+
+int ActionList::Execute() {
+ int currentProgress = 0, maxProgress = 0;
+ Action* a = mFirst;
+ while (a) {
+ maxProgress += a->mProgressCost;
+ a = a->mNext;
+ }
+
+ a = mFirst;
+ while (a) {
+ int rv = a->Execute();
+ if (rv) {
+ LOG(("### execution failed"));
+ return rv;
+ }
+
+ currentProgress += a->mProgressCost;
+ float percent = float(currentProgress) / float(maxProgress);
+ UpdateProgressUI(PROGRESS_PREPARE_SIZE + PROGRESS_EXECUTE_SIZE * percent);
+
+ a = a->mNext;
+ }
+
+ return OK;
+}
+
+void ActionList::Finish(int status) {
+ Action* a = mFirst;
+ int i = 0;
+ while (a) {
+ a->Finish(status);
+
+ float percent = float(++i) / float(mCount);
+ UpdateProgressUI(PROGRESS_PREPARE_SIZE + PROGRESS_EXECUTE_SIZE +
+ PROGRESS_FINISH_SIZE * percent);
+
+ a = a->mNext;
+ }
+
+ if (status == OK) {
+ gSucceeded = true;
+ }
+}
+
+#ifdef XP_WIN
+int add_dir_entries(const NS_tchar* dirpath, ActionList* list) {
+ int rv = OK;
+ WIN32_FIND_DATAW finddata;
+ HANDLE hFindFile;
+ NS_tchar searchspec[MAXPATHLEN];
+ NS_tchar foundpath[MAXPATHLEN];
+
+ NS_tsnprintf(searchspec, sizeof(searchspec) / sizeof(searchspec[0]),
+ NS_T("%s*"), dirpath);
+ mozilla::UniquePtr<const NS_tchar> pszSpec(get_full_path(searchspec));
+
+ hFindFile = FindFirstFileW(pszSpec.get(), &finddata);
+ if (hFindFile != INVALID_HANDLE_VALUE) {
+ do {
+ // Don't process the current or parent directory.
+ if (NS_tstrcmp(finddata.cFileName, NS_T(".")) == 0 ||
+ NS_tstrcmp(finddata.cFileName, NS_T("..")) == 0) {
+ continue;
+ }
+
+ NS_tsnprintf(foundpath, sizeof(foundpath) / sizeof(foundpath[0]),
+ NS_T("%s%s"), dirpath, finddata.cFileName);
+ if (finddata.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
+ NS_tsnprintf(foundpath, sizeof(foundpath) / sizeof(foundpath[0]),
+ NS_T("%s/"), foundpath);
+ // Recurse into the directory.
+ rv = add_dir_entries(foundpath, list);
+ if (rv) {
+ LOG(("add_dir_entries error: " LOG_S ", err: %d", foundpath, rv));
+ FindClose(hFindFile);
+ return rv;
+ }
+ } else {
+ // Add the file to be removed to the ActionList.
+ NS_tchar* quotedpath = get_quoted_path(foundpath);
+ if (!quotedpath) {
+ FindClose(hFindFile);
+ return PARSE_ERROR;
+ }
+
+ mozilla::UniquePtr<Action> action(new RemoveFile());
+ rv = action->Parse(quotedpath);
+ if (rv) {
+ LOG(("add_dir_entries Parse error on recurse: " LOG_S ", err: %d",
+ quotedpath, rv));
+ free(quotedpath);
+ FindClose(hFindFile);
+ return rv;
+ }
+ free(quotedpath);
+
+ list->Append(action.release());
+ }
+ } while (FindNextFileW(hFindFile, &finddata) != 0);
+
+ FindClose(hFindFile);
+ {
+ // Add the directory to be removed to the ActionList.
+ NS_tchar* quotedpath = get_quoted_path(dirpath);
+ if (!quotedpath) {
+ return PARSE_ERROR;
+ }
+
+ mozilla::UniquePtr<Action> action(new RemoveDir());
+ rv = action->Parse(quotedpath);
+ if (rv) {
+ LOG(("add_dir_entries Parse error on close: " LOG_S ", err: %d",
+ quotedpath, rv));
+ } else {
+ list->Append(action.release());
+ }
+ free(quotedpath);
+ }
+ }
+
+ return rv;
+}
+
+#elif defined(HAVE_FTS_H)
+ int add_dir_entries(const NS_tchar* dirpath, ActionList* list) {
+ int rv = OK;
+ FTS* ftsdir;
+ FTSENT* ftsdirEntry;
+ mozilla::UniquePtr<NS_tchar[]> searchpath(get_full_path(dirpath));
+
+ // Remove the trailing slash so the paths don't contain double slashes. The
+ // existence of the slash has already been checked in DoUpdate.
+ searchpath[NS_tstrlen(searchpath.get()) - 1] = NS_T('\0');
+ char* const pathargv[] = {searchpath.get(), nullptr};
+
+ // FTS_NOCHDIR is used so relative paths from the destination directory are
+ // returned.
+ if (!(ftsdir = fts_open(pathargv,
+ FTS_PHYSICAL | FTS_NOSTAT | FTS_XDEV | FTS_NOCHDIR,
+ nullptr))) {
+ return UNEXPECTED_FILE_OPERATION_ERROR;
+ }
+
+ while ((ftsdirEntry = fts_read(ftsdir)) != nullptr) {
+ NS_tchar foundpath[MAXPATHLEN];
+ NS_tchar* quotedpath = nullptr;
+ mozilla::UniquePtr<Action> action;
+
+ switch (ftsdirEntry->fts_info) {
+ // Filesystem objects that shouldn't be in the application's directories
+ case FTS_SL:
+ case FTS_SLNONE:
+ case FTS_DEFAULT:
+ LOG(("add_dir_entries: found a non-standard file: " LOG_S,
+ ftsdirEntry->fts_path));
+ // Fall through and try to remove as a file
+ [[fallthrough]];
+
+ // Files
+ case FTS_F:
+ case FTS_NSOK:
+ // Add the file to be removed to the ActionList.
+ NS_tsnprintf(foundpath, sizeof(foundpath) / sizeof(foundpath[0]),
+ NS_T("%s"), ftsdirEntry->fts_accpath);
+ quotedpath = get_quoted_path(get_relative_path(foundpath));
+ if (!quotedpath) {
+ rv = UPDATER_QUOTED_PATH_MEM_ERROR;
+ break;
+ }
+ action.reset(new RemoveFile());
+ rv = action->Parse(quotedpath);
+ free(quotedpath);
+ if (!rv) {
+ list->Append(action.release());
+ }
+ break;
+
+ // Directories
+ case FTS_DP:
+ // Add the directory to be removed to the ActionList.
+ NS_tsnprintf(foundpath, sizeof(foundpath) / sizeof(foundpath[0]),
+ NS_T("%s/"), ftsdirEntry->fts_accpath);
+ quotedpath = get_quoted_path(get_relative_path(foundpath));
+ if (!quotedpath) {
+ rv = UPDATER_QUOTED_PATH_MEM_ERROR;
+ break;
+ }
+
+ action.reset(new RemoveDir());
+ rv = action->Parse(quotedpath);
+ free(quotedpath);
+ if (!rv) {
+ list->Append(action.release());
+ }
+ break;
+
+ // Errors
+ case FTS_DNR:
+ case FTS_NS:
+ // ENOENT is an acceptable error for FTS_DNR and FTS_NS and means that
+ // we're racing with ourselves. Though strange, the entry will be
+ // removed anyway.
+ if (ENOENT == ftsdirEntry->fts_errno) {
+ rv = OK;
+ break;
+ }
+ [[fallthrough]];
+
+ case FTS_ERR:
+ rv = UNEXPECTED_FILE_OPERATION_ERROR;
+ LOG(("add_dir_entries: fts_read() error: " LOG_S ", err: %d",
+ ftsdirEntry->fts_path, ftsdirEntry->fts_errno));
+ break;
+
+ case FTS_DC:
+ rv = UNEXPECTED_FILE_OPERATION_ERROR;
+ LOG(("add_dir_entries: fts_read() returned FT_DC: " LOG_S,
+ ftsdirEntry->fts_path));
+ break;
+
+ default:
+ // FTS_D is ignored and FTS_DP is used instead (post-order).
+ rv = OK;
+ break;
+ }
+
+ if (rv != OK) {
+ break;
+ }
+ }
+
+ fts_close(ftsdir);
+
+ return rv;
+ }
+
+#else
+
+int add_dir_entries(const NS_tchar* dirpath, ActionList* list) {
+ int rv = OK;
+ NS_tchar foundpath[PATH_MAX];
+ struct {
+ dirent dent_buffer;
+ char chars[NAME_MAX];
+ } ent_buf;
+ struct dirent* ent;
+ mozilla::UniquePtr<NS_tchar[]> searchpath(get_full_path(dirpath));
+
+ DIR* dir = opendir(searchpath.get());
+ if (!dir) {
+ LOG(("add_dir_entries error on opendir: " LOG_S ", err: %d",
+ searchpath.get(), errno));
+ return UNEXPECTED_FILE_OPERATION_ERROR;
+ }
+
+ while (readdir_r(dir, (dirent*)&ent_buf, &ent) == 0 && ent) {
+ if ((strcmp(ent->d_name, ".") == 0) || (strcmp(ent->d_name, "..") == 0)) {
+ continue;
+ }
+
+ NS_tsnprintf(foundpath, sizeof(foundpath) / sizeof(foundpath[0]),
+ NS_T("%s%s"), searchpath.get(), ent->d_name);
+ struct stat64 st_buf;
+ int test = stat64(foundpath, &st_buf);
+ if (test) {
+ closedir(dir);
+ return UNEXPECTED_FILE_OPERATION_ERROR;
+ }
+ if (S_ISDIR(st_buf.st_mode)) {
+ NS_tsnprintf(foundpath, sizeof(foundpath) / sizeof(foundpath[0]),
+ NS_T("%s%s/"), dirpath, ent->d_name);
+ // Recurse into the directory.
+ rv = add_dir_entries(foundpath, list);
+ if (rv) {
+ LOG(("add_dir_entries error: " LOG_S ", err: %d", foundpath, rv));
+ closedir(dir);
+ return rv;
+ }
+ } else {
+ // Add the file to be removed to the ActionList.
+ NS_tchar* quotedpath = get_quoted_path(get_relative_path(foundpath));
+ if (!quotedpath) {
+ closedir(dir);
+ return PARSE_ERROR;
+ }
+
+ mozilla::UniquePtr<Action> action(new RemoveFile());
+ rv = action->Parse(quotedpath);
+ if (rv) {
+ LOG(("add_dir_entries Parse error on recurse: " LOG_S ", err: %d",
+ quotedpath, rv));
+ free(quotedpath);
+ closedir(dir);
+ return rv;
+ }
+ free(quotedpath);
+
+ list->Append(action.release());
+ }
+ }
+ closedir(dir);
+
+ // Add the directory to be removed to the ActionList.
+ NS_tchar* quotedpath = get_quoted_path(get_relative_path(dirpath));
+ if (!quotedpath) {
+ return PARSE_ERROR;
+ }
+
+ mozilla::UniquePtr<Action> action(new RemoveDir());
+ rv = action->Parse(quotedpath);
+ if (rv) {
+ LOG(("add_dir_entries Parse error on close: " LOG_S ", err: %d", quotedpath,
+ rv));
+ } else {
+ list->Append(action.release());
+ }
+ free(quotedpath);
+
+ return rv;
+}
+
+#endif
+
+/*
+ * Gets the contents of an update manifest file. The return value is malloc'd
+ * and it is the responsibility of the caller to free it.
+ *
+ * @param manifest
+ * The full path to the manifest file.
+ * @return On success the contents of the manifest and nullptr otherwise.
+ */
+static NS_tchar* GetManifestContents(const NS_tchar* manifest) {
+ AutoFile mfile(NS_tfopen(manifest, NS_T("rb")));
+ if (mfile == nullptr) {
+ LOG(("GetManifestContents: error opening manifest file: " LOG_S, manifest));
+ return nullptr;
+ }
+
+ struct stat ms;
+ int rv = fstat(fileno((FILE*)mfile), &ms);
+ if (rv) {
+ LOG(("GetManifestContents: error stating manifest file: " LOG_S, manifest));
+ return nullptr;
+ }
+
+ char* mbuf = (char*)malloc(ms.st_size + 1);
+ if (!mbuf) {
+ return nullptr;
+ }
+
+ size_t r = ms.st_size;
+ char* rb = mbuf;
+ while (r) {
+ const size_t count = mmin(SSIZE_MAX, r);
+ size_t c = fread(rb, 1, count, mfile);
+ if (c != count) {
+ LOG(("GetManifestContents: error reading manifest file: " LOG_S,
+ manifest));
+ free(mbuf);
+ return nullptr;
+ }
+
+ r -= c;
+ rb += c;
+ }
+ *rb = '\0';
+
+#ifndef XP_WIN
+ return mbuf;
+#else
+ NS_tchar* wrb = (NS_tchar*)malloc((ms.st_size + 1) * sizeof(NS_tchar));
+ if (!wrb) {
+ free(mbuf);
+ return nullptr;
+ }
+
+ if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, mbuf, -1, wrb,
+ ms.st_size + 1)) {
+ LOG(("GetManifestContents: error converting utf8 to utf16le: %lu",
+ GetLastError()));
+ free(mbuf);
+ free(wrb);
+ return nullptr;
+ }
+ free(mbuf);
+
+ return wrb;
+#endif
+}
+
+int AddPreCompleteActions(ActionList* list) {
+#ifdef XP_MACOSX
+ mozilla::UniquePtr<NS_tchar[]> manifestPath(
+ get_full_path(NS_T("Contents/Resources/precomplete")));
+#else
+ mozilla::UniquePtr<NS_tchar[]> manifestPath(
+ get_full_path(NS_T("precomplete")));
+#endif
+
+ NS_tchar* buf = GetManifestContents(manifestPath.get());
+ if (!buf) {
+ LOG(
+ ("AddPreCompleteActions: error getting contents of precomplete "
+ "manifest"));
+ // Applications aren't required to have a precomplete manifest. The mar
+ // generation scripts enforce the presence of a precomplete manifest.
+ return OK;
+ }
+ NS_tchar* rb = buf;
+
+ int rv;
+ NS_tchar* line;
+ while ((line = mstrtok(kNL, &rb)) != 0) {
+ // skip comments
+ if (*line == NS_T('#')) {
+ continue;
+ }
+
+ NS_tchar* token = mstrtok(kWhitespace, &line);
+ if (!token) {
+ LOG(("AddPreCompleteActions: token not found in manifest"));
+ free(buf);
+ return PARSE_ERROR;
+ }
+
+ Action* action = nullptr;
+ if (NS_tstrcmp(token, NS_T("remove")) == 0) { // rm file
+ action = new RemoveFile();
+ } else if (NS_tstrcmp(token, NS_T("remove-cc")) ==
+ 0) { // no longer supported
+ continue;
+ } else if (NS_tstrcmp(token, NS_T("rmdir")) == 0) { // rmdir if empty
+ action = new RemoveDir();
+ } else {
+ LOG(("AddPreCompleteActions: unknown token: " LOG_S, token));
+ free(buf);
+ return PARSE_ERROR;
+ }
+
+ if (!action) {
+ free(buf);
+ return BAD_ACTION_ERROR;
+ }
+
+ rv = action->Parse(line);
+ if (rv) {
+ delete action;
+ free(buf);
+ return rv;
+ }
+
+ list->Append(action);
+ }
+
+ free(buf);
+ return OK;
+}
+
+int DoUpdate() {
+ NS_tchar manifest[MAXPATHLEN];
+ NS_tsnprintf(manifest, sizeof(manifest) / sizeof(manifest[0]),
+ NS_T("%s/updating/update.manifest"), gWorkingDirPath);
+ ensure_parent_dir(manifest);
+
+ // extract the manifest
+ int rv = gArchiveReader.ExtractFile("updatev3.manifest", manifest);
+ if (rv) {
+ LOG(("DoUpdate: error extracting manifest file"));
+ return rv;
+ }
+
+ NS_tchar* buf = GetManifestContents(manifest);
+ // The manifest is located in the <working_dir>/updating directory which is
+ // removed after the update has finished so don't delete it here.
+ if (!buf) {
+ LOG(("DoUpdate: error opening manifest file: " LOG_S, manifest));
+ return READ_ERROR;
+ }
+ NS_tchar* rb = buf;
+
+ ActionList list;
+ NS_tchar* line;
+ bool isFirstAction = true;
+ while ((line = mstrtok(kNL, &rb)) != 0) {
+ // skip comments
+ if (*line == NS_T('#')) {
+ continue;
+ }
+
+ NS_tchar* token = mstrtok(kWhitespace, &line);
+ if (!token) {
+ LOG(("DoUpdate: token not found in manifest"));
+ free(buf);
+ return PARSE_ERROR;
+ }
+
+ if (isFirstAction) {
+ isFirstAction = false;
+ // The update manifest isn't required to have a type declaration. The mar
+ // generation scripts enforce the presence of the type declaration.
+ if (NS_tstrcmp(token, NS_T("type")) == 0) {
+ const NS_tchar* type = mstrtok(kQuote, &line);
+ LOG(("UPDATE TYPE " LOG_S, type));
+ if (NS_tstrcmp(type, NS_T("complete")) == 0) {
+ rv = AddPreCompleteActions(&list);
+ if (rv) {
+ free(buf);
+ return rv;
+ }
+ }
+ continue;
+ }
+ }
+
+ Action* action = nullptr;
+ if (NS_tstrcmp(token, NS_T("remove")) == 0) { // rm file
+ action = new RemoveFile();
+ } else if (NS_tstrcmp(token, NS_T("rmdir")) == 0) { // rmdir if empty
+ action = new RemoveDir();
+ } else if (NS_tstrcmp(token, NS_T("rmrfdir")) == 0) { // rmdir recursive
+ const NS_tchar* reldirpath = mstrtok(kQuote, &line);
+ if (!reldirpath) {
+ free(buf);
+ return PARSE_ERROR;
+ }
+
+ if (reldirpath[NS_tstrlen(reldirpath) - 1] != NS_T('/')) {
+ free(buf);
+ return PARSE_ERROR;
+ }
+
+ rv = add_dir_entries(reldirpath, &list);
+ if (rv) {
+ free(buf);
+ return rv;
+ }
+
+ continue;
+ } else if (NS_tstrcmp(token, NS_T("add")) == 0) {
+ action = new AddFile();
+ } else if (NS_tstrcmp(token, NS_T("patch")) == 0) {
+ action = new PatchFile();
+ } else if (NS_tstrcmp(token, NS_T("add-if")) == 0) { // Add if exists
+ action = new AddIfFile();
+ } else if (NS_tstrcmp(token, NS_T("add-if-not")) ==
+ 0) { // Add if not exists
+ action = new AddIfNotFile();
+ } else if (NS_tstrcmp(token, NS_T("patch-if")) == 0) { // Patch if exists
+ action = new PatchIfFile();
+ } else {
+ LOG(("DoUpdate: unknown token: " LOG_S, token));
+ free(buf);
+ return PARSE_ERROR;
+ }
+
+ if (!action) {
+ free(buf);
+ return BAD_ACTION_ERROR;
+ }
+
+ rv = action->Parse(line);
+ if (rv) {
+ delete action;
+ free(buf);
+ return rv;
+ }
+
+ list.Append(action);
+ }
+
+ rv = list.Prepare();
+ if (rv) {
+ free(buf);
+ return rv;
+ }
+
+ rv = list.Execute();
+
+ list.Finish(rv);
+ free(buf);
+ return rv;
+}
diff --git a/toolkit/mozapps/update/updater/updater.exe.comctl32.manifest b/toolkit/mozapps/update/updater/updater.exe.comctl32.manifest
new file mode 100644
index 0000000000..9df0057e64
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater.exe.comctl32.manifest
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="Updater"
+ type="win32"
+/>
+<description>Updater</description>
+<dependency>
+ <dependentAssembly>
+ <assemblyIdentity
+ type="win32"
+ name="Microsoft.Windows.Common-Controls"
+ version="6.0.0.0"
+ processorArchitecture="*"
+ publicKeyToken="6595b64144ccf1df"
+ language="*"
+ />
+ </dependentAssembly>
+</dependency>
+<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:security>
+ <ms_asmv3:requestedPrivileges>
+ <ms_asmv3:requestedExecutionLevel level="asInvoker" uiAccess="false" />
+ </ms_asmv3:requestedPrivileges>
+ </ms_asmv3:security>
+</ms_asmv3:trustInfo>
+<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+</compatibility>
+<ms_asmv3:application xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
+ <dpiAware>True/PM</dpiAware>
+ <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
+ </ms_asmv3:windowsSettings>
+</ms_asmv3:application>
+</assembly>
diff --git a/toolkit/mozapps/update/updater/updater.exe.manifest b/toolkit/mozapps/update/updater/updater.exe.manifest
new file mode 100644
index 0000000000..6646ec6534
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater.exe.manifest
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="Updater"
+ type="win32"
+/>
+<description>Updater</description>
+<ms_asmv3:trustInfo xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:security>
+ <ms_asmv3:requestedPrivileges>
+ <ms_asmv3:requestedExecutionLevel level="asInvoker" uiAccess="false" />
+ </ms_asmv3:requestedPrivileges>
+ </ms_asmv3:security>
+</ms_asmv3:trustInfo>
+<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
+ <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
+ <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
+ </application>
+</compatibility>
+<ms_asmv3:application xmlns:ms_asmv3="urn:schemas-microsoft-com:asm.v3">
+ <ms_asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">
+ <dpiAware>True/PM</dpiAware>
+ <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
+ </ms_asmv3:windowsSettings>
+</ms_asmv3:application>
+</assembly>
diff --git a/toolkit/mozapps/update/updater/updater.ico b/toolkit/mozapps/update/updater/updater.ico
new file mode 100644
index 0000000000..48457029d6
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater.ico
Binary files differ
diff --git a/toolkit/mozapps/update/updater/updater.png b/toolkit/mozapps/update/updater/updater.png
new file mode 100644
index 0000000000..6f1251bd03
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater.png
Binary files differ
diff --git a/toolkit/mozapps/update/updater/updater.rc b/toolkit/mozapps/update/updater/updater.rc
new file mode 100644
index 0000000000..aff834a597
--- /dev/null
+++ b/toolkit/mozapps/update/updater/updater.rc
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Microsoft Visual C++ generated resource script.
+//
+#if defined(TEST_UPDATER) || defined(DEP_UPDATER)
+#include "../resource.h"
+#define MANIFEST_PATH "../updater.exe.manifest"
+#define COMCTL32_MANIFEST_PATH "../updater.exe.comctl32.manifest"
+#define ICON_PATH "../updater.ico"
+#else
+#include "resource.h"
+#define MANIFEST_PATH "updater.exe.manifest"
+#define COMCTL32_MANIFEST_PATH "updater.exe.comctl32.manifest"
+#define ICON_PATH "updater.ico"
+#endif
+
+#define APSTUDIO_READONLY_SYMBOLS
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 2 resource.
+//
+#include "winresrc.h"
+
+/////////////////////////////////////////////////////////////////////////////
+#undef APSTUDIO_READONLY_SYMBOLS
+
+/////////////////////////////////////////////////////////////////////////////
+// English (U.S.) resources
+
+#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
+#ifdef _WIN32
+LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
+#pragma code_page(1252)
+#endif //_WIN32
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// RT_MANIFEST
+//
+
+1 RT_MANIFEST MANIFEST_PATH
+IDR_COMCTL32_MANIFEST RT_MANIFEST COMCTL32_MANIFEST_PATH
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Icon
+//
+
+IDI_DIALOG ICON ICON_PATH
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Embedded an identifier to uniquely identiy this as a Mozilla updater.
+//
+
+STRINGTABLE
+{
+ IDS_UPDATER_IDENTITY, "moz-updater.exe-4cdccec4-5ee0-4a06-9817-4cd899a9db49"
+}
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Dialog
+//
+
+IDD_DIALOG DIALOGEX 0, 0, 253, 41
+STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION
+FONT 8, "MS Shell Dlg", 400, 0, 0x1
+BEGIN
+ CONTROL "",IDC_PROGRESS,"msctls_progress32",WS_BORDER,7,24,239,10
+ LTEXT "",IDC_INFO,7,8,239,13,SS_NOPREFIX
+END
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// DESIGNINFO
+//
+
+#ifdef APSTUDIO_INVOKED
+GUIDELINES DESIGNINFO
+BEGIN
+ IDD_DIALOG, DIALOG
+ BEGIN
+ LEFTMARGIN, 7
+ RIGHTMARGIN, 246
+ TOPMARGIN, 7
+ BOTTOMMARGIN, 39
+ END
+END
+#endif // APSTUDIO_INVOKED
+
+
+#ifdef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// TEXTINCLUDE
+//
+
+1 TEXTINCLUDE
+BEGIN
+ "resource.h\0"
+END
+
+2 TEXTINCLUDE
+BEGIN
+ "#include ""winresrc.h""\r\n"
+ "\0"
+END
+
+3 TEXTINCLUDE
+BEGIN
+ "\r\n"
+ "\0"
+END
+
+#endif // APSTUDIO_INVOKED
+
+#endif // English (U.S.) resources
+/////////////////////////////////////////////////////////////////////////////
+
+
+
+#ifndef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 3 resource.
+//
+
+
+/////////////////////////////////////////////////////////////////////////////
+#endif // not APSTUDIO_INVOKED
+
diff --git a/toolkit/mozapps/update/updater/xpcshellCertificate.der b/toolkit/mozapps/update/updater/xpcshellCertificate.der
new file mode 100644
index 0000000000..ea1fd47faa
--- /dev/null
+++ b/toolkit/mozapps/update/updater/xpcshellCertificate.der
Binary files differ